Skip to content

Adaptive climbing in finite populations

A finite population that climbs a random fitness landscape by mutation and strong selection.

  • Name: adaptive_climbing
  • Field: biology/evolution
  • Formalism: agent-based model
  • References: Gros 2015

Factory

@register_model(
    field='biology/evolution',
    tags=('fitness landscape', 'agents', 'random energy model', 'stochastic escape'),
    references=('Gros 2015',),
    defaults={'until': 200},
)
def adaptive_climbing(
    size: int = 10,
    *,
    u: float = 0.1,
    length: int = 20,
    seed: int = 0,
) -> System:
    """Return a finite population that climbs a random fitness landscape by mutation and strong selection.

    The population climbs to a local optimum in an adaptive walk, ever slower near the top. When every individual
    mutates in the same generation, which is likely only in small populations with a large `u`, it escapes to a lower
    genome and starts a new walk, the wandering regime.

    Args:
        size:
            The number of individuals.

        u:
            The probability that an individual mutates in a generation.

        length:
            The number of loci of a genome.

        seed:
            The seed of the fitness landscape.

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

    Examples:
        >>> from skcomplex.models.biology.evolution import adaptive_climbing
        >>> adaptive_climbing(5).elements[0].check().prepare().size_
        5
    """
    fitness = AdaptiveClimbingInteraction._random_fitness(0, seed)
    return System(
        elements=[
            GroupElement(
                'population',
                element=IndividualElement,
                size=size,
                values={'trait': 0.0, 'fitness': fitness},
                variable=True,
                visual=VisualSpec('agents', {'color_by': 'fitness'}),
            ),
        ],
        interactions=[
            AdaptiveClimbingInteraction('climbing', target='population', mutation=u, length=length, seed=seed),
        ],
    )

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 AdaptiveClimbingInteraction(EvolutionInteraction):
    """One generation of a population that climbs a random fitness landscape as a whole, under strong selection.

    All individuals share one genome. Each mutates one random locus with probability `u`, to a genome of random fitness
    in [0, 1], with probability `mutation`. If a mutant is fitter, the whole population takes the fittest mutant. If
    every individual mutated to a less fit genome, the population escapes to the fittest of them. Otherwise it stays.
    It is the best-mutant
    replacement of `EvolutionInteraction` on genomes stored as the integers their bits spell.

    Args:
        label:
            The label of the interaction.

        target:
            The path of the group of individuals.

        mutation:
            The probability that an individual mutates in a generation.

        length:
            The number of loci of a genome.

        seed:
            The seed of the fitness landscape.

    Examples:
        >>> from skcomplex.models.biology.evolution import AdaptiveClimbingInteraction
        >>> AdaptiveClimbingInteraction('climbing', target='population', mutation=0.1).check().replacement
        'best-mutant'
    """

    def __init__(
        self,
        label: str,
        *,
        target: Annotated[str, RoleSpec()],
        mutation: Annotated[float, ParameterSpec(domain=RealSet.closed(0, 1))] = 0.1,
        length: int = 20,
        seed: int = 0,
    ) -> None:
        super().__init__(label, target=target, strategy='trait', replacement='best-mutant', mutation=mutation)
        self.length = length
        self.seed = seed

    def _evaluate(self, strategies: Array, model: ModelViewProtocol) -> Array:
        """Return the random fitness of every genome.

        Args:
            strategies:
                The genomes.

            model:
                The model view of the interaction.

        Returns:
            fitness:
                The fitness of every genome.
        """
        return np.array(
            [AdaptiveClimbingInteraction._random_fitness(int(genome), self.seed) for genome in np.ravel(strategies)],
        )

    def _mutate(self, children: Array, probability: float, rng: np.random.Generator, population: Array) -> Array:
        """Return the genomes, each with one random locus flipped with the probability of mutation.

        Args:
            children:
                The genomes, one row per individual.

            probability:
                The probability that a genome mutates.

            rng:
                The random number generator.

            population:
                The genomes of the population.

        Returns:
            children:
                The mutated genomes.
        """
        flips = (rng.random(len(children)) < probability).astype(np.int64)
        loci = rng.integers(self.length, size=len(children))
        return (children.astype(np.int64) ^ (flips << loci)[:, None]).astype(float)

    @staticmethod
    def _random_fitness(genome: int, seed: int) -> float:
        """Return the fitness of a genome in the random energy model, uniform in [0, 1] and fixed by the seed."""
        return float(np.random.default_rng([seed, genome]).random())