Skip to content

Tangled Nature

The Tangled Nature model, where organisms whose types interact through random couplings coevolve.

  • Name: tangled_nature
  • Field: biology/evolution
  • Formalism: agent-based model
  • References: Christensen et al. 2002; Jensen 2023

Factory

@register_model(
    field='biology/evolution',
    tags=('coevolution', 'agents', 'punctuated equilibrium', 'ecosystems'),
    references=('Christensen et al. 2002', 'Jensen 2023'),
    defaults={'until': 50},
)
def tangled_nature(
    organisms: int = 100,
    *,
    k: float = 5.0,
    mu: float = 0.01,
    p_mut: float = 0.01,
    p_kill: float = 0.2,
    length: int = 8,
    seed: int = 0,
    random_state: int | np.random.Generator | None = 0,
) -> System:
    """Return the Tangled Nature model, where organisms whose types interact through random couplings coevolve.

    Organisms of one random type start the run. Mutants spread through the space of genomes, and the population
    settles into quasi-stable configurations of a few dominant types, the q-ESS, that end in abrupt transitions, a
    punctuated equilibrium. The trait of an organism holds the bits of its genome. A generation is `N / p_kill`
    visits to organisms drawn with replacement, where `N` is the population at its start.

    Args:
        organisms:
            The initial number of organisms.

        k:
            The strength of the couplings.

        mu:
            The limit of resources, which lowers reproduction in large populations.

        p_mut:
            The probability that a bit of an offspring's genome flips.

        p_kill:
            The probability that a chosen organism dies.

        length:
            The number of bits of a genome.

        seed:
            The seed of the couplings.

        random_state:
            The seed or generator of the initial type.

    Returns:
        system:
            The model, with the group `organisms`.

    Examples:
        >>> from skcomplex.models.biology.evolution import tangled_nature
        >>> tangled_nature(20).elements[0].check().prepare().size_
        20
    """
    genome = float(np.random.default_rng(random_state).integers(2**length))
    life_cycle = TangledNatureLifeCycleInteraction(
        'evolution',
        target=ORGANISMS,
        k=k,
        mu=mu,
        p_mut=p_mut,
        p_kill=p_kill,
        length=length,
        seed=seed,
    )
    visual = VisualSpec('histograms', {'color_by': 'trait', 'shown': ('distinct',)})
    return System(
        elements=[
            GroupElement(
                ORGANISMS,
                element=IndividualElement,
                size=organisms,
                values={'trait': genome},
                variable=True,
                visual=visual,
            ),
        ],
        interactions=[life_cycle],
        time=DiscreteTime(
            schedule=PhasesScheduleSpec([PhaseSpec('generation', activation='visits', visits=1 / p_kill)]),
        ),
    )

Components

class IndividualElement(Element):
    """Individual of a finite population, with an integer trait or genome.

    Args:
        label:
            The label of the agent or group.

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

        trait:
            The trait, a type index or the bits of a genome.

        fitness:
            The fitness of the trait.

    Examples:
        >>> from skcomplex.core.elements import GroupElement
        >>> from skcomplex.models.biology.evolution import IndividualElement
        >>> GroupElement('population', element=IndividualElement, size=10, values={'trait': 0.0}).size
        10
    """

    def __init__(
        self,
        label: str,
        visual: VisualSpec | None = VisualSpec('agents', {'color_by': 'trait'}),
        *,
        trait: Annotated[float, StateSpec(domain=ZZ)] = 0.0,
        fitness: Annotated[float, StateSpec(domain=RealSet.closed_open(0, oo))] = 1.0,
    ) -> None:
        super().__init__(label, visual=visual)
        self.trait = trait
        self.fitness = fitness

    @classmethod
    def _get_view(cls) -> type[IndividualView]:
        """Return the view of an individual, `IndividualView`."""
        return IndividualView

class TangledNatureLifeCycleInteraction(LifeCycleInteraction):
    """Life cycle of the organisms of the Tangled Nature model, run by the visits of a phase drawn with replacement.

    A visited organism of type `S` reproduces with probability `1 / (1 + exp(-H(S)))`, where
    `H(S) = k / N sum_S' J(S, S') n(S') - mu N` reads the composition `n` of the population of `N` organisms, and is
    replaced by two offspring, whose genome bits flip with probability `p_mut` each. Then a random organism dies with
    probability `p_kill`. The couplings `J` are uniform in [-1, 1] between different types, 0 within a type, and fixed
    by `seed`.

    Args:
        label:
            The label of the life cycle.

        target:
            The path of the variable group of organisms.

        k:
            The strength of the couplings.

        mu:
            The limit of resources, which lowers reproduction in large populations.

        p_mut:
            The probability that a bit of an offspring's genome flips.

        p_kill:
            The probability that a visit kills a random organism.

        length:
            The number of bits of a genome.

        seed:
            The seed of the couplings.

        phase:
            The phase whose visits run the life cycle.

    Examples:
        >>> from skcomplex.models.biology.evolution import TangledNatureLifeCycleInteraction
        >>> TangledNatureLifeCycleInteraction('evolution', target='organisms').check().offspring
        2
    """

    def __init__(
        self,
        label: str,
        *,
        target: Annotated[str, RoleSpec()],
        k: Annotated[float, ParameterSpec(domain=RealSet.closed_open(0, oo))] = 5.0,
        mu: Annotated[float, ParameterSpec(domain=RealSet.closed_open(0, oo))] = 0.01,
        p_mut: Annotated[float, ParameterSpec(domain=RealSet.closed_open(0, oo))] = 0.01,
        p_kill: Annotated[float, ParameterSpec(domain=RealSet.closed_open(0, oo))] = 0.2,
        length: int = 8,
        seed: int = 0,
        phase: str | None = 'generation',
    ) -> None:
        super().__init__(
            label,
            target=target,
            birth=self.compute_birth,
            offspring=2,
            inherit=('trait',),
            mutate=self.mutate_genome,
            schedule='member',
            phase=phase,
            replace=True,
            cull=self.compute_kill,
            pick='random',
        )
        self.k = k
        self.mu = mu
        self.p_mut = p_mut
        self.p_kill = p_kill
        self.length = length
        self.seed = seed

    @staticmethod
    @lru_cache(maxsize=16)
    def _get_couplings(seed: int, length: int) -> Array:
        """Return the random couplings `J(S, S')`, uniform in [-1, 1] and 0 within a type."""
        size = 2**length
        couplings = np.random.default_rng(seed).uniform(-1.0, 1.0, size=(size, size))
        np.fill_diagonal(couplings, 0.0)
        return couplings

    def compute_birth(self, agent: IndividualView, model: ModelView) -> float:
        """Return the probability `1 / (1 + exp(-H(S)))` that an organism of type `S` reproduces.

        Args:
            agent:
                The organism.

            model:
                The model view of the life cycle.

        Returns:
            probability:
                The probability of reproduction.
        """
        composition = model.composition('target', 'trait')
        size = sum(composition.values())
        row = self._get_couplings(self.seed, self.length)[int(agent.trait)]
        coupling = sum(row[int(np.asarray(genome))] * count for genome, count in composition.items())
        return float(expit(model.parameter('k') / size * coupling - model.parameter('mu') * size))

    def compute_kill(self, agent: IndividualView, model: ModelView) -> float:
        """Return the probability `p_kill` that a visit kills a random organism.

        Args:
            agent:
                The visited organism.

            model:
                The model view of the life cycle.

        Returns:
            probability:
                The probability of a death.
        """
        return model.parameter('p_kill')

    def mutate_genome(self, values: dict[str, object], model: ModelViewProtocol) -> dict[str, object]:
        """Return the values of an offspring whose every genome bit flips with probability `p_mut`.

        Args:
            values:
                The values of the offspring, with the genome of its parent as `trait`.

            model:
                The model view of the life cycle.

        Returns:
            values:
                The values with the mutated genome.
        """
        genome = int(np.asarray(values['trait'], dtype=float))
        flips = np.flatnonzero(model.rng.random(self.length) < model.parameter('p_mut'))
        return {**values, 'trait': float(genome ^ sum(1 << int(locus) for locus in flips))}