Skip to main content

solow_stats/
multitest.rs

1//! Multiple-hypothesis-testing p-value corrections.
2
3/// Correction method for [`multipletests`].
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub enum MultiTestMethod {
6    /// Bonferroni one-step correction.
7    Bonferroni,
8    /// Benjamini–Hochberg FDR step-up procedure (independent / positive corr.).
9    FdrBh,
10    /// Holm step-down (Bonferroni) procedure.
11    Holm,
12}
13
14/// Result of [`multipletests`]: rejection flags and adjusted p-values, both in
15/// the original input order.
16#[derive(Debug, Clone)]
17pub struct MultiTestResult {
18    /// `true` where the corresponding null hypothesis is rejected at `alpha`.
19    pub reject: Vec<bool>,
20    /// Corrected (adjusted) p-values, clamped to `[0, 1]`.
21    pub pvals_corrected: Vec<f64>,
22}
23
24/// Adjust a set of p-values for multiple testing.
25///
26/// Returns rejection decisions at family-wise / false-discovery level `alpha`
27/// and the corrected p-values, in the original order of `pvals`. Mirrors the
28/// reference `multipletests` for the `bonferroni`, `fdr_bh`, and `holm`
29/// methods.
30pub fn multipletests(pvals: &[f64], alpha: f64, method: MultiTestMethod) -> MultiTestResult {
31    let ntests = pvals.len();
32    if ntests == 0 {
33        return MultiTestResult {
34            reject: vec![],
35            pvals_corrected: vec![],
36        };
37    }
38
39    match method {
40        MultiTestMethod::Bonferroni => {
41            // No sorting required; operates in input order directly.
42            let nt = ntests as f64;
43            let alphac_bonf = alpha / nt;
44            let reject = pvals.iter().map(|&p| p <= alphac_bonf).collect();
45            let pvals_corrected = pvals.iter().map(|&p| (p * nt).min(1.0)).collect();
46            MultiTestResult {
47                reject,
48                pvals_corrected,
49            }
50        }
51        MultiTestMethod::Holm => holm(pvals, alpha),
52        MultiTestMethod::FdrBh => fdr_bh(pvals, alpha),
53    }
54}
55
56/// Argsort indices of `pvals` in ascending order (stable, matching numpy's
57/// default quicksort-stable behaviour for distinct keys).
58fn argsort(pvals: &[f64]) -> Vec<usize> {
59    let mut idx: Vec<usize> = (0..pvals.len()).collect();
60    idx.sort_by(|&a, &b| pvals[a].total_cmp(&pvals[b]));
61    idx
62}
63
64/// Scatter `sorted` back to the original order given the sort indices.
65fn unsort(sorted: &[f64], sortind: &[usize]) -> Vec<f64> {
66    let mut out = vec![0.0; sorted.len()];
67    for (k, &orig) in sortind.iter().enumerate() {
68        out[orig] = sorted[k];
69    }
70    out
71}
72
73fn unsort_bool(sorted: &[bool], sortind: &[usize]) -> Vec<bool> {
74    let mut out = vec![false; sorted.len()];
75    for (k, &orig) in sortind.iter().enumerate() {
76        out[orig] = sorted[k];
77    }
78    out
79}
80
81fn holm(pvals: &[f64], alpha: f64) -> MultiTestResult {
82    let ntests = pvals.len();
83    let sortind = argsort(pvals);
84    let sorted: Vec<f64> = sortind.iter().map(|&i| pvals[i]).collect();
85
86    // notreject_i = p_i > alpha / (ntests - i)
87    let mut notreject: Vec<bool> = (0..ntests)
88        .map(|i| sorted[i] > alpha / (ntests - i) as f64)
89        .collect();
90    // From the first non-rejection onward, force non-rejection.
91    let notrejectmin = notreject.iter().position(|&b| b).unwrap_or(ntests);
92    for nr in notreject.iter_mut().skip(notrejectmin) {
93        *nr = true;
94    }
95    let reject_sorted: Vec<bool> = notreject.iter().map(|&b| !b).collect();
96
97    // pvals_corrected = cummax(p_i * (ntests - i)), then clamp to 1.
98    let mut running = f64::NEG_INFINITY;
99    let mut corrected: Vec<f64> = (0..ntests)
100        .map(|i| {
101            let v = sorted[i] * (ntests - i) as f64;
102            running = running.max(v);
103            running
104        })
105        .collect();
106    for c in corrected.iter_mut() {
107        if *c > 1.0 {
108            *c = 1.0;
109        }
110    }
111
112    MultiTestResult {
113        reject: unsort_bool(&reject_sorted, &sortind),
114        pvals_corrected: unsort(&corrected, &sortind),
115    }
116}
117
118fn fdr_bh(pvals: &[f64], alpha: f64) -> MultiTestResult {
119    let ntests = pvals.len();
120    let sortind = argsort(pvals);
121    let sorted: Vec<f64> = sortind.iter().map(|&i| pvals[i]).collect();
122    let nf = ntests as f64;
123
124    // ecdffactor_i = (i+1)/ntests
125    let ecdf: Vec<f64> = (0..ntests).map(|i| (i + 1) as f64 / nf).collect();
126
127    // reject where p_i <= ecdf_i * alpha; then fill below the last rejection.
128    let mut reject_sorted: Vec<bool> = (0..ntests).map(|i| sorted[i] <= ecdf[i] * alpha).collect();
129    if let Some(rejectmax) = reject_sorted.iter().rposition(|&b| b) {
130        for r in reject_sorted.iter_mut().take(rejectmax) {
131            *r = true;
132        }
133    }
134
135    // corrected = reverse-cumulative-min(p_i / ecdf_i), clamped to 1.
136    let raw: Vec<f64> = (0..ntests).map(|i| sorted[i] / ecdf[i]).collect();
137    let mut corrected = vec![0.0; ntests];
138    let mut running = f64::INFINITY;
139    for i in (0..ntests).rev() {
140        running = running.min(raw[i]);
141        corrected[i] = running.min(1.0);
142    }
143
144    MultiTestResult {
145        reject: unsort_bool(&reject_sorted, &sortind),
146        pvals_corrected: unsort(&corrected, &sortind),
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn bonferroni_scales_and_clamps() {
156        let r = multipletests(&[0.01, 0.2, 0.6], 0.05, MultiTestMethod::Bonferroni);
157        assert!((r.pvals_corrected[0] - 0.03).abs() < 1e-12);
158        assert!((r.pvals_corrected[2] - 1.0).abs() < 1e-12); // 1.8 clamped
159        assert_eq!(r.reject, vec![true, false, false]);
160    }
161
162    #[test]
163    fn fdr_bh_monotone() {
164        let r = multipletests(&[0.001, 0.01, 0.03, 0.5], 0.05, MultiTestMethod::FdrBh);
165        // Corrected p-values are non-decreasing in the original (already sorted) order.
166        for w in r.pvals_corrected.windows(2) {
167            assert!(w[0] <= w[1] + 1e-12);
168        }
169    }
170}