Skip to main content

qualia_core_db/solvers/statistics/hypothesis/
nonparametric.rs

1//! Nonparametric & multiple-comparison tests (CI-SKM ch 6) — distribution-free
2//! tests for paired and multi-group comparisons, the standard tools for comparing
3//! classifiers/estimators across datasets. Real p-values from the χ²/F CDFs in
4//! [`distributions`](super::super::distributions); within-block ranking reuses the
5//! statistics ranker.
6
7use super::super::distributions::{chi_squared, fisher_f};
8
9/// Result of a χ²-based test.
10#[derive(Debug, Clone, Copy, PartialEq)]
11pub struct NonparametricResult {
12    pub statistic: f64,
13    pub p_value: f64,
14    pub dof: f64,
15}
16
17/// McNemar's test for two paired binary classifiers: `b` is the count where A is
18/// right and B wrong, `c` where A is wrong and B right (the discordant cells of the
19/// 2×2 agreement table). Uses the continuity-corrected statistic
20/// `(|b−c|−1)²/(b+c)` ~ χ²₁. `None` if `b + c == 0` (no discordance).
21pub fn mcnemar(b: u64, c: u64) -> Option<NonparametricResult> {
22    let nb = b as f64;
23    let nc = c as f64;
24    if b + c == 0 {
25        return None;
26    }
27    let diff = (nb - nc).abs();
28    let stat = if diff >= 1.0 {
29        (diff - 1.0).powi(2) / (nb + nc)
30    } else {
31        0.0
32    };
33    Some(NonparametricResult {
34        statistic: stat,
35        p_value: chi_squared::upper_p(stat, 1.0),
36        dof: 1.0,
37    })
38}
39
40/// Friedman test result, including the Iman-Davenport F-correction.
41#[derive(Debug, Clone, Copy, PartialEq)]
42pub struct FriedmanResult {
43    /// Friedman χ² statistic.
44    pub chi_square: f64,
45    /// p-value of the χ² statistic (df = k−1).
46    pub chi_p_value: f64,
47    pub df: f64,
48    /// Iman-Davenport F statistic (less conservative than the χ²).
49    pub iman_davenport_f: f64,
50    /// p-value of the F statistic (df1 = k−1, df2 = (k−1)(n−1)).
51    pub f_p_value: f64,
52}
53
54/// Friedman test for `k` treatments across `n` blocks (e.g. classifiers × datasets),
55/// `data[block]` of length `k` (the measurements, higher = better). Ranks within
56/// each block (ties averaged), then tests whether the treatments differ. `None` if
57/// fewer than 2 blocks / 2 treatments or ragged input.
58pub fn friedman(data: &[&[f64]]) -> Option<FriedmanResult> {
59    let n = data.len();
60    if n < 2 {
61        return None;
62    }
63    let k = data[0].len();
64    if k < 2 || data.iter().any(|b| b.len() != k) {
65        return None;
66    }
67
68    // Average rank per treatment across blocks (rank ascending so larger value →
69    // larger rank; ties share the average rank).
70    let mut rank_sum = vec![0.0; k];
71    let mut idx = vec![0usize; k];
72    let mut ranks = vec![0.0; k];
73    for block in data {
74        super::super::correlation::rank_into(block, &mut idx, &mut ranks)?;
75        for j in 0..k {
76            rank_sum[j] += ranks[j];
77        }
78    }
79    let mean_rank: Vec<f64> = rank_sum.iter().map(|&s| s / n as f64).collect();
80
81    let kf = k as f64;
82    let nf = n as f64;
83    // χ²_F = 12n/(k(k+1)) · Σ (R̄_j − (k+1)/2)².
84    let grand = (kf + 1.0) / 2.0;
85    let ss: f64 = mean_rank.iter().map(|&r| (r - grand).powi(2)).sum();
86    let chi = 12.0 * nf / (kf * (kf + 1.0)) * ss;
87    let df = kf - 1.0;
88    let chi_p = chi_squared::upper_p(chi, df);
89
90    // Iman-Davenport F.
91    let denom = nf * (kf - 1.0) - chi;
92    let (f_stat, f_p) = if denom.abs() > 1e-12 && (nf - 1.0) > 0.0 {
93        let f = (nf - 1.0) * chi / denom;
94        let df1 = kf - 1.0;
95        let df2 = (kf - 1.0) * (nf - 1.0);
96        let f_clamped = f.max(0.0);
97        (f_clamped, fisher_f::upper_p(f_clamped, df1, df2))
98    } else {
99        (f64::INFINITY, 0.0)
100    };
101
102    Some(FriedmanResult {
103        chi_square: chi,
104        chi_p_value: chi_p,
105        df,
106        iman_davenport_f: f_stat,
107        f_p_value: f_p,
108    })
109}
110
111/// Mann-Whitney U test (rank sum) for two independent samples.
112/// Returns U statistic, p approx using normal for large n, or exact for small.
113/// Simplified normal approx for demo.
114#[derive(Debug, Clone, Copy, PartialEq)]
115pub struct MannWhitneyResult {
116    pub u: f64,
117    pub p_value: f64,
118    pub n1: usize,
119    pub n2: usize,
120}
121
122pub fn mann_whitney_u(x: &[f64], y: &[f64]) -> Option<MannWhitneyResult> {
123    let n1 = x.len();
124    let n2 = y.len();
125    if n1 == 0 || n2 == 0 {
126        return None;
127    }
128    let mut all = Vec::with_capacity(n1 + n2);
129    for &v in x {
130        all.push((v, 0));
131    }
132    for &v in y {
133        all.push((v, 1));
134    }
135    all.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
136    let mut rank_sum1 = 0.0;
137    let mut i = 0;
138    while i < all.len() {
139        let mut j = i;
140        while j < all.len() && (all[j].0 - all[i].0).abs() < 1e-12 {
141            j += 1;
142        }
143        let rank = (i + j) as f64 / 2.0 + 0.5; // average rank
144        for k in i..j {
145            if all[k].1 == 0 {
146                rank_sum1 += rank;
147            }
148        }
149        i = j;
150    }
151    let u1 = rank_sum1 - (n1 as f64 * (n1 as f64 + 1.0) / 2.0);
152    let u2 = (n1 * n2) as f64 - u1;
153    let u = u1.min(u2);
154    // Normal approx
155    let mu = (n1 * n2) as f64 / 2.0;
156    let sigma = ((n1 * n2) as f64 * (n1 + n2 + 1) as f64 / 12.0).sqrt();
157    let z = (u - mu) / sigma.max(1e-9);
158    let p = 2.0 * (1.0 - 0.5 * (1.0 + (z / (2.0f64.sqrt())).tanh())); // rough normal cdf approx
159    Some(MannWhitneyResult {
160        u,
161        p_value: p.clamp(0.0, 1.0),
162        n1,
163        n2,
164    })
165}
166
167/// Kolmogorov-Smirnov one-sample test vs uniform[0,1] for demo.
168/// Returns D statistic and rough p.
169#[derive(Debug, Clone, Copy, PartialEq)]
170pub struct KolmogorovSmirnovResult {
171    pub d: f64,
172    pub p_value: f64,
173}
174
175pub fn ks_1sample(data: &[f64]) -> Option<KolmogorovSmirnovResult> {
176    if data.is_empty() {
177        return None;
178    }
179    let mut sorted = data.to_vec();
180    sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
181    let n = sorted.len() as f64;
182    let mut d = 0.0f64;
183    for (i, &x) in sorted.iter().enumerate() {
184        let cdf = x.clamp(0.0, 1.0);
185        let i_f = i as f64;
186        d = d.max(((i_f + 1.0) / n - cdf).abs());
187        d = d.max((cdf - i_f / n).abs());
188    }
189    // Rough p approx (not exact)
190    let p = (-2.0 * n * d * d).exp().min(1.0);
191    Some(KolmogorovSmirnovResult { d, p_value: p })
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    #[test]
199    fn mcnemar_detects_disagreement() {
200        // Classifier A right/B wrong 30 times; A wrong/B right 5 → significant.
201        let r = mcnemar(30, 5).unwrap();
202        assert_eq!(r.dof, 1.0);
203        assert!(r.statistic > 15.0);
204        assert!(r.p_value < 0.001);
205        // Symmetric discordance → not significant.
206        let sym = mcnemar(20, 18).unwrap();
207        assert!(sym.p_value > 0.5);
208        assert!(mcnemar(0, 0).is_none());
209    }
210
211    #[test]
212    fn friedman_detects_a_consistent_ordering() {
213        // Treatment 2 always best, 0 always worst, across 5 blocks → significant.
214        let b1 = [1.0, 2.0, 3.0];
215        let b2 = [1.1, 2.2, 3.3];
216        let b3 = [0.9, 2.1, 3.1];
217        let b4 = [1.0, 2.5, 3.4];
218        let b5 = [1.2, 2.0, 3.0];
219        let r = friedman(&[&b1, &b2, &b3, &b4, &b5]).unwrap();
220        assert_eq!(r.df, 2.0);
221        assert!(r.chi_square > 6.0, "chi2 {}", r.chi_square);
222        assert!(r.chi_p_value < 0.05);
223        assert!(r.f_p_value < 0.05);
224    }
225
226    #[test]
227    fn friedman_no_difference_is_not_significant() {
228        // Random-ish orderings with no consistent winner.
229        let b1 = [1.0, 2.0, 3.0];
230        let b2 = [3.0, 1.0, 2.0];
231        let b3 = [2.0, 3.0, 1.0];
232        let r = friedman(&[&b1, &b2, &b3]).unwrap();
233        assert!(r.chi_p_value > 0.2, "p {}", r.chi_p_value);
234    }
235
236    #[test]
237    fn guards() {
238        assert!(friedman(&[&[1.0, 2.0][..]]).is_none()); // < 2 blocks
239        let ragged: [&[f64]; 2] = [&[1.0, 2.0], &[1.0]];
240        assert!(friedman(&ragged).is_none());
241    }
242}