♦️ Standard PERMANOVA (via adonis2) is the workhorse for
microbiome association testing. But it has two well-documented blind
spots that require careful consideration:
| Problem | What goes wrong | What permaBrioche does |
|---|---|---|
| Invalid permutations under repeated-measures designs | Excess false positives (small p-values) for subject-level covariates | Confounding-aware block permutations |
| Inflated R² under null, even without repeated-measures | R² > 0 even under the null (no true association) | Null-centered R² or Hájek effect size |
♦️ This demo walks through both problems live, using HMP2 microbiome data bundled with the package.
GitHub: https://github.com/biobakery/permabrioche
Questions / issues: https://github.com/biobakery/permabrioche/issues
library(permabrioche) # our package
library(vegan) # for vegdist; adonis2 comparison
library(ggplot2) # for plotting
set.seed(1234)♦️ We’ll use data derived from the Human Microbiome Project 2 (HMP2), a longitudinal study following individuals over time with repeated stool sampling. It’s already saved in the package - we just have to load it:
d_name <- load(system.file("extdata", "d_hmp.Rda", package = "permabrioche"))
bugs_name <- load(system.file("extdata", "bugs_hmp.Rda", package = "permabrioche"))
meta <- get(d_name)
bugs <- get(bugs_name)
# Align row names
rownames(meta) <- meta$sample
stopifnot(identical(rownames(meta), rownames(bugs)))
# Downsample to 25 subjects for live demo
keep_subjects <- sample(unique(meta$subject), 25)
meta <- meta[meta$subject %in% keep_subjects, ]
bugs <- bugs[rownames(meta), ]♦️ We have 343 samples from 25 subjects, each containing abundances of 100 taxa :
cat("Samples:", nrow(meta), "\n")
#> Samples: 343
cat("Subjects:", length(unique(meta$subject)), "\n")
#> Subjects: 25
cat("Taxa:", ncol(bugs), "\n")
#> Taxa: 100♦️ Let’s use the ‘head’ function to double check that each subject appears multiple times (longitudinal samples). We will show that using standard adonis2 on this type of data can be problematic (and what you should do instead):
knitr::kable(head(meta[, c("sample", "subject")], 8),
caption = "First few rows of sample-level metadata")| sample | subject | |
|---|---|---|
| CSM5FZ4E_P | CSM5FZ4E_P | C3003 |
| CSM5FZ4G_P | CSM5FZ4G_P | C3003 |
| CSM5FZ4K_P | CSM5FZ4K_P | C3003 |
| CSM5FZ4M | CSM5FZ4M | C3003 |
| CSM5LLGB_P | CSM5LLGB_P | M2014 |
| CSM5MCV1_P | CSM5MCV1_P | C3007 |
| CSM5MCV5_P | CSM5MCV5_P | C3007 |
| CSM5MCVB_P | CSM5MCVB_P | C3007 |
colnames(bugs) <- paste0("bug", seq_len(ncol(bugs))) # tidy display
knitr::kable(head(bugs[, 1:6]), caption = "First few rows of the abundance data")| bug1 | bug2 | bug3 | bug4 | bug5 | bug6 | |
|---|---|---|---|---|---|---|
| CSM5FZ4E_P | 3.15531 | 0.09501 | 0.15372 | 39.43736 | 16.52267 | 0.00000 |
| CSM5FZ4G_P | 14.81362 | 0.40161 | 0.00446 | 23.65032 | 11.47776 | 0.00000 |
| CSM5FZ4K_P | 9.92632 | 0.86028 | 0.00504 | 25.66730 | 7.92792 | 0.00000 |
| CSM5FZ4M | 5.78900 | 0.22401 | 0.00000 | 27.08071 | 12.07513 | 0.00000 |
| CSM5LLGB_P | 2.85017 | 4.62317 | 55.70357 | 0.11023 | 0.01096 | 5.65554 |
| CSM5MCV1_P | 0.00000 | 0.00000 | 0.00845 | 5.52779 | 0.00000 | 0.80608 |
♦️ One of the main ingredients in a PERMANOVA is the distance matrix. Just like normal, we can use ‘vegdist’ from the ‘vegan’ package to get our distance matrix:
Visualized example of microbial abundances across two groups
♦️ PERMANOVA answers the question of “are the abundances of these two groups actually different or is it just due to chance?”
♦️ Now we will show that if you have repeated measures in your data, the p-values from adonis2 will be inflated with false positives. This means that these p-values are misleadingly low and are not statistically valid.
❇️ Question: What is a “repeated measure”?
💠 GOAL: Imagine we want to know whether delivery mode is associated with gut microbiome composition.
♦️ We simulate delivery mode here by assigning a random binary label at the subject level — so by construction there is no true association:
set.seed(1234)
# Assign a random binary exposure CONSTANT within subject — true effect = 0
subjects <- unique(meta$subject)
subj_labels <- setNames(
sample(c(0, 1), length(subjects), replace = TRUE),
subjects
)
meta$exposure_invariant <- subj_labels[meta$subject]
cat("Exposure is invariant within subject:\n")
#> Exposure is invariant within subject:
table(meta$subject, meta$exposure_invariant)[1:5, ]
#>
#> 0 1
#> C3003 0 10
#> C3007 0 3
#> C3013 0 22
#> C3016 20 0
#> C3024 1 0♦️From the following illustration, you can see that our data has repeated measures. Within the two groups, there are multiple samples from the same subjects.
♦️ Standard adonis2 permutes sample labels
without regard to which samples belong to the same
subject. Because subjects cluster strongly in microbiome space,
the null distribution is artificially compressed — incorrectly yielding
far too many small p-values.
♦️ Let’s see that in action:
set.seed(1234)
# ---- adonis2: unadjusted (incorrect for repeated measures) ----
res_adonis_unadj <- adonis2(
dist_bc ~ exposure_invariant,
data = meta,
permutations = 999
)
cat("=== adonis2 (unadjusted) ===\n")
#> === adonis2 (unadjusted) ===
print(res_adonis_unadj)
#> Permutation test for adonis under reduced model
#> Permutation: free
#> Number of permutations: 999
#>
#> adonis2(formula = dist_bc ~ exposure_invariant, data = meta, permutations = 999)
#> Df SumOfSqs R2 F Pr(>F)
#> Model 1 4.021 0.04356 15.532 0.001 ***
#> Residual 341 88.286 0.95644
#> Total 342 92.307 1.00000
#> ---
#> Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1set.seed(1234)
# ---- adonis2: adding subject as a covariate (still wrong) ----
res_adonis_varsubj <- adonis2(
dist_bc ~ subject + exposure_invariant,
data = meta,
permutations = 999,
by = "terms"
)
cat("=== adonis2 (exposure + subject) ===\n")
#> === adonis2 (exposure + subject) ===
print(res_adonis_varsubj)
#> Permutation test for adonis under reduced model
#> Terms added sequentially (first to last)
#> Permutation: free
#> Number of permutations: 999
#>
#> adonis2(formula = dist_bc ~ subject + exposure_invariant, data = meta, permutations = 999, by = "terms")
#> Df SumOfSqs R2 F Pr(>F)
#> subject 24 63.108 0.68368 28.638 0.001 ***
#> Residual 318 29.199 0.31632
#> Total 342 92.307 1.00000
#> ---
#> Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1Key point: Even when you add
subjectto the formula,adonis2still permutes labels across subjects and actually drops invariant variables entirely.
♦️ This p-vaue calculation is an issue for both invariant covariates (ones that never change across a subject’s repeated samples) and variant covariates (ones that do change across a subject’s repeated samples).
What permaBrioche does to correct this:
♦️PERMANOVA_repeat_measures() permutes invariant
covariates across subjects (not across samples), so the
permutation scheme respects the repeated-measures structure. For variant
covariates, the permutation is done within subjects.
You do not have to specify which one exists in your data - the function
automatically handles it for you 🙂.
set.seed(1234)
res_perm <- PERMANOVA_repeat_measures(
formula = dist_bc ~ exposure_invariant,
data = meta,
sample_id = "sample",
blocking_variable = "subject",
permutations = 999
)
cat("=== permaBrioche: PERMANOVA_repeat_measures ===\n")
#> === permaBrioche: PERMANOVA_repeat_measures ===
print(res_perm)
#> Permutation test for adonis under reduced model
#> Overall (omnibus) test of all terms jointly
#> Permutation: blocked by subject
#> Number of permutations: 999
#> PERMANOVA_repeat_measures(formula = dist_bc ~ exposure_invariant,
#> data = meta, sample_id = "sample", blocking_variable = "subject",
#> permutations = 999)
#> Df SumOfSqs R2 F Pr(>F)
#> Model 1 3.693 0.04001 14.212 0.32
#> Residual 341 88.614 0.95999
#> Total 342 92.307 1.00000Under the null (no true effect), a valid method should give p ≈ Uniform(0,1). permaBrioche achieves this while a standard run of adonis2 does not.
| Method | p-value | Valid |
|---|---|---|
| adonis2 (unadjusted) | 0.001 | ❌ No |
| adonis2 (+ subject covariate) | 0.001 | ❌ No |
| permaBrioche: PERMANOVA_repeat_measures | 0.320 | ✅ Yes |
PERMANOVA_repeat_measures() can also handle formulas
with multiple covariates of the same type on the
right-hand side. For example, if all covariates are invariant within
subjects (e.g., sex, treatment arm, genotype), the function performs the
appropriate between-subject permutations for each term. Likewise, if all
covariates vary within subjects (e.g., diet, medication use,
time-varying biomarkers), it automatically uses within-subject
permutations. No additional arguments are required.
However it does not support formulas with multiple
covariates of differing types (e.g. sex and medication use).
♦️ Even when there is actually no association between a covariate and the microbiome (no-association null hypothesis), PERMANOVA R² follows a Beta distribution centered above zero:
\[E_0[R^2] = \frac{g - 1}{N - 1}\]
where g = number of levels of the covariate of interest and N = number of samples. With small N this can be substantial.
♦️ So as an example, if your data has 30 samples and your covariate has 4 levels, even though there is no true effect, your R² will be centered around 0.103! 😯:
N <- 30 # 30 samples
g <- 4 # exposure with four levels
expected_null_R2 <- (g - 1) / (N - 1)
cat(sprintf(
"For example, with N=%d samples and g=%d groups, E[R² | null] = %.3f\n",
N, g, expected_null_R2
))
#> For example, with N=30 samples and g=4 groups, E[R² | null] = 0.103❇️ Question: In microbiome data, we often have small sample sizes. As sample size (N) gets smaller, does this deviation increase or decrease?
♦️ In our data, with 343 samples and 2 levels in our covariate, the deviation from 0 is quite small but still meaningful. For small sample sizes and covariates with multiple levels, this deviation increases.
set.seed(1234)
# Simulate 200 null R² values using random label permutations
null_r2 <- replicate(200, {
shuffled_exposure <- sample(meta$exposure_invariant)
meta$shuffled_exposure <- shuffled_exposure
fit <- adonis2(dist_bc ~ shuffled_exposure, data = meta, permutations = 0)
fit["Model", "R2"]
})
# In our case, we can calculate the expected R^2 under the null as
g <- 2
N <- nrow(meta)
expected_null_R2 <- (g - 1) / (N - 1)
ggplot(data.frame(R2 = null_r2), aes(x = R2)) +
geom_histogram(bins = 30, fill = "#4E79A7", colour = "white", alpha = 0.85) +
geom_vline(xintercept = expected_null_R2, colour = "#E15759", linewidth = 1.2,
linetype = "dashed") +
geom_vline(xintercept = 0, colour = "black", linewidth = 0.8) +
annotate("text", x = expected_null_R2 + 0.003, y = Inf,
label = sprintf("E[R²|null] = %.3f", expected_null_R2),
hjust = 0, vjust = 2, colour = "#E15759", size = 4) +
labs(
title = "Simulated null distribution of PERMANOVA R²",
subtitle = "Labels randomly permuted 200 times — true effect is zero",
x = "R²", y = "Count"
) +
theme_minimal(base_size = 13)The distribution is entirely above zero even though there is no real effect. Raw R² can be quite misleading as an effect size.
♦️PERMANOVA_repeat_measures() with
center_R2 = TRUE subtracts the mean null R² estimated from
the permutation distribution, giving an effect size centered at zero
under the null:
\[\tilde{R}^2 = R^2 - \frac{g-1}{N-1}\]
set.seed(1234)
res_perm_centered <- PERMANOVA_repeat_measures(
formula = dist_bc ~ exposure_invariant,
data = meta,
sample_id = "sample",
blocking_variable = "subject",
permutations = 999,
center_R2 = TRUE
)
cat("=== permaBrioche: null-centered R² ===\n")
#> === permaBrioche: null-centered R² ===
print(res_perm_centered)
#> Permutation test for adonis under reduced model
#> Overall (omnibus) test of all terms jointly
#> Permutation: blocked by subject
#> Number of permutations: 999
#> PERMANOVA_repeat_measures(formula = dist_bc ~ exposure_invariant,
#> data = meta, sample_id = "sample", blocking_variable = "subject",
#> permutations = 999, center_R2 = TRUE)
#> Df SumOfSqs R2 F Pr(>F) R2_centered
#> Model 1 3.693 0.04001 14.212 0.32 0.0031862
#> Residual 341 88.614 0.95999
#> Total 342 92.307 1.00000Interpretation:
R2_centered≈ 0 confirms no real effect beyond what chance partitioning would produce. Importantly, the p-value is unchanged.
💠 Using the null-centered R² helps us get a more honest value, but the interpretation is still clunky: “The covariate explains roughly X percentage points more of the variance in community dissimilarity than would be expected by chance.”
There is nothing wrong with using the null-centered R², but it could be nice to have a more concretely interpretable effect size. This is why we introduce the “Hájek estimator”.
♦️The Hájek estimator answers:
On average, how far does exposure move samples away from the control centroid (center), in units of the chosen dissimilarity metric?
♦️This is directly interpretable — e.g. “exposure increases Bray-Curtis distance from the control centroid by 0.05 units” — unlike the abstract variance-explained framing of R².
♦️ To use this estimator, we can simply call the following function:
hajek_repeat_measures()set.seed(1234)
res_hajek <- hajek_repeat_measures(
formula = dist_bc ~ exposure_invariant,
data = meta,
bugs = bugs,
sample_id = "sample",
blocking_variable = "subject",
covariate_name = "exposure_invariant",
permutations = 999,
method = "bray"
)
cat("=== Hájek effect size ===\n")
#> === Hájek effect size ===
cat("Observed τ:", round(res_hajek$observed, 4), "\n")
#> Observed τ: 0.0218
cat("p-value: ", round(res_hajek$pval, 4), "\n")
#> p-value: 0.8188Interpretation: - τ ≈ 0 → exposure does not move samples away from the control centroid - τ > 0 → exposure moves samples farther from the control group - τ < 0 → exposure moves samples closer to the control group
Because our exposure was random, we expect τ ≈ 0 and p ≈ non-significant.
♦️ One interesting feature about this estimand is that in the Euclidean distance setting, the Hájek effect decomposes as:
\[\tau = \tau_{\text{location}} + \tau_{\text{dispersion}}\]
♦️ This is useful because two studies can have the same total τ for completely different biological reasons.
hajek_repeat_measures_loc_and_disp()♦️ The final function in permaBrioche allows us to calculate this decomposition:
This function requires data at two levels — sample level and subject level:
set.seed(1234)
# --- Sample-level blocking vector ---
blocks <- meta$subject
names(blocks) <- meta$sample
blocks <- blocks[rownames(bugs)]
# --- Sample-level within-subject permutation frame (empty = nothing varies within subject) ---
permute_within <- data.frame(row.names = rownames(bugs))
# --- Subject-level data ---
block_data <- aggregate(
exposure_invariant ~ subject,
data = meta,
FUN = function(x) x[1]
)
rownames(block_data) <- block_data$subject
block_data$subject <- NULL♦️ And we put these ingredients into the function in the following manner:
set.seed(1234)
res_ld <- hajek_repeat_measures_loc_and_disp(
D = dist_bc,
permute_within = permute_within,
blocks = blocks,
block_data = block_data,
bugs = bugs,
covariate_name = "exposure_invariant"
)
cat("=== Location–Dispersion Decomposition ===\n")
#> === Location–Dispersion Decomposition ===
print(res_ld)
#> $observed_tau
#> [1] -1368.944
#>
#> $observed_loc
#> [1] -987.3894
#>
#> $observed_disp
#> [1] -409.7602♦️ We can use ggplot to nicely format and display this decomposition:
set.seed(1234)
# Extract components for a simple visual summary
components <- data.frame(
Component = c("Total (τ)", "Location (τ_loc)", "Dispersion (τ_disp)"),
Value = c(
res_ld$observed_tau,
res_ld$observed_loc,
res_ld$observed_disp
)
)
ggplot(components, aes(x = Component, y = Value, fill = Component)) +
geom_col(width = 0.5, show.legend = FALSE) +
geom_hline(yintercept = 0, linewidth = 0.8) +
scale_fill_manual(values = c("#4E79A7", "#59A14F", "#F28E2B")) +
labs(
title = "Hájek effect size decomposition",
subtitle = "Location = centroid shift · Dispersion = variability change",
y = "Effect size (Euclidean units)", x = NULL
) +
theme_minimal(base_size = 13) +
theme(axis.text.x = element_text(size = 11))❇️ Question: Which of the following two outputs would indicate a systematic compositional shift rather than just indicating that one group is more heterogeneous?
Large location, small dispersion
Large dispersion, small location
| Function | What it does | Key argument |
|---|---|---|
PERMANOVA_repeat_measures() |
Valid hypothesis test | blocking_variable |
PERMANOVA_repeat_measures(..., center_R2 = TRUE) |
+ Null-centered R² | center_R2 = TRUE |
hajek_repeat_measures() |
Interpretable distance effect size | covariate_name, method |
hajek_repeat_measures_loc_and_disp() |
Decomposes effect into location + dispersion | block_data, blocks |
Do you have longitudinal / clustered microbiome data?
│
├─ Is your covariate INVARIANT within subject? (the function will figure
this out for you automatically)
│ (e.g. sex, delivery mode, genotype, treatment arm)
│ └─→ PERMANOVA_repeat_measures() ← between-block permutations
│
└─ Is your covariate VARIANT within subject? (the function will figure
this out for you automatically)
(e.g. diet, medication use)
└─→ PERMANOVA_repeat_measures() ← within-block permutations
Do you want an interpretable effect size?
└─→ hajek_repeat_measures()
Do you want to understand HOW the microbiome changed?
└─→ hajek_repeat_measures_loc_and_disp() (Euclidean distance)
Do you want a null-corrected variance summary?
└─→ PERMANOVA_repeat_measures(..., center_R2 = TRUE)
🥇 1. adonis2 alone is not valid for repeated-measures data. Even adding subject as a covariate does not fix the permutation scheme for invariant covariates — false positive rates can exceed 70% on HMP2 data.
🥈 2. PERMANOVA R² is upwardly inflated under the null. A significant positive R² does not necessarily imply a real effect. Use null-centered R² or the Hájek estimator instead.
🥉️ 3. The Hájek estimator gives you a geometrically interpretable effect size in the units of your chosen dissimilarity metric — something R² does not provide, and the location–dispersion decomposition tells you whether a covariate shifts the centroid, increases heterogeneity, or both.
💠 Overall: When running PERMANOVA on any microbiome dataset, make sure the permutation scheme respects its structure, and consider pairing your R² with a null-centered effect size. 😄