---
title: "Model-based multivariate abundance"
description: "Distance-based community methods confound the mean-variance relationship with real composition change. Model each species with a GLM, and test the community by resampling."
date: "2026-08-11 09:00"
categories: [R, community ecology, multivariate, ecology tutorial]
image: thumbnail.png
image-alt: "Boxplots of distance to group centroid for two groups, the second clearly higher, showing a spurious dispersion difference on Bray-Curtis."
---
Ordination and PERMANOVA on a Bray-Curtis matrix are the reflexive first move for community data. Both start by turning a site-by-species table into pairwise dissimilarities, and that step quietly makes a strong assumption: how much a species contributes to the distance depends on its variance, and for count data the variance is tied to the mean. A rare species and a common one carry different weight for reasons that have nothing to do with ecology.
The model-based alternative keeps every species on its own scale. Fit one generalised linear model per species with a shared set of predictors, then test the community by resampling. That single change separates two things a dissimilarity matrix mixes together: a shift in the mean (which species are more or less abundant) and a shift in dispersion (how variable the assemblage is). This post builds the community test by hand and shows the confounding that motivates it.
```{r setup}
#| echo: false
#| message: false
#| warning: false
library(MASS)
library(ggplot2)
te_ink <- "#16241d"; te_body <- "#2c3a31"; te_green <- "#275139"
te_label <- "#46604a"; te_sage <- "#93a87f"; te_paper <- "#f5f4ee"
te_line <- "#dad9ca"; te_faint <- "#5d6b61"; te_warm <- "#b5534e"
theme_te <- function() {
theme_minimal(base_size = 12) +
theme(panel.grid.minor = element_blank(),
panel.grid.major = element_line(colour = "#dad9ca", linewidth = 0.3),
axis.title = element_text(colour = "#2c3a31"),
axis.text = element_text(colour = "#5d6b61"),
plot.title = element_text(colour = "#16241d", face = "bold", size = 13),
plot.subtitle = element_text(colour = "#46604a", size = 10),
strip.text = element_text(colour = "#275139", face = "bold"),
legend.title = element_text(colour = "#2c3a31"),
legend.text = element_text(colour = "#46604a"),
plot.background = element_rect(fill = "white", colour = NA),
panel.background = element_rect(fill = "white", colour = NA))
}
```
## A community with a real change in the mean
We simulate 25 sites in each of two groups, `Control` and `Impact`, and 20 species along an abundant-to-rare gradient. The impact is an abundance reduction: most species fall to 30 per cent of their control mean, and four sensitive species fall further, so the composition changes as well. Crucially the negative binomial size parameter is the same in both groups, so there is no genuine change in dispersion built into the data. Any dispersion signal we later detect on Bray-Curtis is therefore an artefact.
```{r data}
set.seed(4222)
S <- 20
n_per <- 25
grp <- factor(rep(c("Control", "Impact"), each = n_per),
levels = c("Control", "Impact"))
n <- length(grp)
base_mu <- c(rep(14, 3), rep(7, 5), rep(3, 7), rep(1.1, 5)) # control means
mult <- rep(0.30, S) # impact: abundance reduction
mult[1:4] <- 0.12 # four sensitive species
theta_true <- 2 # NB size, identical in both groups
mu_mat <- outer(rep(1, n), base_mu)
mu_mat[grp == "Impact", ] <- sweep(mu_mat[grp == "Impact", ], 2, mult, "*")
Y <- matrix(rnbinom(n * S, mu = as.vector(mu_mat), size = theta_true), n, S)
colnames(Y) <- paste0("sp", 1:S)
mean_ctrl <- mean(Y[grp == "Control", ])
mean_imp <- mean(Y[grp == "Impact", ])
ratio_mean <- mean_imp / mean_ctrl
zeros_ctrl <- 100 * mean(Y[grp == "Control", ] == 0)
zeros_imp <- 100 * mean(Y[grp == "Impact", ] == 0)
c(mean_ctrl = mean_ctrl, mean_imp = mean_imp, ratio = ratio_mean)
```
The impact sites average `r round(mean_imp, 2)` individuals per species against `r round(mean_ctrl, 2)` in the controls, a ratio of `r round(ratio_mean, 2)`, and they carry many more zeros (`r round(zeros_imp)` per cent against `r round(zeros_ctrl)` per cent).
## The mean-variance relationship is the whole problem
For each species, take its mean and its variance across the control sites and plot one against the other on log axes. Count data sit near a line steeper than the Poisson one-to-one reference: the variance grows roughly with the square of the mean, the signature of overdispersion.
```{r meanvar}
sp_mean <- colMeans(Y[grp == "Control", ])
sp_var <- apply(Y[grp == "Control", ], 2, var)
mv_slope <- coef(lm(log(sp_var) ~ log(sp_mean)))[2]
round(mv_slope, 3)
```
```{r fig-meanvar}
#| echo: false
#| fig-alt: "Species variance against mean on log-log axes; points rise more steeply than the Poisson one-to-one line and track the negative binomial reference curve, so variance grows roughly with the square of the mean."
#| fig-cap: "Each point is a species. Distance measures ignore this relationship; a GLM writes it into the likelihood."
gg <- seq(min(sp_mean), max(sp_mean), length = 100)
ref <- rbind(data.frame(mean = gg, var = gg, ref = "Poisson (var = mean)"),
data.frame(mean = gg, var = gg + gg^2 / theta_true,
ref = "negative binomial"))
ggplot(data.frame(mean = sp_mean, var = sp_var), aes(mean, var)) +
geom_line(data = ref, aes(mean, var, linetype = ref), colour = te_ink, linewidth = 0.5) +
geom_point(colour = te_green, size = 2.4) +
scale_x_log10() + scale_y_log10() +
labs(title = "Count data have a strong mean-variance relationship",
x = "species mean abundance (log)", y = "species variance (log)") +
theme_te() + theme(legend.title = element_blank())
```
The log-log slope here is `r round(mv_slope, 2)`, between the Poisson value of one and the quadratic value of two. Bray-Curtis has no way to know this. It treats a difference of ten individuals in a common species and in a rare one on comparable footing, so the abundant species dominate the dissimilarities, and the relationship in the figure leaks into the analysis as if it were signal.
## The community test, by hand
A model-based analysis fits a separate negative binomial GLM to each species, `y ~ group`, and compares it to the null `y ~ 1`. The likelihood-ratio statistic for each species measures how much the group improves the fit, and the community statistic is their sum. We first estimate each species' dispersion once from the full data, then hold it fixed, so the test is a clean comparison of the group term and the permutations below are fast.
```{r fit}
#| warning: false
#| message: false
theta_hat <- numeric(S)
for (s in 1:S) {
fit <- tryCatch(glm.nb(Y[, s] ~ grp), error = function(e) NULL)
theta_hat[s] <- if (is.null(fit)) 1e6 else fit$theta
}
lr_species <- function(y, g, th) {
f1 <- glm(y ~ g, family = negative.binomial(th))
f0 <- glm(y ~ 1, family = negative.binomial(th))
as.numeric(deviance(f0) - deviance(f1))
}
LR_obs <- sapply(1:S, function(s) lr_species(Y[, s], grp, theta_hat[s]))
Tcomm_obs <- sum(LR_obs)
round(Tcomm_obs, 1)
```
The null distribution needs the right exchangeable unit. Species are correlated, so shuffling them one at a time would destroy the between-species structure and give the wrong error rate. Instead we permute the group labels, which keeps every site's full species vector intact and only breaks the link between sites and groups. Under the null of no group effect the labels are exchangeable, and each permutation gives one draw from the community statistic under that null.
```{r perm}
B <- 999
Tperm <- numeric(B)
for (b in 1:B) {
gp <- sample(grp)
Tperm[b] <- sum(sapply(1:S, function(s) lr_species(Y[, s], gp, theta_hat[s])))
}
p_comm <- (1 + sum(Tperm >= Tcomm_obs)) / (B + 1)
c(observed = round(Tcomm_obs, 1), max_null = round(max(Tperm), 1), p = p_comm)
```
The observed community statistic is `r round(Tcomm_obs)`, the largest of `r B` permuted values is `r round(max(Tperm))`, and the resampled p-value is `r ifelse(p_comm < 1/(B+1)*1.5, paste0("below ", signif(1/(B+1),1)), round(p_comm,3))`. The group effect is real, which we know because we built it in. Holding the dispersion fixed at each species' own estimate lets a common species and a rare one both speak, weighted by how much information each actually carries.
## The confound: a mean shift fakes a dispersion difference
Now run the distance-based analysis and check what it reports. We build the Bray-Curtis matrix by hand, place the sites in Euclidean space with principal coordinates, and measure each site's distance to its group centroid. That distance is the multivariate dispersion, and the permutation test on it is the distance-based analogue of a variance test.
```{r permdisp}
bray <- function(M) {
n <- nrow(M); D <- matrix(0, n, n)
for (i in 1:(n - 1)) for (j in (i + 1):n) {
d <- sum(abs(M[i, ] - M[j, ])) / sum(M[i, ] + M[j, ]); D[i, j] <- D[j, i] <- d
}
D
}
D <- bray(Y)
G <- { A <- -0.5 * D^2; Cn <- diag(n) - matrix(1/n, n, n); Cn %*% A %*% Cn }
ev <- eigen(G, symmetric = TRUE)
X <- ev$vectors[, ev$values > 1e-8] %*% diag(sqrt(ev$values[ev$values > 1e-8]))
dcen <- numeric(n)
for (lev in levels(grp)) {
idx <- grp == lev; ctr <- colMeans(X[idx, , drop = FALSE])
dcen[idx] <- sqrt(rowSums(sweep(X[idx, , drop = FALSE], 2, ctr)^2))
}
permdisp_F <- function(d, g) {
gm <- tapply(d, g, mean); grand <- mean(d)
ssb <- sum(table(g) * (gm - grand)^2); ssw <- sum((d - gm[as.character(g)])^2)
(ssb / (nlevels(g) - 1)) / (ssw / (length(d) - nlevels(g)))
}
F_obs <- permdisp_F(dcen, grp)
Fp <- replicate(999, permdisp_F(dcen, sample(grp)))
p_disp <- (1 + sum(Fp >= F_obs)) / 1000
disp_ctrl <- mean(dcen[grp == "Control"]); disp_imp <- mean(dcen[grp == "Impact"])
c(disp_ctrl = round(disp_ctrl, 3), disp_imp = round(disp_imp, 3),
F = round(F_obs, 1), p = p_disp)
```
```{r fig-confound}
#| echo: false
#| fig-alt: "Boxplots of distance to group centroid for Control and Impact; the Impact group sits clearly higher, so a pure mean shift shows up as greater multivariate dispersion on Bray-Curtis."
#| fig-cap: "Only the mean changed in the data, yet the distance-based test reads it as a dispersion difference."
ggplot(data.frame(group = grp, d = dcen), aes(group, d, fill = group)) +
geom_boxplot(width = 0.55, outlier.shape = NA, alpha = 0.85) +
geom_jitter(width = 0.12, height = 0, colour = te_ink, alpha = 0.5, size = 1.4) +
scale_fill_manual(values = c(Control = te_sage, Impact = te_green)) +
labs(title = "A pure mean shift fakes a dispersion difference",
subtitle = sprintf("PERMDISP F = %.1f, p = %s, though dispersion is identical by construction",
F_obs, ifelse(p_disp < 0.0015, "0.001", format(round(p_disp, 3)))),
x = NULL, y = "distance to group centroid (PCoA)") +
guides(fill = "none") + theme_te()
```
The impact sites sit at mean distance `r round(disp_imp, 3)` from their centroid against `r round(disp_ctrl, 3)` for the controls, and the dispersion test returns F = `r round(F_obs, 1)` with p = `r ifelse(p_disp < 0.0015, "0.001", format(round(p_disp,3)))`. Read at face value this says the impacted community is more heterogeneous, perhaps destabilised. It is nothing of the sort. The dispersion parameter is identical in both groups; the only thing that changed is the mean. Lower counts carry proportionally more sampling variability and more zeros, so Bray-Curtis distances stretch, and the variance test picks that up as dispersion. The model-based analysis attributes the same data to the mean, where it belongs, and its p-value of `r ifelse(p_comm < 1/(B+1)*1.5, "0.001", round(p_comm,3))` reflects a location effect, not a dispersion one.
This is the same trap as [testing dispersion after PERMANOVA](../common-permanova-mistakes/): a significant PERMDISP result can be a mean effect wearing a dispersion costume, and the reverse is possible too.
## Honest limits
The community test tells you the assemblage responds to the grouping. It does not tell you which species drive it; the per-species statistics need multiple-testing control before you read them, which is the subject of a later post. It also assumes the mean-variance relationship you fit, and if the negative binomial is wrong the test is not valid, so the residual check matters. And the framing is not settled: Roberts (2017) argued that distance measures are not simply wrong, and that their implied mean-variance ratio can be defensible, so the choice between the two families is partly about what question you are asking. What is not in dispute is that a Bray-Curtis dispersion result should never be read as an ecological dispersion effect without ruling out a mean shift first.
## References
Warton DI, Wright ST, Wang Y 2012. Distance-based multivariate analyses confound location and dispersion effects. Methods in Ecology and Evolution 3:89-101 (10.1111/j.2041-210X.2011.00127.x).
Wang Y, Naumann U, Wright ST, Warton DI 2012. mvabund: an R package for model-based analysis of multivariate abundance data. Methods in Ecology and Evolution 3:471-474 (10.1111/j.2041-210X.2012.00190.x).
Roberts DW 2017. Distance, dissimilarity, and mean-variance ratios in ordination. Methods in Ecology and Evolution 8:1508-1514 (10.1111/2041-210X.12739).
Anderson MJ 2001. A new method for non-parametric multivariate analysis of variance. Austral Ecology 26:32-46.
McArdle BH, Anderson MJ 2001. Fitting multivariate models to community data: a comment on distance-based redundancy analysis. Ecology 82:290-297.
Taylor LR 1961. Aggregation, variance and the mean. Nature 189:732-735 (10.1038/189732a0).
## Related tutorials
- [Common PERMANOVA mistakes](../common-permanova-mistakes/)
- [Model-based unconstrained ordination](../model-based-unconstrained-ordination/)
- [Fourth-corner trait-environment analysis](../fourth-corner-trait-environment/)
- [Checking a model-based multivariate model](../checking-a-model-based-multivariate-model/)