Source code for modelly.zoo.age_seir.model

"""Age-structured SEIR epidemic with a contact matrix.

A population split into age groups, each person susceptible (S), exposed (E), infectious
(I) or recovered (R). A susceptible of group ``a`` is infected in one step with probability
``1 - exp(-beta * sum_b C[a, b] * I_b / N_b)``, where ``C[a, b]`` is the mean number of
contacts a member of ``a`` has with members of ``b`` per step (a contact matrix, as
measured by social-contact surveys or computed by
``modelly.space.networks.contact_matrix``). Exposed people become infectious with
probability ``incubation`` per step and infectious people recover with probability
``recovery`` per step.

Everybody in the same group and state faces the same probabilities, so the population is
a ``CohortTable`` with one row per (group, state): every transition is one binomial draw
per row, with exactly the law of the per-person draws (see ``docs/vectorised_phases.md``).
A million people cost the same as a hundred.

Validation: the final-size relation. Summing the force of infection over the epidemic,
``log(s_a) = log(s0_a) - (beta / recovery) * sum_b C[a, b] * (1 - s_b)``, where ``s_a`` is
the share of group ``a`` never infected; with one group this is the Kermack-McKendrick
relation ``s = s0 * exp(-R0 * (1 - s))``. ``final_size()`` solves it and the test compares
it with the simulated epidemic. ``r0()`` is ``beta / recovery`` times the spectral radius
of the next-generation matrix.

References:
    Kermack, W. O. and McKendrick, A. G. (1927). A contribution to the mathematical theory
    of epidemics. Proceedings of the Royal Society A, 115, 700-721.

    Mossong, J. et al. (2008). Social contacts and mixing patterns relevant to the spread
    of infectious diseases. PLoS Medicine, 5(3), e74.

    Andersson, H. and Britton, T. (2000). Stochastic Epidemic Models and Their Statistical
    Analysis. Lecture Notes in Statistics 151, Springer.
"""

from __future__ import annotations

from enum import IntEnum
from functools import partial

import numpy as np

from modelly.batch.cohorts import CohortTable
from modelly.core.model import Model
from modelly.data.collector import DataCollector
from modelly.scenarios.scenario import Scenario


[docs] class State(IntEnum): """Infection states.""" S = 0 E = 1 I = 2 # noqa: E741 - the epidemiological name R = 3
[docs] class SEIRScenario(Scenario): """Parameters of the age-structured SEIR model. The default contact matrix is illustrative (children, adults, seniors), not survey data; it is reciprocal, ``N[a] * C[a, b] == N[b] * C[b, a]``. Attributes: group_sizes: number of people in each age group. contacts: contact matrix ``C[a][b]``, contacts per step of a member of ``a`` with members of ``b``. beta: probability of transmission per contact with an infectious person. incubation: probability per step that an exposed person becomes infectious. recovery: probability per step that an infectious person recovers. initial_infected: infectious people at the start, per group. steps: default run length used by ``run``. """ group_sizes: tuple = (20_000, 60_000, 20_000) contacts: tuple = ((8.0, 6.0, 1.0), (2.0, 9.0, 1.0), (1.0, 3.0, 2.0)) beta: float = 0.035 incubation: float = 0.25 recovery: float = 0.2 initial_infected: tuple = (10, 10, 10) steps: int = 400
[docs] class AgeSEIR(Model): """SEIR over age groups, one cohort row per (group, state). Example: >>> model = AgeSEIR(SEIRScenario(rng=1, steps=50)) >>> model.run() >>> int(model.counts().sum()) == sum(model.scenario.group_sizes) True """ def __init__(self, scenario: SEIRScenario = SEIRScenario) -> None: """Seed the infectious people; everybody else is susceptible.""" super().__init__(scenario=scenario) s = self.scenario self.contacts = np.asarray(s.contacts, dtype=float) self.sizes = np.asarray(s.group_sizes, dtype=np.int64) self.n_groups = self.sizes.shape[0] groups = np.arange(self.n_groups) seeds = np.asarray(s.initial_infected, dtype=np.int64) self.people = CohortTable(self.rng, group=np.int16, state=np.int8) self.people.add(self.sizes - seeds, group=groups, state=State.S) self.people.add(seeds, group=groups, state=State.I) self.datacollector = DataCollector( model_reporters={ state.name: partial(count_in, state=state) for state in State } ) self.datacollector.collect(self)
[docs] def counts(self) -> np.ndarray: """People per (group, state), shape ``(n_groups, 4)``.""" p = self.people flat = p["group"].astype(np.int64) * len(State) + p["state"] totals = np.bincount( flat, weights=p["count"], minlength=self.n_groups * len(State) ) return totals.astype(np.int64).reshape(self.n_groups, len(State))
[docs] def force(self) -> np.ndarray: """Per-step hazard of infection of a susceptible in each group.""" infectious = self.counts()[:, State.I] / self.sizes return self.scenario.beta * self.contacts @ infectious
[docs] def step(self) -> None: """Recover, then progress, then infect, all from the start-of-step state.""" s = self.scenario p = self.people hazard = self.force() p.split(s.recovery, p["state"] == State.I, state=State.R) p.split(s.incubation, p["state"] == State.E, state=State.I) susceptible = p["state"] == State.S catch = 1.0 - np.exp(-hazard[p["group"][susceptible]]) p.split(catch, susceptible, state=State.E) p.merge() self.datacollector.collect(self)
[docs] def run(self, steps: int | None = None) -> None: """Run ``steps`` steps (the scenario's ``steps`` by default).""" self.run_for(self.scenario.steps if steps is None else steps)
[docs] def r0(self) -> float: """Basic reproduction number: spectral radius of the next-generation matrix.""" s = self.scenario ngm = (s.beta / s.recovery) * self.contacts return float(np.max(np.abs(np.linalg.eigvals(ngm))))
[docs] def final_size(self, tolerance: float = 1e-12) -> np.ndarray: """Share of each group ever infected, from the final-size relation.""" s = self.scenario s0 = 1.0 - np.asarray(s.initial_infected) / self.sizes ngm = (s.beta / s.recovery) * self.contacts escape = np.zeros(self.n_groups) for _ in range(_MAX_ITERATIONS): new = s0 * np.exp(-ngm @ (1.0 - escape)) if np.max(np.abs(new - escape)) < tolerance: break escape = new return 1.0 - new
[docs] def attack_rate(self) -> np.ndarray: """Share of each group ever infected so far.""" return 1.0 - self.counts()[:, State.S] / self.sizes
# Iterating from "nobody escapes" converges monotonically to the epidemic root. _MAX_ITERATIONS = 10_000
[docs] def count_in(model: AgeSEIR, state: State) -> int: """The number of people in ``state``, all groups together (a model reporter).""" return int(model.counts()[:, state].sum())