Skip to content

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