Skip to main content

solow_stats/
oneway.rs

1//! One-way analysis of variance for `k` independent samples.
2//!
3//! Mirrors the reference `anova_oneway` / `anova_generic`, supporting the
4//! classic equal-variance F-test, Welch's unequal-variance test, and the
5//! Brown–Forsythe variant. [`f_oneway`] is the classic equal-variance test in
6//! the familiar SciPy form.
7
8use solow_distributions::f_sf;
9
10/// How heteroscedasticity is treated in [`anova_oneway`].
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum UseVar {
13    /// Equal variances assumed (standard one-way ANOVA).
14    Equal,
15    /// Unequal variances (Welch's ANOVA with Satterthwaite d.o.f.).
16    Unequal,
17    /// Brown–Forsythe variant with Mehrotra-corrected degrees of freedom.
18    BrownForsythe,
19}
20
21/// Result of a one-way ANOVA.
22#[derive(Debug, Clone, Copy)]
23pub struct OnewayResult {
24    /// F-distributed test statistic.
25    pub statistic: f64,
26    /// p-value of the test.
27    pub pvalue: f64,
28    /// Numerator degrees of freedom.
29    pub df_num: f64,
30    /// Denominator degrees of freedom.
31    pub df_denom: f64,
32}
33
34/// Sample mean.
35fn mean(x: &[f64]) -> f64 {
36    x.iter().sum::<f64>() / x.len() as f64
37}
38
39/// Sample variance with `ddof = 1`.
40fn var_unbiased(x: &[f64]) -> f64 {
41    let n = x.len() as f64;
42    let m = mean(x);
43    x.iter().map(|&v| (v - m) * (v - m)).sum::<f64>() / (n - 1.0)
44}
45
46/// One-way ANOVA from summary statistics. Mirrors `anova_generic`.
47pub fn anova_generic(
48    means: &[f64],
49    variances: &[f64],
50    nobs: &[f64],
51    use_var: UseVar,
52    welch_correction: bool,
53) -> OnewayResult {
54    let n_groups = means.len();
55    let ng = n_groups as f64;
56    let nobs_t: f64 = nobs.iter().sum();
57
58    // Group weights depend on the variance assumption.
59    let weights: Vec<f64> = match use_var {
60        UseVar::Unequal => nobs.iter().zip(variances).map(|(&n, &v)| n / v).collect(),
61        UseVar::Equal | UseVar::BrownForsythe => nobs.to_vec(),
62    };
63    let w_total: f64 = weights.iter().sum();
64    let w_rel: Vec<f64> = weights.iter().map(|&w| w / w_total).collect();
65    let meanw_t: f64 = w_rel.iter().zip(means).map(|(&w, &m)| w * m).sum();
66
67    let mut statistic: f64 = weights
68        .iter()
69        .zip(means)
70        .map(|(&w, &m)| w * (m - meanw_t) * (m - meanw_t))
71        .sum::<f64>()
72        / (ng - 1.0);
73    let mut df_num = ng - 1.0;
74    let df_denom;
75
76    match use_var {
77        UseVar::Unequal => {
78            let tmp: f64 = w_rel
79                .iter()
80                .zip(nobs)
81                .map(|(&wr, &n)| (1.0 - wr) * (1.0 - wr) / (n - 1.0))
82                .sum::<f64>()
83                / (ng * ng - 1.0);
84            if welch_correction {
85                statistic /= 1.0 + 2.0 * (ng - 2.0) * tmp;
86            }
87            df_denom = 1.0 / (3.0 * tmp);
88        }
89        UseVar::Equal => {
90            let tmp: f64 = nobs
91                .iter()
92                .zip(variances)
93                .map(|(&n, &v)| (n - 1.0) * v)
94                .sum::<f64>()
95                / (nobs_t - ng);
96            statistic /= tmp;
97            df_denom = nobs_t - ng;
98        }
99        UseVar::BrownForsythe => {
100            let tmp: f64 = nobs
101                .iter()
102                .zip(variances)
103                .map(|(&n, &v)| (1.0 - n / nobs_t) * v)
104                .sum();
105            statistic = nobs
106                .iter()
107                .zip(means)
108                .map(|(&n, &m)| n * (m - meanw_t) * (m - meanw_t))
109                .sum::<f64>()
110                / tmp;
111            df_denom = tmp * tmp
112                / nobs
113                    .iter()
114                    .zip(variances)
115                    .map(|(&n, &v)| {
116                        let f = 1.0 - n / nobs_t;
117                        f * f * v * v / (n - 1.0)
118                    })
119                    .sum::<f64>();
120            // Mehrotra-corrected numerator d.o.f.
121            let sum_v2: f64 = variances.iter().map(|&v| v * v).sum();
122            let sum_nv: f64 = nobs
123                .iter()
124                .zip(variances)
125                .map(|(&n, &v)| n / nobs_t * v)
126                .sum();
127            let sum_nv2: f64 = nobs
128                .iter()
129                .zip(variances)
130                .map(|(&n, &v)| n / nobs_t * v * v)
131                .sum();
132            df_num = tmp * tmp / (sum_v2 + sum_nv * sum_nv - 2.0 * sum_nv2);
133        }
134    }
135
136    let pvalue = f_sf(statistic, df_num, df_denom);
137    OnewayResult {
138        statistic,
139        pvalue,
140        df_num,
141        df_denom,
142    }
143}
144
145/// One-way ANOVA from raw samples. Mirrors `anova_oneway` (no trimming).
146pub fn anova_oneway(groups: &[Vec<f64>], use_var: UseVar, welch_correction: bool) -> OnewayResult {
147    let means: Vec<f64> = groups.iter().map(|g| mean(g)).collect();
148    let variances: Vec<f64> = groups.iter().map(|g| var_unbiased(g)).collect();
149    let nobs: Vec<f64> = groups.iter().map(|g| g.len() as f64).collect();
150    anova_generic(&means, &variances, &nobs, use_var, welch_correction)
151}
152
153/// Classic one-way ANOVA F-test (equal variances), as in SciPy's `f_oneway`.
154///
155/// Returns `(statistic, pvalue)`. Equivalent to
156/// `anova_oneway(.., UseVar::Equal, ..)`.
157pub fn f_oneway(groups: &[Vec<f64>]) -> (f64, f64) {
158    let res = anova_oneway(groups, UseVar::Equal, true);
159    (res.statistic, res.pvalue)
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    #[test]
167    fn equal_variance_matches_scipy_form() {
168        let groups = vec![
169            vec![1.0, 2.0, 3.0],
170            vec![4.0, 5.0, 6.0],
171            vec![7.0, 8.0, 9.0],
172        ];
173        let (f, p) = f_oneway(&groups);
174        assert!(f > 0.0);
175        assert!((0.0..=1.0).contains(&p));
176    }
177
178    #[test]
179    fn df_num_is_k_minus_one() {
180        let groups = vec![vec![1.0, 2.0], vec![3.0, 4.0], vec![5.0, 6.0]];
181        let res = anova_oneway(&groups, UseVar::Equal, true);
182        assert_eq!(res.df_num, 2.0);
183    }
184}