---
title: "Checking a network analysis"
description: "How far sampling effort, network size and unseen links move connectance, H2' and nestedness, what a null model rescues, and the one thing the matrix can never tell you."
date: "2026-08-21 12:00"
categories: [R, ecological networks, species interactions, ggplot2, ecology tutorial]
image: thumbnail.png
image-alt: "An interaction accumulation curve beside metric trajectories that drift steadily as sampling effort increases."
---
The three earlier posts in this series built the toolkit: [metrics](../bipartite-network-metrics/) for describing a web, [modularity](../modularity-in-ecological-networks/) for its compartments, and [null models](../null-models-for-interaction-networks/) for deciding whether any of it beats chance. Each ended on the same unfinished thought: the numbers move when the sampling moves. This post makes that concrete by building a web whose true interaction probabilities are known, then sampling it at different intensities and watching what happens. Some of the damage a null model repairs. One part of it nothing repairs, and that is the honest limit worth carrying into your own data.
```{r}
#| label: setup
#| echo: false
#| message: false
#| warning: false
library(ggplot2)
library(grid)
te_ink <- "#16241d"; te_body <- "#2c3a31"; te_forest <- "#275139"; te_label <- "#46604a"
te_sage <- "#93a87f"; te_paper <- "#f5f4ee"; te_line <- "#dad9ca"; te_gold <- "#c9b458"
te_rust <- "#b5534e"
theme_te <- function(base = 12) theme_minimal(base_size = base) +
theme(plot.background = element_rect(fill = te_paper, colour = NA),
panel.background = element_rect(fill = te_paper, colour = NA),
panel.grid.minor = element_blank(),
panel.grid.major = element_line(colour = te_line, linewidth = 0.3),
axis.text = element_text(colour = te_body), axis.title = element_text(colour = te_label),
plot.title = element_text(colour = te_ink, face = "bold", size = rel(1.02)),
plot.subtitle = element_text(colour = te_label, size = rel(0.9)),
legend.text = element_text(colour = te_body), legend.title = element_text(colour = te_label))
# H2' and NODF as coded in the earlier posts of this series
H2prime <- function(W) {
m <- sum(W); if (m == 0) return(NA_real_)
pm <- W[W > 0] / m; H2 <- -sum(pm * log(pm))
r <- rowSums(W) / m; cc <- colSums(W) / m
H2max <- -sum(r[r > 0] * log(r[r > 0])) - sum(cc[cc > 0] * log(cc[cc > 0]))
rr <- rowSums(W); ccx <- colSums(W); ent <- 0
repeat {
if (sum(rr) <= 0) break
best <- -1; bi <- bj <- NA
for (i in which(rr > 0)) for (j in which(ccx > 0)) { v <- min(rr[i], ccx[j]); if (v > best) { best <- v; bi <- i; bj <- j } }
p <- best / m; ent <- ent - p * log(p); rr[bi] <- rr[bi] - best; ccx[bj] <- ccx[bj] - best
}
if (H2max <= ent) return(0)
(H2max - H2) / (H2max - ent)
}
nodf <- function(M) {
pc <- function(deg, mat, mar) {
idx <- order(deg, decreasing = TRUE); s <- 0; np <- 0
for (a in seq_along(idx)) for (b in seq_along(idx)) if (a < b) {
i <- idx[a]; j <- idx[b]; di <- deg[i]; dj <- deg[j]
if (di > dj && dj > 0) { ov <- if (mar == "r") sum(mat[i, ] & mat[j, ]) / dj else sum(mat[, i] & mat[, j]) / dj; s <- s + ov * 100 }
np <- np + 1
}
c(s = s, np = np)
}
r <- pc(rowSums(M), M, "r"); cc <- pc(colSums(M), M, "c")
as.numeric((r["s"] + cc["s"]) / (r["np"] + cc["np"]))
}
null_swap <- function(M, nsw = 2000) {
for (s in seq_len(nsw)) {
ii <- sample(nrow(M), 2); jj <- sample(ncol(M), 2); sub <- M[ii, jj]
if (all(sub == matrix(c(1, 0, 0, 1), 2))) M[ii, jj] <- matrix(c(0, 1, 1, 0), 2)
else if (all(sub == matrix(c(0, 1, 1, 0), 2))) M[ii, jj] <- matrix(c(1, 0, 0, 1), 2)
}
M
}
```
## A web whose truth you know
Every interaction matrix in the wild is a sample: the observer watched for a fixed number of hours and wrote down what happened. To see what that sampling does, define the true interaction probabilities first, then draw from them.
```{r}
#| label: true-web
set.seed(4263)
A <- 20L; P <- 16L
a_row <- exp(rnorm(A, 0, 0.8)); b_col <- exp(rnorm(P, 0, 0.7))
mu <- runif(A); pos <- seq(0.05, 0.95, length.out = P); niche_w <- 0.22
Ptrue <- outer(a_row, b_col) * exp(-(outer(mu, pos, "-")^2) / (2 * niche_w^2))
Ptrue <- Ptrue / sum(Ptrue)
n_possible <- sum(Ptrue > 1e-6)
c(cells = A * P, ecologically_possible = n_possible)
```
Of the `r A * P` cells, `r n_possible` carry a non-negligible probability: those are the links that exist in this system. The rest are trait mismatches that will never be observed no matter how long you watch. Sampling n interactions is a multinomial draw over the cells.
## The first diagnostic: how much of the web have you seen?
Before any index, ask how complete the sample is. The interaction accumulation curve counts distinct links discovered as interactions are recorded, the network analogue of a species accumulation curve. A curve still climbing at the end of the data is a web with links left to find.
```{r}
#| label: accumulation
set.seed(6)
pool <- sample(rep(seq_len(A * P), round(Ptrue * 100000)))
grid_n <- c(seq(20, 400, 20), seq(500, 3000, 250))
accum <- vapply(grid_n, function(k) length(unique(pool[seq_len(k)])), numeric(1))
acc_df <- data.frame(sampled = grid_n, unique_links = accum)
acc_end <- accum[length(accum)]
c(links_found_at_3000 = acc_end, links_that_exist = n_possible,
fraction = round(acc_end / n_possible, 2))
```
After 3000 recorded interactions the curve has found `r acc_end` of the `r n_possible` links that exist, `r sprintf("%.0f", 100 * acc_end / n_possible)` per cent, and is still rising. Real pollination datasets are usually far short of that (Chacoff et al 2012; Jordano 2016), which matters because the missing links are not a random subset: they are the rare ones.
## Every metric drifts with effort
Now sample the same true web at increasing intensity and recompute the indices from the earlier posts.
```{r}
#| label: effort-drift
samp_metrics <- function(n) {
W <- matrix(rmultinom(1, n, as.vector(Ptrue)), A, P)
Wt <- W[rowSums(W) > 0, colSums(W) > 0, drop = FALSE]
c(effort = n, connectance = sum(W > 0) / (A * P),
H2prime = H2prime(Wt), NODF = nodf((Wt > 0) * 1))
}
set.seed(5)
efforts <- c(50, 100, 200, 400, 800, 1600, 3200)
drift <- as.data.frame(t(vapply(efforts, samp_metrics, numeric(4))))
conn_lo <- drift$connectance[1]; conn_hi <- drift$connectance[nrow(drift)]
h2_lo <- drift$H2prime[1]; h2_hi <- drift$H2prime[nrow(drift)]
nodf_lo <- drift$NODF[1]; nodf_hi <- drift$NODF[nrow(drift)]
round(drift, 3)
```
Nothing about the ecology changed between these rows. Only the watching did, and every number moved a long way. Connectance climbed from `r sprintf("%.2f", conn_lo)` to `r sprintf("%.2f", conn_hi)`, because more effort finds more rare links. Nestedness rose from `r sprintf("%.0f", nodf_lo)` to `r sprintf("%.0f", nodf_hi)`. Most striking, the specialisation index H2' fell from `r sprintf("%.2f", h2_lo)` to `r sprintf("%.2f", h2_hi)`: at low effort the web looks highly specialised, and the apparent specialisation dissolves as sampling improves. H2' was introduced in the first post as the index that travels best because it standardises against the marginal totals. It still drifts by a factor of four here. A thin sample makes generalists look like specialists, which is the same confound that made poorly sampled pollinators score high on d' (Bluthgen et al 2008; Frund et al 2016).
```{r}
#| label: fig-effort
#| echo: false
#| fig-width: 10
#| fig-height: 4.3
#| fig-cap: "Left: the interaction accumulation curve, still climbing after 3000 recorded interactions. Right: connectance, H2' and nestedness against sampling effort on a log scale; all three drift steadily although the underlying web never changes."
#| fig-alt: "An accumulation curve rising steeply then bending but not levelling, and three metric trajectories against effort, two rising and the specialisation index falling."
pa <- ggplot(acc_df, aes(sampled, unique_links)) +
geom_hline(yintercept = n_possible, linetype = "dashed", colour = te_rust, linewidth = 0.5) +
annotate("text", x = 0, y = n_possible, label = "links that exist", hjust = -0.05, vjust = -0.6,
colour = te_rust, size = 3.2) +
geom_line(colour = te_forest, linewidth = 0.9) + geom_point(colour = te_forest, size = 1.4) +
labs(title = "Interaction accumulation", x = "interactions recorded", y = "distinct links found",
subtitle = sprintf("%.0f%% of existing links found at 3000 interactions", 100 * acc_end / n_possible)) +
theme_te()
dl <- rbind(data.frame(effort = drift$effort, value = drift$connectance, metric = "connectance"),
data.frame(effort = drift$effort, value = drift$H2prime, metric = "H2' (specialisation)"),
data.frame(effort = drift$effort, value = drift$NODF / 100, metric = "NODF / 100"))
pb <- ggplot(dl, aes(effort, value, colour = metric)) +
geom_line(linewidth = 0.9) + geom_point(size = 1.9) +
scale_x_log10() +
scale_colour_manual(values = c(connectance = te_forest, `H2' (specialisation)` = te_rust,
`NODF / 100` = te_gold), name = NULL) +
labs(title = "Metrics against sampling effort", x = "interactions recorded (log scale)", y = "value",
subtitle = "same true web throughout") +
theme_te() + theme(legend.position = c(0.72, 0.35),
legend.background = element_rect(fill = te_paper, colour = NA))
grid.newpage(); pushViewport(viewport(layout = grid.layout(1, 2)))
print(pa, vp = viewport(layout.pos.row = 1, layout.pos.col = 1))
print(pb, vp = viewport(layout.pos.row = 1, layout.pos.col = 2))
```
## Size dependence: bigger webs look sparser
The second confound is dimension. Generate webs from the same process at different species richness, with effort scaled to richness so that per-species sampling is constant.
```{r}
#| label: size-dependence
gen_web <- function(S, eff_per_sp = 32) {
a <- exp(rnorm(S, 0, 0.7)); b <- exp(rnorm(S, 0, 0.6))
mu_s <- runif(S); pos_s <- seq(0.05, 0.95, length.out = S)
Pt <- outer(a, b) * exp(-(outer(mu_s, pos_s, "-")^2) / (2 * 0.22^2)); Pt <- Pt / sum(Pt)
W <- matrix(rmultinom(1, eff_per_sp * S, as.vector(Pt)), S, S)
W[rowSums(W) > 0, colSums(W) > 0, drop = FALSE]
}
set.seed(77)
sizes <- c(6, 9, 12, 15, 18, 21, 24)
size_res <- as.data.frame(t(vapply(sizes, function(S) {
reps <- replicate(8, { W <- gen_web(S); c(conn = sum(W > 0) / (nrow(W) * ncol(W)), nd = nodf((W > 0) * 1)) })
c(size = S, connectance = mean(reps["conn", ]), NODF = mean(reps["nd", ]))
}, numeric(3))))
size_conn_lo <- size_res$connectance[nrow(size_res)]; size_conn_hi <- size_res$connectance[1]
round(size_res, 3)
```
The niche width, the abundance spread and the effort per species are identical across these webs. Only the number of species differs, and connectance falls from `r sprintf("%.2f", size_conn_hi)` in the smallest web to `r sprintf("%.2f", size_conn_lo)` in the largest. This is the well-known scaling of connectance with richness (Olesen and Jordano 2002; Bluthgen 2010): a species cannot double its partners just because the web doubled in size, so the realised fraction of possible cells shrinks. Comparing raw connectance between a 20-species web and a 60-species web says more about their dimensions than their ecology.
## What the null model rescues
Given that the raw numbers move, does the null-model machinery from the previous post fare better? Recompute nestedness across effort, but read each value against a fixed-degree null rather than on its own.
```{r}
#| label: null-rescue
set.seed(41)
zt <- as.data.frame(t(vapply(c(100, 200, 400, 800, 1600, 3200), function(n) {
W <- matrix(rmultinom(1, n, as.vector(Ptrue)), A, P)
B <- (W > 0) * 1; B <- B[rowSums(B) > 0, colSums(B) > 0, drop = FALSE]
obs <- nodf(B); nd <- replicate(99, nodf(null_swap(B)))
c(effort = n, raw_NODF = obs, z = (obs - mean(nd)) / sd(nd))
}, numeric(3))))
z_raw_lo <- zt$raw_NODF[1]; z_raw_hi <- zt$raw_NODF[nrow(zt)]
z_range <- range(zt$z)
round(zt, 2)
```
The raw nestedness climbs from `r sprintf("%.0f", z_raw_lo)` to `r sprintf("%.0f", z_raw_hi)` across this range of effort, a factor of `r sprintf("%.1f", z_raw_hi / z_raw_lo)`. The z-score does not follow it: it wanders between `r sprintf("%.1f", z_range[1])` and `r sprintf("%.1f", z_range[2])` with no systematic trend, and never gives a convincing signal. That is the right answer, because this web was built with no nestedness beyond what its degree sequence forces. The null does its job: the verdict is stable even while the raw index quadruples. This is the practical case for reporting departures from a null rather than raw values, and it holds for modularity and specialisation as much as nestedness.
The z-score is noisy, though, and reading a single one as a precise effect size would overstate it. It answers a yes or no question about structure beyond the degrees; it does not restore the raw metric's comparability.
```{r}
#| label: fig-rescue
#| echo: false
#| fig-width: 10
#| fig-height: 4.3
#| fig-cap: "Left: connectance against species richness, with per-species effort held constant. Right: raw nestedness rises steeply with effort while its z-score against a fixed-degree null shows no systematic drift."
#| fig-alt: "A downward curve of connectance against richness, and a two-line panel where the raw nestedness climbs while the z-score stays flat and near zero."
pc <- ggplot(size_res, aes(size, connectance)) +
geom_line(colour = te_forest, linewidth = 0.9) + geom_point(colour = te_forest, size = 2.2) +
labs(title = "Connectance falls with network size", x = "species per side", y = "connectance",
subtitle = "identical ecology and per-species effort") +
theme_te()
zl <- rbind(data.frame(effort = zt$effort, value = zt$raw_NODF / 100, what = "raw NODF / 100"),
data.frame(effort = zt$effort, value = zt$z, what = "z vs fixed-degree null"))
pd <- ggplot(zl, aes(effort, value, colour = what)) +
geom_hline(yintercept = 0, colour = te_line, linewidth = 0.4) +
geom_line(linewidth = 0.9) + geom_point(size = 2) +
scale_x_log10() +
scale_colour_manual(values = c(`raw NODF / 100` = te_gold, `z vs fixed-degree null` = te_forest), name = NULL) +
labs(title = "The null stabilises the verdict", x = "interactions recorded (log scale)", y = "value",
subtitle = "raw index drifts, z-score does not") +
theme_te() + theme(legend.position = c(0.70, 0.85),
legend.background = element_rect(fill = te_paper, colour = NA))
grid.newpage(); pushViewport(viewport(layout = grid.layout(1, 2)))
print(pc, vp = viewport(layout.pos.row = 1, layout.pos.col = 1))
print(pd, vp = viewport(layout.pos.row = 1, layout.pos.col = 2))
```
## The honest limit: a zero has two meanings
Here is what no null model, no standardised index and no amount of care with the analysis can fix. Take a realistic sample and look at the empty cells.
```{r}
#| label: forbidden-vs-missing
set.seed(42)
W_obs <- matrix(rmultinom(1, 600, as.vector(Ptrue)), A, P)
zero_cells <- which(W_obs == 0)
thr <- 1e-4
n_forbidden <- sum(Ptrue[zero_cells] < thr)
n_missing <- sum(Ptrue[zero_cells] >= thr)
exp_visits <- 600 * Ptrue[zero_cells][Ptrue[zero_cells] >= thr]
c(zeros = length(zero_cells), forbidden = n_forbidden, missing = n_missing,
pct_missing = round(100 * n_missing / length(zero_cells)),
median_expected_visits = round(median(exp_visits), 2))
```
The sample has `r length(zero_cells)` empty cells. Of those, `r n_forbidden` are genuine: trait mismatches, phenological non-overlap, links that cannot happen. The other `r n_missing`, which is `r sprintf("%.0f", 100 * n_missing / length(zero_cells))` per cent of the zeros, are real interactions that simply were not seen, most of them expected to occur less than once at this effort (median `r sprintf("%.2f", median(exp_visits))` expected visits). In the matrix these two kinds of zero are the same character. Nothing in the data distinguishes a forbidden link from a missing one, and yet every index in this series treats them identically: connectance counts them as absences, NODF reads them as gaps in a nested pattern, H2' scores them as evidence of choosiness.
Only outside information separates them. Trait measurements can rule a link impossible, phenological records can show two species never overlap, and more effort turns a missing link into an observed one (Olesen et al 2011; Jordano 2016). None of it comes from the matrix. This is the same shape as the honest limits elsewhere on this blog: the unseen species behind a richness estimate, the untestable exchangeability behind a conformal interval, the assumption of no unmeasured confounding behind a causal claim. The checking constrains the reading; it does not license certainty.
## Where this leaves you
Three checks are worth running on any interaction network before the indices are reported. Plot the interaction accumulation curve and say plainly how complete the sample is. Refuse to compare raw connectance, linkage density or nestedness across webs of different size or effort, because those numbers scale with both. Report departures from an explicit null instead of raw values, since the z-score survives a fourfold drift in the raw index that would otherwise be read as ecology.
None of that reaches the deepest problem. Most of the zeros in a typical web are links that exist and were missed, and the matrix cannot tell you which. Network analysis is a description of a sample, and the further your conclusions travel from that sample, the more they lean on evidence that is not in it.
## References
Bluthgen, N. (2010). Why network analysis is often disconnected from community ecology: a critique and an ecologist's guide. *Basic and Applied Ecology*, 11(3), 185-195. https://doi.org/10.1016/j.baae.2010.01.001
Bluthgen, N., Frund, J., Vazquez, D. P. and Menzel, F. (2008). What do interaction network metrics tell us about specialization and biological traits? *Ecology*, 89(12), 3387-3399. https://doi.org/10.1890/07-2121.1
Chacoff, N. P., Vazquez, D. P., Lomascolo, S. B., Stevani, E. L., Dorado, J. and Padron, B. (2012). Evaluating sampling completeness in a desert plant-pollinator network. *Journal of Animal Ecology*, 81(1), 190-200. https://doi.org/10.1111/j.1365-2656.2011.01883.x
Frund, J., McCann, K. S. and Williams, N. M. (2016). Sampling bias is a challenge for quantifying specialization and network structure: lessons from a quantitative niche model. *Oikos*, 125(4), 502-513. https://doi.org/10.1111/oik.02256
Jordano, P. (2016). Sampling networks of ecological interactions. *Functional Ecology*, 30(12), 1883-1893. https://doi.org/10.1111/1365-2435.12763
Olesen, J. M. and Jordano, P. (2002). Geographic patterns in plant-pollinator mutualistic networks. *Ecology*, 83(9), 2416-2424.
Olesen, J. M., Bascompte, J., Dupont, Y. L., Elberling, H., Rasmussen, C. and Jordano, P. (2011). Missing and forbidden links in mutualistic networks. *Proceedings of the Royal Society B: Biological Sciences*, 278(1706), 725-732. https://doi.org/10.1098/rspb.2010.1371
## Related tutorials
- [Bipartite network metrics from scratch](../bipartite-network-metrics/)
- [Null models for interaction networks](../null-models-for-interaction-networks/)
- [Modularity in ecological networks](../modularity-in-ecological-networks/)
- [Rarefaction and accumulation curves](../rarefaction-accumulation-curves/)