flowchart LR
S["<b>S</b><br/>Susceptible"] -->|"infection<br/>(si.flow)"| I["<b>I</b><br/>Infectious"]
style S fill:#3498db,color:#fff
style I fill:#e74c3c,color:#fff
Epidemics with Multiple (Multilayer) Networks
Overview
This example demonstrates how to simulate an epidemic on a multilayer network in EpiModel. A multilayer network consists of two or more network layers that share the same node set but have different edge sets — representing distinct types of relationships. For example, the two layers might represent main partnerships and casual contacts, or sexual contacts and needle-sharing contacts.
The key feature of multilayer models is cross-layer dependency: the degree (number of edges) in one layer can influence edge formation in the other. This models the realistic constraint that individuals have finite relational capacity — being active in one type of partnership reduces availability for the other. The cross-layer dependency is maintained during simulation by a callback function that updates degree attributes between layer resimulations.
This example uses a simple SI model in a closed population to isolate the multilayer network mechanics from disease model complexity. No custom module-fx.R is needed — it uses EpiModel’s built-in SI modules. The multilayer functionality is entirely in the network estimation and simulation layers.
- model.R — Main simulation script (no module-fx.R needed for this example)
Model Structure
| Compartment | Label | Description |
|---|---|---|
| Susceptible | S | Not infected; at risk of infection through contact on either network layer |
| Infectious | I | Infected and capable of transmitting (no recovery in SI) |
Network Layers
The two network layers have different formation models, target statistics, and mean durations. Transmission can occur over edges in either layer.
| Property | Layer 1 | Layer 2 |
|---|---|---|
| Interpretation | Longer-duration partnerships | Shorter-duration partnerships |
| Target edges | 90 | 75 |
| Mean degree | 0.36 | 0.30 |
| Mean duration | 100 time steps | 75 time steps |
| Formation terms | edges + nodematch("race") + nodefactor("deg1+.net2") |
edges + degree(1) + nodefactor("deg1+.net1") |
| Cross-layer term | nodefactor("deg1+.net2") |
nodefactor("deg1+.net1") |
Cross-Layer Dependency
The nodefactor("deg1+.netX") terms create negative degree correlation across layers. The target statistic for these terms (10 out of 90 or 75 total edges) is deliberately small relative to total edges, producing strong negative correlation: most nodes that are active in one layer are inactive in the other.
Setup
suppressMessages(library(EpiModel))
nsims <- 5
ncores <- 5
nsteps <- 500Network Model
Network Initialization
Initialize a network of 500 nodes with a binary demographic attribute (race). This attribute is used for assortative mixing (homophily) in layer 1’s formation model.
n <- 500
nw <- network_initialize(n)
nw <- set_vertex_attribute(nw, "race", rep(0:1, length.out = n))Generating Starting Degree Values with san()
Because the two layers depend on each other’s degree distribution (via cross-layer nodefactor terms), we need reasonable starting values for the degree attributes before fitting the ERGM models. We use san() (simulated annealing from the ergm package) to generate edge sets consistent with our target statistics.
Step 1: Generate layer 1 edges consistent with target mean degree and race homophily. Layer 1 has target stats of 90 total edges and 60 that are race-homophilous (same-race partnerships).
- 1
- Record each node’s exact degree in layer 1 as a vertex attribute.
- 2
- Create a binary indicator (0 vs. 1+) for the cross-layer formation term.
Step 2: Generate layer 2 edges, accounting for the cross-layer constraint from step 1. The nodefactor("deg1+.net1") term ensures that nodes already active in layer 1 are less likely to form layer 2 edges (negative correlation).
nw_san_2 <- san(nw_san ~ edges + degree(1) + nodefactor("deg1+.net1")
+ Sum(matrix(seq_len(network.size(nw)), nrow = 1) ~
degrange(1, by = "deg.net1", levels = -1),
label = "Sum"),
1 target.stats = c(75, 120, 10, 10))
nw_san <- set_vertex_attribute(nw_san, "deg1+.net2",
pmin(get_degree(nw_san_2), 1))- 1
-
Targets: 75 total edges, 120 degree-1 nodes, 10 edges involving nodes active in layer 1, and 10 for the
Sumterm linking layer 2 degree to layer 1 degree categories.
Verify that both SAN networks approximate their target statistics. These do not need to be exact — they are starting conditions for the ERGM estimation.
# Layer 1 targets: 90 edges, 60 homophilous
summary(nw_san ~ edges + nodematch("race") + nodefactor("deg1+.net2")) edges nodematch.race nodefactor.deg1+.net2.1
90 60 10
# Layer 2 targets: 75 edges, 120 degree-1
summary(nw_san_2 ~ edges + degree(1) + nodefactor("deg1+.net1")) edges degree1 nodefactor.deg1+.net1.1
75 120 10
Set the binary degree attributes on the original network object for estimation.
nw <- set_vertex_attribute(nw, "deg1+.net1", pmin(get_degree(nw_san), 1))
nw <- set_vertex_attribute(nw, "deg1+.net2", pmin(get_degree(nw_san_2), 1))Network Model Estimation
The formation models include both layer-specific terms and cross-layer dependency terms. Layer 1 models race homophily; layer 2 models the degree-1 count. Both include a nodefactor term for the other layer’s degree, creating negative degree correlation across layers.
- 1
- Layer 1: 90 edges total, 60 race-homophilous, 10 among nodes with layer-2 edges (strong negative correlation).
- 2
- Layer 2: 75 edges total, 120 degree-1 nodes, 10 among nodes with layer-1 edges.
Dissolution models specify the mean partnership duration for each layer independently.
- 1
- Layer 1: longer, more stable partnerships (mean duration 100 time steps).
- 2
- Layer 2: shorter, more transient partnerships (mean duration 75 time steps).
Although we think of this as one multilayer network, estimation is done separately for each layer. The layers are linked through the shared nodal attributes (deg1+.net1, deg1+.net2).
est.1 <- netest(nw, formation.1, target.stats.1, coef.diss.1)
est.2 <- netest(nw, formation.2, target.stats.2, coef.diss.2)Network Diagnostics
Run diagnostics for each layer. Because the network is relatively small (500 nodes) and some target statistics are small (10 for cross-layer terms), there may be more variability than in a single-layer model.
dx.1 <- netdx(est.1, nsims = nsims, ncores = ncores, nsteps = nsteps)
Network Diagnostics
-----------------------
- Simulating 5 networks
- Calculating formation statistics
print(dx.1)EpiModel Network Diagnostics
=======================
Diagnostic Method: Dynamic
Simulations: 5
Time Steps per Sim: 500
Formation Diagnostics
-----------------------
Target Sim Mean Pct Diff Sim SE Z Score SD(Sim Means)
edges 90 91.201 1.334 1.896 0.633 6.193
nodematch.race 60 59.848 -0.254 2.006 -0.076 4.833
nodefactor.deg1+.net2.1 10 10.515 5.152 0.724 0.712 1.380
SD(Statistic)
edges 9.398
nodematch.race 8.297
nodefactor.deg1+.net2.1 3.308
Duration Diagnostics
-----------------------
Target Sim Mean Pct Diff Sim SE Z Score SD(Sim Means) SD(Statistic)
edges 100 102.418 2.418 2.168 1.115 2.697 9.451
Dissolution Diagnostics
-----------------------
Target Sim Mean Pct Diff Sim SE Z Score SD(Sim Means) SD(Statistic)
edges 0.01 0.01 1.407 0 0.655 0.001 0.011
plot(dx.1)
dx.2 <- netdx(est.2, nsims = nsims, ncores = ncores, nsteps = nsteps)
Network Diagnostics
-----------------------
- Simulating 5 networks
- Calculating formation statistics
print(dx.2)EpiModel Network Diagnostics
=======================
Diagnostic Method: Dynamic
Simulations: 5
Time Steps per Sim: 500
Formation Diagnostics
-----------------------
Target Sim Mean Pct Diff Sim SE Z Score SD(Sim Means)
edges 75 76.621 2.162 1.025 1.582 2.189
degree1 120 121.692 1.410 1.553 1.089 3.992
nodefactor.deg1+.net1.1 10 10.032 0.316 0.504 0.063 2.040
SD(Statistic)
edges 6.783
degree1 10.494
nodefactor.deg1+.net1.1 3.141
Duration Diagnostics
-----------------------
Target Sim Mean Pct Diff Sim SE Z Score SD(Sim Means) SD(Statistic)
edges 75 72.589 -3.214 0.997 -2.418 1.267 6.213
Dissolution Diagnostics
-----------------------
Target Sim Mean Pct Diff Sim SE Z Score SD(Sim Means) SD(Statistic)
edges 0.013 0.013 -0.621 0 -0.314 0.001 0.013
plot(dx.2)
Epidemic Simulation
Parameters
Simple SI model parameters. The high transmission probability and act rate are deliberate — they generate a clearly visible epidemic so we can focus on the network mechanics rather than epidemiological realism.
Cross-Layer Update Function
This is the key mechanism for multilayer feedback. During each time step, EpiModel resimulates each network layer sequentially. Before resimulating layer k, it calls this function to update the cross-layer degree attributes that layer k’s formation model depends on. Without this function, the cross-layer degree attributes would be frozen at their initial values and the layers would evolve independently.
network_layer_updates <- function(dat, at, network) {
if (network == 0) {
1 dat <- set_attr(dat, "deg1+.net2", pmin(get_degree(dat$run$el[[2]]), 1))
} else if (network == 1) {
2 dat <- set_attr(dat, "deg1+.net1", pmin(get_degree(dat$run$el[[1]]), 1))
} else if (network == 2) {
3 dat <- set_attr(dat, "deg1+.net2", pmin(get_degree(dat$run$el[[2]]), 1))
}
return(dat)
}- 1
-
network == 0(before layer 1 resimulation): updatedeg1+.net2from current layer 2 edges. - 2
-
network == 1(before layer 2 resimulation): updatedeg1+.net1from current layer 1 edges. - 3
-
network == 2(after layer 2 resimulation): recalculatedeg1+.net2for summary statistics.
Control Settings and Simulation
control <- control.net(
type = "SI",
nsteps = nsteps,
nsims = nsims,
ncores = ncores,
1 tergmLite = TRUE,
2 resimulate.network = TRUE,
3 dat.updates = network_layer_updates,
4 nwstats.formula = multilayer(
"formation",
~edges + nodefactor("deg1+.net1") + degree(0:4)
)
)
5sim <- netsim(list(est.1, est.2), param, init, control)- 1
-
Use lightweight edgelist representation (required for
dat$run$el[[]]access in the update callback). - 2
- Redraw both layers each time step.
- 3
- The cross-layer degree update callback runs between layer resimulations.
- 4
-
multilayer()specifies per-layer network statistics to track. Layer 1 uses the default formation formula; layer 2 additionally tracks the full degree distribution (degree 0 through 4). - 5
-
Passing a list of
netestobjects tells EpiModel this is a multilayer model. The list order determines layer numbering.
Analysis
Network Formation During Simulation
Verify that network structure is maintained during the epidemic. These plots should show the simulated formation statistics tracking close to their targets throughout the run.
par(mfrow = c(1, 1), mar = c(3, 3, 2, 1), mgp = c(2, 1, 0))
print(sim, network = 1)EpiModel Simulation
=======================
Model class: netsim
Simulation Summary
-----------------------
Model type: SI
No. simulations: 5
No. time steps: 500
No. NW groups: 1
Fixed Parameters
---------------------------
inf.prob = 0.5
act.rate = 2
groups = 1
Model Output
-----------------------
Variables: s.num i.num num si.flow
Networks: sim1 ... sim5
Transmissions: sim1 ... sim5
Formation Statistics
-----------------------
Target Sim Mean Pct Diff Sim SE Z Score SD(Sim Means)
edges 90 85.86 -4.600 2.020 -2.049 10.270
nodematch.race 60 57.11 -4.817 1.279 -2.260 6.311
nodefactor.deg1+.net2.1 10 11.23 12.296 0.556 2.212 1.352
SD(Statistic)
edges 11.488
nodematch.race 7.749
nodefactor.deg1+.net2.1 3.702
Duration and Dissolution Statistics
-----------------------
Not available when:
- `control$tergmLite == TRUE`
- `control$save.network == FALSE`
- `control$save.diss.stats == FALSE`
- dissolution formula is not `~ offset(edges)`
- `keep.diss.stats == FALSE` (if merging)
plot(sim, network = 1, type = "formation",
main = "Layer 1: Formation Diagnostics During Simulation")
print(sim, network = 2)EpiModel Simulation
=======================
Model class: netsim
Simulation Summary
-----------------------
Model type: SI
No. simulations: 5
No. time steps: 500
No. NW groups: 1
Fixed Parameters
---------------------------
inf.prob = 0.5
act.rate = 2
groups = 1
Model Output
-----------------------
Variables: s.num i.num num si.flow
Networks: sim1 ... sim5
Transmissions: sim1 ... sim5
Formation Statistics
-----------------------
Target Sim Mean Pct Diff Sim SE Z Score SD(Sim Means)
edges 75 77.516 3.354 1.502 1.675 5.908
nodefactor.deg1+.net1.1 10 10.619 6.192 0.525 1.180 1.109
degree0 NA 361.565 NA 2.671 NA 10.154
degree1 120 123.728 3.107 2.570 1.451 9.013
degree2 NA 12.992 NA 0.511 NA 2.379
degree3 NA 1.549 NA 0.171 NA 0.452
degree4 NA 0.155 NA 0.052 NA 0.092
SD(Statistic)
edges 9.011
nodefactor.deg1+.net1.1 3.416
degree0 15.578
degree1 14.499
degree2 3.982
degree3 1.369
degree4 0.427
Duration and Dissolution Statistics
-----------------------
Not available when:
- `control$tergmLite == TRUE`
- `control$save.network == FALSE`
- `control$save.diss.stats == FALSE`
- dissolution formula is not `~ offset(edges)`
- `keep.diss.stats == FALSE` (if merging)
plot(sim, network = 2, type = "formation",
main = "Layer 2: Formation Diagnostics During Simulation")
SI Compartment Counts
plot(sim, y = c("s.num", "i.num"),
main = "SI Epidemic on Multilayer Network",
ylab = "Count", xlab = "Time Steps",
mean.col = c("steelblue", "firebrick"),
mean.lwd = 2, mean.smooth = TRUE,
qnts.col = c("steelblue", "firebrick"),
qnts.alpha = 0.2, qnts.smooth = TRUE,
legend = TRUE)
Prevalence
sim <- mutate_epi(sim, prev = i.num / num)
plot(sim, y = "prev",
main = "Prevalence over Time",
ylab = "Prevalence", xlab = "Time Steps",
mean.col = "firebrick", mean.lwd = 2, mean.smooth = TRUE,
qnts.col = "firebrick", qnts.alpha = 0.2, qnts.smooth = TRUE)
Incidence
plot(sim, y = "si.flow",
main = "New Infections per Time Step",
ylab = "Incidence", xlab = "Time Steps",
mean.col = "firebrick", mean.lwd = 2, mean.smooth = TRUE,
qnts.col = "firebrick", qnts.alpha = 0.2, qnts.smooth = TRUE)
Summary Table
df <- as.data.frame(sim)
last_t <- max(df$time)
knitr::kable(data.frame(
Metric = c("Population size",
"Final susceptible",
"Final infected",
"Final prevalence",
"Cumulative new infections",
"Peak incidence (per time step)"),
Value = c(n,
round(mean(df$s.num[df$time == last_t])),
round(mean(df$i.num[df$time == last_t])),
round(mean(df$prev[df$time == last_t]), 3),
round(mean(tapply(df$si.flow, df$sim, sum, na.rm = TRUE))),
max(df$si.flow, na.rm = TRUE))
))| Metric | Value |
|---|---|
| Population size | 500.000 |
| Final susceptible | 67.000 |
| Final infected | 433.000 |
| Final prevalence | 0.866 |
| Cumulative new infections | 423.000 |
| Peak incidence (per time step) | 7.000 |
Parameters
Epidemic
| Parameter | Description | Value |
|---|---|---|
inf.prob |
Per-act transmission probability | 0.5 |
act.rate |
Acts per partnership per time step | 2 |
Network Formation
| Parameter | Layer 1 | Layer 2 |
|---|---|---|
| Target edges | 90 | 75 |
Race homophily (nodematch) |
60 | — |
Degree-1 count (degree(1)) |
— | 120 |
Cross-layer nodefactor |
10 | 10 |
Network Dissolution
| Parameter | Layer 1 | Layer 2 |
|---|---|---|
| Mean duration (time steps) | 100 | 75 |
Key EpiModel Functions for Multilayer Models
| Function / Argument | Purpose |
|---|---|
netsim(list(est.1, est.2), ...) |
Passing a list of netest objects signals a multilayer model |
dat.updates in control.net() |
Callback function for cross-layer attribute updates |
multilayer() in nwstats.formula |
Specifies per-layer network statistics to track |
tergmLite = TRUE |
Uses lightweight edgelist representation (required for dat$run$el[[]] access) |
resimulate.network = TRUE |
Required for multilayer models with cross-layer dependency |
Next Steps
- Layer-specific transmission: different
inf.proboract.rateby edge type (e.g., lower transmission in casual vs. main partnerships) - More than two layers: add a third network layer (e.g., needle-sharing)
- Vital dynamics: add arrivals and departures with custom modules — see SI with Vital Dynamics for the departure and arrival module pattern
- Different disease models: extend to SIR, SEIR, or disease-specific models — see Adding an Exposed State for the progression module pattern
- Heterogeneous populations: add more nodal attributes beyond the binary
raceattribute used here