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