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
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)
