---
title: "Modularity in ecological networks"
description: "Barber's bipartite modularity, a hand-coded label-propagation module finder, the c-z role plot and a null-model test of significance, all in base R on a plant-pollinator web."
date: "2026-08-21 10:00"
categories: [R, ecological networks, species interactions, ggplot2, ecology tutorial]
image: thumbnail.png
image-alt: "A block-structured interaction matrix beside a c-z role plot separating connector and peripheral species."
---
Many interaction webs are not a uniform mesh: species cluster into compartments that interact heavily within and sparsely between, a pattern read as a signature of shared traits, phylogeny or coevolutionary history. **Modularity** puts a number on that clustering, and the module a species sits in, together with how it bridges to other modules, gives each species a role. This post codes the bipartite modularity of Barber (2007), a label-propagation finder in the spirit of Beckett (2016), and the c-z role plot of Guimera and Amaral (2005) as used for pollination networks by Olesen et al (2007), then tests whether the modularity is more than a random web would show. Everything runs in base R.
```{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))
```
## A web with built-in compartments
The test web has three latent compartments: pollinators and plants each fall into one of three groups, interactions within a group are strong, and a small share leak across groups. Poisson counts turn the expected rates into an observed matrix.
```{r}
#| label: build-web
set.seed(4261)
A0 <- 18L; P0 <- 15L; K <- 3L
poll_grp <- sort(sample(rep(seq_len(K), length.out = A0)))
plant_grp <- sort(sample(rep(seq_len(K), length.out = P0)))
poll_ab <- exp(rnorm(A0, 0, 0.4)); plant_ab <- exp(rnorm(P0, 0, 0.4))
lam <- outer(poll_ab, plant_ab) * ifelse(outer(poll_grp, plant_grp, "==") == 1, 1.0, 0.11)
lam <- lam / sum(lam) * 660
W <- matrix(rpois(A0 * P0, lam), A0, P0)
rownames(W) <- paste0("A", seq_len(A0)); colnames(W) <- paste0("P", seq_len(P0))
W <- W[rowSums(W) > 0, colSums(W) > 0, drop = FALSE]
A <- nrow(W); P <- ncol(W); m <- sum(W)
c(pollinators = A, plants = P, interactions = m, connectance = round(sum(W > 0) / (A * P), 3))
```
The web has `r A` pollinators, `r P` plants and `r m` interactions, with connectance `r sprintf("%.2f", sum(W > 0) / (A * P))`.
## Barber's bipartite modularity
Modularity compares the weight inside modules against what the row and column totals alone would put there. For a bipartite web the **modularity matrix** is B, with one entry per cell: the observed weight minus the weight expected if interactions were spread in proportion to the marginal totals. Given a labelling that assigns every pollinator and plant to a module, the modularity Q is the sum of B over all pollinator-plant pairs that share a module, divided by the grand total.
```{r}
#| label: barber-q
ki <- rowSums(W); dj <- colSums(W)
Bmat <- W - outer(ki, dj) / m
Qbip <- function(rl, cl) {
q <- 0
for (i in seq_len(A)) { same <- which(cl == rl[i]); if (length(same)) q <- q + sum(Bmat[i, same]) }
q / m
}
```
A random labelling scores near zero; a good labelling collects the positive entries of B into blocks and pushes Q up. Finding that labelling is the search problem.
## Finding modules by label propagation
The finder alternates two update rules until the labels settle. Each pollinator adopts the module label of the columns it fits best, meaning the label maximising the sum of its B entries over that module's plants; each plant then does the same over pollinators. Because the landscape has local optima, the search restarts from many random labellings and keeps the best.
```{r}
#| label: lpawb
lpawb <- function(seed) {
set.seed(seed)
nl <- A + P
rl <- sample(seq_len(nl), A, replace = TRUE)
cl <- sample(seq_len(nl), P, replace = TRUE)
repeat {
changed <- FALSE
for (i in seq_len(A)) {
labs <- unique(cl); score <- sapply(labs, function(cc) sum(Bmat[i, cl == cc]))
best <- labs[which.max(score)]; if (best != rl[i]) { rl[i] <- best; changed <- TRUE }
}
for (j in seq_len(P)) {
labs <- unique(rl); score <- sapply(labs, function(cc) sum(Bmat[rl == cc, j]))
best <- labs[which.max(score)]; if (best != cl[j]) { cl[j] <- best; changed <- TRUE }
}
if (!changed) break
}
list(rl = rl, cl = cl, Q = Qbip(rl, cl))
}
runs <- lapply(seq_len(40L), function(s) lpawb(1000 + s))
Qs <- vapply(runs, `[[`, numeric(1), "Q")
best <- runs[[which.max(Qs)]]
alllab <- unique(c(best$rl, best$cl)); map <- setNames(seq_along(alllab), alllab)
rlab <- map[as.character(best$rl)]; clab <- map[as.character(best$cl)]
Qobs <- best$Q; nmod <- length(unique(c(rlab, clab)))
c(Q = round(Qobs, 3), modules = nmod, restart_low = round(min(Qs), 3), restart_high = round(max(Qs), 3))
```
The best of `r length(Qs)` restarts reaches **Q = `r sprintf("%.2f", Qobs)`** with `r nmod` modules, and recovers the three planted compartments cleanly. The restarts do not all agree: they range from `r sprintf("%.2f", min(Qs))` to `r sprintf("%.2f", max(Qs))`, which is the first caveat. Module detection is a heuristic search, so a single run can return a worse partition, and the reported modularity is the maximum over many attempts rather than a guaranteed optimum.
## Species roles: connectors, hubs and peripherals
With modules in hand, two numbers describe how a species sits in the web. The **within-module degree z** is how many links a species has to its own module, standardised against the other members of that module: a species with far more within-module links than its peers has a high z and anchors the module. The **participation coefficient c** measures how evenly a species spreads its links across modules: zero if all its links stay in one module, approaching one if they are shared out. Following Olesen et al (2007), a species with c above 0.62 is a connector, one with z above 2.5 is a module hub, both make it a network hub, and neither leaves it peripheral.
```{r}
#| label: roles
Adj <- (W > 0) * 1L
node_mod <- c(rlab, clab); N <- A + P
G <- matrix(0L, N, N); G[1:A, (A + 1):N] <- Adj; G[(A + 1):N, 1:A] <- t(Adj)
deg <- rowSums(G)
zc_mat <- t(vapply(seq_len(N), function(v) {
s <- node_mod[v]; kis <- sum(G[v, node_mod == s]); members <- which(node_mod == s)
kv <- vapply(members, function(u) sum(G[u, node_mod == s]), numeric(1))
z <- if (sd(kv) > 0) (kis - mean(kv)) / sd(kv) else 0
ct <- vapply(sort(unique(node_mod)), function(t) sum(G[v, node_mod == t]), numeric(1))
cpar <- if (deg[v] > 0) 1 - sum((ct / deg[v])^2) else 0
c(z = z, c = cpar)
}, numeric(2)))
role <- ifelse(zc_mat[, "z"] > 2.5 & zc_mat[, "c"] > 0.62, "network hub",
ifelse(zc_mat[, "z"] > 2.5, "module hub",
ifelse(zc_mat[, "c"] > 0.62, "connector", "peripheral")))
role_tab <- list(zc = zc_mat, role = role)
n_conn <- sum(role == "connector"); n_periph <- sum(role == "peripheral")
n_hub <- sum(grepl("hub", role))
c(connector = n_conn, peripheral = n_periph, hub = n_hub)
```
Here `r n_conn` species are connectors, `r n_periph` are peripheral and `r n_hub` are hubs. The absence of hubs is common in real pollination webs: modules are held together more by species that bridge across them than by dominant within-module anchors.
```{r}
#| label: fig-modules
#| echo: false
#| fig-width: 10
#| fig-height: 4.3
#| fig-cap: "Left: the interaction matrix with rows and columns reordered by module, blocks outlined. Right: each species placed by its participation coefficient c and within-module degree z, with the connector and hub thresholds of Olesen et al (2007)."
#| fig-alt: "A reordered interaction matrix showing three shaded blocks along the diagonal, and a c-z scatter separating connectors from peripheral species with no species above the hub threshold."
ro <- order(rlab); co <- order(clab)
Wr <- W[ro, co]; rl2 <- rlab[ro]; cl2 <- clab[co]
dfm <- data.frame(ri = rep(seq_len(A), times = P), ci = rep(seq_len(P), each = A), n = as.vector(Wr))
rblk <- tapply(seq_len(A), rl2, range); cblk <- tapply(seq_len(P), cl2, range)
mods <- intersect(names(rblk), names(cblk))
rects <- do.call(rbind, lapply(mods, function(mm)
data.frame(xmin = cblk[[mm]][1] - 0.5, xmax = cblk[[mm]][2] + 0.5,
ymin = rblk[[mm]][1] - 0.5, ymax = rblk[[mm]][2] + 0.5)))
pa <- ggplot(dfm, aes(ci, ri, fill = n)) +
geom_tile(colour = te_paper, linewidth = 0.3) +
scale_fill_gradient(low = te_paper, high = te_forest, name = "visits") +
geom_rect(data = rects, aes(xmin = xmin, xmax = xmax, ymin = ymin, ymax = ymax),
inherit.aes = FALSE, fill = NA, colour = te_rust, linewidth = 0.9) +
scale_y_reverse(breaks = NULL) + scale_x_continuous(breaks = NULL) +
labs(title = "Modules in the reordered matrix", x = "plants (grouped by module)",
y = "pollinators (grouped by module)",
subtitle = sprintf("Q = %.2f, %d modules", Qobs, nmod)) +
theme_te()
zc <- as.data.frame(role_tab$zc); zc$role <- role_tab$role
zc$type <- ifelse(seq_len(nrow(zc)) <= A, "pollinator", "plant")
pb <- ggplot(zc, aes(c, z, colour = role, shape = type)) +
geom_hline(yintercept = 2.5, linetype = "dashed", colour = te_label, linewidth = 0.4) +
geom_vline(xintercept = 0.62, linetype = "dashed", colour = te_label, linewidth = 0.4) +
geom_point(size = 2.6, alpha = 0.9) +
scale_colour_manual(values = c(connector = te_forest, peripheral = te_gold,
`module hub` = te_rust, `network hub` = te_ink), name = "role") +
scale_shape_manual(values = c(pollinator = 16, plant = 17), name = "node") +
labs(title = "Module roles (c-z space)", x = "participation coefficient c",
y = "within-module degree z",
subtitle = "thresholds: c = 0.62 (connector), z = 2.5 (hub)") +
theme_te()
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))
```
## Is the web more modular than chance?
A raw Q cannot be read on its own, because a random web with the same row and column totals also has positive modularity. The honest comparison is against a null. The Patefield algorithm, available in base R as `r2dtable`, draws random matrices holding the marginal totals fixed; running the same module finder on each gives a null distribution for Q.
```{r}
#| label: null-q
set.seed(99)
nnull <- 199L
nulls <- r2dtable(nnull, ki, dj)
Qnull <- vapply(seq_len(nnull), function(b) {
Wn <- nulls[[b]]; kn <- rowSums(Wn); dn <- colSums(Wn); Bn <- Wn - outer(kn, dn) / sum(Wn)
Aq <- nrow(Wn); Pq <- ncol(Wn)
QN <- function(rl, cl) { q <- 0; for (i in seq_len(Aq)) { s <- which(cl == rl[i]); if (length(s)) q <- q + sum(Bn[i, s]) }; q / sum(Wn) }
lp <- function(seed) {
set.seed(seed); nl <- Aq + Pq; rl <- sample(nl, Aq, TRUE); cl <- sample(nl, Pq, TRUE)
repeat { ch <- FALSE
for (i in seq_len(Aq)) { L <- unique(cl); sc <- sapply(L, function(cc) sum(Bn[i, cl == cc])); bb <- L[which.max(sc)]; if (bb != rl[i]) { rl[i] <- bb; ch <- TRUE } }
for (j in seq_len(Pq)) { L <- unique(rl); sc <- sapply(L, function(cc) sum(Bn[rl == cc, j])); bb <- L[which.max(sc)]; if (bb != cl[j]) { cl[j] <- bb; ch <- TRUE } }
if (!ch) break }
QN(rl, cl)
}
max(vapply(1:6, function(s) lp(500 * b + s), numeric(1)))
}, numeric(1))
z_mod <- (Qobs - mean(Qnull)) / sd(Qnull)
p_mod <- (1 + sum(Qnull >= Qobs)) / (nnull + 1)
c(null_mean = round(mean(Qnull), 3), null_sd = round(sd(Qnull), 3),
observed = round(Qobs, 3), z = round(z_mod, 2), p = round(p_mod, 3))
```
The randomised webs cluster around Q = `r sprintf("%.2f", mean(Qnull))`, well below the observed `r sprintf("%.2f", Qobs)`, giving a z-score of `r sprintf("%.1f", z_mod)` and p = `r sprintf("%.3f", p_mod)`. The web is modular beyond what its totals force. Without the null the raw `r sprintf("%.2f", Qobs)` would be uninterpretable, since even structureless webs return a positive value.
```{r}
#| label: fig-null
#| echo: false
#| fig-width: 10
#| fig-height: 4.3
#| fig-cap: "Left: observed modularity against the distribution of Q from Patefield-randomised webs with the same totals. Right: the modularity found on each of the random restarts, sorted, showing that the search does not always reach the best value."
#| fig-alt: "A histogram of null modularities near 0.12 with the observed value near 0.47 far to the right, and a sorted scatter of restart modularities ranging from about 0.42 to 0.47."
dn <- data.frame(Q = Qnull)
pc <- ggplot(dn, aes(Q)) +
geom_histogram(bins = 22, fill = te_sage, colour = te_paper, linewidth = 0.2) +
geom_vline(xintercept = Qobs, colour = te_rust, linewidth = 1) +
annotate("text", x = Qobs, y = Inf, label = sprintf("observed Q = %.2f", Qobs),
hjust = 1.05, vjust = 1.6, colour = te_rust, size = 3.4) +
labs(title = "Observed modularity vs null", x = "Q of Patefield-randomised webs", y = "count",
subtitle = sprintf("null mean %.2f, z = %.1f, p = %.3f", mean(Qnull), z_mod, p_mod)) +
theme_te()
dq <- data.frame(run = seq_along(Qs), Q = sort(Qs)); dq$best <- dq$Q == max(dq$Q)
pd <- ggplot(dq, aes(run, Q)) +
geom_point(aes(colour = best), size = 2.3) +
scale_colour_manual(values = c(`FALSE` = te_sage, `TRUE` = te_rust), guide = "none") +
geom_hline(yintercept = max(dq$Q), linetype = "dashed", colour = te_rust, linewidth = 0.4) +
labs(title = "Modularity across random restarts", x = "restart (sorted)", y = "Q found",
subtitle = sprintf("best of %d kept; range %.2f to %.2f", length(Qs), min(Qs), max(Qs))) +
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 web can be split into modules, the split scored with Barber's Q, and each species given a role from its within-module degree and its cross-module participation. Three cautions travel with those numbers. The search is heuristic, so the modularity is a best-of-many rather than a proven optimum. The raw Q means nothing without a null, because random webs are modular too. And, as the next posts show, Q and the role thresholds shift with the size and sampling of the web, so values are best compared as departures from a null rather than head to head. Those threads continue in [null models for interaction networks](../null-models-for-interaction-networks/) and [checking a network analysis](../checking-a-network-analysis/).
## References
Barber, M. J. (2007). Modularity and community detection in bipartite networks. *Physical Review E*, 76(6), 066102. https://doi.org/10.1103/PhysRevE.76.066102
Beckett, S. J. (2016). Improved community detection in weighted bipartite networks. *Royal Society Open Science*, 3(1), 140536. https://doi.org/10.1098/rsos.140536
Dormann, C. F. and Strauss, R. (2014). A method for detecting modules in quantitative bipartite networks. *Methods in Ecology and Evolution*, 5(1), 90-98. https://doi.org/10.1111/2041-210X.12139
Guimera, R. and Amaral, L. A. N. (2005). Functional cartography of complex metabolic networks. *Nature*, 433(7028), 895-900. https://doi.org/10.1038/nature03288
Newman, M. E. J. and Girvan, M. (2004). Finding and evaluating community structure in networks. *Physical Review E*, 69(2), 026113. https://doi.org/10.1103/PhysRevE.69.026113
Olesen, J. M., Bascompte, J., Dupont, Y. L. and Jordano, P. (2007). The modularity of pollination networks. *Proceedings of the National Academy of Sciences*, 104(50), 19891-19896. https://doi.org/10.1073/pnas.0706375104
## Related tutorials
- [Bipartite network metrics from scratch](../bipartite-network-metrics/)
- [Nestedness and the NODF metric](../nestedness-nodf/)
- [Null models for interaction networks](../null-models-for-interaction-networks/)
- [Checking a network analysis](../checking-a-network-analysis/)