Networks over time#
A panel study observes the same people’s network several times: the friendships in a school each year, the advice ties in a firm before and after a merger. A temporal ERGM, as R’s tergm fits, models each network given the one before it: which ties form, which persist, and which dissolve.
Sampson’s monks named the brothers they liked at three times, before a crisis split the monastery:
import ergmx
from ergmx import datasets
waves = [datasets.load(f"samplk{t}") for t in (1, 2, 3)]
[g.ecount() for g in waves]
[55, 57, 56]
Formation and persistence#
tergm’s operators evaluate terms on views of each transition, from a previous network to the current one:
Operator |
Evaluates its terms on |
A positive coefficient means |
|---|---|---|
the union of the previous and the current network |
more ties form |
|
their intersection: the ties that persisted |
more ties persist |
|
the same, with the statistics negated |
more ties dissolve |
|
the current network |
(a cross-sectional effect) |
|
the dyads that changed |
more change |
Form(~edges) only changes when a tie forms, and Persist(~edges) when one
persists or dissolves, so a model of only Form() and Persist() (or
Diss()) is separable (Krivitsky and Handcock 2014): formation and
dissolution are independent given the previous network, each an ERGM of its
own. Terms outside the operators describe the current network, as Cross().
ergmx.tergm() fits the model to the transitions of a series by
conditional maximum likelihood (CMLE), as tergm(..., estimate="CMLE"):
fit = ergmx.tergm(
waves,
"Form(~edges + mutual + gwesp(0.5, fixed=TRUE)) + Persist(~edges + mutual)",
seed=1,
)
fit.summary()
Monte Carlo Conditional Maximum Likelihood Results:
Estimate Std. Error MCMC % z value Pr(>|z|)
Form(1)~edges -3.7735 0.3662 0 -10.304 <1e-04 ***
Form(1)~mutual 1.9429 0.4092 0 4.748 <1e-04 ***
Form(1)~gwesp.OTP.fixed.0.5 0.3603 0.1530 0 2.356 0.01849 *
Persist(1)~edges 0.4592 0.2588 0 1.774 0.07599 .
Persist(1)~mutual 0.7163 0.4880 0 1.468 0.14221
---
Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
Log-likelihood: -188.1562 (MC SE 0.169) AIC: 386.3123 BIC: 408.3960
Fitted to 2 transitions, each conditional on the network before it.
Converged after 3 iterations (4 chains, 1024 samples).
Formation is rare (the Form(1)~edges coefficient), but much more likely
for a tie that reciprocates one (mutual) or closes two-paths (gwesp).
Liking, once there, tends to persist, a little more so when reciprocated.
R’s tergm gives the same estimates. The names follow tergm, with the linear
model’s column as in N(): Form(1)~edges.
Every transition has the same coefficients by default. The operators’ lm
argument lets them change over time, with the attributes .Time (the time of
the current network: 1 and 2 here), .TimeID (the transition’s position) and
.TimeDelta (the time since the previous network): Form(~edges, lm=~.Time)
estimates a trend in formation. ergmx.NetSeries() builds the series
with its times, when they are not 0, 1, 2…; tergm() builds it from a
list. The operators also take N()’s subset,
offset and label.
With a dyad-independent model, the CMLE is a logistic regression and exact:
Form(~edges) + Persist(~edges) gives the log-odds that a non-tie became a
tie, and that a tie persisted. Dyad-dependent models are fitted by Monte
Carlo MLE; their samples mix, as in tergm, proposals that toggle a dyad that
differs from the previous network, which undo formations and dissolutions
efficiently. Diagnostics, goodness of fit (also by transition, with
ergmx.gofN()) and model comparison work as for other fits.
Missing dyads#
Missing dyads (na edges) of the networks transitioned to are treated as
missing, as in a cross-sectional fit. Those of the networks transitioned
from (all but the last) are what the next transition is conditioned on,
and must be imputed first, as tergm’s NA.impute: na_impute="next" (each
missing dyad takes its value in the next wave), "previous", "majority"
(the more common value of the network’s observed dyads), "0" or "1";
several apply in turn, so ["previous", "0"] fills what the previous wave
can’t with non-ties. NetSeries() and tergm() take it:
fit = ergmx.tergm(waves_with_nonresponse, "Form(~edges + mutual) + Persist(~edges)",
na_impute="next")
Simulating the process#
A fitted temporal model describes a process: start from a network, and draw
each next one from the model given the current one.
fit.simulate(time_slices=...) runs it forward
from the last network of the series (or nw_start="first", a position, or a
network), as tergm’s simulate(fit, nw.start=, time.slices=). Coefficients
that change over time (lm=~.Time) continue their trend: the k-th step
after the last network has the time .Time + k .TimeDelta and the position
.TimeID + k, and the linear models’ predictions for them.
future = fit.simulate(time_slices=20, seed=1, monitor="edges + mutual")
future
DynamicSimulation: 20 time steps from a network of 18 vertices
time edges formed dissolved MCMC steps
0 56
1 56 21 21 3102
2 62 24 18 3956
3 65 21 18 3479
4 64 19 20 5092
5 55 18 27 4721
...
16 68 18 20 3643
17 53 10 25 3084
18 49 13 17 2936
19 47 18 20 3754
20 47 17 17 5449
The result, a ergmx.DynamicSimulation, has the networks
(future.networks), the model’s statistics of each transition
(future.stats), the monitor formula’s statistics of each network, the
ties that formed and dissolved at each step, and their durations:
finished, ongoing = future.durations()
print(f"{len(finished)} ties dissolved after {finished.mean():.1f} steps on average; "
f"{len(ongoing)} still present")
future.monitor["mutual"]
382 ties dissolved after 2.8 steps on average; 47 still present
array([13., 16., 15., 17., 14., 10., 14., 13., 13., 13., 14., 14., 16.,
16., 21., 23., 15., 9., 9., 11.])
ergmx.simulate_dynamic() simulates from any coefficients and starting
network. A time step’s Markov chain runs as in tergm: from the current
network, until the number of dyads that differ from it stops growing (a test
on its exponentially weighted increments), then as long again. Its length
adapts to the model and the network size; min_steps and max_steps bound
it, and equal values fix it.
A process from one network: the EGMME#
Often there is a single network, a cross-section, and some knowledge of how
long ties last: a survey of current partnerships, with their mean duration.
tergm’s equilibrium generalized method of moments (EGMME) finds a process
whose equilibrium matches both: coefficients of formation and persistence
such that, simulated for a long time, the network has the observed
statistics and its ties the observed ages. tergm(estimate="EGMME") takes
the network, the model, and targets, a formula of the statistics to match,
which can include statistics of tie ages: mean.age, edge.ages (their
sum), edges.ageinterval(from, to), edgecov.ages(x) and
nodefactor.mean.age(attr), with a tie’s age 1 in the step it formed, as in
tergm.
The Florentine marriages, as a process where marriages last ten time steps on average and families with a common partner marry more readily:
flo = datasets.load("flomarriage")
observed = ergmx.summary_stats(flo, "edges + gwesp(0, fixed=TRUE)")
egmme = ergmx.tergm(
flo,
"Form(~edges + gwesp(0, fixed=TRUE)) + Persist(~edges)",
estimate="EGMME",
targets="edges + gwesp(0, fixed=TRUE) + mean.age",
target_stats=[*observed.values(), 10],
seed=1,
)
egmme
Equilibrium Generalized Method of Moments Results:
Estimate Std. Error z value Pr(>|z|)
Form~edges -3.9943 0.4492 -8.892 6.03e-19
Form~gwesp.fixed.0 0.1372 0.3698 0.371 0.711
Persist~edges 2.1958 0.1945 11.287 1.52e-29
Targets:
target simulated
edges 20.000 20.906
gwesp.fixed.0 8.000 8.704
mean.age 10.000 9.818
Converged.
The estimate is found as tergm does: from EpiModel’s approximation of the
edges coefficients (formation, the log-odds of the density minus the log of
the mean duration; persistence, the log of the duration minus one),
stochastic approximation with Polyak averaging, and the delta method’s
standard errors, with the gradient of the targets estimated by central
differences under common random numbers. It needs at least as many targets
as coefficients. The fit’s simulate() runs the
process, and its tie ages are monitors there too:
run = egmme.simulate(time_slices=500, seed=2, monitor="edges + mean.age")
run.monitor["edges"][100:].mean(), run.monitor["mean.age"][100:].mean()
(np.float64(20.4825), np.float64(10.408881552375817))