qualia_core_db/solvers/statistics/hypothesis/
nonparametric.rs1use super::super::distributions::{chi_squared, fisher_f};
8
9#[derive(Debug, Clone, Copy, PartialEq)]
11pub struct NonparametricResult {
12 pub statistic: f64,
13 pub p_value: f64,
14 pub dof: f64,
15}
16
17pub 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#[derive(Debug, Clone, Copy, PartialEq)]
42pub struct FriedmanResult {
43 pub chi_square: f64,
45 pub chi_p_value: f64,
47 pub df: f64,
48 pub iman_davenport_f: f64,
50 pub f_p_value: f64,
52}
53
54pub 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 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 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 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#[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; 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 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())); Some(MannWhitneyResult {
160 u,
161 p_value: p.clamp(0.0, 1.0),
162 n1,
163 n2,
164 })
165}
166
167#[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 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 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 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 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 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()); let ragged: [&[f64]; 2] = [&[1.0, 2.0], &[1.0]];
240 assert!(friedman(&ragged).is_none());
241 }
242}