"""Axelrod's model of the dissemination of culture.
Every site of a square grid holds a culture: ``features`` features, each taking one of
``traits`` values. At each event a random site and a random neighbour interact with
probability equal to their cultural overlap (the share of features on which they agree);
when they interact, the site copies one of the features on which they differ. Similar
neighbours become more similar, and neighbours with nothing in common never interact, so
the grid freezes into regions of identical culture. Few traits per feature end in a single
culture; many traits leave many regions (the transition studied by Castellano, Marsili and
Vespignani).
The sites are the rows of a ``CellIndex`` over a von Neumann grid, the cultures one
``(n_sites, features)`` integer array. Events stay sequential, as in the original, but a
step pre-draws ``events_per_step`` sites, neighbours (``CellIndex.random_neighbor``) and
uniforms in three numpy calls and runs the events on plain lists. After each step the
model checks, over every bond at once, whether a pair with partial overlap is left; when
none is, the culture map is frozen and ``running`` turns ``False``. Cultural regions are
the connected components of the bonds with full overlap.
Validation: with five features on a 10 x 10 grid, two traits end in one region for most
seeds and thirty traits in many; the mean number of regions grows with the traits.
References:
Axelrod, R. (1997). The dissemination of culture: a model with local convergence and
global polarization. Journal of Conflict Resolution, 41(2), 203-226.
Castellano, C., Marsili, M. and Vespignani, A. (2000). Nonequilibrium phase transition
in a model for social influence. Physical Review Letters, 85(16), 3536-3539.
"""
from __future__ import annotations
import numpy as np
from modelly.core.model import Model
from modelly.data.collector import DataCollector
from modelly.scenarios.scenario import Scenario
from modelly.space.cells.index import CellIndex
from modelly.space.grid.grids import OrthogonalVonNeumannGrid
[docs]
class AxelrodScenario(Scenario):
"""Parameters of Axelrod's culture model.
Attributes:
width: grid width (sites).
height: grid height (sites).
features: cultural features per site.
traits: values each feature can take.
events_per_step: interactions attempted per step.
steps: maximum run length of ``run``; it stops earlier once the map is frozen.
"""
width: int = 10
height: int = 10
features: int = 5
traits: int = 10
events_per_step: int = 2_000
steps: int = 500
[docs]
class Axelrod(Model):
"""Sites copy cultural traits from similar neighbours until no bond is active.
Example:
>>> model = Axelrod(AxelrodScenario(rng=1, traits=2))
>>> model.run()
>>> model.running, model.n_regions()
(False, 1)
"""
def __init__(self, scenario: AxelrodScenario = AxelrodScenario) -> None:
"""Draw a uniformly random culture for every site."""
super().__init__(scenario=scenario)
s = self.scenario
self.grid = OrthogonalVonNeumannGrid((s.width, s.height), random=self.random)
self.index = CellIndex(self.grid)
shape = (self.index.n, s.features)
self.culture = self.rng.integers(0, s.traits, size=shape)
upper = self.index.adjacency.tocoo()
keep = upper.row < upper.col # each bond once
self.bonds = np.stack((upper.row[keep], upper.col[keep]), axis=1)
self.datacollector = DataCollector(
model_reporters={"regions": region_count, "active_bonds": active_bonds}
)
self.datacollector.collect(self)
[docs]
def step(self) -> None:
"""Run ``events_per_step`` events, then stop if no bond is active."""
s = self.scenario
sites = self.rng.integers(0, self.index.n, size=s.events_per_step)
partners = self.index.random_neighbor(sites, self.rng)
uniforms = self.rng.random((s.events_per_step, 2)).tolist()
culture = self.culture.tolist()
for site, partner, (accept, pick) in zip(
sites.tolist(), partners.tolist(), uniforms
):
interact(culture[site], culture[partner], accept, pick)
self.culture[:] = culture
if active_bonds(self) == 0:
self.running = False
self.datacollector.collect(self)
[docs]
def run(self, steps: int | None = None) -> None:
"""Step until the map is frozen, at most ``steps`` times (scenario default)."""
for _ in range(self.scenario.steps if steps is None else steps):
if not self.running:
break
self.step()
[docs]
def overlaps(self) -> np.ndarray:
"""Number of shared features across every bond."""
left, right = self.bonds[:, 0], self.bonds[:, 1]
return (self.culture[left] == self.culture[right]).sum(axis=1)
[docs]
def n_regions(self) -> int:
"""Connected regions of identical culture."""
from scipy.sparse import coo_matrix # noqa: PLC0415 - scipy is heavy
from scipy.sparse.csgraph import connected_components # noqa: PLC0415
same = self.bonds[self.overlaps() == self.scenario.features]
n = self.index.n
ones = np.ones(same.shape[0])
graph = coo_matrix((ones, (same[:, 0], same[:, 1])), shape=(n, n))
return int(connected_components(graph, directed=False)[0])
[docs]
def interact(site: list[int], partner: list[int], accept: float, pick: float) -> None:
"""One event: with probability overlap / F, ``site`` copies one differing feature."""
differ = [k for k, (a, b) in enumerate(zip(site, partner)) if a != b]
if not differ or len(differ) == len(site):
return
if accept < 1.0 - len(differ) / len(site):
feature = differ[int(pick * len(differ))]
site[feature] = partner[feature]
[docs]
def region_count(model: Axelrod) -> int:
"""The number of cultural regions (a model reporter)."""
return model.n_regions()
[docs]
def active_bonds(model: Axelrod) -> int:
"""Bonds whose sites share some but not all features (a model reporter)."""
overlap = model.overlaps()
return int(((overlap > 0) & (overlap < model.scenario.features)).sum())