Iterated prisoner's dilemma on a lattice with strategies of cooperation rounds
Argun's evolutionary iterated prisoner's dilemma on a lattice, with strategies of n cooperation rounds.
- Name:
iterated_lattice_dilemma - Field:
social/cooperation - Formalism: lattice dynamics
- References: Argun et al. 2021; Axelrod and Hamilton 1981
Select this system
from skcomplex.systems import create_simulation, create_system, describe
system = create_system('iterated_lattice_dilemma')
entry = describe('iterated_lattice_dilemma')
trajectory = create_simulation('iterated_lattice_dilemma').prepare().run().trajectory_
Factory
@register_system(
field='social/cooperation',
tags=('lattice', 'games', 'cooperation', 'iterated', 'mutation'),
references=('Argun et al. 2021', 'Axelrod and Hamilton 1981'),
defaults={'until': 100, 'dt': 1},
)
def iterated_lattice_dilemma(
shape: Sequence[int] = (30, 30),
*,
rounds: int = 7,
reward: float = 0.6,
sucker: float = 1.5,
temptation: float = 0.0,
punishment: float = 1.0,
mutation: float = 0.01,
extremes: bool = False,
random_state: int | np.random.Generator | None = 0,
) -> System:
"""Return Argun's evolutionary iterated prisoner's dilemma on a lattice, with strategies of `n` cooperation rounds.
The payoffs are years in prison, `T < R < P < S`, so lower is better. A strategy `n` cooperates until round `n`
or until the opponent defects, and defects afterwards, so `n = 0` always defects and `n = N` always cooperates.
Against a strategy `m` the best reply is `n = m - 1`. Every step each player plays the `N`-round game with its four
von Neumann neighbours, adopts the strategy of the lowest total among itself and its neighbours, random on ties,
and mutates to a random strategy with the probability `mu` (Argun et al. 2021 sections 13.2 and 13.3). With every
strategy allowed and `S = 1.5`, cooperative strategies prevail for `R` below about 0.5, all strategies coexist and
cycle between about 0.5 and 0.75, and defecting strategies dominate above, so the default `R = 0.6` lies in the
middle of the cycling regime, where a finite lattice is not absorbed by defection. With only the two extreme
strategies,
`extremes=True`, cooperation dominates at `R = 0.82`, coexists with defection at `R = 0.84`, and vanishes at
`R = 0.86` (Exercise 13.3).
Args:
shape:
The number of rows and columns of the periodic lattice.
rounds:
The number `N` of rounds of a game.
reward:
The punishment `R` of mutual cooperation.
sucker:
The punishment `S` of a betrayed cooperator.
temptation:
The punishment `T` of a defector who betrays.
punishment:
The punishment `P` of mutual defection.
mutation:
The probability `mu` of a mutation to a random strategy per player and step.
extremes:
Whether only the strategies 0, always defect, and `N`, always cooperate, are allowed.
random_state:
The seed or generator of the initial strategies.
Returns:
system:
The system, with the strategies `players.strategy`.
Examples:
>>> from skcomplex.systems.social.cooperation import iterated_lattice_dilemma
>>> iterated_lattice_dilemma((10, 10)).elements[0].prepare().size_
100
"""
rng = np.random.default_rng(random_state)
if extremes:
strategies = rounds * rng.integers(2, size=tuple(shape)).astype(float)
else:
strategies = rng.integers(rounds + 1, size=tuple(shape)).astype(float)
players = LatticeElement(
'players',
element=IteratedPrisonerElement,
space=LatticeSpace(tuple(shape), neighborhood='von_neumann'),
values={
'strategy': strategies,
'rounds': rounds,
'reward': reward,
'sucker': sucker,
'temptation': temptation,
'punishment': punishment,
'mutation': mutation,
'extremes': float(extremes),
},
)
return System(elements=[players], interactions=[IteratedDilemmaInteraction('play', target='players')])
Components
Elements:
IteratedPrisonerElement, fromElement
Interactions:
IteratedDilemmaInteraction, fromLocalRuleInteraction
class IteratedPrisonerElement(Element):
"""Player of the lattice iterated prisoner's dilemma, who cooperates for a number of rounds and then defects.
The strategy `n` of a player is the number of rounds it intends to cooperate, until round `n` included or until its
opponent defects, after which it defects for the rest of the game. The payoffs are years in prison, so lower is
better. The play, the revision, and the mutation are an `IteratedDilemmaInteraction` of the lattice, which
`iterated_lattice_dilemma` adds.
Args:
label:
The label of the player or the lattice group.
visual:
How the agent is drawn, or `None` for `default_visual_`.
strategy:
The number `n` of intended cooperation rounds, from 0, always defect, to `rounds`, always cooperate.
rounds:
The number `N` of rounds of a game.
reward:
The punishment `R` of mutual cooperation.
sucker:
The punishment `S` of a cooperator who is betrayed.
temptation:
The punishment `T` of a defector who betrays.
punishment:
The punishment `P` of mutual defection.
mutation:
The probability `mu` that a player mutates to a random strategy at the end of a step.
extremes:
1 to allow only the strategies `0` and `N`, and 0 to allow every strategy from 0 to `N`.
Examples:
>>> from skcomplex.systems.social.cooperation import IteratedPrisonerElement
>>> IteratedPrisonerElement('player').rounds
7
"""
def __init__(
self,
label: str,
visual: VisualSpec | None = None,
*,
strategy: Annotated[float, StateSpec(domain=NN)] = 0.0,
rounds: Annotated[int, ParameterSpec(domain=NN)] = 7,
reward: Annotated[float, ParameterSpec(domain=RealSet.closed_open(0, oo))] = 0.72,
sucker: Annotated[float, ParameterSpec(domain=RealSet.closed_open(0, oo))] = 1.5,
temptation: Annotated[float, ParameterSpec(domain=RealSet.closed_open(0, oo))] = 0.0,
punishment: Annotated[float, ParameterSpec(domain=RealSet.closed_open(0, oo))] = 1.0,
mutation: Annotated[float, ParameterSpec(domain=RealSet.closed(0, 1))] = 0.01,
extremes: Annotated[float, ParameterSpec(domain=RealSet.closed(0, 1))] = 0.0,
) -> None:
super().__init__(label, visual=visual)
self.strategy = strategy
self.rounds = rounds
self.reward = reward
self.sucker = sucker
self.temptation = temptation
self.punishment = punishment
self.mutation = mutation
self.extremes = extremes
class IteratedDilemmaInteraction(LocalRuleInteraction):
"""Play of the iterated prisoner's dilemma between neighbours, revision to the best strategy, and mutation.
It is a stochastic `LocalRuleInteraction` of the strategies over the cells within two von Neumann steps of a
player. Every step each player plays the `N`-round prisoner's dilemma with its four von Neumann neighbours and sums
its years in prison. A player of strategy `n` against one of strategy `m < n` gets `m R + S + (N - m - 1) P`, the
other `m R + T + (N - m - 1) P`, and two players of strategy `n` get `n R + (N - n) P`. Every player then adopts
the strategy of the lowest score among itself and its four neighbours, a random one of them on ties, and finally
mutates to a uniformly drawn strategy with the probability `mu`.
Args:
label:
The label of the interaction.
target:
The path of the players on the lattice.
Examples:
>>> from skcomplex.systems.social.cooperation import IteratedDilemmaInteraction
>>> IteratedDilemmaInteraction('play', target='players').state
'strategy'
"""
def __init__(self, label: str, *, target: Annotated[str, RoleSpec()]) -> None:
super().__init__(label, target=target, state='strategy', rule=self._play, neighborhood=DIAMOND, stochastic=True)
@staticmethod
def years(
own: Array,
other: Array,
*,
rounds: float | Array,
reward: float | Array,
sucker: float | Array,
temptation: float | Array,
punishment: float | Array,
) -> Array:
"""Return the years in prison of a player of strategy `n` against one of strategy `m` in an `N`-round game.
Args:
own:
The strategies `n` of the players.
other:
The strategies `m` of their opponents.
rounds:
The number `N` of rounds.
reward:
The punishment `R` of mutual cooperation.
sucker:
The punishment `S` of a betrayed cooperator.
temptation:
The punishment `T` of a defector who betrays.
punishment:
The punishment `P` of mutual defection.
Returns:
years:
The years in prison of every player.
Examples:
>>> import numpy as np
>>> from skcomplex.systems.social.cooperation import IteratedDilemmaInteraction
>>> years = IteratedDilemmaInteraction.years(
... np.arange(11.0), np.full(11, 6.0), rounds=10, reward=0.5, sucker=1.5, temptation=0.0, punishment=1.0
... )
>>> int(np.argmin(years))
5
"""
first = np.minimum(own, other)
betrayal = np.where(own < other, temptation, sucker)
mixed = first * reward + betrayal + (rounds - first - 1) * punishment
return np.where(own == other, own * reward + (rounds - own) * punishment, mixed)
def _play(
self,
*values: Array,
rounds: float | Array,
reward: float | Array,
sucker: float | Array,
temptation: float | Array,
punishment: float | Array,
mutation: float | Array,
extremes: float | Array,
random: Array,
) -> Array:
"""Return the strategies after the play against the neighbours, the revision, and the mutation."""
cells = dict(zip(DIAMOND.offsets, values, strict=True))
payoffs = {'rounds': rounds, 'reward': reward, 'sucker': sucker, 'temptation': temptation}
def score(center: Pair) -> Array:
own = cells[center]
return sum(
(
IteratedDilemmaInteraction.years(
own,
cells[center[0] + dx, center[1] + dy],
punishment=punishment,
**payoffs,
)
for dx, dy in VON_NEUMANN
),
start=np.zeros_like(own),
)
candidates = (ORIGIN, *VON_NEUMANN)
scores = np.stack([score(offset) for offset in candidates])
strategies = np.stack([cells[offset] for offset in candidates])
tied = np.isclose(scores, scores.min(axis=0))
mutation = np.broadcast_to(mutation, random.shape)
choice = np.clip((random - mutation) / np.maximum(1 - mutation, 1e-12), 0.0, 1.0 - 1e-12)
rank = (choice * tied.sum(axis=0)).astype(int)
picked = np.argmax(np.cumsum(tied, axis=0) > rank, axis=0)
revised = np.take_along_axis(strategies, picked[None], axis=0)[0]
draw = random / np.maximum(mutation, 1e-12)
mutant = np.where(
np.broadcast_to(extremes, random.shape) > 0,
np.where(draw < HALF, 0.0, rounds),
np.minimum(np.floor(draw * (np.asarray(rounds) + 1)), rounds),
)
return np.where(random < mutation, mutant, revised)