Latent Mobility Infections
Code
import numpyro
numpyro.enable_x64()
import jax.numpy as jnp
import jax.random as random
import numpy as np
from numpyro.infer import Predictive
import pandas as pd
import plotnine as p9
import warnings
from plotnine.exceptions import PlotnineWarning
warnings.filterwarnings("ignore", category=PlotnineWarning)
from _tutorial_theme import theme_tutorial
Code
from pyrenew.latent import MobilitySubpopulationInfections, RandomWalk
from pyrenew.deterministic import DeterministicPMF, DeterministicVariable
Overview
SubpopulationInfections gives each subpopulation its own renewal
equation, and infections in one subpopulation do not depend on
infections in any other. MobilitySubpopulationInfections keeps the
same \(\mathcal{R}(t)\) structure, a shared baseline plus deviations that
sum to zero, but treats subpopulations as home regions whose residents
spend part of each day in other regions. Infections among residents of a
region then depend on transmission in the regions they visit and on the
infectious visitors their own region receives.
The class implements Equation 2 of Roy, Clapham, and Mishra (2025), Incorporating human mobility to enhance epidemic response and estimate real-time reproduction numbers. For home region \(i\) on day \(t\):
where:
- \(I_{t,i}\) is the modeled infection count
- \(m_{jk}\) is the fraction of residents of home region \(k\) present at location \(j\)
- \(N_k\) is the number of residents of region \(k\)
- \(S_{t,i}\) is the number of residents of region \(i\) not infected before day \(t\)
- \(R_j(t)\) is the reproduction number at location \(j\), where transmission happens
- \(g\) is the generation interval
PyRenew works with infections per resident. With depletion enabled,
PyRenew adds a ceiling to Equation 2: each day’s new infections cannot
exceed the remaining susceptible population. With M the mobility
matrix and p the population fractions, one day of the computation with
depletion enabled is:
present = M @ p
share_present = M * p[None, :] / jnp.where(present > 0, present, 1.0)[:, None]
infectiousness = reversed_gen_int @ history
susceptible = 1.0 - cumulative
new_infections_raw = M.T @ (rt * (share_present @ infectiousness))
new_infections = jnp.minimum(susceptible * new_infections_raw, susceptible)
share_present[j, k]is the share of the people present at locationjwho live in regionk.reversed_gen_intis the generation interval in reverse order andhistoryholds the new infections per resident on each of the lastlen(reversed_gen_int)days, oldest first, with one column per home region. With this ordering, the vector-matrix product pairs each day’s infections with the generation-interval probability for that many days back, soinfectiousnessis the per-region weighted sum of recent new infections per resident.cumulativeis the share of each region’s residents infected before today, summed over every earlier day starting with the first day of the initialization period.new_infectionsis capped atsusceptible.rtmultiplies at each location, because \(R_j(t)\) describes transmission where it happens.M.Tassigns infections acquired at each location back to the home region of the person infected.susceptiblescales infections after they are assigned to home regions, because residents of a region lose susceptibility wherever they were infected.
The Mobility Matrix
This tutorial uses the three regions of Roy et al. (Fig 2), with populations of 20, 10, and 15 million, and the mobility matrix of their Fig 2b.
Code
M = np.array(
[
[0.9, 0.07, 0.21],
[0.05, 0.85, 0.09],
[0.05, 0.08, 0.7],
]
)
p = np.array([20.0, 10.0, 15.0]) / 45.0
region_names = ["Region 1", "Region 2", "Region 3"]
matrix_df = pd.DataFrame(
{
"location": np.repeat(region_names, len(region_names)),
"home_region": np.tile(region_names, len(region_names)),
"fraction": M.ravel(),
}
)
(
p9.ggplot(matrix_df, p9.aes(x="home_region", y="location", fill="fraction"))
+ p9.geom_tile(color="white", size=1)
+ p9.geom_text(
p9.aes(label="fraction", color="fraction > 0.5"),
format_string="{:.2f}",
size=12,
show_legend=False,
)
+ p9.scale_fill_cmap(cmap_name="Blues", limits=(0, 1))
+ p9.scale_color_manual(values=["black", "white"])
+ p9.scale_y_discrete(limits=region_names[::-1])
+ p9.coord_equal()
+ p9.labs(
x="Home region",
y="Daytime location",
fill="Fraction of residents",
title="Mobility Matrix",
)
+ theme_tutorial
)

A mobility matrix is a square matrix with one row and one column per
region. M[j, k] is the fraction of residents of home region k
present at location j. Columns are home regions and each column sums
to \(1\). Rows are the same regions, viewed as the locations where
residents spend the day; the diagonal is the fraction of residents who
stay home. In region 3, 70% of residents stay home on a typical day, 21%
spend it in region 1, and 9% in region 2.
- The matrix must represent home regions by column, not rows. A matrix
with home regions in its rows, so that each row sums to 1, must be
transposed before use, otherwise
MobilitySubpopulationInfectionsrejects it which an error message that says to pass the transpose. - A matrix stored as a data frame must be converted with
.to_numpy(), with rows and columns in that order. - Regions must be in the same order in the matrix, in
subpop_fractions, in a vector-valuedI0_rvorlog_rt_time_0_rv, and in every observation’ssubpop_indices.
You may want to model only some of the areas represented in your raw mobility matrix, or your matrix may include an “everywhere else” bin. To use it in this model, drop any flows to un-modeled locations, then renormalize by columns, so columns once again sum to 1. Roy et al. do this for the twelve local authorities of North East England.
Code
M_full = np.array(
[
[0.85, 0.05, 0.10, 0.02],
[0.05, 0.90, 0.05, 0.03],
[0.05, 0.03, 0.80, 0.05],
[0.05, 0.02, 0.05, 0.90],
]
)
M_area = M_full[:3, :3]
M_area = M_area / M_area.sum(axis=0, keepdims=True)
print(f"Column sums after renormalizing: {M_area.sum(axis=0)}")
Column sums after renormalizing: [1. 1. 1.]
Building the Latent Process
The latent process takes the matrix as a DeterministicVariable. The
matrix is data: it is fixed when the process is constructed and is never
estimated.
I0_rv is the number of new incident infections per resident on the
first day of the initialization period, the earliest day on the time
axis. Infections grow from that value over the initialization period,
and every initialization day counts toward susceptible depletion.
Each region starts at its own \(\mathcal{R}(t)\), given as one value per
region by log_rt_time_0_rv. Region 2 starts below 1. The random walks
have a very small step size, so \(\mathcal{R}(t)\) stays close to its
starting value in every region.
Code
gen_int_rv = DeterministicPMF(
"gen_int", jnp.array([0.16, 0.32, 0.25, 0.14, 0.07, 0.04, 0.02])
)
n_init = 7
I0 = 1e-6
start_rt = jnp.array([1.3, 0.8, 1.2])
def simulate(matrix, susceptible_depletion=True, n_days=120):
"""Simulate one draw of the mobility latent process; return its recorded values."""
process = MobilitySubpopulationInfections(
name="mobility",
gen_int_rv=gen_int_rv,
n_initialization_points=n_init,
I0_rv=DeterministicVariable("I0", I0),
log_rt_time_0_rv=DeterministicVariable("log_rt_time_0", jnp.log(start_rt)),
baseline_rt_process=RandomWalk(
innovation_sd_rv=DeterministicVariable("innovation_sd", 0.001),
),
subpop_rt_deviation_process=RandomWalk(
innovation_sd_rv=DeterministicVariable("innovation_sd", 0.001),
),
mobility_matrix_rv=DeterministicVariable("mobility_matrix", matrix),
susceptible_depletion=susceptible_depletion,
)
def sampler():
"""Sample the latent process and record infections per resident."""
latent = process.sample(n_days_post_init=n_days, subpop_fractions=p)
numpyro.deterministic("infections", latent.all_subpops)
samples = Predictive(sampler, num_samples=1)(random.PRNGKey(0))
return {
name.removeprefix("mobility::"): np.asarray(value[0])
for name, value in samples.items()
}
def to_long(values, run):
"""Convert a (days, regions) array to a long data frame, after initialization."""
values = values[n_init:]
n_days = values.shape[0]
return pd.DataFrame(
{
"day": np.repeat(np.arange(n_days), values.shape[1]),
"region": np.tile(region_names, n_days),
"value": values.ravel(),
"run": run,
}
)
Every call to simulate uses the same random key, so the
\(\mathcal{R}(t)\) trajectories are the same whatever the matrix.
Mobility Versus No Mobility
With the identity matrix, every resident stays home, and each region follows its own renewal equation. With depletion on, this is the model Roy et al. compare against for regions not linked by mobility.
Code
isolated = simulate(np.eye(3))
linked = simulate(M)
infections_df = pd.concat(
[
to_long(isolated["infections"], "Identity matrix"),
to_long(linked["infections"], "Fig 2b matrix"),
]
)
(
p9.ggplot(infections_df, p9.aes(x="day", y="value", color="run"))
+ p9.geom_line(size=1)
+ p9.facet_wrap("~ region", ncol=3)
+ p9.scale_y_log10()
+ p9.labs(
x="Days",
y="New infections per resident",
title="Infections With and Without Mobility",
color="",
)
+ theme_tutorial
+ p9.theme(legend_position="bottom")
)

On its own, region 2, with \(\mathcal{R}(t)\) below 1, has a declining epidemic. Linked to the other regions, it has an outbreak: its residents are infected while visiting regions 1 and 3, and infectious visitors from those regions bring infections to region 2. Roy et al. show the same effect in their S2 Fig.
Where \(\mathcal{R}(t)\) Applies
rt_subpop[t, j] is the reproduction number at location j, where
transmission happens. It is not the reproduction number of the residents
of region j, who are also infected in the regions they visit.
With depletion enabled, rt_subpop_adjusted is the reproduction number
at each location after susceptibility adjustment. After the
initialization period it is
rt_subpop * (share_present @ susceptible_fraction).
Code
rt_df = pd.concat(
[
to_long(linked["rt_subpop"], "rt_subpop"),
to_long(linked["rt_subpop_adjusted"], "rt_subpop_adjusted"),
]
)
(
p9.ggplot(rt_df, p9.aes(x="day", y="value", color="run"))
+ p9.geom_line(size=1)
+ p9.geom_hline(yintercept=1.0, linetype="dashed", color="grey")
+ p9.facet_wrap("~ region", ncol=3)
+ p9.labs(
x="Days",
y=r"$\mathcal{R}(t)$ at location",
title="Reproduction Number Before and After Depletion",
color="",
)
+ theme_tutorial
+ p9.theme(legend_position="bottom")
)

Susceptible Depletion
Susceptible depletion is on by default, as in Eq 2.
Code
depleted = simulate(M, susceptible_depletion=True, n_days=250)
not_depleted = simulate(M, susceptible_depletion=False, n_days=250)
susceptible_df = pd.concat(
[
to_long(depleted["susceptible_fraction"], "Depletion on"),
to_long(not_depleted["susceptible_fraction"], "Depletion off"),
]
)
(
p9.ggplot(susceptible_df, p9.aes(x="day", y="value", color="run"))
+ p9.geom_line(size=1)
+ p9.facet_grid("run ~ region", scales="free_y")
+ p9.labs(
x="Days",
y="1 - cumulative infections per resident",
title="Susceptibility With and Without Depletion",
)
+ p9.guides(color="none")
+ theme_tutorial
+ p9.theme(figure_size=(10, 7))
)

With depletion off, the recorded susceptible fraction becomes negative, indicating that cumulative infections exceed the population. All residents are susceptible at the start of the initialization period, as in Roy et al.
Sources of Infection
Roy et al. (Eqs 3 to 5) split each region’s new infections into three sources:
- local: residents infected at home by infectious residents of the same region;
- incoming: residents infected at home by infectious visitors from other regions;
- outgoing: residents infected while visiting other regions.
PyRenew does not provide a built-in function to compute this split, but it can be computed from recorded values, the matrix, the population fractions, and the generation interval. The generation interval must be fixed, because a sampled one is not recorded.
Code
per_resident = linked["infections"]
rt_subpop = linked["rt_subpop"]
susceptible_fraction = linked["susceptible_fraction"]
reversed_gen_int = np.flip(np.asarray(gen_int_rv()))
n_gen = reversed_gen_int.size
present = M @ p
share_present = M * p[None, :] / np.where(present > 0, present, 1.0)[:, None]
sources = []
for t in range(n_init, per_resident.shape[0]):
infectiousness = reversed_gen_int @ per_resident[t - n_gen : t]
at_location = rt_subpop[t] * (share_present @ infectiousness)
susceptible = susceptible_fraction[t]
local = (
susceptible
* np.diag(M)
* rt_subpop[t]
* np.diag(share_present)
* infectiousness
)
incoming = susceptible * np.diag(M) * at_location - local
new_infections_raw = M.T @ at_location
outgoing = susceptible * new_infections_raw - local - incoming
ceiling_scale = 1.0 / np.maximum(new_infections_raw, 1.0)
sources.append(np.stack([local, incoming, outgoing]) * ceiling_scale)
sources = np.stack(sources)
total = sources.sum(axis=1)
print(
"Largest absolute difference between the summed sources and the infections: "
f"{np.max(np.abs(total - per_resident[n_init:])):.1e}"
)
Largest absolute difference between the summed sources and the infections: 4.3e-19
The three sources add up to each day’s new infections, to floating-point precision.
Code
source_names = ["Local", "Incoming", "Outgoing"]
share = np.divide(
sources,
total[:, np.newaxis, :],
out=np.zeros_like(sources),
where=total[:, np.newaxis, :] > 0,
)
n_days = share.shape[0]
sources_df = pd.DataFrame(
{
"day": np.repeat(np.arange(n_days), 9),
"source": np.tile(np.repeat(source_names, 3), n_days),
"region": np.tile(region_names, 3 * n_days),
"share": share.ravel(),
}
)
sources_df["source"] = pd.Categorical(
sources_df["source"], categories=source_names, ordered=True
)
(
p9.ggplot(sources_df, p9.aes(x="day", y="share", fill="source"))
+ p9.geom_area()
+ p9.facet_wrap("~ region", ncol=3)
+ p9.labs(
x="Days",
y="Share of new infections",
title="Sources of New Infections",
fill="",
)
+ theme_tutorial
+ p9.theme(legend_position="bottom")
)

More than half of region 2’s infections come from mobility: its residents are infected while visiting regions with \(\mathcal{R}(t)\) above 1, or at home by visitors from those regions.
Connecting to Observation Processes
MobilitySubpopulationInfections is configured with PyrenewBuilder
like SubpopulationInfections, and model.run() takes the same
arguments. The matrix is part of the model definition, not run-time
data, so fitting and prediction use the same matrix, and no run-time
argument can replace it. To model another jurisdiction, build a model
with that jurisdiction’s matrix.
Before fitting, run() checks that the matrix has one row and one
column per entry of subpop_fractions. Empty locations are allowed and
contribute no infections.
For building complete models with observation processes, see: