Spatial prisoner's dilemma with best-neighbour and Fermi imitation
The spatial prisoner's dilemma of Nowak and May, with best-neighbour or Fermi imitation.
- Name:
spatial_prisoners_dilemma - Field:
social/cooperation - Formalism: lattice dynamics
- References: Nowak and May 1992; Szabó and Fáth 2007; Argun et al. 2021; Gros 2015
Factory
@register_model(
field='social/cooperation',
tags=('lattice', 'games', 'cooperation', 'evolution'),
references=('Nowak and May 1992', 'Szabó and Fáth 2007', 'Argun et al. 2021', 'Gros 2015'),
defaults={'until': 50, 'dt': 1},
)
def spatial_prisoners_dilemma(
shape: Sequence[int] = (50, 50),
*,
temptation: float = 1.85,
imitation: str = 'best',
noise: float = 0.1,
share: float = 0.9,
random_state: int | np.random.Generator | None = 0,
) -> System:
"""Return the spatial prisoner's dilemma of Nowak and May, with best-neighbour or Fermi imitation.
Args:
shape:
The number of rows and columns of the periodic lattice.
temptation:
The payoff of a defector against a cooperator.
imitation:
`'best'` to copy the best neighbour, or `'fermi'` to copy a random neighbour with the Fermi probability.
noise:
The noise of the Fermi imitation.
share:
The share of cooperators at the start.
random_state:
The seed or generator of the initial strategies.
Returns:
system:
The model, with the strategies `players.strategy`.
Examples:
>>> from skcomplex.models.social.cooperation import spatial_prisoners_dilemma
>>> spatial_prisoners_dilemma((10, 10)).elements[0].check().prepare().size_
100
"""
players = LatticeElement(
'players',
element=PrisonerElement,
space=LatticeSpace(tuple(shape)),
values={
'strategy': _random_strategies(shape, share, random_state),
'temptation': temptation,
'noise': noise,
},
)
return System(
elements=[players],
interactions=[PrisonersDilemmaInteraction('play', target='players', imitation=imitation)],
)
Components
class PrisonerElement(Element):
"""Player of the spatial prisoner's dilemma, a cooperator at 1 and a defector at 0.
The play and the imitation are a `PrisonersDilemmaInteraction` of the lattice, which `spatial_prisoners_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 initial strategy, 1 or 0.
temptation:
The payoff of a defector against a cooperator.
noise:
The noise of the Fermi imitation.
Examples:
>>> from skcomplex.models.social.cooperation import PrisonerElement
>>> PrisonerElement('player').temptation
1.85
"""
def __init__(
self,
label: str,
visual: VisualSpec | None = None,
*,
strategy: Annotated[float, StateSpec(domain=FiniteEnumeratedSet([0, 1]))] = 1.0,
temptation: Annotated[float, ParameterSpec(domain=RealSet.closed_open(0, oo))] = 1.85,
noise: Annotated[float, ParameterSpec(domain=RealSet.open(0, oo))] = 0.1,
) -> None:
super().__init__(label, visual=visual)
self.strategy = strategy
self.temptation = temptation
self.noise = noise
class PrisonersDilemmaInteraction(LocalRuleInteraction):
"""Play of the weak prisoner's dilemma between neighbours, and imitation of more successful neighbours.
It is a stochastic `LocalRuleInteraction` of the strategies over the 5 by 5 square around a player, a cooperator
at 1 and a defector at 0. Every step each player plays the weak dilemma, with reward 1, temptation `temptation`,
and 0 otherwise, against itself and its eight neighbours. It then copies the best player among them, or a random
neighbour with the Fermi probability `1 / (1 + exp((P_own - P_other) / noise))`. The temptation and the noise are
parameters of the players.
Args:
label:
The label of the interaction.
target:
The path of the players on the lattice.
imitation:
`'best'` for the best neighbour, or `'fermi'` for the Fermi rule.
Raises:
ValidationError:
The imitation is unknown, raised by `check`.
Examples:
>>> from skcomplex.models.social.cooperation import PrisonersDilemmaInteraction
>>> PrisonersDilemmaInteraction('play', target='players', imitation='fermi').check().imitation
'fermi'
"""
def __init__(self, label: str, *, target: Annotated[str, RoleSpec()], imitation: str = 'best') -> None:
super().__init__(label, target=target, state='strategy', rule=self._play, neighborhood=SQUARE, stochastic=True)
self.imitation = imitation
def _check(self) -> None:
"""Raise an error for an unknown imitation.
Raises:
ValidationError:
The imitation is unknown.
"""
super()._check()
if self.imitation not in IMITATIONS:
error_msg = f'Unknown imitation {self.imitation!r}. Use one of {", ".join(IMITATIONS)}.'
raise ValidationError(error_msg)
def _play(self, *values: Array, temptation: float | Array, noise: float | Array, random: Array) -> Array:
"""Return the strategies after the play of every player against its neighbours and the imitation."""
cells = dict(zip(SQUARE.offsets, values, strict=True))
def payoff(center: Pair) -> Array:
own = cells[center]
around = sum(cells[PrisonersDilemmaInteraction._shift(center, offset)] for offset in (*MOORE, ORIGIN))
return own * around + (1 - own) * around * temptation
payoffs = [payoff(offset) for offset in MOORE]
strategies = [cells[offset] for offset in MOORE]
if self.imitation == 'best':
return PrisonersDilemmaInteraction._imitate_best(cells[ORIGIN], payoff(ORIGIN), payoffs, strategies)
return PrisonersDilemmaInteraction._imitate_fermi(
cells[ORIGIN],
payoff(ORIGIN),
payoffs,
strategies,
noise=noise,
random=random,
)
@staticmethod
def _shift(first: Pair, second: Pair) -> Pair:
"""Return the sum of two offsets."""
return first[0] + second[0], first[1] + second[1]
@staticmethod
def _imitate_fermi(
own: Array,
own_payoff: Array,
payoffs: list[Array],
strategies: list[Array],
*,
noise: float | Array,
random: Array,
) -> Array:
"""Return the strategy of a random neighbour with probability `1 / (1 + exp((P_own - P_other) / noise))`."""
count = len(payoffs)
chosen = np.minimum((random * count).astype(int), count - 1)
uniform = random * count - chosen
picked = np.take_along_axis(np.stack(payoffs), chosen[None], axis=0)[0]
strategy = np.take_along_axis(np.stack(strategies), chosen[None], axis=0)[0]
exponent = np.clip((own_payoff - picked) / np.maximum(noise, 1e-12), -50, 50)
return np.where(uniform < 1 / (1 + np.exp(exponent)), strategy, own)
@staticmethod
def _imitate_best(own: Array, own_payoff: Array, payoffs: list[Array], strategies: list[Array]) -> Array:
"""Return the strategy of the largest payoff among a player and its neighbours, its own on ties."""
best, following = own_payoff.copy(), own.copy()
for payoff, strategy in zip(payoffs, strategies, strict=True):
following = np.where(payoff > best, strategy, following)
best = np.maximum(best, payoff)
return following