NKCS coevolution on coupled NK landscapes
The NKCS model of Kauffman and Johnsen, species climbing NK landscapes coupled to each other's genes.
- Name:
nkcs - Field:
biology/evolution - Formalism: agent-based system
- References: Kauffman and Johnsen 1991; Thurner et al. 2018
Select this system
from skcomplex.systems import create_simulation, create_system, describe
system = create_system('nkcs')
entry = describe('nkcs')
trajectory = create_simulation('nkcs').prepare().run().trajectory_
Factory
@register_system(
field='biology/evolution',
tags=('coevolution', 'fitness landscape', 'avalanches', 'criticality', 'agents'),
references=('Kauffman and Johnsen 1991', 'Thurner et al. 2018'),
defaults={'until': 200},
)
def nkcs(
n_species: int = 10,
*,
n: int = 12,
k: int = 2,
c: int = 1,
s: int = 2,
seed: int = 0,
random_state: int | np.random.Generator | None = 0,
) -> System:
"""Return the NKCS model of Kauffman and Johnsen, species climbing NK landscapes coupled to each other's genes.
Every gene of a species depends on `k` genes of its own species and on `c` genes in each of `s` partner species.
A species keeps a point mutation when it raises its fitness, which changes the landscapes of the species that
depend on it, so adaptations trigger coevolutionary avalanches. Rugged landscapes, `k` above about `c s`, freeze
the ecosystem once every species sits on a peak, and smoother ones keep it in a chaotic regime of endless
adaptation.
Args:
n_species:
The number of species.
n:
The number of genes of every species.
k:
The number of genes of its own species each gene depends on.
c:
The number of genes of every partner species each gene depends on.
s:
The number of partner species of every species.
seed:
The seed of the landscapes and the couplings.
random_state:
The seed or generator of the initial genomes.
Returns:
system:
The system, with the genomes `species.genome`, fitnesses `species.fitness`, and accepted mutations
`species.moves` of the group `species`.
Examples:
>>> from skcomplex.systems.biology.evolution import nkcs
>>> nkcs(5).elements[0].prepare().size_
5
"""
genomes = np.random.default_rng(random_state).integers(0, 2**n, size=n_species).astype(float)
species = GroupElement('species', element=CoevolvingSpeciesElement, size=n_species, values={'genome': genomes})
climbing = CoevolutionaryClimbingInteraction('climbing', species='species', n=n, k=k, c=c, s=s, seed=seed)
return System(elements=[species], interactions=[climbing], time=DiscreteTime())
Components
Elements:
CoevolvingSpeciesElement, fromElement
Interactions:
CoevolutionaryClimbingInteraction, fromInteraction
class CoevolvingSpeciesElement(Element):
"""Species of an NKCS ecosystem, a genome whose fitness depends on its own genes and on genes of other species.
A `CoevolutionaryClimbingInteraction` mutates the genome and sets the fitness and the number of accepted moves of
the last step.
Args:
label:
The label of the species or of the group of species.
visual:
How the element is drawn, or `None` for `default_visual_`.
genome:
The bits of the genome, as an integer.
fitness:
The fitness of the genome against the current genomes of the other species.
moves:
Whether the species accepted a mutation in the last step.
Examples:
>>> from skcomplex.systems.biology.evolution import CoevolvingSpeciesElement
>>> CoevolvingSpeciesElement('species', genome=5.0).genome
5.0
"""
def __init__(
self,
label: str,
visual: VisualSpec | None = None,
*,
genome: Annotated[float, StateSpec(domain=NN)] = 0.0,
fitness: Annotated[float, StateSpec(domain=RealSet.closed_open(0, oo))] = 0.0,
moves: Annotated[float, StateSpec(domain=NN)] = 0.0,
) -> None:
super().__init__(label, visual=visual)
self.genome = genome
self.fitness = fitness
self.moves = moves
@classmethod
def _get_view(cls) -> type[CoevolvingSpeciesView]:
"""Return the view of a species, `CoevolvingSpeciesView`."""
return CoevolvingSpeciesView
class CoevolutionaryClimbingInteraction(Interaction):
"""Hill climbing of species on coupled NK landscapes, one point mutation per species and step, the NKCS model.
Every gene of a species contributes to its fitness through a random table of its own state, the states of the next
`k` genes of the species, and the states of `c` random genes in each of `s` other species. The fitness of a species
is the mean contribution of its `n` genes. At every step the species are picked in turn, and each flips one random
bit and keeps the flip when it raises its fitness against the current genomes of the others. The tables, the
partner species, and the coupled genes are fixed by `seed`.
Args:
label:
The label of the interaction.
species:
The path of the group of species.
n:
The number of genes of every species.
k:
The number of genes of its own species each gene depends on.
c:
The number of genes of every partner species each gene depends on.
s:
The number of partner species of every species.
seed:
The seed of the landscapes and the couplings.
Examples:
>>> from skcomplex.systems.biology.evolution import CoevolutionaryClimbingInteraction
>>> CoevolutionaryClimbingInteraction('climbing', species='species', k=4).k
4
"""
def __init__(
self,
label: str,
*,
species: Annotated[str, RoleSpec()],
n: int = 12,
k: int = 2,
c: int = 1,
s: int = 2,
seed: int = 0,
) -> None:
super().__init__(label)
self.species = species
self.n = n
self.k = k
self.c = c
self.s = s
self.seed = seed
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, system: SystemView) -> None:
"""Let every species in turn try one point mutation, and update the fitness of all.
Args:
system:
The system.
"""
species = system.agents(self.species, CoevolvingSpeciesView)
genomes = [int(item.genome) for item in species]
for index, item in enumerate(species):
mutant = genomes[index] ^ (1 << int(system.rng.integers(self.n)))
trial = [*genomes[:index], mutant, *genomes[index + 1 :]]
accepted = self._fitness(index, trial) > self._fitness(index, genomes)
if accepted:
genomes[index] = mutant
item.moves = float(accepted)
for index, item in enumerate(species):
item.genome = float(genomes[index])
item.fitness = self._fitness(index, genomes)
def _fitness(self, index: int, genomes: list[int]) -> float:
"""Return the fitness of a species, the mean contribution of its genes at the current genomes."""
partners, genes, tables = self._landscape(len(genomes), (self.n, self.k, self.c, self.s, self.seed))
own = genomes[index]
total = 0.0
for locus in range(self.n):
bits = [(own >> ((locus + offset) % self.n)) & 1 for offset in range(self.k + 1)]
for slot, partner in enumerate(partners[index]):
bits.extend((genomes[int(partner)] >> int(gene)) & 1 for gene in genes[index, locus, slot])
key = sum(bit << position for position, bit in enumerate(bits))
total += tables[index, locus, key]
return total / self.n
@staticmethod
@lru_cache(maxsize=8)
def _landscape(size: int, settings: tuple[int, int, int, int, int]) -> tuple[Array, Array, Array]:
"""Return the partner species, the coupled genes of every gene, and the tables, from `(n, k, c, s, seed)`."""
n, k, c, s, seed = settings
rng = np.random.default_rng(seed)
others = [np.delete(np.arange(size), index) for index in range(size)]
partners = np.array([rng.choice(choices, size=s, replace=False) for choices in others]).reshape(size, s)
genes = rng.integers(0, n, size=(size, n, s, c))
tables = rng.random((size, n, 2 ** (1 + k + c * s)))
return partners.astype(float), genes.astype(float), tables