Skip to content

Replicator dynamics and hawks and doves

The replicator dynamics of a game, by default hawks and doves, which settle at a share V / C of hawks.

  • Name: replicator_dynamics
  • Field: biology/evolution
  • Formalism: ordinary differential equations
  • References: Taylor and Jonker 1978; Maynard Smith 1982; Gros 2015; Szabó and Fáth 2007

The replicator_dynamics example

Example

Factory

@register_model(
    field='biology/evolution',
    tags=('evolutionary game', 'replicator', 'ode', 'game theory'),
    references=('Taylor and Jonker 1978', 'Maynard Smith 1982', 'Gros 2015', 'Szabó and Fáth 2007'),
    defaults={'until': 30.0, 'dt': 0.1},
)
def replicator_dynamics(
    x: Sequence[float] | None = None,
    *,
    payoff: Sequence[Sequence[float]] | None = None,
    V: float = 2.0,
    C: float = 3.0,
) -> System:
    """Return the replicator dynamics of a game, by default hawks and doves, which settle at a share `V / C` of hawks.

    Every strategy is a member of the group `population`, and it grows with its payoff above the mean payoff. The
    hawks and doves game has the payoff matrix `((V - C) / 2, V), (0, V / 2)` with hawks first. When the cost of a fight
    `C` exceeds the value `V`, hawks and doves coexist with `V / C` hawks, and otherwise hawks take over. A payoff
    matrix gives any other game, such as the prisoner's dilemma or rock, paper, scissors.

    Args:
        x:
            The initial shares of the strategies, or `None` for equal shares.

        payoff:
            The payoff matrix of another game, one row per strategy, or `None` for hawks and doves.

        V:
            The value of the resource contested by hawks and doves.

        C:
            The cost of a fight between hawks.

    Returns:
        system:
            The model, with the shares `population.x` of the strategies, hawks first.

    Examples:
        >>> from skcomplex.models.biology.evolution import replicator_dynamics
        >>> replicator_dynamics(payoff=((0, -1, 1), (1, 0, -1), (-1, 1, 0))).elements[0].check().prepare().size_
        3
    """
    matrix = (((V - C) / 2, V), (0.0, V / 2)) if payoff is None else tuple(tuple(row) for row in payoff)
    strategies = len(matrix)
    shares = [1 / strategies] * strategies if x is None else list(x)
    population = GroupElement('population', element=StrategyElement, size=strategies, values={'x': shares})
    weights = np.asarray(matrix, dtype=float)
    replication = ReplicatorInteraction(
        'replication',
        target='population',
        fitness=lambda s: total(s.target.x, weights=weights),
    )
    return System(elements=[population], interactions=[replication])

Components

class StrategyElement(Element):
    """Strategy of an evolutionary game, with the share `x` of the population that plays it.

    Args:
        label:
            The label of the strategy or of the group of strategies.

        visual:
            How the element is drawn, or `None` for `default_visual_`.

        x:
            The initial share.

    Examples:
        >>> from skcomplex.models.biology.evolution import StrategyElement
        >>> StrategyElement('hawks', x=0.4).x
        0.4
    """

    def __init__(
        self,
        label: str,
        visual: VisualSpec | None = None,
        *,
        x: Annotated[object, StateSpec(domain=RealSet.closed_open(0, oo))] = 0.5,
    ) -> None:
        super().__init__(label, visual=visual)
        self.x = x

class ReplicatorInteraction(RateInteraction):
    """Replicator equation of the shares of a group, `dx_i/dt = x_i (f_i - sum_j x_j f_j)`, with a fitness `f`.

    Every member of the group holds the share of a type, such as a strategy or a molecule. Its share grows with its
    fitness above the mean fitness of the group, so the total of the shares stays constant.

    Args:
        label:
            The label of the interaction.

        target:
            The path of the group of types.

        fitness:
            The function of the namespace that returns the fitness of a member, such as
            `lambda s: total(s.target.x, weights=payoff)` for the payoff of a strategy against the others.

        state:
            The name of the state of the members that holds the share.

    Raises:
        ValidationError:
            The fitness is not callable, raised by `check`.

    Examples:
        >>> from skcomplex.core.elements import GroupElement
        >>> from skcomplex.core.laws.utils import total
        >>> from skcomplex.core.system import System
        >>> from skcomplex.engine.runtime import Simulation
        >>> from skcomplex.models.biology.evolution import ReplicatorInteraction, StrategyElement
        >>> population = GroupElement('population', element=StrategyElement, size=2, values={'x': [0.5, 0.5]})
        >>> payoff = ((1.0, 1.0), (0.0, 0.0))
        >>> replication = ReplicatorInteraction(
        ...     'replication', target='population', fitness=lambda s: total(s.target.x, weights=payoff)
        ... )
        >>> system = System(elements=[population], interactions=[replication])
        >>> trajectory = Simulation(system, dt=0.1).check().prepare().run(until=20.0).trajectory_
        >>> [round(float(share), 3) for share in trajectory.values('population.x')[-1]]
        [1.0, 0.0]
    """

    def __init__(
        self,
        label: str,
        *,
        target: Annotated[str, RoleSpec()],
        fitness: Callable[[SimpleNamespace], object] | None = None,
        state: str = 'x',
    ) -> None:
        super().__init__(label, target=target, state=state)
        self.fitness = fitness

    def _check_fitness(self) -> None:
        """Raise an error for a fitness that is not callable, the hook a subclass that derives its fitness overrides."""
        if not callable(self.fitness):
            error_msg = f'The fitness of {self.label!r} is {self.fitness!r}. Give a function of the namespace.'
            raise ValidationError(error_msg)

    def _check(self) -> None:
        """Raise an error for a state, functions, or a fitness the interaction cannot take.

        Raises:
            ValidationError:
                The state is not a name, or the fitness is not callable.
        """
        super()._check()
        self._check_fitness()

    def _get_fitness(self, namespace: SimpleNamespace) -> Expression:
        """Return the fitness of a member, the hook a subclass overrides to derive it from its parameters."""
        fitness = self.fitness
        if fitness is None:
            error_msg = f'The fitness of {self.label!r} is missing. Give a function of the namespace.'
            raise ValidationError(error_msg)
        value: Expression = fitness(namespace)
        return value

    def _get_rate(self, namespace: SimpleNamespace) -> Expression:
        """Return the replicator rate `x_i (f_i - sum_j x_j f_j)`.

        Args:
            namespace:
                The namespace of the symbols the rate can use, with the namespace of the target.

        Returns:
            rate:
                The rate of the share.
        """
        share = getattr(namespace.target, self.state)
        fitness = self._get_fitness(namespace)
        rate: Expression = share * (fitness - total(share * fitness))
        return rate

Equations

population.x[i]: diff(x_population(t), t) == (_reduced_0 - _reduced_1)*x_population(t)