Skip to content

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, from Element

Interactions:

  • IteratedDilemmaInteraction, from LocalRuleInteraction
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)