51 Appendix: Epidemics over Observed Networks
Everything in the course so far has followed one workflow: observe a network egocentrically (a sample of nodes and their partnerships), fit a temporal ERGM with netest, and simulate an epidemic from that fitted model. The fit is what lets a small sample stand in for a whole population, and what lets the network keep evolving during the run.
Sometimes you do not need the fit. If you have a dynamic network census, every node and every partnership directly observed over a series of time steps, you already have the object netsim would otherwise have to generate. This chapter shows the alternative workflow: place the observed networkDynamic object straight into the simulation and run the epidemic over it, with no ERGM estimation at all.
netest exists to turn partial, egocentric data into a generative model you can simulate from. A full dynamic census is not partial, so there is nothing to estimate. You hand netsim the observed network, tell it not to regenerate the network each step (resimulate.network = FALSE), and supply two small custom modules so EpiModel reads from the observed object instead of from a model fit. Everything else, the transmission process, the trackers, the plots, is the EpiModel you already know.
This is the one workflow in the course that bypasses the ERGM pipeline entirely. It is the right tool when the network really is observed in full over time (some contact-tracing, sensor, or animal-tracking datasets are), and the wrong tool when you have a sample and want to generalize from it, which is the far more common case and the reason the rest of the course teaches estimation.
For the opposite end of the same pipeline, see Module 10, where a small egocentric sample has to be reconciled into target statistics before anything can be fit. Reading the two together shows what estimation is actually buying: everything Module 10 spends its effort on, balance and degrees of freedom, is work a full census makes unnecessary.
Download the R script to follow along here.
51.1 Setup
We use a simulated dynamic network from the networkDynamicData package and treat it as if it were an observed census. The network is 1000 nodes with edge spells (onset and terminus times for each partnership) recorded over roughly 100 discrete time steps.
51.2 Two Custom Modules
Because there is no model fit, EpiModel’s built-in initialization and infection modules, which both expect a netest object, do not apply. We replace them with two functions that work from the observed network directly. Both follow the read-process-write module contract from Module 9.
51.2.1 Initialization
init_obsnw replaces the built-in initialize.net. It builds the master data object, drops the observed networkDynamic straight into it, seeds the initial infections, and (optionally) stores disease status on the network as a temporally extended attribute so we can color network plots by status later.
Code
init_obsnw <- function(x, param, init, control, s) {
dat <- create_dat_object(param, init, control)
dat$num.nw <- 1L
dat$run$nw[[1]] <- x # the observed network goes in as-is
dat <- set_param(dat, "groups", 1)
i.num <- get_init(dat, "i.num")
n <- network.size(dat$run$nw[[1]])
dat <- append_core_attr(dat, 1, n)
status <- rep("s", n)
status[sample(1:n, i.num)] <- "i" # seed i.num random infections
dat <- set_attr(dat, "status", status)
infTime <- rep(NA, n)
infTime[which(status == "i")] <- 1
dat <- set_attr(dat, "infTime", infTime)
track.nw.attr <- get_param(dat, "track.nw.attr", override.null.error = TRUE)
if (!is.null(track.nw.attr) && track.nw.attr) {
dat$run$nw[[1]] <- networkDynamic::activate.vertex.attribute(
dat$run$nw[[1]], prefix = "testatus",
value = get_attr(dat, "status"), onset = 1, terminus = Inf
)
}
dat <- prevalence.net(dat, 1)
return(dat)
}51.2.2 Infection
infect_obsnw handles transmission. It reads the discordant (susceptible-infectious) partnerships active at the current step off the observed network with discord_edgelist(), exactly as a normal infection module would, and draws transmission. It supports two modes: a constant per-act probability (inf.prob), or a two-stage, duration-dependent probability (inf.prob.stage1 / inf.prob.stage2), selected automatically by which parameters you supplied.
Code
infect_obsnw <- function(dat, at) {
active <- get_attr(dat, "active")
status <- get_attr(dat, "status")
infTime <- get_attr(dat, "infTime")
act.rate <- get_param(dat, "act.rate")
inf.prob.stage1 <- get_param(dat, "inf.prob.stage1", override.null.error = TRUE)
time_varying <- !is.null(inf.prob.stage1)
if (time_varying) {
inf.prob.stage2 <- get_param(dat, "inf.prob.stage2")
dur.stage1 <- get_param(dat, "dur.stage1")
} else {
inf.prob <- get_param(dat, "inf.prob")
}
idsInf <- which(active == 1 & status == "i")
nActive <- sum(active == 1)
nElig <- length(idsInf)
totInf <- 0
if (nElig > 0 && nElig < nActive) {
del <- discord_edgelist(dat, at) # SI pairs active on the observed network
if (!is.null(del)) {
if (time_varying) {
infDur.del <- at - infTime[del$inf]
del$transProb <- ifelse(infDur.del <= dur.stage1,
inf.prob.stage1, inf.prob.stage2)
} else {
del$transProb <- inf.prob
}
del$actRate <- act.rate
del$finalProb <- 1 - (1 - del$transProb) ^ del$actRate
transmit <- rbinom(nrow(del), 1, del$finalProb)
del <- del[which(transmit == 1), ]
idsNewInf <- unique(del$sus)
totInf <- length(idsNewInf)
if (totInf > 0) {
status[idsNewInf] <- "i"
infTime[idsNewInf] <- at
dat <- set_attr(dat, "status", status)
dat <- set_attr(dat, "infTime", infTime)
track.nw.attr <- get_param(dat, "track.nw.attr", override.null.error = TRUE)
if (!is.null(track.nw.attr) && track.nw.attr) {
dat$run$nw[[1]] <- networkDynamic::activate.vertex.attribute(
dat$run$nw[[1]], prefix = "testatus",
value = "i", onset = at, terminus = Inf, v = idsNewInf
)
}
}
}
}
dat <- set_epi(dat, "si.flow", at, totInf)
return(dat)
}51.3 Loading the Observed Network
The concurrencyComparisonNets dataset provides an observed dynamic network as its base object. We remove its pre-recorded disease status attribute, since we simulate our own epidemic on top of the observed contact structure.
Code
NetworkDynamic properties:
distinct change times: 102
maximal time range: -Inf until Inf
Includes optional net.obs.period attribute:
Network observation period info:
Number of observation spells: 1
Maximal time range observed: 2 until 102
Temporal mode: discrete
Time unit: step
Suggested time increment: 1
Network attributes:
vertices = 1000
directed = FALSE
hyper = FALSE
loops = FALSE
multiple = FALSE
bipartite = FALSE
net.obs.period: (not shown)
total edges= 1916
missing edges= 0
non-missing edges= 1916
Vertex attribute names:
vertex.names
Edge attribute names not shown
51.4 Example 1: A Basic SI Epidemic
The first run is a plain SI model: a constant 50% per-act transmission probability and one act per partnership per step.
The control settings are where the observed-network workflow shows itself. We pass our two custom modules, keep the built-in prevalence.net recorder, and set resimulate.network = FALSE so the observed edges are used as-is. There is no ERGM fit to compute network statistics from, so we turn off save.nwstats. Critically, nsteps matches the number of observed time steps in the network.
Note that the first argument to netsim is the observed network nw, not a fitted netest object. That substitution is the whole point.
EpiModel Simulation
=======================
Model class: netsim
Simulation Summary
-----------------------
Model type:
No. simulations: 5
No. time steps: 100
No. NW groups: 1
Fixed Parameters
---------------------------
inf.prob = 0.5
act.rate = 1
groups = 1
Model Functions
-----------------------
initialize.FUN
resim_nets.FUN
summary_nets.FUN
infection.FUN
nwupdate.FUN
prevalence.FUN
verbose.FUN
Model Output
-----------------------
Variables: s.num i.num num si.flow
Networks: sim1 ... sim5
Transmissions: sim1 ... sim5
51.5 A Caveat: Match nsteps to the Observation Window
The observed network has edge activity recorded over a finite window of about 100 steps. By a networkDynamic convention, edges active at the last observed time stay active indefinitely, so nothing stops you from simulating past the window, but the results stop meaning anything: the edge set is frozen, so it no longer reflects real turnover.
You can see the frozen edges directly. The dyads active at step 100 and step 200 are identical, because nothing dissolves after the observation ends:
[,1] [,2]
[1,] 834 4
[2,] 251 9
[3,] 722 9
[4,] 93 10
[5,] 169 10
[,1] [,2]
[1,] 445 46
[2,] 464 78
[3,] 380 270
[4,] 777 365
[5,] 850 808
[,1] [,2]
[1,] 445 46
[2,] 464 78
[3,] 380 270
[4,] 777 365
[5,] 850 808
Running 200 steps on the roughly 100-step network shows the symptom: prevalence plateaus and incidence falls to zero once the frozen structure takes over.
Code
control_caveat <- control.net(
type = NULL, nsteps = 200, nsims = nsims, ncores = ncores,
initialize.FUN = init_obsnw, infection.FUN = infect_obsnw,
prevalence.FUN = prevalence.net,
resimulate.network = FALSE, save.nwstats = FALSE, verbose = FALSE
)
sim_caveat <- netsim(nw, param, init, control_caveat)Code
par(mfrow = c(1, 2), mar = c(3, 3, 2, 1), mgp = c(2, 1, 0))
plot(sim_caveat, y = "i.num", main = "Prevalence past the window",
ylab = "Infected count", xlab = "Time step", legend = FALSE)
plot(sim_caveat, y = "si.flow", main = "Incidence past the window",
ylab = "New infections", xlab = "Time step", legend = FALSE)
The rule is simple: match nsteps to the number of observed time steps.
51.6 Example 2: Time-Varying Transmission
The second run uses the duration-dependent mode of the infection module. Transmission is low in the primary stage (5% per act for the first 5 steps after infection) and higher afterward (15% per act), a common shape for infections with an early low-shedding period. Setting track.nw.attr = TRUE stores disease status on the network so we can visualize it.
Code
param <- param.net(
inf.prob.stage1 = 0.05,
inf.prob.stage2 = 0.15,
dur.stage1 = 5,
act.rate = 1,
track.nw.attr = TRUE
)
init <- init.net(i.num = 10)
control <- control.net(
type = NULL, nsteps = nsteps, nsims = nsims, ncores = ncores,
initialize.FUN = init_obsnw, infection.FUN = infect_obsnw,
prevalence.FUN = prevalence.net,
resimulate.network = FALSE, save.nwstats = FALSE, verbose = FALSE
)Because we tracked status on the network, we can plot the observed network colored by disease status at an early and a late step.
Code

The same stored attribute can be read back at the individual level with the networkDynamic API, which is how you would extract who was infected when for a downstream analysis:
51.7 Comparing the Two Runs
The constant, high transmission of Example 1 saturates quickly; the lower, staged transmission of Example 2 climbs more slowly.
Code
sim <- mutate_epi(sim, prev = i.num / num)
sim2 <- mutate_epi(sim2, prev = i.num / num)
par(mfrow = c(1, 2), mar = c(3, 3, 2, 1), mgp = c(2, 1, 0))
plot(sim, y = "i.num", main = "Ex 1: constant transmission",
ylab = "Infected count", xlab = "Time step", legend = FALSE)
plot(sim2, y = "i.num", main = "Ex 2: time-varying transmission",
ylab = "Infected count", xlab = "Time step", legend = FALSE)
51.8 When to Use This
| Fit and simulate (the rest of the course) | Observed network (this chapter) | |
|---|---|---|
| You have | an egocentric sample of the network | a full dynamic census of nodes and edges over time |
| Network step | netest fits a TERGM you simulate from |
the observed networkDynamic object is used directly |
resimulate.network |
TRUE (the model regenerates the network) |
FALSE (the observed edges are fixed) |
| Custom modules | usually not needed for the network itself | a custom initialize.FUN and infection.FUN |
| Run length | any nsteps the model supports |
nsteps capped at the observation window |
The estimation workflow is the general-purpose one, because full network censuses are rare and samples are the norm. Reach for the observed-network approach only when you genuinely have the whole network over time and want to run transmission on exactly that structure without a modeling layer in between.
51.9 Your Turn
- Convert the model to SIS or SIR by adding a recovery module (the Module 9 pattern), and see how the fixed observation window interacts with recovery.
- Swap in a different observed dynamic network from
networkDynamicDataand confirm you resetnstepsto its own observation length. - Bridge the two workflows: take a static observed network, assume an edge dissolution rate, and fit a TERGM with
netest, the approach from the Module 6 network-census lab, then contrast it with using a dynamic census directly here.