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.

NoteThe one idea: skip the fit when the network is fully observed

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.

ImportantVersion note

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.

Note

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.

Code
library(EpiModel)
library(networkDynamicData)
set.seed(12345)

nsims  <- 5
ncores <- 5
nsteps <- 100

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.

Code
data(concurrencyComparisonNets)
nw <- base
print(nw)
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.

Code
obs <- netcensus(nw)
Dropping temporally extended vertex attribute `status.active` from the census network; netsim keeps its own disease status.
Code
obs
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.

Code
param <- param.net(inf.prob = 0.5, act.rate = 1)
init  <- init.net(i.num = 10)

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.

Code
control <- control.net(
  type = "SI",
  nsteps = nsteps,
  nsims = nsims,
  ncores = ncores,
  resimulate.network = FALSE,
  verbose = FALSE
)

The first argument to netsim is the netcensus object, not a fitted netest object. That substitution is the whole point.

Code
sim <- netsim(obs, param, init, control)
Code
sim
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:

Code
head(get.dyads.active(nw, at = 1),   5)
     [,1] [,2]
[1,]  834    4
[2,]  251    9
[3,]  722    9
[4,]   93   10
[5,]  169   10
Code
head(get.dyads.active(nw, at = 100), 5)
     [,1] [,2]
[1,]  445   46
[2,]  464   78
[3,]  380  270
[4,]  777  365
[5,]  850  808
Code
head(get.dyads.active(nw, at = 200), 5)
     [,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
control_caveat <- control.net(
  type = "SI", nsteps = 200, nsims = nsims, ncores = ncores,
  resimulate.network = FALSE, verbose = FALSE
)
sim_caveat <- netsim(obs, param, init, control_caveat)
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
inf.prob <- by_infection_duration(c(primary = 0.05, later = 0.15),
                                  durations = c(5, Inf))
inf.prob
Values by stage of infection (time steps since infection):
   Stage Steps Value
 primary   1-5  0.05
   later    6+  0.15
Code
param <- param.net(
  inf.prob = inf.prob,
  act.rate = 1
)
init <- init.net(i.num = 10)

control <- control.net(
  type = "SI", nsteps = nsteps, nsims = nsims, ncores = ncores,
  resimulate.network = FALSE, verbose = FALSE
)
Code
sim2 <- netsim(obs, param, init, control)

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
par(mfrow = c(1, 2), mar = c(1, 1, 1, 1))
plot(sim2, type = "network", col.status = TRUE, at = 2,      sims = 1)
plot(sim2, type = "network", col.status = TRUE, at = nsteps, sims = 1)

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:

Code
nwd <- get_network(sim2, 1)
head(get.vertex.attribute.active(nwd, "testatus", at = nsteps), 10)
 [1] "s" "i" "s" "s" "s" "s" "i" "s" "s" "i"

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
control_lite <- control.net(
  type = "SI", nsteps = nsteps, nsims = nsims, ncores = ncores,
  tergmLite = TRUE, verbose = FALSE
)
sim3 <- netsim(obs, param, init, control_lite)
Code
df2 <- as.data.frame(sim2, out = "mean")
df3 <- as.data.frame(sim3, out = "mean")
round(rbind(networkDynamic = df2$i.num[c(25, 50, 100)],
            tergmLite = df3$i.num[c(25, 50, 100)]), 1)
               [,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 type and adding rec.rate, and see how the fixed observation window interacts with recovery.
  • Swap in a different observed dynamic network from networkDynamicData and confirm you reset nsteps to its own observation length. The hospital dataset (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.