Simulation
In this section, we provide a tutorial of the simulation methods in EpiLearn. In general, we focus on the simulation of static and dynamic properties including graph structure and node features.
Note
Coming from 0.0.x? Time_geo was removed with no replacement, and
Gravity_model changed signature. Both changes, and everything else, are
listed in MIGRATION.md.
Static Properties
Random Static Graph
- epilearn.utils.simulation.get_random_graph(num_nodes=None, connect_prob=None, block_sizes=None, num_edges=None, graph_type='erdos_renyi')
Generates a random static graph using one of the supported graph types: Erdos-Renyi, Stochastic Blockmodel, or Barabasi-Albert.
- Parameters:
num_nodes (int) – Number of nodes in the graph.
connect_prob (float, optional) – Probability of edge creation (for Erdos-Renyi and Stochastic Blockmodel graphs).
block_sizes (list of int, optional) – Sizes of blocks (for Stochastic Blockmodel graph).
num_edges (int, optional) – Number of edges (for Barabasi-Albert graph).
graph_type (str) – Type of graph to generate. Options are ‘erdos_renyi’, ‘stochastic_blockmodel’, ‘barabasi_albert’. Default is ‘erdos_renyi’.
- Returns:
Adjacency matrix of the generated graph.
- Return type:
torch.Tensor
graph_type selects the generator: 'erdos_renyi' needs num_nodes and
connect_prob, 'stochastic_blockmodel' takes block_sizes plus a
block-wise probability matrix, and 'barabasi_albert' takes num_edges
(edges attached per new node). All three return a dense torch.float32
adjacency matrix, ready to pass as Dataset(graph=...).
from epilearn.utils.simulation import get_random_graph
adj = get_random_graph(num_nodes=25, connect_prob=0.2, graph_type='erdos_renyi')
sbm = get_random_graph(block_sizes=[10, 15],
connect_prob=[[0.4, 0.05], [0.05, 0.4]],
graph_type='stochastic_blockmodel')
ba = get_random_graph(num_nodes=25, num_edges=2, graph_type='barabasi_albert')
print(adj.shape, sbm.shape, ba.shape)
# torch.Size([25, 25]) torch.Size([25, 25]) torch.Size([25, 25])
Static Features
- epilearn.utils.simulation.get_graph_from_features(features, adj=None, G=1)
Generate a graph from node features using cosine similarity.
This function generates a graph where each edge weight is computed based on the cosine similarity between the feature vectors of the connected nodes. If an adjacency matrix is provided, the cosine similarity is adjusted by the corresponding entry in the adjacency matrix.
- Parameters:
features (torch.Tensor) – A tensor of shape (num_nodes, feat_dim) where num_nodes is the number of nodes and feat_dim is the dimensionality of the feature vectors.
adj (torch.Tensor, optional) – A tensor of shape (num_nodes, num_nodes) representing the adjacency matrix, where adj[i, j] denotes the distance or weight between node i and node j. If None, the cosine similarity is used directly as the edge weight. Default is None.
- Returns:
A tensor of shape (num_nodes, num_nodes) representing the generated graph’s adjacency matrix, where each entry [i, j] contains the adjusted cosine similarity between nodes i and j.
- Return type:
torch.Tensor
Given fixed node features, a graph can be built from the cosine similarity between
nodes. The optional adj holds the distances between nodes: pass it and each
similarity is divided by the corresponding distance, penalizing far-apart pairs.
import torch
from epilearn.utils.simulation import get_graph_from_features
feature = torch.rand(10, 20) # 10 nodes, 20 features each
adj = torch.randint(10, 100, (10, 10)) # pairwise distances
graph1 = get_graph_from_features(features=feature, adj=None)
graph2 = get_graph_from_features(features=feature, adj=adj)
Gravity Model
The gravity model turns populations and connection strengths into mobility flows — in epidemics, the regional contact and transmission driven by human movement. It is parameterized by two population exponents and a connectivity decay:
rho and theta are the source/target population exponents (typically
0.5-1.0), delta is the connectivity decay (0.2-0.5 local, 0.5-1.0 regional,
1.0-2.0 long range), and normalize divides the flow by the two regions’ total
population. Setting rho=0, theta=0, normalize=False gives the diffusive
special case — flow is just the edge weight times the population difference —
which is what simulate_spatiotemporal_regions uses when no model is given.
Warning
\(w_{ij}\) is connection strength: higher means more flow, and 0 means
no edge. That is the opposite convention from a distance matrix, so convert
distances to strengths and normalize them to [0, 1] before use.
- class epilearn.utils.simulation.Gravity_model(rho: float = 0.0, theta: float = 0.0, delta: float = 1.0, normalize: bool = False)
Elegant gravity model for human mobility in epidemic simulations.
Computes flow between regions based on population attraction and connectivity strength:
\[F_{ij} = N_i^{\rho} \cdot N_j^{\theta} \cdot \exp((w_{ij} - 1) / \delta)\]where higher w_{ij} indicates stronger connection between regions.
- Parameters:
rho (float) – Source population exponent (typically 0.5-1.0).
theta (float) – Target population exponent (typically 0.5-1.0).
delta (float) – Connectivity decay parameter. Controls sensitivity to connectivity variations. Recommended: 0.2-2.0 for normalized connectivity [0, 1].
normalize (bool) – If True, normalize flows by total population (default True).
Notes
Unified Connectivity Semantics:
Connectivity values represent connection strength where: - Higher values = Stronger connection = More flow - Lower values = Weaker connection = Less flow - 0 = No connection
This is consistent across both diffusive and gravity models.
It is recommended to normalize your connectivity matrix to [0, 1] range:
>>> # Normalize connectivity matrix (edge weights) >>> max_conn = connectivity_matrix.max() >>> normalized_connectivity = connectivity_matrix / max_conn >>> >>> # Choose delta based on desired spatial spread >>> # Sharp decay (local): delta = 0.2-0.5 >>> # Moderate (regional): delta = 0.5-1.0 >>> # Gradual (long-range): delta = 1.0-2.0
Examples
>>> # Diffusive flow (special case: rho=0, theta=0) >>> diffusive = Gravity_model(rho=0, theta=0, delta=1.0, normalize=False) >>> >>> # Gravity model with normalized connectivity >>> gravity = Gravity_model(rho=1.0, theta=1.0, delta=0.5, normalize=True) >>> >>> # Using with normalized edge weights >>> weights_normalized = adjacency / adjacency.max() >>> result = simulate_spatiotemporal_regions( ... model, states, weights_normalized, steps=100, gravity_model=gravity)
- __init__(rho: float = 0.0, theta: float = 0.0, delta: float = 1.0, normalize: bool = False)
- compute_flow(pop_i: float, pop_j: float, connectivity: float) float
Compute flow between two regions.
- Parameters:
pop_i (float) – Population sizes of regions i and j.
pop_j (float) – Population sizes of regions i and j.
connectivity (float) –
Connection strength between regions (recommended range: 0-1). Higher values = stronger connection = more flow. - 0: No connection - 1: Maximum connection strength
Interpretation is unified across all models: - Diffusive: Direct multiplier on population flow - Gravity: Exponential enhancement of attraction
- Returns:
Flow magnitude or edge weight for further computation.
- Return type:
float
Notes
Unified Connectivity Semantics:
Connectivity always represents connection strength where higher = stronger:
- Diffusive model (rho=0, theta=0):
Formula: F = connectivity × (N_i - N_j)
Range: [0.0, 1.0]
Interpretation: Fraction of population difference that flows per timestep
Example: 0.1 = 10% of population difference travels per day
- Gravity model (rho>0, theta>0):
Formula: F = [N_i^ρ × N_j^θ × exp((connectivity-1)/δ)] × (N_i - N_j)/(N_i + N_j)
Range: [0.0, 1.0] normalized
connectivity=1.0 gives baseline gravity attraction
connectivity>1.0 enhances flow (if using unnormalized weights)
connectivity<1.0 reduces flow
Delta parameter controls sensitivity: * Small delta (0.2-0.5): Sharp response to connectivity differences * Large delta (1.0-2.0): Gradual response to connectivity differences
Normalization Strategy:
For any connectivity matrix (edge weights, similarity scores, etc.): 1. Find max value: max_conn = connectivity_matrix.max() 2. Normalize: connectivity_matrix = connectivity_matrix / max_conn 3. Choose delta based on desired sensitivity:
Sharp (local spread): delta = 0.2-0.5
Moderate: delta = 0.5-1.0
Gradual (long-range): delta = 1.0-2.0
- compute_mobility_matrix(populations: Tensor, connectivity_matrix: Tensor) Tensor
Vectorized computation of full mobility matrix.
- Parameters:
populations (torch.Tensor) – Population of each region, shape (num_regions,).
connectivity_matrix (torch.Tensor) – Connectivity matrix, shape (num_regions, num_regions). Higher values = stronger connection = more flow (unified semantics).
- Returns:
Mobility flow matrix, shape (num_regions, num_regions).
- Return type:
torch.Tensor
- compute_net_flow(pop_i: float, pop_j: float, connectivity: float) float
Compute net directional flow from region i to region j.
- Parameters:
pop_i (float) – Population sizes of regions i and j.
pop_j (float) – Population sizes of regions i and j.
connectivity (float) – Connection strength between regions. Higher values = stronger connection = more flow (unified across all models).
- Returns:
Net flow (positive = i→j, negative = j→i).
- Return type:
float
Given population numbers in each node (or say region) and a connectivity matrix, edge weights can be obtained for a pair of nodes or for the whole graph at once:
import torch
from epilearn.utils.simulation import Gravity_model
node_populations = torch.tensor([1000., 2000., 1500.])
connectivity = torch.tensor([[0.0, 0.8, 0.2], # connection strength in [0, 1]
[0.8, 0.0, 0.5],
[0.2, 0.5, 0.0]])
gravity = Gravity_model(rho=1.0, theta=1.0, delta=0.5, normalize=True)
print(round(gravity.compute_flow(1000.0, 2000.0, 0.8), 2)) # 446.88
print(round(gravity.compute_net_flow(1000.0, 2000.0, 0.8), 2)) # -148.96, i.e. j -> i
print(gravity.compute_mobility_matrix(node_populations, connectivity))
# tensor([[ 0.0000, 446.8800, 121.1379],
# [446.8800, 0.0000, 315.3253],
# [121.1379, 315.3253, 0.0000]])
# diffusive special case: the mobility matrix is the connectivity itself
diffusive = Gravity_model(rho=0.0, theta=0.0, delta=1.0, normalize=False)
print(diffusive.is_diffusive) # True
Dynamic Properties
The three simulators below all take a compartmental model and return a dict of tensors. They differ only in what a “unit” is:
Function |
Level |
|
|---|---|---|
|
one population |
|
|
individuals on a contact graph |
|
|
regions with mobility |
|
The models live in epilearn.utils.compartmental_models
(SIRModel, SEIRModel, SIRSModel, SEIRVIModel); see Utilities
for their parameters and for the parameter_schedule /input_schedule
mechanism used below.
Temporal Simulation
simulate_temporal_epidemic integrates the ODE at the population level. State
vectors are counts, and their length must match model.compartments.
Interventions enter through parameter_schedule: a callable
f(step_idx, t, state) -> dict | None, a {step_idx: dict} mapping, or a
sequence indexed by step, where None leaves the base parameters alone.
import epilearn
from epilearn.utils.compartmental_models import SIRModel
from epilearn.utils.simulation import simulate_temporal_epidemic
model = SIRModel(beta=0.3, gamma=0.1)
run = simulate_temporal_epidemic(model,
initial_state=[9990.0, 10.0, 0.0], # [S, I, R]
steps=160, dt=1.0)
print(run.keys()) # time, trajectory, compartments
print(run['trajectory'].shape, run['compartments'])
# torch.Size([161, 3]) ('S', 'I', 'R')
epilearn.visualize.plot_series(run['trajectory'].numpy(),
columns=['Susceptible', 'Infected', 'Recovered'])
def lockdown(step_idx, t, state):
"""Halve transmission between day 20 and day 70."""
return {'beta': 0.15} if 20 <= step_idx < 70 else None
npi = simulate_temporal_epidemic(model, [9990.0, 10.0, 0.0], steps=200,
parameter_schedule=lockdown)
for r in (run, npi):
print(f"peak {r['trajectory'][:, 1].max():.0f} on day {r['trajectory'][:, 1].argmax()}")
# peak 3006 on day 38 <- unmitigated
# peak 1078 on day 86 <- with the lockdown
noisy = simulate_temporal_epidemic(model, [9990.0, 10.0, 0.0], steps=160,
process_noise=5.0, seed=42)
process_noise (a scalar or a per-compartment vector, with seed for
reproducibility) adds Gaussian noise after each step and re-projects to
non-negative values, which is the easiest way to get a realistic-looking series to
train a model on. Without it, simulate_temporal_epidemic is just
model.simulate(initial_state, steps).
- epilearn.utils.simulation.simulate_temporal_epidemic(model: CompartmentalModel, initial_state: Sequence[float] | Tensor | ndarray, steps: int, dt: float = 1.0, parameter_schedule: Mapping[int, Dict[str, float]] | Sequence | None = None, input_schedule: Mapping[int, Dict[str, float]] | Sequence | None = None, process_noise: float | Sequence[float] | Tensor | None = None, method: str = 'rk4', seed: int | None = None)
Run a temporal (population-level) simulation for the provided compartmental model.
Individual-Level Simulation
simulate_spatiotemporal_individual runs a stochastic node-level process on a
contact graph: susceptible nodes are infected at rate
\(1 - e^{-\beta\,\Delta t\,\sum_j A_{ij}\mathbb{1}[j \text{ infectious}]}\), then
progress and recover with the model’s sigma / gamma / omega.
create_initial_conditions_individual builds a random Erdos-Renyi contact network
plus matching initial states.
from epilearn.utils.compartmental_models import SEIRModel
from epilearn.utils.simulation import (create_initial_conditions_individual,
simulate_spatiotemporal_individual)
model = SEIRModel(beta=0.35, gamma=0.1, sigma=0.2)
node_states, contact_graph = create_initial_conditions_individual(
model, num_individuals=200, p_edge=0.03,
initial_compartment_fractions={'S': 0.98, 'I': 0.02}, seed=0)
print(node_states.shape, contact_graph.shape)
# torch.Size([200]) torch.Size([200, 200])
res = simulate_spatiotemporal_individual(model, contact_graph, node_states,
steps=60, stochastic=True, seed=0)
print(res.keys())
# time, trajectory, counts, compartments, contact_graph, dynamic_graph, node_features
print(res['trajectory'].shape, res['counts'].shape)
# torch.Size([61, 200]) compartment index, torch.Size([61, 4]) S/E/I/R totals
print(res['node_features'].shape, res['dynamic_graph'].shape)
# torch.Size([61, 200, 4]) one-hot, model-ready, torch.Size([61, 200, 200])
# node_features is already shaped [T, N, F], so this is a training set:
from epilearn.data import Dataset
dataset = Dataset(x=res['node_features'],
y=res['node_features'][:, :, model.compartments.index('I')],
graph=contact_graph,
dynamic_graph=res['dynamic_graph'])
contact_graph may also be time-varying: pass a (steps+1, N, N) tensor, or
a callable f(t, step_idx) -> adjacency to rewire the network as the epidemic
runs (for example, to model contact reduction).
Warning
create_initial_conditions_individual renormalizes
initial_compartment_fractions so they sum to 1. Passing only the infected
share — including the default {'I': 0.01} — therefore seeds the whole
population as infectious. Always give the full distribution, e.g.
{'S': 0.98, 'I': 0.02}. The model must also have a compartment literally
named 'S', otherwise the simulator raises ValueError.
- epilearn.utils.simulation.simulate_spatiotemporal_individual(model: CompartmentalModel, contact_graph: Tensor | ndarray | Graph | Callable, initial_states: Sequence[int] | Sequence[str] | Tensor | ndarray | Dict[str, Iterable[int]], steps: int, dt: float = 1.0, stochastic: bool = True, seed: int | None = None)
Simulate an individual-level compartmental process on a contact graph. Nodes follow the provided model (SIR/SEIR/SIRS) with infection pressure driven by neighbors. Returns per-step node features (one-hot compartment indicators) and a dynamic graph tensor of shape (time, N, N).
- Parameters:
model (CompartmentalModel) – The compartmental model (e.g., SIRModel, SEIRModel, SIRSModel).
contact_graph (Union[torch.Tensor, np.ndarray, nx.Graph, Callable]) –
Contact graph specification. Can be: - Static: Binary adjacency matrix (num_nodes, num_nodes) or NetworkX graph - Time-varying: Tensor of shape (steps+1, num_nodes, num_nodes) - Dynamic: Callable function f(t, step_idx) -> adjacency_matrix that returns
the contact graph at each time step
initial_states (Union[Sequence[int], Sequence[str], torch.Tensor, np.ndarray, Dict[str, Iterable[int]]]) – Initial compartment states for each individual.
steps (int) – Number of simulation steps.
dt (float) – Time step size (default 1.0).
stochastic (bool) – Whether to use stochastic transitions (default True).
seed (int, optional) – Random seed for reproducibility.
- Returns:
Simulation results with keys: - ‘time’: Time axis (steps+1,) - ‘trajectory’: Individual state history (steps+1, num_nodes) with compartment indices - ‘counts’: Compartment counts over time (steps+1, num_compartments) - ‘compartments’: Compartment names - ‘contact_graph’: Static or initial contact graph - ‘dynamic_graph’: Time-varying contact graphs (steps+1, num_nodes, num_nodes) - ‘node_features’: One-hot encoded compartment states (steps+1, num_nodes, num_compartments)
- Return type:
dict
- epilearn.utils.simulation.create_initial_conditions_individual(model, num_individuals=100, p_edge=0.01, initial_compartment_fractions: dict = {'I': 0.01}, seed=42)
Create initial conditions for individual-level simulations.
- Parameters:
model (CompartmentalModel) – The compartmental model (e.g., SIRModel, SIRSModel, SEIRModel) that defines the disease dynamics and compartment structure
num_individuals (int) – Number of individuals in the network
p_edge (float) – Edge probability for Erdos-Renyi random graph (contact network)
initial_compartment_fractions (dict) – Dictionary mapping compartment names to initial fractions. Example: {‘I’: 0.01} means 1% start infected, rest susceptible Example: {‘S’: 0.9, ‘E’: 0.05, ‘I’: 0.05} for SEIR model
seed (int) – Random seed for reproducibility
- Returns:
node_states: torch.Tensor of shape (num_individuals,) containing compartment indices
adjacency: torch.Tensor of shape (num_individuals, num_individuals) contact graph
- Return type:
tuple
Region-Level (Metapopulation) Simulation
simulate_spatiotemporal_regions alternates two phases per step: integrate each
region’s internal dynamics, then move people along the graph using a
Gravity_model. Flows are antisymmetric, so the
total population is conserved. create_initial_conditions_region sets up the
regions, and create_regional_forcing_params gives each one its own
multi-frequency seasonal forcing, which desynchronizes the regional waves instead
of leaving them in lock-step.
from epilearn.utils.compartmental_models import SIRSModel
from epilearn.utils.simulation import (create_initial_conditions_region,
create_regional_forcing_params,
simulate_spatiotemporal_regions,
Gravity_model)
model = SIRSModel(beta=0.35, gamma=0.1, omega=0.02) # SIRS never burns out
init = create_initial_conditions_region(model, n_regions=30, p_edge=0.12,
pop_range=(5000, 20000),
n_initial_infected=3,
initial_infected_size=50, seed=1)
print(init['n_regions'], init['n_edges']) # 30 59
print(init['region_states'].shape) # torch.Size([30, 3])
forcing = create_regional_forcing_params(init['n_regions'], seed=1)
print(round(forcing[0]['amp1'], 4), forcing[0]['period1']) # 0.1967 30.0
res = simulate_spatiotemporal_regions(
model,
region_states=init['region_states'], # counts, [n_regions, n_compartments]
adjacency_graph=init['adjacency'], steps=120, dt=1.0,
forcing_params=forcing, forcing_parameter='beta',
method='euler', # 'euler' is the stable choice here
gravity_model=Gravity_model(rho=1.0, theta=1.0, delta=0.5, normalize=True),
travel_rate=0.01) # global mobility scaling
print(res['trajectory'].shape) # torch.Size([121, 30, 3])
print(res['dynamic_graph'].shape) # torch.Size([121, 30, 30])
print(res['effective_reproduction_number'].shape) # torch.Size([121, 30])
# straight into a spatiotemporal task
from epilearn.data import Dataset
dataset = Dataset(x=res['trajectory'], # [T, N, n_compartments]
y=res['trajectory'][:, :, 1], # infectious counts
graph=init['adjacency'],
dynamic_graph=res['directed_flow'])
Three of the returned tensors are worth calling out. dynamic_graph is the
signed net-flow graph per step (F[i, j] > 0 means i → j, and
F == -F.T), so feed a model directed_flow instead — the same tensor
clamped at 0. parameter_history (steps+1, n_regions) records the value of
forcing_parameter actually used, and effective_reproduction_number
(steps+1, n_regions) is the per-region \(R_t\) derived from it (None
for models without gamma or without an 'S' compartment). Also returned:
counts, regional_totals, adjacency, adjacency_history and
node_features.
Warning
create_initial_conditions_region samples the seeded regions without
replacement, so n_initial_infected >= n_regions raises ValueError:
Cannot take a larger sample than population when 'replace=False'. The default
is n_initial_infected=20, so any run with fewer than 21 regions must pass a
smaller value. ensure_connected=True (the default) also keeps only the
largest connected component, so read the surviving region count back from
init['n_regions'].
- epilearn.utils.simulation.simulate_spatiotemporal_regions(model: CompartmentalModel, region_states: Tensor | ndarray, adjacency_graph: Tensor | ndarray | Graph | Callable, steps: int, dt: float = 1.0, forcing_params: List[Dict[str, float]] | None = None, forcing_parameter: str = 'beta', method: str = 'euler', gravity_model: Gravity_model | None = None, travel_rate: float = 1.0) Dict[str, Tensor]
Simulate region-level epidemics with mobility-driven population flows.
This function implements a two-step process: 1. Internal dynamics update (with optional multi-frequency forcing) 2. Population flow based on mobility model (diffusive or gravity-based)
The flows are applied AFTER internal dynamics. Flow models are configured via the gravity_model parameter, which supports both diffusive and gravity-based flows.
- Parameters:
model (CompartmentalModel) – Compartmental epidemic model (must be SIRS-like for infinite stability).
region_states (Union[torch.Tensor, np.ndarray]) – Initial states for each region as counts (not fractions), shape (num_regions, num_compartments). For SIRS: [S, I, R] counts for each region.
adjacency_graph (Union[torch.Tensor, np.ndarray, nx.Graph, Callable]) – Connectivity/adjacency graph specification where higher values = stronger connections. Edge weights represent connection strength (travel rates, similarity, interaction frequency). Can be: - Static: Weighted matrix (num_regions, num_regions) or NetworkX graph - Time-varying: Tensor of shape (steps+1, num_regions, num_regions) - Dynamic: Callable f(t, step_idx) -> adjacency_matrix
steps (int) – Number of simulation steps.
dt (float) – Time step size (default 1.0 for daily updates).
forcing_params (List[Dict[str, float]], optional) – Region-specific forcing parameters with keys: ‘amp1’, ‘phase1’, ‘period1’, ‘amp2’, ‘phase2’, ‘period2’, ‘amp3’, ‘phase3’, ‘period3’.
forcing_parameter (str) – Name of the parameter to apply forcing to (default ‘beta’).
method (str) – Integration method (‘euler’ recommended for stability, default ‘euler’).
gravity_model (Gravity_model, optional) – Mobility model instance. If None, defaults to diffusive model. - Diffusive: Gravity_model(rho=0, theta=0, delta=1.0, normalize=False) - Gravity: Gravity_model(rho=1.0, theta=1.0, delta=100.0, normalize=True)
travel_rate (float) – Global mobility scaling factor applied to every edge-wise flow (default 1.0). Use this to tune the overall level of inter-regional travel without modifying the adjacency weights.
- Returns:
Simulation results with keys: - ‘time’: Time axis (steps+1,) - ‘trajectory’: Regional state history (steps+1, num_regions, num_compartments) - ‘compartments’: Compartment names - ‘adjacency’: Static adjacency or provided tensor (for backward compatibility) - ‘adjacency_history’: Adjacency tensor for every timestep (steps+1, num_regions, num_regions) - ‘dynamic_graph’: Signed flow graphs (steps+1, num_regions, num_regions) - ‘directed_flow’: Non-negative directed flows derived from dynamic_graph - ‘parameter_history’: Value of forcing_parameter used per region and timestep - ‘effective_reproduction_number’: R_t estimates when applicable - ‘counts’: Aggregate compartment counts summed across regions - ‘regional_totals’: Population of each region at every timestep - ‘node_features’: Alias for trajectory (kept for convenience)
- Return type:
dict
Notes
Flow Models:
Both models use unified connectivity semantics (higher = stronger connection):
- Diffusive (default):
F(i,j) = connectivity[i,j] × (N_i - N_j)
- Gravity:
F(i,j) = [N_i^ρ × N_j^θ × exp((connectivity[i,j]-1)/δ)] × (N_i - N_j)/(N_i + N_j)
Both ensure population conservation through antisymmetric flows.
Connectivity Normalization:
Normalize edge weights to [0, 1] for best results: >>> adjacency_normalized = adjacency / adjacency.max()
Examples
- Diffusive flow (default):
>>> result = simulate_spatiotemporal_regions( ... model, states, adjacency, steps=100)
- Diffusive flow (explicit):
>>> diffusive = Gravity_model(rho=0, theta=0, delta=1.0, normalize=False) >>> result = simulate_spatiotemporal_regions( ... model, states, adjacency, steps=100, gravity_model=diffusive)
- Gravity model:
>>> # Normalize connectivity (edge weights) >>> adjacency_norm = adjacency / adjacency.max() >>> gravity = Gravity_model(rho=1.0, theta=1.0, delta=0.5, normalize=True) >>> result = simulate_spatiotemporal_regions( ... model, states, adjacency_norm, steps=100, gravity_model=gravity)
- epilearn.utils.simulation.create_initial_conditions_region(model, n_regions=100, p_edge=0.01, pop_range=(800, 20000), n_initial_infected=20, initial_infected_size=100, initial_compartment_fractions=None, ensure_connected=True, seed=42)
Create initial conditions for spatiotemporal epidemic simulation based on a compartmental model.
- Parameters:
model (CompartmentalModel) – The compartmental model (e.g., SIRModel, SIRSModel, SEIRModel) that defines the disease dynamics and compartment structure
n_regions (int) – Number of regions in the network
p_edge (float) – Edge probability for Erdos-Renyi random graph
pop_range (tuple of (int, int)) – Range for random population initialization (min_pop, max_pop)
n_initial_infected (int) – Number of regions to seed with initial infections
initial_infected_size (int) – Number of individuals initially infected in each seeded region
initial_compartment_fractions (dict or None) – Optional dictionary mapping compartment names to initial fractions. If None, all individuals start in ‘S’ (susceptible). Example: {‘S’: 0.9, ‘E’: 0.05, ‘I’: 0.05} for SEIR model
ensure_connected (bool) – If True, extract largest connected component
seed (int) – Random seed for reproducibility
- Returns:
Dictionary containing: - ‘adjacency’: torch.Tensor of shape (n_regions, n_regions) - ‘region_states’: torch.Tensor of shape (n_regions, n_compartments) - ‘graph’: networkx.Graph object - ‘n_regions’: int (actual number of regions after connectivity check) - ‘n_edges’: int - ‘infected_nodes’: numpy.ndarray of initially infected region indices - ‘model’: the compartmental model used - ‘compartment_names’: list of compartment names - ‘total_population’: total population across all regions
- Return type:
dict
- epilearn.utils.simulation.create_regional_forcing_params(num_regions: int, base_amplitudes: Sequence[float] = (0.2, 0.15, 0.1), periods: Sequence[float] = (30.0, 47.0, 73.0), amplitude_noise: float = 0.1, seed: int | None = None) List[Dict[str, List[float] | float]]
Create region-specific multi-frequency forcing parameters.
Each region gets slightly different amplitudes and random phases to desynchronize dynamics across regions.
- Parameters:
num_regions (int) – Number of regions.
base_amplitudes (Sequence[float]) – Base amplitudes for each frequency component.
periods (Sequence[float]) – Periods for each frequency component (in time units).
amplitude_noise (float) – Relative noise level for amplitude perturbations (default 0.1 = 10%).
seed (int, optional) – Random seed for reproducibility.
- Returns:
List of dictionaries, one per region, with keys: ‘amp1’, ‘phase1’, ‘period1’, ‘amp2’, ‘phase2’, ‘period2’, ‘amp3’, ‘phase3’, ‘period3’.
- Return type:
List[Dict[str, Union[List[float], float]]]
Torch Compartmental Layers
Separately from the ODE utilities above, epilearn.models.Temporal.Compartmental
ships nn.Module compartmental layers, useful when you want an SIR-like model
that participates in autograd. SIR takes horizon (total simulation steps),
infection_rate and recovery_rate; NetworkSIR spreads the disease over a
graph and adds num_nodes. Both are called as model(states, [graph,] steps),
where steps=None runs the full horizon. SIS, SEIR, NetworkSIS
and NetworkSEIR live in the same module.
import epilearn
import torch
from epilearn.models.Temporal.Compartmental import SIR, NetworkSIR
# 25 nodes, all susceptible except nodes 3 and 10; columns are [S, I, R]
initial_states = torch.zeros(25, 3)
initial_states[:, 0] = 1
initial_states[[3, 10], 0] = 0
initial_states[[3, 10], 1] = 1
# population level: the layer takes the aggregated [S, I, R] counts
model = SIR(horizon=190, infection_rate=0.05, recovery_rate=0.05)
preds = model(initial_states.sum(0), steps=None)
print(preds.shape) # torch.Size([190, 3])
epilearn.visualize.plot_series(preds.detach().numpy(),
columns=['Susceptible', 'Infected', 'Recovered'])
# node level: same states, plus a graph
initial_graph = epilearn.utils.simulation.get_random_graph(num_nodes=25, connect_prob=0.20)
net = NetworkSIR(num_nodes=initial_graph.shape[0], horizon=120,
infection_rate=0.05, recovery_rate=0.05)
preds = net(initial_states, initial_graph, steps=None)
print(preds.shape) # torch.Size([120, 25, 3])
epilearn.visualize.plot_graph(preds.argmax(2)[15].detach().numpy(),
initial_graph.to_sparse().indices().detach().numpy(),
classes=['Susceptible', 'Infected', 'Recovered'])
Note
These classes moved from epilearn.models.Temporal.SIR to
epilearn.models.Temporal.Compartmental in 0.1.0; the old path still works as
an alias. epilearn.models.SpatialTemporal.NetworkSIR.NetSIR is the
equivalent model in the SpatialTemporal family and takes the same
(x, adj, steps) call.