Evolutionary dynamics with game transitions
Evolutionary dynamics with game transitions on a random regular graph, after Su, McAvoy, Wang, and Nowak.
- Name:
game_transitions - Field:
social/cooperation - Formalism: agent-based system
- References: Su, McAvoy, Wang, and Nowak 2019
Select this system
from skcomplex.systems import create_simulation, create_system, describe
system = create_system('game_transitions')
entry = describe('game_transitions')
trajectory = create_simulation('game_transitions').prepare().run().trajectory_
Factory
@register_system(
field='social/cooperation',
tags=('network', 'games', 'cooperation', 'game transitions', 'evolution'),
references=('Su, McAvoy, Wang, and Nowak 2019',),
defaults={'until': 100},
)
def game_transitions(
n_players: int = 100,
*,
degree: int = 4,
first_benefit: float = 3.5,
second_benefit: float = 2.6,
cost: float = 1.0,
selection: float = 0.1,
after_cooperation: float = 0.0,
after_defection: float = 1.0,
transitions: bool = True,
update: str = 'death-birth',
share: float = 0.5,
random_state: int | np.random.Generator | None = 0,
) -> System:
"""Return evolutionary dynamics with game transitions on a random regular graph, after Su, McAvoy, Wang, and Nowak.
Every edge carries a donation game, game 1 of benefit `b1` or game 2 of benefit `b2 < b1`, at the same cost `c`.
Players accumulate the payoffs of the games with all their neighbours, one player is replaced every time step by
death-birth updating or pairwise comparison, and the game of every edge changes with the actions played on it:
with the default transitions mutual cooperation leads to game 1 and every other outcome to game 2 (arXiv 1905.10269
section 2 and section 3.1). Under weak selection and death-birth updating on a `k`-regular graph, cooperation is
favoured if `b1 / c > k - xi (b1 - b2) / c` with `xi = (k - 1) / 2`, lowered from the rule `b1 / c > k` of a
single game, and with the probabilities `p` and `q` of game 2 after mutual cooperation and after one defection,
`xi = (k - 1) q / 2 - (k + 1) p / 2` (Eq. 1 and Eq. 3). Under pairwise comparison cooperation is favoured if
`(b1 - b2) / (2 c) > 1` (Eq. 2), whatever `b1`, and never with a single game. The defaults `b1 = 3.5`,
`b2 = b1 - 0.9`, and `k = 4` lie in the range of Fig. 2 where transitions favour cooperation, since
`3.5 > 4 - 1.5 * 0.9`, and game 1 alone does not, since `3.5 < 4`.
Args:
n_players:
The number of players.
degree:
The number `k` of neighbours of every player.
first_benefit:
The benefit `b1` of game 1.
second_benefit:
The benefit `b2` of game 2.
cost:
The cost `c` of cooperating.
selection:
The intensity `delta` of selection.
after_cooperation:
The probability `p` of game 2 after mutual cooperation.
after_defection:
The probability `q` of game 2 after one cooperation and one defection.
transitions:
Whether the games change, or every edge plays game 1 throughout.
update:
`'death-birth'` or `'pairwise'`.
share:
The share of cooperators at the start.
random_state:
The seed or generator of the graph and of the initial strategies.
Returns:
system:
The system, with the group `players` on the nodes of the graph, and the reporter `evolution.game_one`.
Examples:
>>> from skcomplex.systems.social.cooperation import game_transitions
>>> game_transitions(20).elements[0].prepare().size_
20
"""
rng = np.random.default_rng(random_state)
graph = nx.random_regular_graph(degree, n_players, seed=int(rng.integers(2**31)))
players = GroupElement(
'players',
element=DonorElement,
size=n_players,
values={'strategy': (rng.random(n_players) < share).astype(float)},
)
evolution = GameTransitionsInteraction(
'evolution',
target='players',
first_benefit=first_benefit,
second_benefit=second_benefit,
cost=cost,
selection=selection,
after_cooperation=after_cooperation,
after_defection=after_defection,
transitions=transitions,
update=update,
)
return System(elements=[players], interactions=[evolution], space=NetworkSpace(graph))
Components
Elements:
DonorElement, fromElement
Interactions:
GameTransitionsInteraction, fromInteraction
class DonorElement(Element):
"""Player of donation games on the edges of a graph, a cooperator at 1 and a defector at 0.
Args:
label:
The label of the player or the group.
visual:
How the agent is drawn, or `None` for `default_visual_`.
strategy:
The strategy, 1 to cooperate and 0 to defect.
payoff:
The payoff accumulated over the games with every neighbour in the last time step.
Examples:
>>> from skcomplex.systems.social.cooperation import DonorElement
>>> DonorElement('player').strategy
1.0
"""
def __init__(
self,
label: str,
visual: VisualSpec | None = VisualSpec('agents', {'color_by': 'strategy', 'color_range': (0.0, 1.0)}),
*,
strategy: Annotated[float, StateSpec(domain=FiniteEnumeratedSet([0, 1]))] = 1.0,
payoff: Annotated[float, StateSpec()] = 0.0,
) -> None:
super().__init__(label, visual=visual)
self.strategy = strategy
self.payoff = payoff
@classmethod
def _get_view(cls) -> type[DonorView]:
"""Return the class of the views of the agents, whose attributes are the states."""
return DonorView
class GameTransitionsInteraction(Interaction):
"""Evolutionary dynamics with game transitions, where the game on every edge follows the actions played on it.
Every edge of the graph carries game 1 or game 2, donation games where a cooperator pays `c` to give its partner
`b1` or `b2`, and every edge starts with game 1. In every time step every player plays the game of every edge with
its neighbour and accumulates the payoff `pi`, of fitness `1 - delta + delta pi`. Under death-birth updating a
uniformly drawn player dies and its neighbours compete for the site in proportion to their fitness, and under
pairwise comparison a uniformly drawn player copies a random neighbour with the probability
`1 / (1 + exp(-delta (pi_j - pi_i)))`. The games then change: an edge goes to game 2 with the probability
`after_cooperation` after mutual cooperation, with `after_defection` after one defection, and surely after mutual
defection, and to game 1 otherwise. The newcomer of a site plays the games of its prior occupant. Every step of the
system has one time step per player, and the share of edges with game 1 is reported as `game_one`.
Args:
label:
The label of the interaction.
target:
The path of the group of players on the nodes of a network.
first_benefit:
The benefit `b1` of the more valuable game 1.
second_benefit:
The benefit `b2` of game 2.
cost:
The cost `c` of cooperating.
selection:
The intensity `delta` of selection.
after_cooperation:
The probability `p` of game 2 after mutual cooperation.
after_defection:
The probability `q` of game 2 after one cooperation and one defection.
transitions:
Whether the games change, or every edge plays game 1 throughout.
update:
`'death-birth'` or `'pairwise'`.
Raises:
ValidationError:
The update is unknown, raised by `prepare`.
Examples:
>>> from skcomplex.systems.social.cooperation import GameTransitionsInteraction
>>> GameTransitionsInteraction('evolution', target='players').update
'death-birth'
"""
reports: ClassVar[tuple[str, ...]] = ('game_one',)
def __init__(
self,
label: str,
*,
target: Annotated[str, RoleSpec()],
first_benefit: Annotated[float, ParameterSpec()] = 3.5,
second_benefit: Annotated[float, ParameterSpec()] = 2.6,
cost: Annotated[float, ParameterSpec(domain=RealSet.closed_open(0, oo))] = 1.0,
selection: Annotated[float, ParameterSpec(domain=RealSet.closed_open(0, oo))] = 0.1,
after_cooperation: Annotated[float, ParameterSpec(domain=RealSet.closed(0, 1))] = 0.0,
after_defection: Annotated[float, ParameterSpec(domain=RealSet.closed(0, 1))] = 1.0,
transitions: bool = True,
update: str = 'death-birth',
) -> None:
super().__init__(label)
self.target = target
self.first_benefit = first_benefit
self.second_benefit = second_benefit
self.cost = cost
self.selection = selection
self.after_cooperation = after_cooperation
self.after_defection = after_defection
self.transitions = transitions
self.update = update
def _check(self) -> None:
"""Raise an error for an unknown update.
Raises:
ValidationError:
The update is unknown.
"""
super()._check()
if self.update not in UPDATES:
error_msg = f'Unknown update {self.update!r}. Use one of {", ".join(UPDATES)}.'
raise ValidationError(error_msg)
def _get_laws(self, s: SimpleNamespace) -> list[BaseLaw]:
"""Return the evolution rule.
Args:
s:
The namespace of the symbols the laws can use.
Returns:
laws:
The laws of the component.
"""
return [RuleLaw(self.evolve)]
def _get_edges(self, players: list[DonorView]) -> tuple[IntArray, IntArray]:
"""Return the two ends of every edge, as indices of the players."""
index = {player.id: position for position, player in enumerate(players)}
pairs = sorted(
{
(min(index[player.id], index[other.id]), max(index[player.id], index[other.id]))
for player in players
for other in player.neighbors()
if isinstance(other, DonorView)
},
)
ends = np.array(pairs, dtype=int).reshape(-1, 2)
return ends[:, 0], ends[:, 1]
def _get_payoffs(self, strategies: Array, games: Array, first: IntArray, second: IntArray) -> Array:
"""Return the payoff of every player over the games of all its edges."""
benefits = np.where(games == 1, self.first_benefit, self.second_benefit)
gains = benefits * strategies[second] - self.cost * strategies[first]
returns = benefits * strategies[first] - self.cost * strategies[second]
return np.bincount(first, gains, strategies.size) + np.bincount(second, returns, strategies.size)
def _replace(self, strategies: Array, payoffs: Array, neighbors: list[IntArray], rng: np.random.Generator) -> None:
"""Replace the strategy of one uniformly drawn player by death-birth or pairwise comparison."""
player = int(rng.integers(strategies.size))
around = neighbors[player]
if around.size == 0:
return
if self.update == 'pairwise':
other = int(around[rng.integers(around.size)])
exponent = np.clip(-self.selection * (payoffs[other] - payoffs[player]), -500.0, 500.0)
if rng.random() < 1 / (1 + np.exp(exponent)):
strategies[player] = strategies[other]
return
fitness = np.maximum(1 - self.selection + self.selection * payoffs[around], 1e-12)
strategies[player] = strategies[around[rng.choice(around.size, p=fitness / fitness.sum())]]
def evolve(self, system: SystemView) -> None:
"""Run one time step per player: play the games, replace one player, and change the games.
Args:
system:
The system.
"""
players = system.agents(self.target, DonorView)
storage = system.storage
if 'first' not in storage:
sources, targets = self._get_edges(players)
storage['first'], storage['second'] = sources, targets
storage['games'] = np.ones(sources.size)
storage['neighbors'] = [
np.concatenate([targets[sources == node], sources[targets == node]]) for node in range(len(players))
]
first, second, games = storage['first'], storage['second'], storage['games']
neighbors = storage['neighbors']
if not isinstance(first, np.ndarray) or not isinstance(second, np.ndarray) or not isinstance(games, np.ndarray):
return
if not isinstance(neighbors, list):
return
rng = system.rng
strategies = np.array([player.strategy for player in players])
payoffs = np.zeros(len(players))
for _ in range(len(players)):
payoffs = self._get_payoffs(strategies, games, first, second)
actions = strategies[first] + strategies[second]
self._replace(strategies, payoffs, neighbors, rng)
if self.transitions:
second_game = np.where(actions == MUTUAL_COOPERATION, self.after_cooperation, self.after_defection)
second_game = np.where(actions == 0, 1.0, second_game)
games[:] = np.where(rng.random(games.size) < second_game, 2.0, 1.0)
for player, strategy, payoff in zip(players, strategies.tolist(), payoffs.tolist(), strict=True):
player.strategy = strategy
player.payoff = payoff
system.report('game_one', float(np.mean(games == 1)) if games.size else 1.0)