Skip to content

NK fitness landscapes

Agents that climb Kauffman's NK fitness landscape from random genomes until they reach local peaks.

  • Name: nk_landscape
  • Field: biology/evolution
  • Formalism: agent-based model
  • References: Kauffman and Levin 1987; Downey 2018; Thurner et al. 2018

Factory

@register_model(
    field='biology/evolution',
    tags=('fitness landscape', 'agents', 'NK model', 'hill climbing', 'ruggedness'),
    references=('Kauffman and Levin 1987', 'Downey 2018', 'Thurner et al. 2018'),
    defaults={'until': 50},
)
def nk_landscape(
    climbers: int = 50,
    *,
    n: int = 10,
    k: int = 2,
    seed: int = 0,
    random_state: int | np.random.Generator | None = 0,
) -> System:
    """Return agents that climb Kauffman's NK fitness landscape from random genomes until they reach local peaks.

    The landscape grows more rugged with `k`, from a single peak at `k = 0` to a random landscape at `k = n - 1`, so
    the climbers end on more peaks of lower fitness.

    Args:
        climbers:
            The number of agents.

        n:
            The number of bits of a genome.

        k:
            The number of other bits each bit interacts with.

        seed:
            The seed of the landscape.

        random_state:
            The seed or generator of the initial genomes.

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

    Examples:
        >>> from skcomplex.models.biology.evolution import nk_landscape
        >>> nk_landscape(5).elements[0].check().prepare().size_
        5
    """
    genomes = np.random.default_rng(random_state).integers(0, 2**n, size=climbers)
    fitness = [ClimberElement._nk_fitness(int(genome), n, k, seed) for genome in genomes]
    group = GroupElement(
        'climbers',
        element=ClimberElement,
        size=climbers,
        values={
            'genome': genomes.astype(float),
            'fitness': np.asarray(fitness),
            'n': float(n),
            'k': float(k),
            'seed': float(seed),
        },
        variable=True,
    )
    return System(elements=[group])

Components

class ClimberElement(Element):
    """Agent that walks an NK fitness landscape, flipping one random bit of its genome when that raises its fitness.

    The fitness of a genome of `n` bits is the mean of the contributions of its bits, each a random function of the bit
    and the next `k` bits, fixed by `seed`.

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

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

        genome:
            The bits of the genome.

        fitness:
            The fitness of the genome.

        n:
            The number of bits.

        k:
            The number of other bits each bit interacts with.

        seed:
            The seed of the landscape.

    Examples:
        >>> from skcomplex.core.elements import GroupElement
        >>> from skcomplex.models.biology.evolution import ClimberElement
        >>> GroupElement('climbers', element=ClimberElement, size=10).size
        10
    """

    def __init__(
        self,
        label: str,
        visual: VisualSpec | None = VisualSpec('agents', {'color_by': 'fitness', 'color_range': (0.0, 1.0)}),
        *,
        genome: Annotated[float, StateSpec(domain=NN)] = 0.0,
        fitness: Annotated[float, StateSpec(domain=RealSet.closed_open(0, oo))] = 0.0,
        n: Annotated[float, ParameterSpec(domain=RealSet.open(0, oo))] = 10.0,
        k: Annotated[float, ParameterSpec(domain=NN)] = 2.0,
        seed: Annotated[float, ParameterSpec(domain=NN)] = 0.0,
    ) -> None:
        super().__init__(label, visual=visual)
        self.genome = genome
        self.fitness = fitness
        self.n = n
        self.k = k
        self.seed = seed

    @classmethod
    def _get_view(cls) -> type[ClimberView]:
        """Return the view of a climber, `ClimberView`."""
        return ClimberView

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

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

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

    def climb(self, agent: ClimberView, model: ModelView) -> None:
        """Flip a random bit, and keep the flip when it raises the fitness.

        Args:
            agent:
                The climber.

            model:
                The model.
        """
        n, k, seed = int(agent.n), int(agent.k), int(agent.seed)
        mutant = int(agent.genome) ^ (1 << int(model.rng.integers(n)))
        fitness = ClimberElement._nk_fitness(mutant, n, k, seed)
        if fitness > agent.fitness:
            agent.genome = float(mutant)
            agent.fitness = fitness

    @staticmethod
    @lru_cache(maxsize=16)
    def _nk_tables(seed: int, n: int, k: int) -> Array:
        """Return the fitness contributions of every locus for every configuration of itself and its `k` neighbours."""
        return np.random.default_rng(seed).random((n, 2 ** (k + 1)))

    @staticmethod
    def _nk_fitness(genome: int, n: int, k: int, seed: int) -> float:
        """Return the NK fitness of a genome, the mean contribution of its loci, each read with the next `k` loci."""
        tables = ClimberElement._nk_tables(seed, n, k)
        bits = [(genome >> locus) & 1 for locus in range(n)]
        total = 0.0
        for locus in range(n):
            index = sum(bits[(locus + offset) % n] << offset for offset in range(k + 1))
            total += tables[locus, index]
        return total / n