← Back to explainers
Explainer

What Is a Multiple-Comparisons Correction (Bonferroni, Holm, and False Discovery Rate)?

Run enough p-values at 0.05 and some will read "significant" by chance alone.

Learn what Bonferroni, Holm, and Benjamini-Hochberg false discovery rate corrections actually do to a batch of p-values, and why this repo's own benchmark harness - dozens of p-values per audit, 1,320 in total - needs one. Applies all three to COMPAS's real 90-p-value slice from paper/results-frozen/results_fairness.csv, verified against statsmodels, showing exactly which "significant" findings survive correction and which don't.

Run enough p-values at a 0.05 threshold and some will read "significant" by chance alone - a multiple-comparisons correction is how you find out which of this repo's dozens of per-audit findings actually survive that scrutiny.

The One-Sentence Definition

A multiple-comparisons correction (Bonferroni, Holm, or Benjamini-Hochberg false discovery rate) adjusts the significance threshold a batch of p-values is judged against, so that running many hypothesis tests at once doesn't inflate the chance of calling a fluke "significant" just because enough tests were run.

Why It Matters

What Is a Bootstrap Confidence Interval (and a Permutation Test)? attaches a p-value to every fairness gap this repo measures - but it attaches one to every single gap, not just one per audit. The benchmark harness computes six metrics, across every protected attribute, for three model families and five mitigation strategies, for each of seven audits. For a single-protected-attribute audit like COMPAS (race) that's 6 metrics x 3 models x 5 strategies = 90 p-values from one audit alone; across the full frozen benchmark, paper/results-frozen/results_fairness.csv carries 1,320 p-values in total.

At a raw 0.05 threshold, each individual test has a 5% chance of reading "significant" purely by chance, even when nothing real is happening. Run 90 independent tests and the probability that at least one looks significant by chance alone climbs to 1 - 0.95^90 ≈ 99% - not a rare edge case, a near-certainty. Bootstrap Confidence Intervals already names this exact gap in its own limitations section: "No correction (e.g. Bonferroni) is currently applied across this full set." This explainer covers what such a correction actually does to a real slice of those p-values, and what it costs.

Core Concept

There are two different questions a correction can answer, and they trade off against each other:

Family-wise error rate (FWER) - the probability of even one false positive anywhere in the whole batch of tests. Bonferroni and Holm both control FWER.

False discovery rate (FDR) - not "no false positives anywhere," but "of the findings I call significant, only a controlled fraction are expected to be false." Benjamini-Hochberg (BH-FDR) controls this instead of FWER, and is far less conservative because it's answering an easier question.

Concrete Example: COMPAS - Audit 01

COMPAS declares one protected attribute (race), so its full grid is 5 strategies x 3 models x 6 metrics = 90 p-values - real numbers straight from paper/results-frozen/results_fairness.csv. At the raw 0.05 threshold, 67 of the 90 read "significant." After correction:

CorrectionSignificant (of 90)Threshold used
Raw (p < 0.05)670.05 for every test
Bonferroni530.05 / 90 = 0.00056 for every test
Holm55sequential, from 0.00056 up to 0.05
Benjamini-Hochberg FDR64sequential, scaling with rank

A sample of individual rows across that boundary shows what each correction actually does to a specific finding:

StrategyModelMetricp-valueRawBonferroniHolmBH-FDR
baselinelogistic_regressiondemographic_parity_diff0.0000significantsignificantsignificantsignificant
unawareness_proxy_removalrandom_forestequal_opportunity_diff0.0010significantnotsignificantsignificant
unawareness_proxy_removallogistic_regressionequalized_odds_diff0.0015significantnotnotsignificant
baselinerandom_forestaccuracy_equality_diff0.0065significantnotnotsignificant
in_processingrandom_forestdemographic_parity_diff0.0225significantnotnotsignificant
in_processinglogistic_regressionequal_opportunity_diff0.0385significantnotnotnot
baselinelogistic_regressionaccuracy_equality_diff0.0395significantnotnotnot
unawarenesslogistic_regressionaccuracy_equality_diff0.9455notnotnotnot

The first row (p essentially 0) survives every correction - nothing about running 90 tests changes the conclusion that COMPAS's baseline demographic parity gap by race is real. The last row was never significant to begin with. The six rows in between are exactly the point: identical raw p-values read as five different verdicts depending on which question you ask ("no false positives anywhere" vs. "a controlled fraction of my discoveries are false"), and two of them - both around p = 0.038-0.040 - are "significant" under the repo's current uncorrected reporting but wouldn't survive even the most permissive correction here.

Detection Code

A from-scratch implementation of all three corrections, verified against statsmodels.stats.multitest.multipletests on the exact 90-row COMPAS/race slice above (identical significant-count results: 53 Bonferroni, 55 Holm, 64 BH-FDR):

import numpy as np


def bonferroni(p_values, alpha=0.05):
    """FWER control: judge every p-value against alpha / m."""
    m = len(p_values)
    threshold = alpha / m
    return np.asarray(p_values) <= threshold


def holm(p_values, alpha=0.05):
    """FWER control, step-down: sort ascending, compare the i-th smallest
    (1-indexed) to alpha / (m - i + 1). The first failure stops rejection
    for every larger p-value too, even ones that would pass their own step."""
    p_values = np.asarray(p_values)
    m = len(p_values)
    order = np.argsort(p_values)
    reject_sorted = np.zeros(m, dtype=bool)
    for i, idx in enumerate(order):  # i is 0-indexed; rank = i + 1
        threshold = alpha / (m - i)
        if p_values[idx] <= threshold:
            reject_sorted[i] = True
        else:
            break  # every remaining (larger) p-value is not rejected
    reject = np.zeros(m, dtype=bool)
    reject[order] = reject_sorted
    return reject


def benjamini_hochberg(p_values, alpha=0.05):
    """FDR control: sort ascending, find the largest rank i where
    p_(i) <= (i / m) * alpha, then reject every p-value at or below
    that rank - not just the ones that individually clear their own step."""
    p_values = np.asarray(p_values)
    m = len(p_values)
    order = np.argsort(p_values)
    sorted_p = p_values[order]
    ranks = np.arange(1, m + 1)
    passes = sorted_p <= (ranks / m) * alpha
    reject_sorted = np.zeros(m, dtype=bool)
    if passes.any():
        cutoff_rank = np.max(np.nonzero(passes)) + 1  # last passing rank, 1-indexed
        reject_sorted[:cutoff_rank] = True
    reject = np.zeros(m, dtype=bool)
    reject[order] = reject_sorted
    return reject


# Usage example, on the p-values from any one audit's results_fairness.csv slice:
# p_values = fairness_df[fairness_df["audit"] == "compas"]["p_value"].to_numpy()
# raw_sig = p_values < 0.05
# print(raw_sig.sum(), bonferroni(p_values).sum(), holm(p_values).sum(), benjamini_hochberg(p_values).sum())
# 67 53 55 64 - matching the table above.

Limitations

1. A correction trades false positives for false negatives

Every one of these methods makes it harder to call something significant - that's the whole point, but it's not free. A real, smaller effect that would have cleared the raw 0.05 threshold can fail Bonferroni or Holm once corrected for 90 simultaneous tests, exactly like two of the rows in the table above. Choosing FWER over FDR (or vice versa) is choosing which error you'd rather risk, not eliminating error.

2. The correction is only as meaningful as the test batch it's applied to

Correcting across "every p-value COMPAS produces" and correcting across "every p-value this repo has ever produced" give different answers to different questions - there's no single universally correct choice of m. This explainer corrects within one audit (90 tests) because that's the natural unit a reader evaluates a single audit's claims against; a reader asking "should I trust any significant finding across all seven audits" would need to correct across all 1,320.

3. None of these three methods account for correlated tests

Many of these 90 p-values are not independent - the same underlying data drives all six metrics for a given (strategy, model) cell, and adjacent strategies share most of their rows. Bonferroni and Holm's FWER guarantees hold regardless (they're valid under arbitrary dependence), but BH-FDR's guarantee technically assumes independence or a specific positive-dependence structure; under general dependence a stricter variant (Benjamini-Yekutieli) is the theoretically safe choice, at the cost of even fewer discoveries.

4. This explainer's own correction isn't applied anywhere in the actual benchmark

The table above is illustrative, computed for this explainer - faircode/significance.py and faircode/benchmark.py do not currently apply any of these corrections to the harness's real output. Reading any single p-value from results_fairness.csv today still means reading it uncorrected, in the context this explainer describes.

Further Reading

Part of The Fair Code Project - exposing and fixing algorithmic bias with real data and open code.