29  Clustering and Heterogeneity

The previous tutorial changed timing, how partnerships overlap and how long they last. This one changes structure: whether contacts cluster into tight local groups, and whether contact patterns and durations depend on who people are. This is the most involved network we build by hand in these visualization tutorials, so we build it up one term at a time.

Note

Download the R script to follow along with this tutorial here.

29.1 Setup

Same packages, animation helper, and fixed epidemic as the timing tutorial. The inf.prob = 1 means every contact transmits with certainty, a deliberate extreme so that differences between the movies and the infection counts come from the network structure alone, not from the disease.

Code
library("EpiModel")
library("ndtv")
library("htmlwidgets")

slice.par  <- list(start = 1, end = 25, interval = 1, aggregate.dur = 1, rule = "any")
render.par <- list(tween.frames = 10, show.time = FALSE)
plot.par   <- list(mar = c(0, 0, 0, 0))

animate <- function(sim, ...) {
  nw <- get_network(sim)
  nw <- color_tea(nw, verbose = FALSE)
  compute.animation(nw, slice.par = slice.par, verbose = FALSE)
  render.d3movie(
    nw, render.par = render.par, plot.par = plot.par,
    vertex.col = "ndtvcol", edge.col = "darkgrey",
    vertex.border = "lightgrey", displaylabels = FALSE,
    output.mode = "htmlWidget", ...)
}

param   <- param.net(inf.prob = 1)
init    <- init.net(i.num = 10)
control <- control.net(type = "SI", nsteps = 25, nsims = 1)

29.2 Clustering: Triangles

Clustering means contacts that close triangles: if A is tied to B and to C, are B and C also tied to each other? A raw triangle term tends to make ERGMs numerically unstable (degenerate), so we use gwesp, the geometrically weighted edgewise shared partner term, which is better behaved. We call it gwesp(0, TRUE): the first argument is the decay parameter (set to 0), and fixed = TRUE holds it fixed rather than estimating it. With the decay fixed at 0, the statistic simplifies to the number of edges that have at least one shared partner, and each such edge closes at least one triangle.

Better behaved is not unconditionally well behaved: gwesp still has a feasible range, and asking for too much clustering at a fixed mean degree pushes back toward the instability we were avoiding. The null value here is under 1, so we target 15 of the 40 edges sitting in a triangle. (As with every target, 15 is an expected value; the realized count fluctuates around it.)

Code
set.seed(1234)
nw <- network_initialize(n = 100)
coef.diss <- dissolution_coefs(dissolution = ~offset(edges), duration = 20)
est.tri   <- netest(nw, ~edges + gwesp(0, TRUE), c(40, 15), coef.diss)
sim.tri   <- netsim(est.tri, param, init, control)
Code
animate(sim.tri, vertex.cex = 0.9)

Are triangles associated with faster or slower spread? In these specifications, slower. A triangle gives you a redundant path: once A infects B, B’s tie to C is unlikely to reach anyone new, because C is probably already infected by A. Clustering concentrates transmission opportunities among people who have already been reached, so a contact that could have opened a new part of the network gets spent on someone reachable anyway. The same number of edges does less work.

Note“Slower” is a direction here, not a law about triangles

Clustering slows spread in this setup, and the replicated runs below back that. But whether it does, and by how much, depends on the disease natural history, the rest of the network (degree distribution and degree correlations), the seeding, and which outcome you measure: final size, peak height, or early growth rate can respond differently, and in some regimes clustering can even speed early local growth. Read this as the direction under these specifications, not as a property of triangles in general.

Here is the replicated evidence for the direction, 200 runs each:

Code
final_prev <- function(est, nsims = 200) {
  sim <- netsim(est, param, init,
                control.net(type = "SI", nsteps = 25, nsims = nsims,
                            ncores = 5, verbose = FALSE))
  d <- as.data.frame(sim, out = "mean"); round(d$i.num[d$time == 25], 1)
}
set.seed(1234)
nw0 <- network_initialize(n = 100)
est.base <- netest(nw0, ~edges, 40, coef.diss, verbose = FALSE)

tri.evidence <- data.frame(
  specification = c("Baseline (edges only)", "With clustering (gwesp target 15)"),
  mean_infected_of_100 = c(final_prev(est.base), final_prev(est.tri)))
Code
tri.evidence
                      specification mean_infected_of_100
1             Baseline (edges only)                 60.2
2 With clustering (gwesp target 15)                 45.2

By step 25, the clustered network has reached fewer people on average than the baseline with the same mean degree. Because inf.prob = 1 eventually saturates whatever the network can reach, this gap is about how fast the infection spreads within the 25-step window, not the final size it would reach given unlimited time.

29.3 Heterogeneous Networks: Attributes

So far every node has been interchangeable except for infection status. Now we let contact patterns depend on who people are, using two ordinary (non-special) nodal attributes: a binary community (think of any geographic, demographic, or social split that shapes who contacts whom) and an integer age between 18 and 50.

Code
set.seed(1234)
nw <- network_initialize(n = 100)
nw <- set_vertex_attribute(nw, "community", rbinom(100, 1, 0.5))
nw <- set_vertex_attribute(nw, "age", sample(18:50, 100, TRUE))

We build the formation model up one term at a time, so each addition’s job is separable:

  • edges, target 50: overall mean degree of 1 (50 * 2 / 100). We use a slightly denser network than earlier so the added structure has enough edges to be visible.
  • nodematch("community"), target 40: of the 50 edges, 40 are within-community, so only 10 bridge the two communities. This is the community structure.
  • nodefactor("community"), target 70: lets mean degree differ by community. Its statistic is the summed degree of the non-reference group; nodefactor uses the lowest attribute value (community = 0) as the reference, so 70 is the total degree of the community = 1 nodes, giving them more ties than average. This is activity heterogeneity.
  • absdiff("age"), target 100: a mixing term for a continuous attribute. Its statistic is the sum of absolute age differences across the 50 edges, so a target of 100 means partners average 2 years apart, strong age assortativity.
  • concurrent, target 30: nodes with two or more simultaneous partners. At mean degree 1, ppois(1, 1, lower.tail = FALSE) * 100 is about 26 by chance, so 30 asks for slightly more.

Assembled, with each target supplied in the same order as its term (the order is positional and nothing labels it, so we write both out one per line):

Code
1formation <- ~edges +
2  nodematch("community") +
3  nodefactor("community") +
4  absdiff("age") +
5  concurrent

target.stats <- c(50, 40, 70, 100, 30)
1
Overall edges (mean degree 1).
2
Within-community edges (community structure).
3
Degree of the community = 1 nodes (activity heterogeneity).
4
Summed absolute age gap across edges (age assortativity).
5
Concurrent nodes.

For now we keep a single homogeneous duration; the next section makes duration depend on partnership type.

Code
est.attr <- netest(nw, formation, target.stats,
                   dissolution_coefs(~offset(edges), duration = 20))

To read the two attributes in the movie, we encode community as node shape (a square vs an effectively round polygon) and age as node size.

Code
sim.attr <- netsim(est.attr, param, init, control)
nw.a <- get_network(sim.attr)
community <- get_vertex_attribute(nw.a, "community")
age       <- get_vertex_attribute(nw.a, "age")
Code
animate(sim.attr,
        vertex.sides = ifelse(community == 1, 4, 50),
        vertex.cex   = age / 25)

The infection now moves preferentially within a community and among people close in age, because that is where the edges are.

29.4 Heterogeneous Dissolution

Every dissolution model so far has been a single global number: one duration for every edge. Here we let duration depend on the kind of partnership. Within-community ties will average 10 steps and cross-community ties 20, so within-community ties are both more common and shorter-lived.

Code
coef.diss.het <- dissolution_coefs(
1  dissolution = ~offset(edges) + offset(nodematch("community")),
2  duration = c(20, 10))
1
The dissolution formula, which must be dyad-independent. edges is the baseline that applies to every edge; nodematch("community") adjusts it for edges whose endpoints match.
2
The order is the whole trap. The first duration is the reference group, the edges that do not match on community. The second is the matched group. Swap them and the model is still valid, just backwards from what you meant, and nothing warns you.
NoteThe dissolution model is a leading subset of the formation model

The two dissolution terms here, edges and nodematch("community"), are exactly the first two terms of the formation model, in the same order. That is required, not incidental. dissolution_coefs computes one correction per dissolution term (coef.form.corr), and netest subtracts each correction from the positionally corresponding formation coefficient. So the dissolution formula has to be a leading prefix of the formation formula: same terms, same order, at the start. (This is the current EpiModel convention; the underlying ergm/tergm machinery has been reworked over recent releases, so if you use an unfamiliar version, confirm against ?dissolution_coefs.)

NoteWhy heterogeneous dissolution is harder to read than to write

Specifying this model is easy: you name a duration per partnership type, and the two numbers are independent, 20 means 20 and 10 means 10. Reading it back is where it gets hard, because what the model stores is not what you supplied.

The coefficients are on the logit scale of persistence, where p = 1 - 1/duration. The first coefficient is the reference group’s persistence logit, logit(1 - 1/20) = 2.944, identical to the homogeneous duration = 20 model. But the second number is not the coefficient for a 10-step tie. It is the increment added to the baseline for matched edges: the matched persistence logit is logit(1 - 1/10) = 2.197, and the stored value is 2.197 - 2.944 = -0.747. Three things follow:

  • You cannot read a coefficient without knowing the reference group. -0.747 means nothing on its own.
  • The sign of the increment is direction, not duration. Negative means the matched group’s ties are shorter than the reference’s.
  • Equal durations give an increment of exactly zero, the homogeneous model reappearing as a special case.
NoteWhat can and cannot vary

Dissolution heterogeneity has to be a property of an edge, not a node. nodematch asks “are these two the same?” and nodemix asks “which cell of the mixing matrix does this pair fall in?” Both are questions about the dyad, so every edge has exactly one answer and therefore one duration. nodefactor is a question about a node, and an edge has two of them: an edge between a 20-year-old and a 50-year-old has no single age-group duration without inventing a rule. So nodematch and nodemix are supported for dissolution and nodefactor is not, even though it is perfectly dyad-independent. NME-II’s multi-layer models take the other route, giving each relationship type its own network with its own dissolution model.

Now we fit the full model and, crucially, check the durations came out where we asked. This is the only way to see the heterogeneity rather than assert it.

Code
est.het <- netest(nw, formation, target.stats, coef.diss.het)
Code
dx <- netdx(est.het, nsims = 5, nsteps = 500, ncores = 5, verbose = FALSE)
dx
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                      50   47.476   -5.048  0.526  -4.798         1.771
nodematch.community        40   37.825   -5.437  0.439  -4.952         0.924
nodefactor.community.1     70   66.025   -5.678  0.752  -5.282         1.572
absdiff.age               100   93.035   -6.965  1.589  -4.382         4.417
concurrent                 30   27.665   -7.783  0.352  -6.625         1.013
                       SD(Statistic)
edges                          6.599
nodematch.community            5.813
nodefactor.community.1        10.078
absdiff.age                   18.959
concurrent                     5.254

Duration Diagnostics
----------------------- 
                    Target Sim Mean Pct Diff Sim SE Z Score SD(Sim Means)
edges                   20   20.654    3.272  0.680   0.963         2.546
nodematch.community     10   10.019    0.190  0.157   0.121         0.338
                    SD(Statistic)
edges                       6.664
nodematch.community         1.782

Dissolution Diagnostics
----------------------- 
                    Target Sim Mean Pct Diff Sim SE Z Score SD(Sim Means)
edges                 0.05    0.049   -2.642  0.001  -0.947         0.002
nodematch.community   0.10    0.101    0.871  0.001   0.888         0.001
                    SD(Statistic)
edges                        0.07
nodematch.community          0.05

The Duration Diagnostics report the two groups on their natural scale, in time steps: the edges row is the reference (non-matched) duration and the nodematch.community row is the matched one. They land close to the 20 and 10 we asked for.

NoteTwo dissolution terms, two corrected formation coefficients

It is a common shorthand, and one earlier version of this tutorial used, that the edges dissolution approximation “corrects only the edges coefficient.” That is true only for a homogeneous dissolution model. Here the dissolution model has two terms, so netest corrects both corresponding formation coefficients: edges and nodematch("community"). You can see it by comparing est.het$coef.form.crude (the cross-sectional fit) with est.het$coef.form (the corrected coefficients the simulation uses): the first two entries differ, and the rest are untouched. That coefficient correction is exact for these dyad-independent terms. The realized dynamic statistics are a separate matter: because the edges dissolution approximation is only a first-order approximation, every formation statistic sits a few percent below its target in dynamic simulation (in the Formation Diagnostics above, all five are 5 to 8 percent under). The dyad-dependent concurrent term is the one the correction does not fully reach, so it drifts the most, about 8 percent here; that bias grows as durations get shorter, which is a reason not to pick durations shorter than the science requires.

Code
plot(dx, type = "duration")

Finally, the movie, with community and age encoded as before.

Code
sim.het <- netsim(est.het, param, init, control)
nw.h <- get_network(sim.het)
community <- get_vertex_attribute(nw.h, "community")
age       <- get_vertex_attribute(nw.h, "age")
Code
animate(sim.het,
        vertex.sides = ifelse(community == 1, 4, 50),
        vertex.cex   = age / 25)

29.5 Takeaways

  • Clustering (triangles, via gwesp) slows spread in these specifications, because triangles create redundant paths to people already reached. Whether it does in general depends on the disease, the network, the seeding, and the outcome.
  • Nodal attributes let contact patterns depend on who people are, steering which subgroups the infection reaches.
  • Heterogeneous dissolution lets partnership duration depend on the kind of partnership, and it is the one place in the course where dissolution is not a single global number. Reading its coefficients requires knowing the reference group, and its dissolution formula must be a leading subset of the formation formula.

Where do all these numbers come from? Every one was chosen, not measured. That is fine for demonstrating a mechanism, which is all this module set out to do, but it is exactly the wrong basis for a research model; the modules that follow are about estimating formation targets and durations from real network data. First, the lab gives you a chance to change one thing at a time and predict what the movie will do. (For anyone who wants to go further, an optional appendix after the lab develops the timing ideas into a single organizing question: how fast the network turns over, relative to the epidemic.)