---
title: "Null models for interaction networks"
description: "Why a network metric means nothing on its own: three nulls (equiprobable, proportional, fixed-fixed), a nestedness verdict that flips with the null, and the robustness of a web to species loss, all in base R."
date: "2026-08-21 11:00"
categories: [R, ecological networks, species interactions, ggplot2, ecology tutorial]
image: thumbnail.png
image-alt: "Three overlaid null distributions of nestedness with the observed value inside one and far outside the others."
---
The earlier posts on [network metrics](../bipartite-network-metrics/) and [modularity](../modularity-in-ecological-networks/) ended on the same warning: a raw number cannot be read on its own, because a structureless web still returns a positive connectance, nestedness or modularity. The fix is a null model, a way of shuffling the web to ask what its value would be by chance. The catch is that there is more than one way to shuffle, and the verdict can flip depending on which one you pick. This post codes three nulls, shows a nestedness result changing from decisive to non-significant across them, and then applies the same logic to how robust a web is to species loss. All of it is base R, including the Patefield algorithm, which ships as `r2dtable`.
```{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))
# NODF (Almeida-Neto et al 2008); the full treatment is in the nestedness post
nodf <- function(M) {
paircontrib <- function(deg, mat, margin) {
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 (margin == "row") 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 <- paircontrib(rowSums(M), M, "row"); cc <- paircontrib(colSums(M), M, "col")
as.numeric((r["s"] + cc["s"]) / (r["np"] + cc["np"]))
}
```
## A web nested by its degree distribution
The test web is deliberately plain: interaction probability is the product of a pollinator's activity and a plant's activity, with no extra structure layered on. Heterogeneous activity alone makes the matrix look nested, because busy species interact with many partners and the quiet ones interact with subsets of those.
```{r}
#| label: build-web
set.seed(4262)
A0 <- 16L; P0 <- 14L
a_row <- exp(rnorm(A0, 0, 0.9)); b_col <- exp(rnorm(P0, 0, 0.8))
Pr <- outer(a_row, b_col); Pr <- Pr / max(Pr)
B <- matrix(rbinom(A0 * P0, 1, 0.85 * Pr), A0, P0)
B[cbind(seq_len(A0), max.col(Pr))] <- 1
B[cbind(max.col(t(Pr)), seq_len(P0))] <- 1
B <- B[rowSums(B) > 0, colSums(B) > 0, drop = FALSE]
A <- nrow(B); P <- ncol(B); L <- sum(B)
NODF_obs <- nodf(B)
c(pollinators = A, plants = P, links = L, connectance = round(L / (A * P), 3), NODF = round(NODF_obs, 2))
```
The web has `r A` pollinators, `r P` plants, connectance `r sprintf("%.2f", L / (A * P))` and an observed nestedness of NODF = `r sprintf("%.1f", NODF_obs)`. On its own that number says nothing. The question is what a random web would give.
## Three ways to shuffle
Each null keeps some feature of the observed web and randomises the rest, and they differ in how much they keep.
The **equiprobable** null keeps only the number of links: it scatters the same count of interactions across the matrix with every cell equally likely. The **proportional** null, in the spirit of Vazquez and Aizen (2003), keeps the marginal activity: a cell's chance of being filled rises with the product of its row and column degree, so busy species stay busy on average. The **fixed-fixed** null keeps both degree sequences exactly: every species ends with the same number of partners it started with, only rewired. The last is the strict test, generated by swapping checkerboard pairs that leave every row and column total untouched.
```{r}
#| label: nulls
null_equi <- function() { z <- numeric(A * P); z[sample(A * P, L)] <- 1; matrix(z, A, P) }
rd0 <- rowSums(B); cd0 <- colSums(B)
Pp <- outer(rd0, cd0); Pp <- pmin(Pp / sum(Pp) * L, 1)
null_prop <- function() matrix(rbinom(A * P, 1, Pp), A, P)
null_swap <- function(M, nsw = 3000) {
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
}
nrep <- 299L
set.seed(11); N_equi <- replicate(nrep, nodf(null_equi()))
set.seed(12); N_prop <- replicate(nrep, nodf(null_prop()))
set.seed(13); N_swap <- replicate(nrep, nodf(null_swap(B)))
zscore <- function(obs, nd) (obs - mean(nd)) / sd(nd)
pval <- function(obs, nd) (1 + sum(nd >= obs)) / (length(nd) + 1)
```
## The verdict flips
Reading the observed NODF against each null gives three different answers.
```{r}
#| label: verdict
verdict <- data.frame(
null = c("equiprobable", "proportional", "fixed-fixed"),
null_mean = c(mean(N_equi), mean(N_prop), mean(N_swap)),
z = c(zscore(NODF_obs, N_equi), zscore(NODF_obs, N_prop), zscore(NODF_obs, N_swap)),
p = c(pval(NODF_obs, N_equi), pval(NODF_obs, N_prop), pval(NODF_obs, N_swap)))
z_equi <- verdict$z[1]; z_prop <- verdict$z[2]; z_swap <- verdict$z[3]
p_swap <- verdict$p[3]
round(verdict[, -1], 3)
```
Against the equiprobable null the web is overwhelmingly nested, with a z-score of `r sprintf("%.1f", z_equi)`. Against the proportional null it is still clearly nested, z = `r sprintf("%.1f", z_prop)`. Against the fixed-fixed null it is not nested at all: z = `r sprintf("%.1f", z_swap)` and p = `r sprintf("%.2f", p_swap)`. The observed NODF of `r sprintf("%.1f", NODF_obs)` sits almost exactly on the fixed-fixed null mean of `r sprintf("%.1f", mean(N_swap))`, because once the degree sequence is fixed the nestedness is nearly pinned. The reading is the standard one (Ulrich, Almeida-Neto and Gotelli 2009): this web is no more nested than its degree distribution forces it to be, and the dramatic result under the looser nulls came from the activity heterogeneity, not from any extra nested arrangement.
```{r}
#| label: fig-nulls
#| echo: false
#| fig-width: 10
#| fig-height: 4.3
#| fig-cap: "Left: the distribution of NODF under each null, with the observed value marked. Right: the z-score of the observed NODF against each null; the dashed line is the two-sided five per cent threshold."
#| fig-alt: "Three overlaid histograms of null nestedness, the observed line far right of the equiprobable and proportional nulls but inside the fixed-fixed null, and a bar chart of z-scores dropping from about 17 to near zero across the three nulls."
dfn <- rbind(data.frame(NODF = N_equi, null = "equiprobable"),
data.frame(NODF = N_prop, null = "proportional"),
data.frame(NODF = N_swap, null = "fixed-fixed"))
dfn$null <- factor(dfn$null, levels = c("equiprobable", "proportional", "fixed-fixed"))
pa <- ggplot(dfn, aes(NODF, fill = null)) +
geom_histogram(bins = 30, colour = te_paper, linewidth = 0.15, alpha = 0.85, position = "identity") +
geom_vline(xintercept = NODF_obs, colour = te_ink, linewidth = 1) +
annotate("text", x = NODF_obs, y = Inf, label = sprintf("observed = %.0f", NODF_obs),
hjust = 1.05, vjust = 1.6, colour = te_ink, size = 3.3) +
scale_fill_manual(values = c(equiprobable = te_gold, proportional = te_sage, `fixed-fixed` = te_forest), name = "null model") +
labs(title = "Nestedness against three nulls", x = "NODF of randomised webs", y = "count",
subtitle = "far right of two nulls, inside the third") +
theme_te()
pb <- ggplot(verdict, aes(factor(null, levels = null), z, fill = null)) +
geom_col(width = 0.62) +
geom_hline(yintercept = 1.96, linetype = "dashed", colour = te_rust, linewidth = 0.5) +
geom_text(aes(label = sprintf("p = %.3f", p)), vjust = -0.5, colour = te_body, size = 3.2) +
scale_fill_manual(values = c(equiprobable = te_gold, proportional = te_sage, `fixed-fixed` = te_forest), guide = "none") +
labs(title = "Significance depends on the null", x = NULL, y = "z-score of observed NODF",
subtitle = "dashed line: z = 1.96") +
theme_te() + theme(axis.text.x = element_text(size = rel(0.92)))
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))
```
## Robustness to species loss
The same logic runs through a different question: how well does the web hold together as species disappear? A common measure removes plants one at a time and counts the pollinators that lose every partner and go secondarily extinct (Memmott et al 2004). The **attack-tolerance curve** plots the fraction of pollinators still surviving against the fraction of plants removed, and the **robustness R** is the area under it (Burgos et al 2007): near one if pollinators persist until almost every plant is gone, near zero if the web collapses early.
```{r}
#| label: robustness
survival_curve <- function(M, order_cols) {
removed <- logical(ncol(M)); surv <- numeric(ncol(M) + 1); surv[1] <- 1
for (k in seq_along(order_cols)) {
removed[order_cols[k]] <- TRUE
surv[k + 1] <- mean(rowSums(M[, !removed, drop = FALSE]) > 0)
}
surv
}
robustness_R <- function(surv) { x <- seq(0, 1, length.out = length(surv)); sum((surv[-1] + surv[-length(surv)]) / 2 * diff(x)) }
deg_c <- colSums(B)
surv_most <- survival_curve(B, order(deg_c, decreasing = TRUE))
surv_least <- survival_curve(B, order(deg_c, decreasing = FALSE))
set.seed(1); surv_rand <- survival_curve(B, sample(P))
R_most <- robustness_R(surv_most); R_least <- robustness_R(surv_least)
set.seed(7); R_rand <- mean(replicate(200, robustness_R(survival_curve(B, sample(P)))))
c(most_connected = round(R_most, 3), random = round(R_rand, 3), least_connected = round(R_least, 3))
```
The order of loss dominates the answer. Removing the best-connected plants first collapses the web fast, giving R = `r sprintf("%.2f", R_most)`; removing the least-connected first barely touches the pollinators, R = `r sprintf("%.2f", R_least)`; a random order sits between at `r sprintf("%.2f", R_rand)`. A single robustness figure is meaningless without saying which extinction order it assumes, just as a single NODF is meaningless without a null.
And robustness itself needs a null. Is this web more or less robust than a random web with the same degree sequence?
```{r}
#| label: robustness-null
set.seed(21)
R_null <- replicate(199, {
M <- null_swap(B)
mean(replicate(30, robustness_R(survival_curve(M, sample(ncol(M))))))
})
z_R <- zscore(R_rand, R_null)
c(null_mean = round(mean(R_null), 3), null_sd = round(sd(R_null), 3),
observed = round(R_rand, 3), z = round(z_R, 2))
```
The observed random-order robustness of `r sprintf("%.2f", R_rand)` sits right on the null mean of `r sprintf("%.2f", mean(R_null))`, z = `r sprintf("%.2f", z_R)`. The web is exactly as robust as its degree sequence implies, no more and no less. As with nestedness, the interesting quantity is the departure from a null, and here there is none.
```{r}
#| label: fig-robustness
#| echo: false
#| fig-width: 10
#| fig-height: 4.3
#| fig-cap: "Left: attack-tolerance curves for three removal orders, with the robustness R of each. Right: the observed random-order robustness against the fixed-degree null distribution."
#| fig-alt: "Three survival curves, steepest when the best-connected plants go first and shallowest when the least-connected go first, and a histogram of null robustness with the observed value sitting at its centre."
mkcurve <- function(surv, lab) data.frame(frac_removed = seq(0, 1, length.out = length(surv)), surv = surv, order = lab)
dfc <- rbind(mkcurve(surv_most, "most-connected first"), mkcurve(surv_rand, "random"),
mkcurve(surv_least, "least-connected first"))
dfc$order <- factor(dfc$order, levels = c("most-connected first", "random", "least-connected first"))
pc <- ggplot(dfc, aes(frac_removed, surv, colour = order)) +
geom_line(linewidth = 1) + geom_point(size = 1.3) +
scale_colour_manual(values = c(`most-connected first` = te_rust, random = te_label, `least-connected first` = te_forest), name = "removal order") +
labs(title = "Secondary extinctions", x = "fraction of plants removed", y = "fraction of pollinators surviving",
subtitle = sprintf("R: most %.2f, random %.2f, least %.2f", R_most, R_rand, R_least)) +
theme_te()
dr <- data.frame(R = R_null)
pd <- ggplot(dr, aes(R)) +
geom_histogram(bins = 22, fill = te_sage, colour = te_paper, linewidth = 0.2) +
geom_vline(xintercept = R_rand, colour = te_rust, linewidth = 1) +
annotate("text", x = R_rand, y = Inf, label = sprintf("observed = %.2f", R_rand), hjust = 1.05, vjust = 1.6, colour = te_rust, size = 3.3) +
labs(title = "Robustness vs fixed-degree null", x = "robustness R of randomised webs", y = "count",
subtitle = sprintf("null mean %.2f, z = %.2f", mean(R_null), z_R)) +
theme_te()
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))
```
## Where this leaves you
A network metric is a comparison waiting for a baseline. The equiprobable null asks whether the web has any structure at all, the proportional null whether it has structure beyond the activity of its species, and the fixed-fixed null whether it has structure beyond the full degree sequence. They are different questions, and here they gave nestedness answers ranging from decisive to null. The robustness of the web behaves the same way: it depends on the assumed order of loss and, measured against a null, adds nothing to what the degree sequence already sets. Choosing the null is choosing the question, and the last post, [checking a network analysis](../checking-a-network-analysis/), turns to a confound that no null repairs: how hard the web was sampled.
## References
Bascompte, J., Jordano, P., Melian, C. J. and Olesen, J. M. (2003). The nested assembly of plant-animal mutualistic networks. *Proceedings of the National Academy of Sciences*, 100(16), 9383-9387. https://doi.org/10.1073/pnas.1633576100
Burgos, E., Ceva, H., Perazzo, R. P. J., Devoto, M., Medan, D., Zimmermann, M. and Delbue, A. M. (2007). Why nestedness in mutualistic networks? *Journal of Theoretical Biology*, 249(2), 307-313. https://doi.org/10.1016/j.jtbi.2007.07.030
Memmott, J., Waser, N. M. and Price, M. V. (2004). Tolerance of pollination networks to species extinctions. *Proceedings of the Royal Society B: Biological Sciences*, 271(1557), 2605-2611. https://doi.org/10.1098/rspb.2004.2909
Patefield, W. M. (1981). Algorithm AS 159: an efficient method of generating random R x C tables with given row and column totals. *Journal of the Royal Statistical Society. Series C (Applied Statistics)*, 30(1), 91-97. https://doi.org/10.2307/2346669
Ulrich, W., Almeida-Neto, M. and Gotelli, N. J. (2009). A consumer's guide to nestedness analysis. *Oikos*, 118(1), 3-17. https://doi.org/10.1111/j.1600-0706.2008.17053.x
Vazquez, D. P. and Aizen, M. A. (2003). Null model analyses of specialization in plant-pollinator interactions. *Ecology*, 84(9), 2493-2501. https://doi.org/10.1890/02-0587
## Related tutorials
- [Bipartite network metrics from scratch](../bipartite-network-metrics/)
- [Modularity in ecological networks](../modularity-in-ecological-networks/)
- [Nestedness and the NODF metric](../nestedness-nodf/)
- [Checking a network analysis](../checking-a-network-analysis/)