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: wrap the observed networkDynamic object with netcensus(), hand it to netsim in place of a netest fit, 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. netcensus() turns the observed network into a layer object that netsim accepts anywhere a netest fit would go. The simulation reads the partnerships active at each time step from the observed object, and everything else, the built-in disease types, 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. It is also not a way to model how the observed ties arise: a network whose ties you want to reproduce from their predictors, whatever their turnover, is an ERGM’s job.
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.
netcensus() was added in EpiModel 2.7.0. Earlier versions require the workaround this chapter used to teach: a custom initialization module that places the networkDynamic object on dat$run$nw[[1]] and a custom infection module that reads it with discord_edgelist(). The EpiModel Gallery rabies example shows the same netcensus() workflow on an observed wildlife network.
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 Loading the Observed Network
The concurrencyComparisonNets dataset provides an observed dynamic network as its base object. It carries a pre-recorded disease status as a temporally extended vertex attribute, status.active, from the simulation that produced it. netcensus() drops such attributes with a message, since netsim keeps its own disease status.
NetworkDynamic properties:
distinct change times: 104
maximal time range: -Inf until Inf
Dynamic (TEA) attributes:
Vertex TEAs: status.active
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:
status.active vertex.names
Edge attribute names not shown
netcensus() wraps the network as a layer object. Its print method reports the observation window, taken from the network’s net.obs.period attribute, and how many partnerships are active per step across it.
Dropping temporally extended vertex attribute `status.active` from the census network; netsim keeps its own disease status.
EpiModel Observed Network Layer
=======================
Model class: netcensus
Network type: dynamic (networkDynamic)
Layer Summary
-----------------------
Nodes: 1000
Observation window: [2, 102), time steps 2 to 101
Edges ever observed: 1916
Edges active per step: min 356 , median 378 , max 416
Mean degree per step: 0.765
Vertex attributes: none
Two things to notice. First, the window runs from 2 to 102, so the observed steps are 2 through 101; the nsteps we set above stays inside it. Second, there is no netdx step here: netdx() refuses a netcensus object, because there is no fitted model whose fit could be diagnosed. The print above is the diagnostic.
51.3 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 the ones you already know from the built-in models. type = "SI" selects the built-in modules. resimulate.network = FALSE tells netsim that no layer needs regenerating each step, which is true here because the only layer is observed; a netcensus layer is skipped by the network resimulation in either case, so the setting matters only when the census sits next to an estimated layer. Nothing custom is passed in.
The first argument to netsim is the netcensus object, not a fitted netest object. That substitution is the whole point.
EpiModel Simulation
=======================
Model class: netsim
Simulation Summary
-----------------------
Model type: SI
No. simulations: 5
No. time steps: 100
No. NW groups: 1
Model Parameters
---------------------------
inf.prob = 0.5
act.rate = 1
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) SD(Statistic)
edges NA 382.69 NA 4.038 NA 0 16.53
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)
- the network is a `netclique` or `netcensus` layer
The network statistics table prints the number of active partnerships per step, with no target: an observed layer has nothing to be compared against. The transmission matrix records every infection with the layer it crossed, which for a one-layer model is always network 1.
51.4 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
netsim warns when nsteps reaches the end of the window. Running 200 steps on the roughly 100-step network shows the symptom the warning is about: prevalence plateaus and incidence falls to zero once the frozen structure takes over.
Code
Warning: `nsteps` (200) reaches the end of the observation window [2, 102) of
network 1, a dynamic `netcensus` layer. Past the window, the layer holds only
the edges whose spells extend beyond it: spells censored at the end (terminus
Inf) stay active, and spells that end with the observation leave the layer
empty.
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.5 Example 2: Transmission by Duration of Infection
The second run makes transmission depend on how long the infector has been infected: 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. The built-in infection module supports this through by_infection_duration(), which takes a value and a length in time steps for each stage of infection. The last stage lasts until the infection ends, so its duration is Inf (see ?by_infection_duration). This is variation over the course of each infection, not over calendar time; a change at a given time step of the simulation is the job of scenarios.
Code
Values by stage of infection (time steps since infection):
Stage Steps Value
primary 1-5 0.05
later 6+ 0.15
Without tergmLite, netsim stores disease status on the networkDynamic object as a temporally extended attribute, so 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.6 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: transmission by stage",
ylab = "Infected count", xlab = "Time step", legend = FALSE)
51.7 Running Faster with tergmLite
The runs above keep the full networkDynamic object, which is what makes the status-colored network plots possible. For a large census or many simulations, tergmLite = TRUE is faster: netsim extracts the active edgelist from the observed object at each step and carries nothing else. Every other setting is unchanged.
Code
[,1] [,2] [,3]
networkDynamic 28.2 62.4 265.4
tergmLite 30.6 71.8 295.0
The two modes read the same partnerships at each step, so they differ only by simulation noise.
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 |
netcensus() wraps the observed networkDynamic object |
| Diagnostics | netdx checks the fit against its targets |
none to run; print(obs) summarizes the observed edges |
| Vital dynamics | arrivals and departures supported | refused; the node set is the observed one |
| Custom modules | as needed for the disease process | the same; nothing extra for the network |
| 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. A netcensus layer can also sit in the same layer list as netest fits and netclique layers (see the multi-layer tutorial), with per-layer transmission probabilities set through multilayer() in param.net().
51.9 Your Turn
- Convert the model to SIS or SIR by changing
typeand addingrec.rate, 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. Thehospitaldataset (RFID proximity contacts in a hospital ward, timed in seconds) needs its spells rescaled to a coarser step first. - 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.