---
title: "Checking a multiple-testing analysis"
description: "Three diagnostics for a false-discovery-rate analysis: the p-value histogram, estimating the null proportion, and the sensitivity of the discovery set."
date: "2026-08-02 12:00"
categories: [R, multiple testing, model diagnostics, ecology tutorial]
image: thumbnail.png
---
The previous three tutorials chose an error rate and a correction: [Bonferroni and Holm](../family-wise-error-and-bonferroni/) for the family-wise error, [Benjamini-Hochberg](../false-discovery-rate-benjamini-hochberg/) for the false discovery rate, [Benjamini-Yekutieli](../dependence-and-fdr-benjamini-yekutieli/) when the tests are correlated. This closing post asks how to check the analysis before you trust it. Three diagnostics carry most of the weight: the shape of the p-value histogram, an estimate of how many nulls are true, and the sensitivity of the discovery set to the choices you made. All are base R.
## Setup
```{r}
#| label: setup
#| include: false
library(ggplot2)
te_ink<-"#16241d"; te_body<-"#2c3a31"; te_forest<-"#275139"; te_faint<-"#5d6b61"
te_paper<-"#f5f4ee"; te_line<-"#dad9ca"
c_bh<-"#275139"; c_bonf<-"#cda23f"
theme_te <- function(base=13) 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=.3),
axis.text=element_text(colour=te_body), axis.title=element_text(colour=te_ink),
plot.title=element_text(colour=te_ink,face="bold"),
plot.subtitle=element_text(colour=te_faint),
strip.text=element_text(colour=te_ink,face="bold"))
BH <- function(p, q = 0.05){
m <- length(p); o <- order(p); s <- p[o]
below <- s <= (seq_len(m) / m) * q
k <- if(any(below)) max(which(below)) else 0
rej <- rep(FALSE, m); if(k > 0) rej[o[seq_len(k)]] <- TRUE
rej
}
```
## The p-value histogram
Every correction in this series takes p-values as its raw material and assumes they are valid: under a true null, a p-value is uniform on the unit interval. The single most useful check is to plot the histogram of all your p-values and look at its shape. A valid analysis with some real effects shows a flat carpet from the null tests plus a spike near zero from the true ones. A flat histogram with no spike means nothing is there. A histogram that rises toward zero when nothing should be there means the p-values are not valid, usually because unmodelled dependence or overdispersion has made the tests anticonservative.
We simulate three screens of `r 200` taxa: all nulls with valid tests, all nulls with overdispersed statistics analysed as if standard normal (the pseudoreplication trap), and a real mixture of `r 40` true effects among `r 160` nulls.
```{r}
#| label: histograms
m <- 200; m1 <- 40; m0 <- m - m1; mu <- 3
nonnull <- c(rep(TRUE, m1), rep(FALSE, m0))
gof_unif <- function(p, B = 10){
ct <- table(cut(p, breaks = seq(0, 1, length.out = B + 1), include.lowest = TRUE))
chisq.test(as.numeric(ct))$p.value
}
set.seed(303)
p_null <- 2 * pnorm(-abs(rnorm(m))) # valid, all null
p_mis <- 2 * pnorm(-abs(rnorm(m, sd = 1.4))) # overdispersed, all null
p_mix <- 2 * pnorm(-abs(rnorm(m, mean = ifelse(nonnull, mu, 0)))) # 40 true effects
c(valid_GOF = round(gof_unif(p_null), 3),
misspecified_GOF = signif(gof_unif(p_mis), 3),
mixture_GOF = signif(gof_unif(p_mix), 3))
c(frac_below_05_valid = mean(p_null < 0.05),
frac_below_05_misspecified = mean(p_mis < 0.05),
frac_below_05_mixture = mean(p_mix < 0.05))
```
A chi-square test of uniformity gives the valid nulls a comfortable p-value of `r round(gof_unif(p_null),3)`, and rejects uniformity decisively for both the misspecified case and the real mixture. The two rejections mean opposite things, which is why the picture matters. The misspecified histogram rises toward zero even though not one taxon has a real effect: `r round(mean(p_mis<0.05)*100)` per cent of its p-values fall below 0.05, against the `r round(mean(p_null<0.05)*100)` per cent you expect, close to the `r round(2*pnorm(-qnorm(0.975)/1.4),3)` predicted for statistics inflated this way. Feed those p-values to any correction and it will report discoveries that are artefacts of the unmodelled variance, not biology.
```{r}
#| label: fig-histograms
#| fig-cap: "Histograms of p-values for a valid all-null screen, an overdispersed all-null screen, and a screen with forty real effects. Uniform under the null; a spike with no signal (centre) flags misspecification."
#| fig-alt: "Three histograms. Left is flat around the dashed uniform line. Centre rises sharply toward zero despite no true effects. Right is flat with a tall spike near zero from real effects."
d <- data.frame(p = c(p_null, p_mis, p_mix),
panel = factor(rep(c("valid (all null)","misspecified (all null)","real signal (40 true)"),
each = m),
levels = c("valid (all null)","misspecified (all null)","real signal (40 true)")))
ggplot(d, aes(p)) +
geom_histogram(breaks = seq(0, 1, .05), fill = c_bh, colour = te_paper, linewidth = .2) +
facet_wrap(~panel) +
geom_hline(yintercept = m/20, linetype = "dashed", colour = te_faint) +
labs(x = "p-value", y = "count", title = "The p-value histogram is the first diagnostic") +
theme_te()
```
## Estimating the proportion of true nulls
Benjamini-Hochberg implicitly assumes every null could be true, which is conservative when many are not. Storey's approach estimates the true-null proportion `pi0` directly: high p-values are almost all from nulls, so `pi0_hat(lambda) = #{p > lambda} / (m (1 - lambda))` for some cut `lambda`. Adaptive procedures then run BH at the sharper level `q / pi0_hat`.
```{r}
#| label: pi0
pi0_hat <- function(p, lam) min(1, mean(p > lam) / (1 - lam))
sweep <- sapply(c(0.3, 0.5, 0.8), function(l)
c(mixture = pi0_hat(p_mix, l), valid_null = pi0_hat(p_null, l),
misspecified = pi0_hat(p_mis, l)))
colnames(sweep) <- c("lambda=0.3","lambda=0.5","lambda=0.8")
round(sweep, 3)
```
On the real mixture the estimate lands near the truth of `r round(m0/m,2)` (between `r round(pi0_hat(p_mix,0.3),3)` and `r round(pi0_hat(p_mix,0.8),3)` across the cut), and adaptive BH would gain a little power. But the estimate depends on `lambda`, which is a choice, and it inherits the same fragility as the histogram. On the misspecified screen `pi0_hat` drops to around `r round(pi0_hat(p_mis,0.5),2)`, falsely implying that a quarter of the taxa have real effects when none do. The estimator trusts that the nulls are uniform; when they are not, it is misled in exactly the way the histogram exposes. The diagnostic that catches the problem is the picture, not the point estimate.
## The discovery set is a function of your choices
The deepest check is to see how much the result depends on the decisions you could have made differently. The number of discoveries is a function of the target level `q`, and of the far larger choice between controlling the family-wise error and the false discovery rate.
```{r}
#| label: sensitivity
set.seed(2718)
z <- rnorm(m, mean = ifelse(nonnull, mu, 0)); p <- 2 * pnorm(-abs(z))
padj <- p.adjust(p, "BH")
q_grid <- c(0.01, 0.02, 0.05, 0.10, 0.15, 0.20)
Rq <- sapply(q_grid, function(qq) sum(padj <= qq))
setNames(Rq, q_grid)
c(bonferroni_at_05 = sum(p.adjust(p, "bonferroni") < 0.05), bh_at_05 = sum(padj <= 0.05))
```
Sweeping `q` from 0.01 to 0.20 moves the discovery count from `r Rq[1]` to `r Rq[length(Rq)]`. On the very same data, controlling the family-wise error with Bonferroni yields `r sum(p.adjust(p,"bonferroni")<0.05)` discoveries where the false discovery rate yields `r sum(padj<=0.05)`: the choice of error rate moves the answer far more than any wiggle in `q`. And no procedure among them identifies *which* taxa are the real effects. Of the `r m1` genuine effects here, `r sum(padj<=0.05 & nonnull)` are recovered at `q = 0.05`; the rest sit below the line, indistinguishable from nulls at this threshold.
The same logic localises a single finding. A taxon enters the discovery set at the `q` equal to its own adjusted p-value, its tipping point.
```{r}
#| label: tipping
ord <- order(padj)
borderline <- ord[which(padj[ord] > 0.05)[1]]
round(padj[borderline], 4)
```
The borderline taxon here has an adjusted p-value of `r round(padj[borderline],4)`, so it is a discovery at `q = 0.07` but not at `q = 0.05`. Whether you report it is a decision about how many false leads you will tolerate, not a fact the data settle.
```{r}
#| label: fig-sensitivity
#| fig-cap: "Number of Benjamini-Hochberg discoveries as a function of the target level q, with the Bonferroni count for comparison. The discovery set is a monotone function of a choice, not a fixed list."
#| fig-alt: "A rising step function of discoveries against q, from about 16 at q=0.01 to 45 at q=0.20, with a dashed line at the Bonferroni count of 10 and a dotted marker at q=0.05."
qg <- seq(0.005, 0.20, length.out = 60)
Rqc <- sapply(qg, function(qq) sum(padj <= qq))
ggplot(data.frame(q = qg, R = Rqc), aes(q, R)) +
geom_step(colour = c_bh, linewidth = 1) +
geom_hline(yintercept = sum(p.adjust(p, "bonferroni") < 0.05),
linetype = "dashed", colour = c_bonf) +
geom_vline(xintercept = 0.05, linetype = "dotted", colour = te_faint) +
annotate("text", x = 0.02, y = sum(p.adjust(p, "bonferroni") < 0.05) + 1.5,
label = "Bonferroni (q=0.05): 10", colour = c_bonf, hjust = 0, size = 3.3) +
annotate("text", x = 0.052, y = 6, label = "q=0.05", colour = te_faint, hjust = 0, size = 3.2) +
labs(x = "target FDR level q", y = "number of discoveries",
title = "The discovery set is a function of q, not a fact") +
theme_te()
```
## What the checks do and do not settle
The three diagnostics constrain the analysis without proving it correct. The histogram tells you whether the p-values are valid, and it is the check that survives when the null distribution is wrong; the null-proportion estimate sharpens power but trusts the same uniformity and is misled when it fails; the sensitivity sweep shows that the discovery set is a decision at a chosen threshold under a chosen error rate, not a list of true effects. A multiple-testing correction never hands you the taxa that matter. It hands you a ranked, thresholded set whose contents move with `q`, with the error rate you picked, and with the dependence you were willing to assume. Reporting those choices alongside the discoveries is what makes the result honest.
## References
Storey JD 2002. J R Stat Soc Ser B 64(3):479-498 (10.1111/1467-9868.00346).
Storey JD, Tibshirani R 2003. Proc Natl Acad Sci USA 100(16):9440-9445 (10.1073/pnas.1530509100).
Benjamini Y, Hochberg Y 1995. J R Stat Soc Ser B 57(1):289-300 (10.1111/j.2517-6161.1995.tb02031.x).
Murtaugh PA 2014. Ecology 95(3):611-617 (10.1890/13-0590.1).
Gotelli NJ, Ellison AM 2013. A Primer of Ecological Statistics, 2nd ed. Sinauer Associates. ISBN 978-1-60535-064-6.
## Related tutorials
- [The false discovery rate and Benjamini-Hochberg](../false-discovery-rate-benjamini-hochberg/)
- [Family-wise error and the Bonferroni correction](../family-wise-error-and-bonferroni/)
- [Dependence and the Benjamini-Yekutieli correction](../dependence-and-fdr-benjamini-yekutieli/)
- [GLM residual diagnostics](../glm-residual-diagnostics/)