Skip to main content

solow_stats/
variance_tests.rs

1//! Homogeneity-of-variance tests.
2//!
3//! * [`levene`] — Levene's (1960) robust variance test, with the choice
4//!   of centre (mean or median for the Brown-Forsythe variant).
5//! * [`bartlett`] — Bartlett's classic normal-theory variance test.
6//! * [`fligner`] — Fligner-Killeen rank-based variance test.
7
8use solow_core::{Error, Result};
9
10/// Test-statistic + p-value pair returned by variance tests.
11#[derive(Clone, Copy, Debug, PartialEq)]
12pub struct VarianceTestResult {
13    /// Statistic (F for Levene, χ² for Bartlett/Fligner).
14    pub statistic: f64,
15    /// Two-sided p-value under the null of equal variances.
16    pub pvalue: f64,
17    /// Degrees-of-freedom pair `(dfn, dfd)` where applicable.
18    pub df: (f64, f64),
19}
20
21/// Centering strategy for Levene's test.
22#[derive(Clone, Copy, Debug, PartialEq)]
23pub enum LeveneCenter {
24    /// Deviations from the group mean (classical Levene).
25    Mean,
26    /// Deviations from the group median (Brown-Forsythe).
27    Median,
28}
29
30/// Levene's test / Brown-Forsythe variant.
31pub fn levene(groups: &[Vec<f64>], center: LeveneCenter) -> Result<VarianceTestResult> {
32    if groups.len() < 2 {
33        return Err(Error::Value("levene: need ≥ 2 groups".into()));
34    }
35    let k = groups.len();
36    let n_total: usize = groups.iter().map(|g| g.len()).sum();
37    if n_total < k + 1 {
38        return Err(Error::Value("levene: too few samples".into()));
39    }
40    let mut group_z: Vec<Vec<f64>> = Vec::with_capacity(k);
41    for g in groups {
42        let c = match center {
43            LeveneCenter::Mean => g.iter().sum::<f64>() / g.len() as f64,
44            LeveneCenter::Median => median(g),
45        };
46        group_z.push(g.iter().map(|v| (v - c).abs()).collect());
47    }
48    // ANOVA on the |z| values.
49    let grand: f64 = group_z.iter().flatten().sum::<f64>() / n_total as f64;
50    let mut ss_between = 0.0_f64;
51    let mut ss_within = 0.0_f64;
52    for g in &group_z {
53        let mg: f64 = g.iter().sum::<f64>() / g.len() as f64;
54        ss_between += g.len() as f64 * (mg - grand).powi(2);
55        for &v in g {
56            ss_within += (v - mg).powi(2);
57        }
58    }
59    let dfn = (k - 1) as f64;
60    let dfd = (n_total - k) as f64;
61    let f = (ss_between / dfn) / (ss_within / dfd).max(1e-300);
62    let pvalue = f_survival(f, dfn, dfd);
63    Ok(VarianceTestResult {
64        statistic: f,
65        pvalue,
66        df: (dfn, dfd),
67    })
68}
69
70/// Bartlett's test — classical normal-theory χ² test on log-variance
71/// ratios.
72pub fn bartlett(groups: &[Vec<f64>]) -> Result<VarianceTestResult> {
73    if groups.len() < 2 {
74        return Err(Error::Value("bartlett: need ≥ 2 groups".into()));
75    }
76    let k = groups.len() as f64;
77    let mut ni = Vec::with_capacity(groups.len());
78    let mut si2 = Vec::with_capacity(groups.len());
79    for g in groups {
80        if g.len() < 2 {
81            return Err(Error::Value(
82                "bartlett: each group must have ≥ 2 samples".into(),
83            ));
84        }
85        let n = g.len() as f64;
86        let m = g.iter().sum::<f64>() / n;
87        let v = g.iter().map(|x| (x - m).powi(2)).sum::<f64>() / (n - 1.0);
88        ni.push(n);
89        si2.push(v);
90    }
91    let n_total: f64 = ni.iter().sum();
92    let sp2: f64 = ni
93        .iter()
94        .zip(si2.iter())
95        .map(|(n, v)| (n - 1.0) * v)
96        .sum::<f64>()
97        / (n_total - k);
98    let numer = (n_total - k) * sp2.ln()
99        - ni.iter()
100            .zip(si2.iter())
101            .map(|(n, v)| (n - 1.0) * v.ln())
102            .sum::<f64>();
103    let one_over_n_minus_1: f64 = ni.iter().map(|n| 1.0 / (n - 1.0)).sum();
104    let one_over_total: f64 = 1.0 / (n_total - k);
105    let c = 1.0 + 1.0 / (3.0 * (k - 1.0)) * (one_over_n_minus_1 - one_over_total);
106    let chi2 = numer / c;
107    let dfn = k - 1.0;
108    let pvalue = chi2_survival(chi2, dfn);
109    Ok(VarianceTestResult {
110        statistic: chi2,
111        pvalue,
112        df: (dfn, 0.0),
113    })
114}
115
116/// Fligner-Killeen (1976) rank-based variance test.
117pub fn fligner(groups: &[Vec<f64>]) -> Result<VarianceTestResult> {
118    if groups.len() < 2 {
119        return Err(Error::Value("fligner: need ≥ 2 groups".into()));
120    }
121    let n_total: usize = groups.iter().map(|g| g.len()).sum();
122    // Combine and centre by group median.
123    let mut deviations: Vec<(f64, usize)> = Vec::with_capacity(n_total);
124    for (i, g) in groups.iter().enumerate() {
125        let m = median(g);
126        for &v in g {
127            deviations.push(((v - m).abs(), i));
128        }
129    }
130    deviations.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
131    // Rank in ascending order; standard normal scores of the ranks.
132    let mut a_scores = vec![0.0_f64; n_total];
133    for i in 0..n_total {
134        let rank = (i + 1) as f64;
135        let quantile = 0.5 * (rank / (n_total as f64 + 1.0) + 1.0);
136        a_scores[i] = inv_normal_cdf(quantile);
137    }
138    let mean_a: f64 = a_scores.iter().sum::<f64>() / n_total as f64;
139    let var_a: f64 = a_scores.iter().map(|a| (a - mean_a).powi(2)).sum::<f64>() / n_total as f64;
140    // Per-group mean of the transformed scores.
141    let mut group_sum = vec![0.0_f64; groups.len()];
142    let mut group_n = vec![0.0_f64; groups.len()];
143    for (i, (_, gi)) in deviations.iter().enumerate() {
144        group_sum[*gi] += a_scores[i];
145        group_n[*gi] += 1.0;
146    }
147    let mut chi2 = 0.0_f64;
148    for j in 0..groups.len() {
149        let mj = group_sum[j] / group_n[j];
150        chi2 += group_n[j] * (mj - mean_a).powi(2);
151    }
152    chi2 /= var_a.max(1e-300);
153    let dfn = (groups.len() - 1) as f64;
154    let pvalue = chi2_survival(chi2, dfn);
155    Ok(VarianceTestResult {
156        statistic: chi2,
157        pvalue,
158        df: (dfn, 0.0),
159    })
160}
161
162fn median(x: &[f64]) -> f64 {
163    let mut v: Vec<f64> = x.to_vec();
164    v.sort_by(|a, b| a.partial_cmp(b).unwrap());
165    let n = v.len();
166    if n % 2 == 0 {
167        0.5 * (v[n / 2 - 1] + v[n / 2])
168    } else {
169        v[n / 2]
170    }
171}
172
173fn inv_normal_cdf(p: f64) -> f64 {
174    // Beasley-Springer-Moro rational approximation.
175    let a = [
176        -3.969_683_028_665_376e1,
177        2.209_460_984_245_205e2,
178        -2.759_285_104_469_687e2,
179        1.383_577_518_672_69e2,
180        -3.066_479_806_614_716e1,
181        2.506_628_277_459_239,
182    ];
183    let b = [
184        -5.447_609_879_822_406e1,
185        1.615_858_368_580_409e2,
186        -1.556_989_798_598_866e2,
187        6.680_131_188_771_972e1,
188        -1.328_068_155_288_572e1,
189    ];
190    let c = [
191        -7.784_894_002_430_293e-3,
192        -3.223_964_580_411_365e-1,
193        -2.400_758_277_161_838,
194        -2.549_732_539_343_734,
195        4.374_664_141_464_968,
196        2.938_163_982_698_783,
197    ];
198    let d = [
199        7.784_695_709_041_462e-3,
200        3.224_671_290_700_398e-1,
201        2.445_134_137_142_996,
202        3.754_408_661_907_416,
203    ];
204    let p_low = 0.02425;
205    let p_high = 1.0 - p_low;
206    if p < p_low {
207        let q = (-2.0 * p.ln()).sqrt();
208        return (((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5])
209            / ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1.0);
210    }
211    if p <= p_high {
212        let q = p - 0.5;
213        let r = q * q;
214        return (((((a[0] * r + a[1]) * r + a[2]) * r + a[3]) * r + a[4]) * r + a[5]) * q
215            / (((((b[0] * r + b[1]) * r + b[2]) * r + b[3]) * r + b[4]) * r + 1.0);
216    }
217    let q = (-2.0 * (1.0 - p).ln()).sqrt();
218    -((((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5])
219        / ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1.0))
220}
221
222fn f_survival(f: f64, d1: f64, d2: f64) -> f64 {
223    if f <= 0.0 {
224        return 1.0;
225    }
226    let x = d2 / (d2 + d1 * f);
227    regularised_incomplete_beta(x, d2 / 2.0, d1 / 2.0)
228}
229
230fn chi2_survival(x: f64, df: f64) -> f64 {
231    if x <= 0.0 {
232        return 1.0;
233    }
234    1.0 - lower_regularised_gamma(df / 2.0, x / 2.0)
235}
236
237fn lower_regularised_gamma(s: f64, x: f64) -> f64 {
238    if x < 0.0 || s <= 0.0 {
239        return 0.0;
240    }
241    if x < s + 1.0 {
242        gamma_series(s, x)
243    } else {
244        1.0 - gamma_continued_fraction(s, x)
245    }
246}
247
248fn gamma_series(s: f64, x: f64) -> f64 {
249    let mut sum = 1.0 / s;
250    let mut term = sum;
251    for n in 1..200 {
252        term *= x / (s + n as f64);
253        sum += term;
254        if term.abs() < sum.abs() * 3e-15 {
255            break;
256        }
257    }
258    sum * (-x + s * x.ln() - ln_gamma(s)).exp()
259}
260
261fn gamma_continued_fraction(s: f64, x: f64) -> f64 {
262    let mut b = x + 1.0 - s;
263    let mut c = 1.0 / 1e-300;
264    let mut d = 1.0 / b;
265    let mut h = d;
266    for i in 1..200 {
267        let an = -(i as f64) * (i as f64 - s);
268        b += 2.0;
269        d = an * d + b;
270        if d.abs() < 1e-300 {
271            d = 1e-300;
272        }
273        c = b + an / c;
274        if c.abs() < 1e-300 {
275            c = 1e-300;
276        }
277        d = 1.0 / d;
278        let delta = d * c;
279        h *= delta;
280        if (delta - 1.0).abs() < 3e-15 {
281            break;
282        }
283    }
284    (-x + s * x.ln() - ln_gamma(s)).exp() * h
285}
286
287fn regularised_incomplete_beta(x: f64, a: f64, b: f64) -> f64 {
288    if x <= 0.0 {
289        return 0.0;
290    }
291    if x >= 1.0 {
292        return 1.0;
293    }
294    let ln_beta = ln_gamma(a) + ln_gamma(b) - ln_gamma(a + b);
295    let front = ((a * x.ln() + b * (1.0 - x).ln()) - ln_beta).exp() / a;
296    if x < (a + 1.0) / (a + b + 2.0) {
297        front * betacf(x, a, b)
298    } else {
299        1.0 - front * betacf(1.0 - x, b, a)
300    }
301}
302
303fn betacf(x: f64, a: f64, b: f64) -> f64 {
304    let mut c = 1.0_f64;
305    let qab = a + b;
306    let qap = a + 1.0;
307    let qam = a - 1.0;
308    let mut d = 1.0 - qab * x / qap;
309    if d.abs() < 1e-300 {
310        d = 1e-300;
311    }
312    d = 1.0 / d;
313    let mut h = d;
314    for m in 1..200 {
315        let mf = m as f64;
316        let two_m = 2.0 * mf;
317        let mut aa = mf * (b - mf) * x / ((qam + two_m) * (a + two_m));
318        d = 1.0 + aa * d;
319        if d.abs() < 1e-300 {
320            d = 1e-300;
321        }
322        c = 1.0 + aa / c;
323        if c.abs() < 1e-300 {
324            c = 1e-300;
325        }
326        d = 1.0 / d;
327        h *= d * c;
328        aa = -(a + mf) * (qab + mf) * x / ((a + two_m) * (qap + two_m));
329        d = 1.0 + aa * d;
330        if d.abs() < 1e-300 {
331            d = 1e-300;
332        }
333        c = 1.0 + aa / c;
334        if c.abs() < 1e-300 {
335            c = 1e-300;
336        }
337        d = 1.0 / d;
338        let delta = d * c;
339        h *= delta;
340        if (delta - 1.0).abs() < 3e-15 {
341            break;
342        }
343    }
344    h
345}
346
347fn ln_gamma(x: f64) -> f64 {
348    let g = 7.0;
349    let cof = [
350        0.999_999_999_999_809_93,
351        676.520_368_121_885_1,
352        -1_259.139_216_722_402_8,
353        771.323_428_777_653_13,
354        -176.615_029_162_140_59,
355        12.507_343_278_686_905,
356        -0.138_571_095_265_720_12,
357        9.984_369_578_019_571_5e-6,
358        1.505_632_735_149_311_6e-7,
359    ];
360    if x < 0.5 {
361        std::f64::consts::PI.ln() - (std::f64::consts::PI * x).sin().ln() - ln_gamma(1.0 - x)
362    } else {
363        let x = x - 1.0;
364        let mut a = cof[0];
365        let t = x + g + 0.5;
366        for (i, &c) in cof.iter().enumerate().skip(1) {
367            a += c / (x + i as f64);
368        }
369        0.5 * (2.0 * std::f64::consts::PI).ln() + (x + 0.5) * t.ln() - t + a.ln()
370    }
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376
377    #[test]
378    fn levene_detects_variance_difference_between_two_groups() {
379        let a = vec![1.0_f64, 2.0, 3.0, 4.0, 5.0];
380        let b = vec![10.0_f64, 100.0, 200.0, 300.0, 400.0];
381        let r = levene(&[a, b], LeveneCenter::Median).unwrap();
382        assert!(r.pvalue < 0.1);
383    }
384
385    #[test]
386    fn bartlett_detects_variance_difference() {
387        let a = vec![1.0_f64, 2.0, 3.0, 4.0, 5.0];
388        let b = vec![10.0_f64, 100.0, 200.0, 300.0, 400.0];
389        let r = bartlett(&[a, b]).unwrap();
390        assert!(r.pvalue < 0.1);
391    }
392
393    #[test]
394    fn fligner_detects_variance_difference() {
395        let a = vec![1.0_f64, 2.0, 3.0, 4.0, 5.0, 6.0];
396        let b = vec![10.0_f64, 100.0, 200.0, 300.0, 400.0, 500.0];
397        let r = fligner(&[a, b]).unwrap();
398        assert!(r.pvalue < 0.1);
399    }
400}