Skip to content

Moran process

The Moran process, a population of constant size where fitter traits fix by selection and drift.

  • Name: moran_process
  • Field: biology/evolution
  • Formalism: agent-based model
  • References: Moran 1958; Nowak 2006; Jensen 2023

Factory

@register_model(
    field='biology/evolution',
    tags=('population genetics', 'agents', 'fixation', 'genetic drift', 'mutation'),
    references=('Moran 1958', 'Nowak 2006', 'Jensen 2023'),
    defaults={'until': 100},
)
def moran_process(
    size: int = 50,
    *,
    mutants: int = 1,
    selection: float = 0.0,
    mutation: float = 0.0,
) -> System:
    """Return the Moran process, a population of constant size where fitter traits fix by selection and drift.

    The population starts with `mutants` individuals of trait 1 among individuals of trait 0. Without mutation one trait
    eventually fixes, the mutant with probability `(1 - 1 / r^m) / (1 - 1 / r^N)` for the relative fitness
    `r = 1 + selection`. With mutation the traits diffuse along the trait axis, in clusters rather than as a spreading
    Gaussian, which distinguishes evolutionary diffusion from a random walk.

    Args:
        size:
            The number of individuals.

        mutants:
            The initial number of individuals of trait 1.

        selection:
            The selective advantage of one unit of the trait.

        mutation:
            The probability that an offspring's trait mutates by one unit.

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

    Examples:
        >>> from skcomplex.models.biology.evolution import moran_process
        >>> float(moran_process(10, mutants=3).elements[0].values['trait'].sum())
        3.0
    """
    traits = np.zeros(size)
    traits[:mutants] = 1.0
    return System(
        elements=[
            GroupElement(
                'population',
                element=IndividualElement,
                size=size,
                values={'trait': traits, 'fitness': (1 + selection) ** traits},
                variable=True,
            ),
        ],
        interactions=[MoranStepInteraction('moran', target='population', advantage=selection, mutation=mutation)],
    )

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 MoranStepInteraction(EvolutionInteraction):
    """One generation of the Moran process: as many birth and death events as there are individuals.

    At every event an individual reproduces with probability proportional to its fitness `(1 + advantage)^trait`,
    its offspring mutates to the trait one above or one below with probability `mutation / 2` each, and it replaces an
    individual chosen uniformly at random, so the size of the population stays constant. It is the Moran replacement
    of `EvolutionInteraction` with proportional selection and steps of the trait.

    Args:
        label:
            The label of the interaction.

        target:
            The path of the group of individuals.

        advantage:
            The selective advantage of one unit of the trait.

        mutation:
            The probability that an offspring mutates.

        phase:
            The phase of the step the generation runs in, or `None` for the default phase.

    Examples:
        >>> from skcomplex.models.biology.evolution import MoranStepInteraction
        >>> MoranStepInteraction('moran', target='population', advantage=0.1).check().replacement
        'moran'
    """

    def __init__(
        self,
        label: str,
        *,
        target: Annotated[str, RoleSpec()],
        advantage: Annotated[float, ParameterSpec()] = 0.0,
        mutation: Annotated[float, ParameterSpec(domain=RealSet.closed(0, 1))] = 0.0,
        phase: str | None = None,
    ) -> None:
        super().__init__(
            label,
            target=target,
            strategy='trait',
            selection='proportional',
            replacement='moran',
            mutation=mutation,
            alphabet=None,
            change='step',
            phase=phase,
        )
        self.advantage = advantage

    def _evaluate(self, strategies: Array, model: ModelViewProtocol) -> Array:
        """Return the fitness `(1 + advantage)^trait` of every trait.

        Args:
            strategies:
                The traits.

            model:
                The model view of the interaction.

        Returns:
            fitness:
                The fitness of every trait.
        """
        return (1 + model.parameter('advantage')) ** np.ravel(strategies)