Skip to content

Simplicial contagion

The simplicial contagion of Iacopini et al., an SIS process on the links and triangles of a complex.

  • Name: simplicial_contagion
  • Field: social/spreading
  • Formalism: jump process
  • References: Iacopini et al. 2019; Majhi et al. 2022

Factory

@register_model(
    field='social/spreading',
    tags=('contagion', 'network', 'hypergraph', 'simplicial complex', 'bistability'),
    references=('Iacopini et al. 2019', 'Majhi et al. 2022'),
    defaults={'until': 20.0, 'dt': 0.5},
    observables=lambda system: [ObservableSpec('prevalence', group='nodes', variable='infected')],
)
def simplicial_contagion(
    n_nodes: int = 200,
    *,
    infectivity: float = 0.75,
    triangle_infectivity: float = 3.0,
    mean_degree: float = 20.0,
    mean_triangles: float = 6.0,
    recovery: float = 1.0,
    share: float = 0.4,
    seed: int = 0,
) -> System:
    """Return the simplicial contagion of Iacopini et al., an SIS process on the links and triangles of a complex.

    A random simplicial complex joins every pair of nodes with probability `p1` and fills every triple with probability
    `p2`, chosen so that a node has `mean_degree` links and `mean_triangles` triangles on average. A susceptible node is
    infected at rate `beta = infectivity * recovery / <k>` by every infected neighbour, and at rate `beta_triangle =
    triangle_infectivity * recovery / <k_triangle>` by every triangle whose two other members are infected, and an
    infected node recovers at rate `recovery`. The mean-field prevalence follows `rho' = -rho + infectivity rho (1 -
    rho) + triangle_infectivity rho^2 (1 - rho)` in units of the recovery time. For `triangle_infectivity > 1` and
    infectivities below the epidemic threshold 1, an endemic state coexists with the healthy one, so the epidemic
    dies out from a small seed and persists from a large one.

    Args:
        n_nodes:
            The number of nodes.

        infectivity:
            The rescaled rate of infection through links.

        triangle_infectivity:
            The rescaled rate of infection through triangles.

        mean_degree:
            The mean number of links of a node, which sets `p1` together with the sides of the triangles.

        mean_triangles:
            The mean number of triangles of a node, which sets `p2`.

        recovery:
            The rate of recovery.

        share:
            The share of infected nodes at the start.

        seed:
            The seed of the complex and the infected nodes.

    Returns:
        system:
            The model, with the states `nodes.infected` and the prevalence `prevalence`.

    Examples:
        >>> from skcomplex.models.social.spreading import simplicial_contagion
        >>> system = simplicial_contagion(30, mean_degree=6.0, mean_triangles=2.0)
        >>> system.elements[0].check().prepare().size_
        30
    """
    pairs = math.comb(n_nodes - 1, PAIR - 1)
    triples = math.comb(n_nodes - 1, TRIANGLE - 1)
    triangles = min(mean_triangles / triples, 1.0)
    links = min(max(mean_degree - 2 * mean_triangles, 0.0) / pairs, 1.0)
    graph, hyperedges = _simplicial_complex(n_nodes, links, triangles, seed)
    sizes = [len(members) for members in hyperedges]
    degree = max(PAIR * sizes.count(PAIR) / n_nodes, 1.0)
    shared = max(TRIANGLE * sizes.count(TRIANGLE) / n_nodes, 1.0)
    rng = np.random.default_rng(seed + 1)
    infected = (rng.permutation(n_nodes) < round(share * n_nodes)).astype(float)
    nodes = LatticeElement(
        'nodes',
        element=InfectiveElement,
        space=NetworkSpace(graph, hyperedges=hyperedges),
        values={'infected': infected, 'recovery': recovery},
        visual=VisualSpec('sites', {'states': ['infected'], 'low': 0.0, 'high': 1.0}),
    )
    infection = SimplicialInfectionInteraction(
        'infection',
        target='nodes',
        beta=infectivity * recovery / degree,
        beta_triangle=triangle_infectivity * recovery / shared,
    )
    return System(elements=[nodes], interactions=[infection])

Components

class InfectiveElement(Element):
    """Node of a contagion, susceptible at 0 and infected at 1, that recovers at a rate.

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

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

        infected:
            The initial state, 0 or 1.

        recovery:
            The rate at which an infected node recovers.

    Examples:
        >>> from skcomplex.models.social.spreading import InfectiveElement
        >>> InfectiveElement('node').recovery
        1.0
    """

    def __init__(
        self,
        label: str,
        visual: VisualSpec | None = None,
        *,
        infected: Annotated[float, StateSpec(domain=FiniteEnumeratedSet([0, 1]))] = 0.0,
        recovery: Annotated[float, ParameterSpec(domain=RealSet.closed_open(0, oo))] = 1.0,
    ) -> None:
        super().__init__(label, visual=visual)
        self.infected = infected
        self.recovery = recovery

    def _get_laws(self, s: SimpleNamespace) -> list[BaseLaw]:
        """Return the recovery of an infected node."""
        return [JumpLaw(s.recovery * where(s.infected == 1, 1, 0), {s.infected: -1})]

class SimplicialInfectionInteraction(HyperedgeInteraction):
    """Infection of a susceptible node by the other members of a link or a filled triangle, when all are infected.

    A susceptible node of a link is infected at rate `beta` when its neighbour is infected, and a susceptible node of a
    triangle at rate `beta_triangle` when both other members are infected.

    Args:
        label:
            The label of the interaction, unique among the parts of its system.

        target:
            The path of the group of nodes.

        beta:
            The rate of infection through a link.

        beta_triangle:
            The rate of infection through a triangle.

    Examples:
        >>> from skcomplex.models.social.spreading import SimplicialInfectionInteraction
        >>> SimplicialInfectionInteraction('infection', target='nodes', beta_triangle=0.5).beta_triangle
        0.5
    """

    def __init__(
        self,
        label: str,
        *,
        target: Annotated[str, RoleSpec()],
        beta: Annotated[float, ParameterSpec(domain=RealSet.closed_open(0, oo))] = 0.1,
        beta_triangle: Annotated[float, ParameterSpec(domain=RealSet.closed_open(0, oo))] = 0.1,
    ) -> None:
        super().__init__(label)
        self.target = target
        self.beta = beta
        self.beta_triangle = beta_triangle

    def _get_laws(self, s: SimpleNamespace) -> list[BaseLaw]:
        """Return the infection of the focal node when every other member of its link or triangle is infected."""
        infected = s.member.infected
        rate = s.beta if len(s.others) + 1 == PAIR else s.beta_triangle
        exposed = math.prod((other.infected for other in s.others), start=where(infected == 0, 1, 0))
        return [JumpLaw(rate * exposed, {infected: 1})]