Measles in England and Wales — Spatial SEIR Model¶
Dataset¶
This notebook uses the England and Wales measles dataset compiled by Grenfell, Bjørnstad, and Kappey (2001) and further analysed by Conlan et al. (2010). It covers 954 cities and towns in England and Wales during the pre-vaccine era, 1944–1964, with case counts reported at bi-weekly intervals (26 reports per year, 546 reporting periods total).
Each location in the dataset provides:
- Annual population estimates and crude birth rates (1944–1964)
- Bi-weekly observed measles case counts
- Geographic coordinates (latitude, longitude)
A companion 954 × 954 matrix of pairwise geodesic distances (km) between all cities is also provided.
Modelling goals¶
We simulate spatial SEIR dynamics across all 954 cities simultaneously using
laser.cohorts. Key model features:
- Gravity-model spatial coupling — the force of infection in each city receives contributions from infectious individuals in other cities, weighted by population size and inverse distance.
- Seasonal forcing — transmission rate follows a sinusoidal annual cycle, peaking in mid-January to reflect school-term contact patterns.
- Vital dynamics — births (via
ConstantPopBirths) replenish the susceptible pool; non-disease mortality (viaNonDiseaseMortality) removes individuals from all compartments at the crude birth rate. - External importation — a small
Importationintervention periodically seeds one infectious individual into every city, preventing extinction in small towns.
Note: The cohort model tracks population counts (integers) rather than individual agents. This makes it extremely fast for 954-node simulations but means that sub-integer state transitions are rounded stochastically.
Citations¶
- Grenfell BT, Bjørnstad ON, Kappey J. Travelling waves and spatial hierarchies in measles epidemics. Nature. 2001;414:716–723.
- Conlan AJ, Rohani P, Lloyd AL, Keeling M, Grenfell BT. Resolving the impact of waiting time distributions on the persistence of measles. J R Soc Interface. 2010;7(45):623–640.
import sys
from pathlib import Path
import numpy as np
import matplotlib.pyplot as plt
from laser.core import PropertySet
from laser.core.utils import grid
from laser.core.migration import gravity as gravity_model
from laser.generic.utils import ValuesMap
from laser.cohorts import Model, Campaign, Intervention
from laser.cohorts.vitaldynamics import NonDiseaseMortality, ConstantPopBirths
import laser.cohorts.SEIR as SEIR
data_dir = Path("data")
sys.path.insert(0, str(data_dir))
from EnglandWalesMeasles import data as engwal
distances = np.load(data_dir / "EnglandWalesDistances.npy")
print(f"Places: {len(engwal.placenames)}")
print(f"Years: {engwal.years[0]+1900} – {engwal.years[-1]+1900}")
print(f"Reporting periods: {len(engwal.reports)}")
print(f"Distances matrix: {distances.shape}")
Places: 954 Years: 1944 – 1964 Reporting periods: 1096 Distances matrix: (954, 954)
placenames = engwal.placenames
n_places = len(placenames)
# Per-city populations and birth rates across 1944-1964
populations_annual = np.array([engwal.places[p].population for p in placenames]) # (954, 21)
births_annual = np.array([engwal.places[p].births for p in placenames]) # (954, 21)
# Annual birth rate per capita per city
cbr_annual = births_annual / populations_annual # (954, 21) — dimensionless fraction per year
# Latitude and longitude for mapping
latitudes = np.array([engwal.places[p].latitude for p in placenames])
longitudes = np.array([engwal.places[p].longitude for p in placenames])
# Observed bi-weekly cases per city — (954, 546)
obs_cases = np.array([engwal.places[p].cases for p in placenames])
print(f"Population range: {populations_annual[:,0].min():,} \u2013 {populations_annual[:,0].max():,}")
print(f"Mean annual CBR: {cbr_annual.mean():.4f}")
Population range: 777 – 2,462,500 Mean annual CBR: 0.0166
# 1944 populations and birth rates
pop_1944 = populations_annual[:, 0].astype(np.float64) # (954,)
cbr_1944 = cbr_annual[:, 0] # (954,) annual fraction
# Seed infection: use first bi-weekly report as initial I estimate
initial_cases = obs_cases[:, 0].astype(np.float64) # (954,)
# SEIR initial conditions (approximate):
# R0 ≈ 15 → endemic S* ≈ N/R0
R0_approx = 15.0
S_frac = 1.0 / R0_approx # ~6.7% susceptible at endemic equilibrium
I0 = np.maximum(initial_cases, 1).astype(np.int32) # at least 1 per city
E0 = I0.copy() # assume similar number exposed
S0 = np.maximum((pop_1944 * S_frac).astype(np.int32) - I0 - E0, 0)
R0_state = (pop_1944.astype(np.int32) - S0 - E0 - I0) # remainder immune
print("Initial conditions:")
print(f" Total N: {pop_1944.sum():,.0f}")
print(f" Total S: {S0.sum():,}")
print(f" Total E: {E0.sum():,}")
print(f" Total I: {I0.sum():,}")
print(f" Total R: {R0_state.sum():,}")
Initial conditions: Total N: 30,182,538 Total S: 2,009,335 Total E: 1,232 Total I: 1,232 Total R: 28,170,739
# Gravity model parameters (tunable)
k_gravity = 5e-4 # coupling strength
a_gravity = 0.0 # origin-population exponent (0 = no origin-size weighting)
b_gravity = 1.0 # destination-population exponent
c_gravity = 2.0 # distance decay exponent
# laser.core.migration.gravity computes:
# network[i,j] = k * pop[i]^a * pop[j]^b / distance[i,j]^c
# (diagonal set to 1 internally to avoid div-by-zero, then zeroed in output)
gravity_matrix = gravity_model(
pop_1944, distances,
k=k_gravity, a=a_gravity, b=b_gravity, c=c_gravity,
)
print(f"Gravity matrix shape: {gravity_matrix.shape}")
print(f"Max off-diagonal entry: {gravity_matrix.max():.3e}")
Gravity matrix shape: (954, 954) Max off-diagonal entry: 4.949e+01
# Simulation: 20 years starting 1944
nticks = 20 * 365 # 7300 days
# Measles SEIR parameters
sigma_val = 1.0 / 12.0 # E→I progression rate (12-day incubation)
gamma_val = 1.0 / 5.0 # I→R recovery rate (5-day infectious period)
beta0 = sigma_val * R0_approx # baseline transmission rate
# Seasonal forcing: sinusoidal with peak in mid-January.
# Day 15 = Jan 15 peak (day 0 = Jan 1 of first year).
epsilon = 0.3 # seasonal amplitude
t = np.arange(nticks)
seasonal_1d = 1.0 + epsilon * np.cos(2.0 * np.pi * (t - 15) / 365.0)
seasonal_1d = np.maximum(seasonal_1d, 0.0) # clip to non-negative
# Tile to (nticks, n_places) — same seasonality everywhere
seasonality = np.tile(seasonal_1d[:, None], (1, n_places)) # (7300, 954)
# Per-city daily mortality rate from 1944 data (convert annual fraction → daily)
r_mort_per_city = cbr_1944 / 365.0 # (954,)
r_mortality_2d = np.tile(r_mort_per_city[None, :], (nticks, 1)) # (7300, 954)
print(f"beta\u2080 = {beta0:.4f}/day, \u03c3 = {sigma_val:.4f}/day, \u03b3 = {gamma_val:.4f}/day")
print(f"R\u2080 (approx) = {beta0 / gamma_val:.1f}")
print(f"Seasonal factor range: {seasonal_1d.min():.3f} \u2013 {seasonal_1d.max():.3f}")
beta₀ = 1.2500/day, σ = 0.0833/day, γ = 0.2000/day R₀ (approx) = 6.2 Seasonal factor range: 0.700 – 1.300
class Importation(Intervention):
"""Seed infectious individuals directly into specified nodes.
Simulates external reintroduction of measles from outside the modelled
population. Each execution increments the I compartment of targeted
nodes by `count` individuals at the *next* tick (tick + 1).
Args:
model: The parent Model instance (passed by Campaign).
Schedule parameters:
count (int): Number of infectious individuals to import per node
per firing. Defaults to 1.
"""
@property
def states(self):
return []
@property
def properties(self):
return []
def execute(self, tick, who, where, params, notes):
count = int(params.get("count", 1))
target = where if where is not None else list(range(len(self.model.scenario)))
I_idx = self.model.states.get_state_index("I")
if I_idx is None:
return
for node in target:
self.model.states[tick + 1][I_idx, node] += count
Campaign.register(Importation)
# Import 1 case into every city every 30 days
import_period = 30
when_ticks = list(range(0, nticks, import_period))
schedule = [{"who": "*", "what": "Importation", "when": when_ticks,
"where": "*", "parameters": {"count": 1}, "notes": "external import"}]
# Scenario: 954 nodes
scenario = grid(M=n_places, N=1)
scenario["S"] = S0
scenario["E"] = E0
scenario["I"] = I0
scenario["R"] = R0_state
params = PropertySet({"nticks": nticks})
model = Model(scenario, params)
# Set gravity network BEFORE assigning components
model.network = gravity_matrix.astype(np.float32)
campaign = Campaign(model, schedule)
betas = ValuesMap.from_scalar(beta0, nticks, n_places)
r_recoveries = ValuesMap.from_scalar(gamma_val, nticks, n_places)
r_progressions = ValuesMap.from_scalar(sigma_val, nticks, n_places)
model.components = [
SEIR.Susceptible(model),
SEIR.Exposed(model, r_progression=r_progressions),
SEIR.Infectious(model, r_recovery=r_recoveries),
SEIR.Recovered(model),
NonDiseaseMortality(model, r_mortality=r_mortality_2d),
ConstantPopBirths(model),
campaign,
SEIR.Transmission(model, beta=betas, seasonality=seasonality),
]
print(f"Running {nticks}-day SEIR model with {n_places} nodes…")
model.run()
print("Done.")
Running 7300-day SEIR model with 954 nodes…
--------------------------------------------------------------------------- ValueError Traceback (most recent call last) Cell In[7], line 31 27 SEIR.Transmission(model, beta=betas, seasonality=seasonality), 28 ] 29 30 print(f"Running {nticks}-day SEIR model with {n_places} nodes…") ---> 31 model.run() 32 print("Done.") File ~/projects/laser-fresh/laser.cohorts/src/laser/cohorts/model.py:163, in Model.run(self) 161 component.start_step(tick) 162 for component in self.components: --> 163 component.step(tick) 164 for component in self.components: 165 component.end_step(tick) File ~/projects/laser-fresh/laser.cohorts/src/laser/cohorts/components.py:598, in TransmissionCommon.step(self, tick) 595 foi -= transfer.sum(axis=1) 596 foi = -np.expm1(-foi) # Convert to probability of infection --> 598 newly_infected = np.random.binomial(S, foi).astype(self._flow.dtype) 599 self._flow[tick] = newly_infected 600 S -= newly_infected File numpy/random/mtrand.pyx:3475, in numpy.random.mtrand.RandomState.binomial() -> 3475 'Could not get source, probably due dynamically evaluated source code.' File numpy/random/_common.pyx:393, in numpy.random._common.check_array_constraint() --> 393 'Could not get source, probably due dynamically evaluated source code.' File numpy/random/_common.pyx:379, in numpy.random._common._check_array_cons_bounded_0_1() --> 379 'Could not get source, probably due dynamically evaluated source code.' ValueError: p < 0, p > 1 or p contains NaNs
# Save outputs for use in nb_16_ew_analysis.ipynb
incidence = model.nodes.newly_infectious # (7300, 954) int32 — daily E→I per city
np.save(data_dir / "ew_sim_incidence.npy", incidence)
np.save(data_dir / "ew_sim_populations.npy", pop_1944)
np.save(data_dir / "ew_placenames.npy", np.array(placenames))
print(f"Saved incidence {incidence.shape} \u2192 data/ew_sim_incidence.npy")
print(f"Saved populations {pop_1944.shape} \u2192 data/ew_sim_populations.npy")
print(f"Saved placenames {len(placenames)} \u2192 data/ew_placenames.npy")
# Show simulated incidence for a handful of cities
highlight = ["London", "York", "Bristol", "Manchester", "Birmingham"]
highlight = [p for p in highlight if p in placenames]
t_years = t / 365.0 + 1944 # calendar year on x-axis
fig, axes = plt.subplots(len(highlight), 1, figsize=(14, 2.5 * len(highlight)), sharex=True)
if len(highlight) == 1:
axes = [axes]
for ax, city in zip(axes, highlight):
idx = placenames.index(city)
inc_city = incidence[:, idx]
ax.plot(t_years, inc_city, linewidth=0.6, color="firebrick")
ax.set_ylabel("Daily cases")
ax.set_title(f"{city} (N={pop_1944[idx]:,.0f})")
axes[-1].set_xlabel("Year")
fig.suptitle("Simulated measles incidence \u2014 England & Wales SEIR model", y=1.01)
plt.tight_layout()
plt.show()
# Total incidence per city over the full simulation
total_inc = incidence.sum(axis=0) # (954,)
incidence_rate = total_inc / pop_1944 # per-capita
fig, ax = plt.subplots(figsize=(7, 10))
sc = ax.scatter(longitudes, latitudes,
c=np.log1p(incidence_rate),
s=3.0 * np.sqrt(pop_1944 / pop_1944.max()) * 100,
cmap="YlOrRd", alpha=0.8, edgecolors="none")
plt.colorbar(sc, ax=ax, label="log(1 + per-capita incidence)")
ax.set_xlabel("Longitude")
ax.set_ylabel("Latitude")
ax.set_title("Per-capita cumulative simulated incidence\n(marker size \u221a population)")
plt.tight_layout()
plt.show()