Skip to content

Finite two-level birth-death process of defectors and punishers

The finite two-level birth-death process of defectors and altruistic punishers in competing groups.

  • Name: two_level_birth_death
  • Field: social/cooperation
  • Formalism: agent-based system
  • References: Cooney 2024; Luo 2014

Select this system

from skcomplex.systems import create_simulation, create_system, describe

system = create_system('two_level_birth_death')
entry = describe('two_level_birth_death')
trajectory = create_simulation('two_level_birth_death').prepare().run().trajectory_

Factory

@register_system(
    field='social/cooperation',
    tags=('agents', 'multilevel selection', 'punishment', 'birth-death', 'gillespie'),
    references=('Cooney 2024', 'Luo 2014'),
    defaults={'until': 100},
)
def two_level_birth_death(
    groups: int = 50,
    *,
    members: int = 20,
    selection: float = 0.1,
    conflict_rate: float = 0.2,
    benefit: float = 2.0,
    cost: float = 1.0,
    punishment: float = 0.5,
    interaction_cost: float = 0.5,
    fixed_cost: float = 0.0,
    random_state: int | np.random.Generator | None = 0,
) -> System:
    """Return the finite two-level birth-death process of defectors and altruistic punishers in competing groups.

    The population has `m` groups of `n` members. Within groups, punishers and defectors reproduce at the rates
    `1 + w pi_P(z)` and `1 + w pi_D(z)` of the fraction `z` of punishers, with `pi_P = (b + k) z - (c + q + k)` and
    `pi_D = (b - p) z`, and the offspring replaces a random member of the group. Every group fights a random other
    group at the rate `Lambda` and wins with the probability `1/2 + (z - u) / 2`, and the winner's copy replaces the
    loser (Cooney 2024, arXiv 2405.18419 section 3.1 and appendix A.1). With many large groups, the density of the
    fractions of punishers follows `two_level_replicator` with `lambda = Lambda / w`, so between-group conflict
    sustains the punishers above `lambda* = c + q - p` and the defectors take over without it. The groups start with
    uniformly drawn numbers of punishers.

    Args:
        groups:
            The number `m` of groups.

        members:
            The number `n` of members of every group.

        selection:
            The intensity `w` of individual-level selection.

        conflict_rate:
            The rate `Lambda` at which a group fights another group.

        benefit:
            The benefit `b` of the donation game.

        cost:
            The cost `c` of cooperating.

        punishment:
            The punishment `p` of a defector by every punisher it meets.

        interaction_cost:
            The cost `k` a punisher pays per defector it meets.

        fixed_cost:
            The fixed cost `q` of being able to punish.

        random_state:
            The seed or generator of the initial groups.

    Returns:
        system:
            The system, with the group `groups`.

    Examples:
        >>> from skcomplex.systems.social.cooperation import two_level_birth_death
        >>> two_level_birth_death(10).elements[0].prepare().size_
        10
    """
    rng = np.random.default_rng(random_state)
    population = GroupElement(
        'groups',
        element=PunisherGroupElement,
        size=groups,
        values={'punishers': rng.integers(members + 1, size=groups).astype(float)},
    )
    competition = TwoLevelBirthDeathInteraction(
        'competition',
        target='groups',
        members=members,
        selection=selection,
        conflict_rate=conflict_rate,
        benefit=benefit,
        cost=cost,
        punishment=punishment,
        interaction_cost=interaction_cost,
        fixed_cost=fixed_cost,
    )
    return System(elements=[population], interactions=[competition])

Components

Elements:

  • PunisherGroupElement, from Element

Interactions:

  • TwoLevelBirthDeathInteraction, from Interaction
class PunisherGroupElement(Element):
    """Group of defectors and altruistic punishers of a fixed size, held by its number of punishers.

    Args:
        label:
            The label of the group or the population of groups.

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

        punishers:
            The number of altruistic punishers of the group.

    Examples:
        >>> from skcomplex.systems.social.cooperation import PunisherGroupElement
        >>> PunisherGroupElement('group', punishers=3.0).punishers
        3.0
    """

    def __init__(
        self,
        label: str,
        visual: VisualSpec | None = None,
        *,
        punishers: Annotated[float, StateSpec(domain=NN)] = 0.0,
    ) -> None:
        super().__init__(label, visual=visual)
        self.punishers = punishers

    @classmethod
    def _get_view(cls) -> type[PunisherGroupView]:
        """Return the class of the views of the agents, whose attributes are the states."""
        return PunisherGroupView

class TwoLevelBirthDeathInteraction(Interaction):
    """Two-level birth-death process of Luo and coauthors with pairwise conflicts between groups.

    Within a group of `n` members with `i` punishers, a punisher reproduces at the rate `1 + w pi_P(i / n)` and a
    defector at `1 + w pi_D(i / n)`, and the offspring replaces a random member of the group, the parent included.
    Every group fights a randomly drawn other group at the rate `Lambda`, wins with the probability
    `rho = 1/2 + (z - u) / 2` of the fractions of punishers, and the winner's copy replaces the loser. The events of
    every step of one unit of time are drawn exactly, by the Gillespie algorithm.

    Args:
        label:
            The label of the interaction.

        target:
            The path of the population of groups.

        members:
            The number `n` of members of every group.

        selection:
            The intensity `w` of individual-level selection.

        conflict_rate:
            The rate `Lambda` at which a group fights another group.

        benefit:
            The benefit `b` of the donation game.

        cost:
            The cost `c` of cooperating.

        punishment:
            The punishment `p` of a defector by every punisher it meets.

        interaction_cost:
            The cost `k` a punisher pays per defector it meets.

        fixed_cost:
            The fixed cost `q` of being able to punish.

    Examples:
        >>> from skcomplex.systems.social.cooperation import TwoLevelBirthDeathInteraction
        >>> TwoLevelBirthDeathInteraction('competition', target='groups').members
        20
    """

    def __init__(
        self,
        label: str,
        *,
        target: Annotated[str, RoleSpec()],
        members: Annotated[int, ParameterSpec(domain=NN)] = 20,
        selection: Annotated[float, ParameterSpec(domain=RealSet.closed_open(0, oo))] = 0.1,
        conflict_rate: Annotated[float, ParameterSpec(domain=RealSet.closed_open(0, oo))] = 0.2,
        benefit: Annotated[float, ParameterSpec()] = 2.0,
        cost: Annotated[float, ParameterSpec()] = 1.0,
        punishment: Annotated[float, ParameterSpec()] = 0.5,
        interaction_cost: Annotated[float, ParameterSpec()] = 0.5,
        fixed_cost: Annotated[float, ParameterSpec()] = 0.0,
    ) -> None:
        super().__init__(label)
        self.target = target
        self.members = members
        self.selection = selection
        self.conflict_rate = conflict_rate
        self.benefit = benefit
        self.cost = cost
        self.punishment = punishment
        self.interaction_cost = interaction_cost
        self.fixed_cost = fixed_cost

    def _get_laws(self, s: SimpleNamespace) -> list[BaseLaw]:
        """Return the birth-death rule.

        Args:
            s:
                The namespace of the symbols the laws can use.

        Returns:
            laws:
                The laws of the component.
        """
        return [RuleLaw(self.compete)]

    def _get_birth_rates(self, punishers: Array) -> tuple[Array, Array]:
        """Return the rates at which the punishers and the defectors of every group reproduce."""
        members = float(self.members)
        z = punishers / members
        payoff_punisher = (self.benefit + self.interaction_cost) * z - (
            self.cost + self.fixed_cost + self.interaction_cost
        )
        payoff_defector = (self.benefit - self.punishment) * z
        punisher_births = punishers * np.maximum(1 + self.selection * payoff_punisher, 0.0)
        defector_births = (members - punishers) * np.maximum(1 + self.selection * payoff_defector, 0.0)
        return punisher_births, defector_births

    def compete(self, system: SystemView) -> None:
        """Draw the births, deaths, and conflicts of one unit of time.

        Args:
            system:
                The system.
        """
        groups = system.agents(self.target, PunisherGroupView)
        size = len(groups)
        if size < PAIR:
            return
        rng = system.rng
        members = float(self.members)
        punishers = np.array([group.punishers for group in groups])
        time = 0.0
        while True:
            punisher_births, defector_births = self._get_birth_rates(punishers)
            rates = np.concatenate([punisher_births, defector_births, np.full(size, float(self.conflict_rate))])
            total = rates.sum()
            time += rng.exponential(1 / total)
            if time > 1.0:
                break
            event = int(np.searchsorted(np.cumsum(rates), rng.random() * total, side='right'))
            group, kind = event % size, event // size
            if kind == 0:
                punishers[group] += rng.random() >= punishers[group] / members
            elif kind == 1:
                punishers[group] -= rng.random() < punishers[group] / members
            else:
                other = int(rng.integers(size - 1))
                other += other >= group
                victory = 0.5 + (punishers[group] - punishers[other]) / (2 * members)
                winner, loser = (group, other) if rng.random() < victory else (other, group)
                punishers[loser] = punishers[winner]
        for group_view, value in zip(groups, punishers.tolist(), strict=True):
            group_view.punishers = value