library(vegan)
library(ggplot2)
te_paper <- "#f5f4ee"
te_ink <- "#16241d"
te_body <- "#2c3a31"
te_forest <- "#275139"
te_rust <- "#b5534e"
te_gold <- "#c9b458"
te_line <- "#dad9ca"
theme_datasheet <- function() {
theme_minimal(base_size = 12) +
theme(plot.background = element_rect(fill = te_paper, colour = NA),
panel.background = element_rect(fill = te_paper, colour = NA),
panel.grid.major = element_line(colour = te_line, linewidth = 0.3),
panel.grid.minor = element_blank(),
text = element_text(colour = te_body),
plot.title = element_text(colour = te_ink, face = "bold"),
strip.text = element_text(colour = te_ink, face = "bold"),
axis.text = element_text(colour = te_body))
}Power for a community study
Sample size questions have a standard answer for a t-test and no answer at all for a community study. The literature is full of designs with five or eight plots per group, an adonis2 result, and no statement of what the study could have detected. The gap is not that power analysis is hard here; it is that the first input is missing. Before you can ask how many plots, you have to say what a difference between communities means, and that decision is made by the dissimilarity index, not by the test.
This post simulates a PERMANOVA design end to end: a known difference, a sample size grid, three common indices, and a null case as a calibration check. The result is that the power of the same design, on the same data, over the same range of sample sizes, spans the full interval from nothing to certainty depending on which index you picked in the first line of the script.
A community and two ways of changing it
Thirty species with log-normal mean abundances and negative binomial counts, so the mean-variance relationship is realistic rather than Poisson. Two treatment scenarios, both plausible field outcomes.
n_sp <- 30
set.seed(20260805)
mu_base <- exp(rnorm(n_sp, mean = 1.6, sd = 0.9))
## scenario A: every taxon multiplied by 1.5, so proportions are untouched
mu_A <- mu_base * 1.5
## scenario B: turnover, with the community total held at the reference total
odd <- seq_len(n_sp) %% 2 == 1
mu_B <- mu_base
mu_B[odd] <- mu_base[odd] * 1.6
mu_B[!odd] <- mu_base[!odd] *
(sum(mu_base) - sum(mu_base[odd] * 1.6)) / sum(mu_base[!odd])
a_mult <- unique(round(mu_A / mu_base, 6))
b_up <- max(mu_B / mu_base)
total_ref <- sum(mu_base)
total_A <- sum(mu_A)
total_B <- sum(mu_B)
b_down <- min(mu_B / mu_base)Scenario A is more of everything: every taxon is 1.5 times as abundant in the treated plots, so the expected community total rises from 337 to 506 individuals while relative composition is untouched. Scenario B is turnover: half the species rise by 60 per cent, the other half fall to 48 per cent of their reference means, and the expected total stays at 337. A field ecologist would call both a treatment effect. Two of the three indices below disagree.
scen <- data.frame(
species = rep(order(order(mu_base)), 3),
mu = c(mu_base, mu_A, mu_B),
scenario = rep(c("reference", "A: all taxa x 1.5", "B: turnover, total held"),
each = n_sp))
scen$scenario <- factor(scen$scenario,
levels = c("reference", "A: all taxa x 1.5", "B: turnover, total held"))
ggplot(scen, aes(x = species, y = mu, colour = scenario, shape = scenario)) +
geom_point(size = 2.1, alpha = 0.9) +
scale_y_log10() +
scale_colour_manual(values = c("reference" = te_body,
"A: all taxa x 1.5" = te_forest,
"B: turnover, total held" = te_rust)) +
scale_shape_manual(values = c(16, 17, 15)) +
labs(x = "species, ordered by reference abundance",
y = "expected abundance (log scale)", colour = NULL, shape = NULL,
title = "Two treatment scenarios a field study would both call an effect") +
theme_datasheet() +
theme(legend.position = "top")
Three indices, one design
Bray-Curtis on raw counts, binary Jaccard on presence and absence, and Euclidean distance on Hellinger-transformed counts. All three are standard, all three are defensible, and they answer different questions. Bray-Curtis on raw counts responds to total abundance because it is not closed. The Hellinger transformation divides each plot by its own total, so it discards abundance and keeps proportions. Binary Jaccard discards abundance entirely.
draw <- function(mu, n, size = 5) {
matrix(rnbinom(n * n_sp, mu = rep(mu, each = n), size = size), n, n_sp)
}
dist_of <- function(m, index) {
if (index == "Bray-Curtis") vegdist(m, "bray")
else if (index == "Jaccard (binary)") vegdist(m, "jaccard", binary = TRUE)
else dist(decostand(m, "hellinger"))
}
one_run <- function(mu_treat, n, index, base = mu_base, size = 5, perms = 199) {
m <- rbind(draw(base, n, size), draw(mu_treat, n, size))
g <- factor(rep(c("reference", "treated"), each = n))
a <- adonis2(dist_of(m, index) ~ g, permutations = perms)
c(p = a$`Pr(>F)`[1], r2 = a$R2[1])
}n_grid <- c(5, 8, 12, 20)
indices <- c("Bray-Curtis", "Jaccard (binary)", "Hellinger")
reps <- 200
grid <- expand.grid(n = n_grid, index = indices,
scenario = c("A: all taxa x 1.5", "B: turnover, total held"),
stringsAsFactors = FALSE)
grid$power <- NA_real_
grid$r2 <- NA_real_
set.seed(11)
for (k in seq_len(nrow(grid))) {
mu_t <- if (grid$scenario[k] == "A: all taxa x 1.5") mu_A else mu_B
out <- replicate(reps, one_run(mu_t, grid$n[k], grid$index[k]))
grid$power[k] <- mean(out["p", ] <= 0.05)
grid$r2[k] <- median(out["r2", ])
}
pick <- function(sc, ix, nn) {
grid$power[grid$scenario == sc & grid$index == ix & grid$n == nn]
}
sc_A <- "A: all taxa x 1.5"
sc_B <- "B: turnover, total held"
mc_se <- sqrt(0.05 * 0.95 / reps)At twenty plots per group, scenario A is detected by Bray-Curtis in 1.000 of runs, by binary Jaccard in 0.065 and by Hellinger in 0.050. The last two numbers are not low power. They are the nominal error rate: those indices see no difference at all, because the difference was removed before the test ran. Eight plots per group would have been ample for the first index and useless for the other two.
Scenario B reverses part of the ranking. Bray-Curtis reaches 0.985 at five plots per group, Hellinger 0.995, and binary Jaccard climbs slowly from 0.120 to 0.720 over the whole grid.
offsets <- c("Bray-Curtis" = -0.45, "Jaccard (binary)" = 0, "Hellinger" = 0.45)
grid$n_off <- grid$n + offsets[grid$index]
ggplot(grid, aes(x = n_off, y = power, colour = index, shape = index)) +
geom_hline(yintercept = 0.05, linetype = "dotted", colour = te_ink) +
annotate("text", x = 12, y = 0.115, label = "nominal 0.05",
colour = te_ink, size = 3.3) +
geom_line(linewidth = 0.8) +
geom_point(size = 2.6) +
facet_wrap(~ scenario) +
scale_x_continuous(breaks = n_grid) +
scale_y_continuous(limits = c(0, 1.02)) +
scale_colour_manual(values = c("Bray-Curtis" = te_forest,
"Jaccard (binary)" = te_rust,
"Hellinger" = te_gold)) +
scale_shape_manual(values = c(16, 17, 15)) +
labs(x = "plots per group", y = "power at alpha = 0.05", colour = NULL, shape = NULL,
title = "The same design, the same effect, three answers") +
theme_datasheet() +
theme(legend.position = "top")
The null case, which is the part that gets skipped
A power curve without a null run is a curve with no zero on it. Here both groups are drawn from the reference community, so every rejection is a false one.
set.seed(303)
null_rate <- sapply(indices, function(ix) {
mean(replicate(reps, one_run(mu_base, 12, ix)["p"]) <= 0.05)
})
null_rate Bray-Curtis Jaccard (binary) Hellinger
0.075 0.045 0.060
At twelve plots per group the three indices reject in 0.075, 0.045 and 0.060 of runs. With 200 replicates the Monte Carlo standard error on a rate near the nominal level is 0.015, so all three sit within sampling error of 0.05. That matters for reading the power table: the flat Jaccard and Hellinger curves in scenario A are exactly this rate, not a weak signal.
null_df <- data.frame(index = factor(names(null_rate), levels = indices),
rate = as.numeric(null_rate))
null_df$lo <- pmax(0, null_df$rate - 1.96 * mc_se)
null_df$hi <- null_df$rate + 1.96 * mc_se
ggplot(null_df, aes(x = index, y = rate, fill = index)) +
geom_col(width = 0.5) +
geom_errorbar(aes(ymin = lo, ymax = hi), width = 0.12, colour = te_ink) +
geom_hline(yintercept = 0.05, linetype = "dotted", colour = te_ink) +
annotate("text", x = 0.62, y = 0.062, label = "nominal", colour = te_ink, size = 3.4) +
scale_fill_manual(values = c("Bray-Curtis" = te_forest,
"Jaccard (binary)" = te_rust,
"Hellinger" = te_gold), guide = "none") +
labs(x = NULL, y = "false positive rate", title = "Calibration check: no difference in the data") +
theme_datasheet()
Effect size on each scale
The reason the three curves separate is visible in the variance explained. The same twenty-plot study gives a median R-squared of 0.150 for Bray-Curtis under scenario A, against 0.026 for Hellinger. Under scenario B those become 0.256 and 0.239. The index is not filtering noise; it is defining the signal.
Would binary Jaccard do better in a sparser community?
The matrix above is species-rich and nearly filled, so presence and absence carries almost nothing. That is a property of the simulation, not of Jaccard, and it is worth measuring rather than assuming. The same means divided by twelve give a matrix with many structural zeros.
mu_s <- mu_base / 12
mu_sA <- mu_s * 1.5
mu_sB <- mu_s
mu_sB[odd] <- mu_s[odd] * 1.6
mu_sB[!odd] <- mu_s[!odd] * (sum(mu_s) - sum(mu_s[odd] * 1.6)) / sum(mu_s[!odd])
set.seed(202)
occupancy <- mean(draw(mu_s, 500) > 0)
sparse <- expand.grid(index = indices, scenario = c(sc_A, sc_B), stringsAsFactors = FALSE)
sparse$power <- NA_real_
for (k in seq_len(nrow(sparse))) {
mu_t <- if (sparse$scenario[k] == sc_A) mu_sA else mu_sB
sparse$power[k] <- mean(replicate(reps, one_run(mu_t, 12, sparse$index[k],
base = mu_s)["p"]) <= 0.05)
}
sparse_jac_A <- sparse$power[sparse$index == "Jaccard (binary)" & sparse$scenario == sc_A]
rich_jac_A <- pick(sc_A, "Jaccard (binary)", 12)With 44 per cent of cells occupied, binary Jaccard detects scenario A in 0.205 of runs at twelve plots per group, against 0.075 in the rich matrix. Adding individuals to a sparse community also adds detections, so a presence index stops being blind to abundance. The lesson survives in a weaker form: the index still decides what the study can see, and now sparsity decides how much.
comp <- rbind(
data.frame(index = grid$index[grid$n == 12], scenario = grid$scenario[grid$n == 12],
power = grid$power[grid$n == 12], matrix = "rich (few zeros)"),
data.frame(index = sparse$index, scenario = sparse$scenario,
power = sparse$power, matrix = "sparse (many zeros)"))
comp$index <- factor(comp$index, levels = indices)
ggplot(comp, aes(x = index, y = power, fill = matrix)) +
geom_col(position = position_dodge(width = 0.7), width = 0.62) +
facet_wrap(~ scenario) +
scale_fill_manual(values = c("rich (few zeros)" = te_forest,
"sparse (many zeros)" = te_gold)) +
scale_y_continuous(limits = c(0, 1.05)) +
labs(x = NULL, y = "power at twelve plots per group", fill = NULL,
title = "Sparsity changes what a presence index can detect") +
theme_datasheet() +
theme(legend.position = "top", axis.text.x = element_text(angle = 20, hjust = 1))
Turning this into a design
The machinery is short enough to adapt in an afternoon. Write down the community you expect, write down the change you would not want to miss, choose the index that matches the question you are actually asking, run the grid, and run the null case as well. The choice of index deserves the same paragraph in the methods as the sample size, because the two are not separable: reporting that a study had eighty per cent power says nothing until the index is named.
If the question is about total abundance, an index that normalises by plot totals will not answer it. If the question is about relative composition, an index that responds to total abundance will answer a different question and may answer it very convincingly. The dissimilarity index tutorial sets out which index tracks which gradient; this post prices those properties in plots per group.
Honest limits
Every number here belongs to this generator: thirty species, this abundance distribution, this dispersion parameter, balanced groups, and a treatment applied uniformly across taxa. Real communities have patchy responses, correlated species, and unbalanced designs, all of which move power. The rates carry a Monte Carlo standard error of about 0.035 at the steep part of a curve, so differences of a few percentage points between neighbouring cells are noise.
The deeper limit is that a simulated power analysis needs an assumed effect, and if you knew the effect you would not be running the study. The output is not a prediction; it is a statement of what the design could have found had the assumption been right, which is exactly what a reader needs when the result is not significant.
References
Anderson MJ 2001 Austral Ecology 26(1):32-46 (10.1111/j.1442-9993.2001.01070.pp.x)
Legendre P, Gallagher ED 2001 Oecologia 129(2):271-280 (10.1007/s004420100716)
Warton DI, Wright ST, Wang Y 2012 Methods in Ecology and Evolution 3(1):89-101 (10.1111/j.2041-210X.2011.00127.x)