flowchart LR
S["<b>S</b>"] -->|"infection<br/>(family or community)"| E
E["<b>E</b>"] -->|"~4 days"| Ip
Ip["<b>I_p</b><br/>presymp"] -->|"~70%"| Is["<b>I_s</b><br/>symp"]
Ip -->|"~30%"| Ia["<b>I_a</b><br/>asymp"]
Is -->|"~7 days"| R["<b>R</b>"]
Ia -->|"~7 days"| R
style S fill:#3498db,color:#fff
style E fill:#9b59b6,color:#fff
style Ip fill:#f39c12,color:#fff
style Is fill:#e74c3c,color:#fff
style Ia fill:#e67e22,color:#fff
style R fill:#27ae60,color:#fff
Age-Stratified RSV on a Multilayer Network
Overview
Every other infectious-disease example in the Gallery models a sexually transmitted pathogen over long-duration partnership networks. RSV is a fundamentally different kind of disease: a respiratory virus that spreads through casual contact, with severity concentrated at the two ends of the age distribution (infants and elderly), and with a 2023-vintage portfolio of age-targeted biomedical interventions (older-adult vaccines, infant monoclonal antibodies). The example brings together three EpiModel capabilities that no current example combines:
A multilayer contact network. Two age-aware layers run simultaneously: a family layer (long-duration ties, low-degree, adults as hubs to children and elderly) and a community layer (transient ties, high-degree, age-assortative). The “family” layer is a stylized close-contact layer rather than a fixed household roster: edges have a long mean duration but still resimulate each step. Both layers are estimated via
nodemixso we can target specific cells of the age-by-age mixing matrix.A five-stratum age structure. Infants, young (1-4), school-age (5-17), adults, and elderly. Age governs network contacts, per-infection severity, and intervention eligibility.
Age-targeted interventions matched to real products. Older-adult vaccination (Arexvy / Abrysvo style), infant passive antibody (Nirsevimab style), and a community-layer NPI overlay.
The policy question the example answers: given the same season, which intervention prevents the most hospitalizations, and how does dose efficiency compare across age targets?
- model.R — Main simulation script
- module-fx.R — Custom module functions
Model Structure
Disease Compartments
| Status | Sub-stage | Description |
|---|---|---|
| S | – | Susceptible |
| E | – | Exposed (latent, ~4 days) |
| I | inf_stage = "ip" |
Presymptomatic infectious (~2 days) |
| I | inf_stage = "is" |
Symptomatic infectious (~7 days) |
| I | inf_stage = "ia" |
Asymptomatic infectious (~7 days, 0.5x infectiousness) |
| R | – | Recovered |
The presymptomatic and asymptomatic split is what motivates NPIs for respiratory pathogens. The standard status attribute holds the SEIR compartment; an additional inf_stage attribute carries the I substage.
Age Structure
| Group | Range | Pop fraction | Community degree | Hosp rate per inf |
|---|---|---|---|---|
| infant | 0-1 yr | ~1.2% | ~1 | 0.050 |
| young | 1-4 | ~5% | ~5 | 0.008 |
| school | 5-17 | ~16% | ~8 | 0.002 |
| adult | 18-64 | ~60% | ~6 | 0.005 |
| elderly | 65+ | ~17% | ~3 | 0.030 |
Hospitalization rates per infection are applied post-hoc as multipliers on simulated infection counts. This keeps the disease model itself simple (no hospitalization compartment) while letting the analysis answer the burden-weighted question that actually matters for policy.
Setup
suppressMessages(library(EpiModel))
# The rendered page uses a smaller N for faster Quarto rendering (the
# heavy chunks below are not marked `eval: false`, so they run during
# render). The qualitative findings are stable at this scale per the
# convergence-analysis section near the bottom of the page. The standalone
# `model.R` ships with N = 5000, nsims = 5 by default.
N <- 2000
nsims <- 3
ncores <- 3
nsteps <- 120# Load the custom modules (init_attrs, infect, progress) used in the
# control.net() call below. The code excerpts shown later in this page
# under "Custom Modules" are pulled from this same file.
source("module-fx.R")Population Generation
The population generator samples households with realistic age compositions, then flattens them into per-person age and household vectors. The household structure is used only to keep the joint age distribution realistic — it isn’t used as an ERGM constraint. The family network ERGM, defined below, models adult-centered contact patterns directly via nodemix.
generate_population <- function(N, seed = NULL) {
if (!is.null(seed)) set.seed(seed)
hh_types <- list(
list(p = 0.18, ages = c("adult")),
list(p = 0.20, ages = c("adult", "adult")),
list(p = 0.10, ages = c("elderly")),
list(p = 0.10, ages = c("elderly", "elderly")),
list(p = 0.04, ages = c("adult", "young", "school")),
1 list(p = 0.03, ages = c("adult", "adult", "infant")),
list(p = 0.05, ages = c("adult", "adult", "young")),
list(p = 0.05, ages = c("adult", "adult", "young", "school")),
list(p = 0.10, ages = c("adult", "adult", "school")),
list(p = 0.05, ages = c("adult", "adult", "school", "school")),
list(p = 0.05, ages = c("adult", "adult", "school", "elderly")),
list(p = 0.05, ages = c("adult", "adult", "adult"))
)
probs <- sapply(hh_types, function(h) h$p)
ages <- character(0); hh_id <- integer(0); cur <- 1L
while (length(ages) < N) {
idx <- sample.int(length(hh_types), 1, prob = probs)
m <- hh_types[[idx]]$ages
ages <- c(ages, m); hh_id <- c(hh_id, rep(cur, length(m)))
cur <- cur + 1L
}
list(age = ages[1:N], household = hh_id[1:N])
}
set.seed(123)
pop <- generate_population(N, seed = 123)
counts <- table(factor(pop$age,
levels = c("adult","elderly","infant","school","young")))- 1
-
Households containing infants always also contain at least one adult. This supports a plausible joint age distribution (enough infant-adult co-residence to make the
adult.infantnodemix target meaningful), but the household IDs themselves are not used as an ERGM constraint — adult-infant transmission in the simulation is governed by the family layer’sadult.infantmixing target, not by which infant lives with which adult.
The Multilayer Network with nodemix
The two layers share the same node set and age attribute but are estimated as independent ERGMs and run simultaneously inside netsim. ergm’s nodemix("age", levels2 = ...) lets us target specific cells of the upper-triangle mixing matrix — diagonal (within-age, e.g. school.school) and off-diagonal (cross-age, e.g. adult.infant) cells in one model. The cell ordering used by levels2 is upper-triangle column-major with alphabetical factor levels (adult, elderly, infant, school, young):
| Index | Cell | Index | Cell |
|---|---|---|---|
| 1 | adult.adult | 9 | infant.school |
| 2 | adult.elderly | 10 | school.school |
| 3 | elderly.elderly | 11 | adult.young |
| 4 | adult.infant | 12 | elderly.young |
| 5 | elderly.infant | 13 | infant.young |
| 6 | infant.infant | 14 | school.young |
| 7 | adult.school | 15 | young.young |
| 8 | elderly.school |
Network Initialization
nw <- network_initialize(N)
nw <- set_vertex_attribute(nw, "age", pop$age)Family Layer: adults as hubs
fam_md <- c(adult = 3, elderly = 2, infant = 2, school = 3, young = 3)
fam_edges <- round(sum(counts * fam_md[names(counts)]) / 2)
fam_cell_targets <- c(
1 adult.adult = round(counts["adult"] * 1.0 / 2),
2 adult.infant = round(counts["infant"] * 2.0),
adult.school = round(counts["school"] * 1.5),
adult.young = round(counts["young"] * 1.7),
elderly.elderly = round(counts["elderly"] * 1.0 / 2),
school.school = round(counts["school"] * 0.5 / 2)
)
3formation_fam <- ~edges + nodemix("age", levels2 = c(1, 4, 7, 11, 3, 10))
4coef.diss_fam <- dissolution_coefs(~offset(edges), duration = nsteps)
target_fam <- c(fam_edges, as.numeric(fam_cell_targets))
est_fam <- netest(nw, formation_fam, target_fam, coef.diss_fam,
verbose = FALSE)- 1
- About one within-adult tie per adult on average (couples + adult roommates).
- 2
- Two adult ties per infant on average (the two parents).
- 3
- Sparse cell selection. Six of the 15 mixing cells are constrained; the other nine are left to ergm to fit. This keeps the parameterization identifiable while controlling the cells that carry epidemiological signal.
- 4
- Long mean duration. Family ties have a mean duration equal to the simulation horizon. Ties still resimulate each step, so a fraction turn over during the season – the layer is a stylized “long-duration close contact” layer rather than a fixed household roster.
Community Layer: age-assortative, high-degree
com_md <- c(adult = 6, elderly = 3, infant = 1, school = 8, young = 5)
com_edges <- round(sum(counts * com_md[names(counts)]) / 2)
com_cell_targets <- c(
adult.adult = round(counts["adult"] * 3.0 / 2),
elderly.elderly = round(counts["elderly"] * 1.5 / 2),
1 school.school = round(counts["school"] * 5.0 / 2),
young.young = round(counts["young"] * 2.5 / 2),
adult.school = round(counts["school"] * 0.5)
)
formation_com <- ~edges + nodemix("age", levels2 = c(1, 3, 10, 15, 7))
2coef.diss_com <- dissolution_coefs(~offset(edges), duration = 1)
target_com <- c(com_edges, as.numeric(com_cell_targets))
est_com <- netest(nw, formation_com, target_com, coef.diss_com,
verbose = FALSE)- 1
- The model’s school amplifier. School-age children have 5 within-school ties on average — by far the highest density of any cell in this parameterization. School-age contact is widely understood to drive seasonal respiratory virus transmission, but the specific magnitude here is a model choice, not a calibrated estimate.
- 2
- Transient. Each day brings a new set of community contacts.
Why nodemix, not nodefactor or absdiff
nodefactor("age")controls per-age activity (the marginal degree distribution) but not who-mixes-with-whom. Without homophily terms the school amplifier doesn’t emerge.nodematch("age", diff = TRUE)adds within-age homophily for each level but leaves cross-age cells unconstrained.absdiffwith a numeric age treats age as a continuous distance — works for symmetric homophily but cannot encode the asymmetric “adult-as-family-hub” structure.nodemixis the most expressive: every diagonal and off-diagonal cell is individually targetable. We constrain only the epidemiologically meaningful cells, leaving the rest free, so ergm has room to fit without saturation.
Custom Modules
Three custom modules: init_attrs (one-shot setup of per-node intervention status), infect (S to E transmission across both layers), and progress (E to I_p to I_s/I_a to R). Full code in module-fx.R; key excerpts below.
infect: per-layer transmission
for (k in 1:2) {
el <- dat$run$el[[k]]
if (is.null(el) || nrow(el) == 0) next
head <- el[, 1]; tail <- el[, 2]
if (k == 2 && com.contact.mult < 1) {
keep <- which(rbinom(nrow(el), 1, com.contact.mult) == 1)
head <- head[keep]; tail <- tail[keep]
}
# ... identify (sus, inf) pairs on this layer's discordant edges ...
base.p <- layer_probs[k]
stages <- inf_stage[inf]
trans.p <- ifelse(stages %in% "ia", base.p * asymp.mult, base.p)
vax <- vax_status[sus]
eff <- rep(0, length(sus))
eff[!is.na(vax) & vax == "elderly_vax"] <- vax.eff.elderly
eff[!is.na(vax) & vax == "infant_proph"] <- vax.eff.infant
trans.p <- trans.p * (1 - eff)
new_inf <- sus[rbinom(length(trans.p), 1, trans.p) == 1]
all_new <- c(all_new, new_inf)
}Per-step transmission probability per discordant edge is built up from:
- Per-layer iteration. With
tergmLite = TRUE, each network layer’s current edgelist is indat$run$el[[k]]. We walk each layer separately rather than callingdiscord_edgelist(), because we want different transmission rates on the family vs community layer. - NPI community-edge thinning. When NPIs are active, we drop a fraction of community edges on the fly (representing reduced contact intensity).
- Asymptomatic infectiousness multiplier. Each row’s transmission probability is halved if the infectious partner is in state
"ia". - Vaccine / prophylaxis efficacy on the susceptible side. Looking up the susceptible’s
vax_statuslets us apply leaky reductions for the two age-targeted interventions.
Disease + Intervention Parameters
make_param <- function(elderly.cov = 0, elderly.eff = 0.75,
infant.cov = 0, infant.eff = 0.70,
npi.start = -1, npi.end = -1,
npi.mask.eff = 0.4, npi.contact.mult = 0.7) {
param.net(
1 inf.prob.family = 0.05,
inf.prob.community = 0.018,
asymp.inf.mult = 0.5,
ei.rate = 1 / 4,
ip.rate = 1 / 2,
ir.rate = 1 / 7,
asymp.prob = 0.3,
elderly.vax.coverage = elderly.cov,
elderly.vax.efficacy = elderly.eff,
infant.proph.coverage = infant.cov,
infant.proph.efficacy = infant.eff,
npi.start = npi.start, npi.end = npi.end,
npi.mask.efficacy = npi.mask.eff,
npi.contact.mult = npi.contact.mult
)
}
init <- init.net(i.num = round(0.005 * N))
control <- control.net(
type = NULL,
nsims = nsims, ncores = ncores, nsteps = nsteps,
tergmLite = TRUE,
resimulate.network = TRUE,
initAttr.FUN = init_attrs,
infection.FUN = infect,
progress.FUN = progress,
verbose = FALSE
)- 1
-
Per-edge per-day transmission probabilities. The family and community rates differ because family contacts are higher-intensity than casual community contacts (
inf.prob.family > inf.prob.community).
Scenarios
Five scenarios run on the same fitted networks with identical disease parameters; only the intervention configuration differs.
Each intervention is implemented as a stylized per-edge reduction in susceptibility to infection. Real RSV vaccines and monoclonal-antibody prophylaxis are evaluated against medically-attended outcomes — LRTI, hospitalization, severe disease — not sterilizing protection. Using a susceptibility reduction here is a teaching simplification; it lets the same infections × per-age hospitalization risk pipeline approximate the policy-relevant outcome, but it does not represent the underlying biology or the regulatory endpoints.
Real-world CDC guidance also differs from the model’s blanket-eligibility wording:
- Adult vaccine (Arexvy / Abrysvo): a single dose for adults 75+ and for adults 50-74 at increased risk of severe disease. The model’s “all 65+” scope is a simplification.
- Infant prophylaxis: maternal RSV vaccination OR an infant long-acting monoclonal antibody (nirsevimab or clesrovimab). Only the infant-side monoclonal antibody is modeled here.
See the References section at the bottom of the page.
sims <- list(
none = netsim(list(est_fam, est_com), make_param(),
init, control),
elderly_vax = netsim(list(est_fam, est_com),
make_param(elderly.cov = 0.7),
init, control),
infant_proph = netsim(list(est_fam, est_com),
make_param(infant.cov = 0.8),
init, control),
both = netsim(list(est_fam, est_com),
make_param(elderly.cov = 0.7, infant.cov = 0.8),
init, control),
npi = netsim(list(est_fam, est_com),
make_param(npi.start = 20, npi.end = 80),
init, control)
)Analysis
Computing Hospitalizations and Doses
Hospitalizations are derived post-hoc by multiplying age-stratified cumulative infections by per-infection hospitalization risks. See the Hospitalization-rates section above and the References for the rationale.
hosp_rate <- c(infant = 0.05, young = 0.008, school = 0.002,
adult = 0.005, elderly = 0.030)
age_groups <- c("infant", "young", "school", "adult", "elderly")
age_pop <- as.numeric(counts[age_groups])
names(age_pop) <- age_groups
scns <- list(
none = list(),
elderly_vax = list(elderly.cov = 0.7),
infant_proph = list(infant.cov = 0.8),
both = list(elderly.cov = 0.7, infant.cov = 0.8),
npi = list(npi.start = 20, npi.end = 80)
)
labels <- c(none = "No intervention",
elderly_vax = "Elderly vaccination",
infant_proph = "Infant prophylaxis",
both = "Both (elderly + infant)",
npi = "NPI (days 20-80)")
cols_scn <- c(none = "gray40", elderly_vax = "seagreen",
infant_proph = "purple", both = "darkblue",
npi = "firebrick")
cum_inf_by_scn <- list()
hosp_by_scn <- list()
doses_by_scn <- numeric(0)
for (s in names(sims)) {
df <- as.data.frame(sims[[s]])
last_t <- max(df$time)
cum_per_age <- sapply(age_groups, function(a)
mean(df[df$time == last_t, paste0("cuminf.", a)], na.rm = TRUE))
cum_inf_by_scn[[s]] <- cum_per_age
hosp_by_scn[[s]] <- cum_per_age * hosp_rate[age_groups]
doses <- 0
if (!is.null(scns[[s]]$elderly.cov))
doses <- doses + scns[[s]]$elderly.cov * age_pop["elderly"]
if (!is.null(scns[[s]]$infant.cov))
doses <- doses + scns[[s]]$infant.cov * age_pop["infant"]
doses_by_scn[s] <- doses
}Age-Stratified Cumulative Attack Rates
par(mfrow = c(2, 3), mar = c(3, 3.5, 2, 1), mgp = c(2.2, 0.7, 0))
for (a in age_groups) {
col_name <- paste0("cuminf.", a)
max_y <- 0
for (s in names(sims)) {
df <- as.data.frame(sims[[s]])
m <- tapply(df[[col_name]], df$time, mean, na.rm = TRUE) /
age_pop[a]
max_y <- max(max_y, max(m, na.rm = TRUE))
}
for (i in seq_along(sims)) {
s <- names(sims)[i]
df <- as.data.frame(sims[[s]])
m <- tapply(df[[col_name]], df$time, mean, na.rm = TRUE) /
age_pop[a]
if (i == 1) {
plot(as.numeric(names(m)), m, type = "l", lwd = 2,
col = cols_scn[s], xlab = "Day",
ylab = "Cumulative attack rate",
main = paste0(toupper(substr(a, 1, 1)),
substr(a, 2, nchar(a)),
" (N=", age_pop[a], ")"),
ylim = c(0, max(max_y, 0.01) * 1.05))
} else {
lines(as.numeric(names(m)), m, lwd = 2, col = cols_scn[s])
}
}
}
plot.new()
legend("center", legend = labels, col = cols_scn, lwd = 2,
bty = "n", cex = 1.0)
Hospitalization Burden by Age
hosp_mat <- sapply(hosp_by_scn, function(h) h)
par(mar = c(7, 4, 3, 1), mgp = c(2.5, 1, 0))
bp <- barplot(hosp_mat, names.arg = names(sims), las = 2,
col = c("#3498db", "#f39c12", "#e74c3c",
"#27ae60", "#8e44ad"),
ylab = "Hospitalizations",
main = "Stacked Hospitalizations by Age (lower = better)")
tot <- colSums(hosp_mat)
text(bp, tot + max(tot) * 0.03, sprintf("%.1f", tot),
cex = 0.9, font = 2)
legend("topright", legend = age_groups,
fill = c("#3498db", "#f39c12", "#e74c3c", "#27ae60", "#8e44ad"),
bty = "n", cex = 0.9)
Per-Dose Efficiency
The per-dose comparison highlights a real policy trade-off: infant prophylaxis has high per-dose efficiency (each dose targets a 5% per-infection hospitalization risk), but the total infant population is small. Older-adult vaccination uses many more doses for a comparable per-dose return but produces a much larger absolute reduction in hospitalizations.
none_hosp <- sum(hosp_by_scn$none)
averted_total <- sapply(hosp_by_scn, function(h) none_hosp - sum(h))
per_dose <- ifelse(doses_by_scn > 0, averted_total / doses_by_scn, NA)
par(mfrow = c(1, 2), mar = c(7, 5, 3, 1), mgp = c(3, 1, 0))
# Per-dose efficiency (only scenarios with doses)
keep <- which(doses_by_scn > 0)
bp1 <- barplot(per_dose[keep],
names.arg = labels[names(per_dose)[keep]],
col = cols_scn[names(per_dose)[keep]],
las = 2, cex.names = 0.8,
ylab = "Hosp. averted / dose",
main = "Per-Dose Efficiency",
ylim = c(0, max(per_dose[keep], na.rm = TRUE) * 1.2))
text(bp1, per_dose[keep] + max(per_dose[keep], na.rm = TRUE) * 0.03,
sprintf("%.3f", per_dose[keep]), cex = 0.85, font = 2)
# Total hospitalizations averted across all scenarios
bp2 <- barplot(averted_total,
names.arg = labels[names(averted_total)],
col = cols_scn[names(averted_total)],
las = 2, cex.names = 0.8,
ylab = "Hospitalizations averted",
main = "Total Averted (vs. no-intervention)",
ylim = c(0, max(averted_total) * 1.2))
text(bp2, averted_total + max(averted_total) * 0.03,
sprintf("%.1f", averted_total), cex = 0.85, font = 2)
Convergence Analysis
Coefficient of variation (across nsims = 5) of cumulative infections per age group, at the baseline scenario:
| N | infant CV | young | school | adult | elderly |
|---|---|---|---|---|---|
| 500 | 0.91 | 0.28 | 0.29 | 0.21 | 0.13 |
| 1000 | 0.70 | 0.13 | 0.07 | 0.06 | 0.05 |
| 2000 | 0.28 | 0.14 | 0.04 | 0.05 | 0.03 |
| 5000 | 0.22 | 0.08 | 0.04 | 0.04 | 0.05 |
| 10000 | 0.21 | 0.04 | 0.02 | 0.02 | 0.05 |
At N = 1000, the infant attack rate has CV ~0.70 across replicates — enough that the infant-prophylaxis story is unreliable. N = 5000 brings infant CV to ~0.22 and all other strata under 0.05, with full-run time under 60 seconds on a laptop. CI mode uses N = 500 for speed but produces results that are not meant to be interpretable.
Module Execution Order
resim_nets (each layer) -> summary_nets -> initAttr -> infection -> progress -> nwupdate -> prevalence
EpiModel’s default pipeline for multilayer networks. Both layer ERGMs are resimulated each day; then our init_attrs runs once on day 1; then infect walks both layers’ edgelists; then progress runs the E to I_p to I_s/I_a to R transitions.
Next Steps
- Multi-season dynamics. Add R-to-S waning of natural immunity (
rs.rate) and run for multiple winters — see SIR with Time-Varying Vaccination for the SIRS pattern. - Calibration to surveillance data. Fit
inf.prob.familyandinf.prob.communityto match published age-specific attack rates. - Waning vaccine immunity. A V-to-S flow at age-specific rates would let the model evaluate two- or three-season campaigns.
- Combined intervention timing. Pair the biomedical interventions here with the windowed / reactive activation patterns from SIR with Time-Varying Vaccination.
- Explicit households. Replace the family-layer
nodemixwithblocks()or pre-constructed household edgelists to enforce strict clique structure (the trade-off: ERGM convergence is harder). - Cross-layer dependency. The current implementation treats the two layers as independent. See Multinets for a
dat.updatescallback that links degree across layers.
References
CDC guidance (accessed November 2025):
- CDC, RSV vaccine guidance for adults: https://www.cdc.gov/rsv/hcp/vaccine-clinical-guidance/adults.html
- CDC, RSV immunization guidance for infants and young children: https://www.cdc.gov/rsv/hcp/vaccine-clinical-guidance/infants-young-children.html
- CDC ACIP, GRADE review for protein-subunit RSV vaccines in older adults: https://www.cdc.gov/acip/grade/protein-subunit-rsv-vaccines-older-adults.html
- CDC MMWR, Nirsevimab recommendations (Jones et al., 2023): https://www.cdc.gov/mmwr/volumes/72/wr/mm7234a4.htm
- CDC RSV Surveillance (RSV-NET): https://www.cdc.gov/rsv/research/rsv-net/dashboard.html
Epidemiological background:
- Hall CB, Weinberg GA, Iwane MK, et al. (2009). The burden of respiratory syncytial virus infection in young children. N Engl J Med 360(6):588-598.
- Falsey AR, Hennessey PA, Formica MA, et al. (2005). Respiratory syncytial virus infection in elderly and high-risk adults. N Engl J Med 352(17):1749-1759.
The model’s network mean degrees, age-mixing targets, per-edge transmission probabilities, and per-infection hospitalization risks are illustrative choices selected to reproduce the qualitative RSV pattern (high burden at age extremes; school-age children as a transmission amplifier in the model). They are not calibrated to any specific surveillance dataset or season.