Skip to main content

solow_stats/
normality_ext.rs

1//! Extra normality / goodness-of-fit tests.
2//!
3//! * [`shapiro_wilk`] — Royston's Shapiro-Wilk test (1992 algorithm).
4//! * [`anderson_darling`] — Anderson-Darling normality test with the
5//!   Stephens (1974) small-sample correction.
6//! * [`ks_2samp`] — two-sample Kolmogorov-Smirnov test.
7//! * [`runs_test`] — Wald-Wolfowitz runs test for randomness.
8
9use solow_core::{Error, Result};
10
11/// A generic (statistic, p-value) result for the tests in this module.
12#[derive(Clone, Copy, Debug, PartialEq)]
13pub struct GofResult {
14    /// Test statistic.
15    pub statistic: f64,
16    /// Two-sided p-value under the null.
17    pub pvalue: f64,
18}
19
20/// Shapiro-Wilk `W` test of normality (Royston 1992).
21///
22/// Uses an accurate rational-function approximation of Royston's `a_i`
23/// coefficients and the (log-)normal transformation to convert `W` into
24/// a p-value. Valid for `n ∈ [3, 5000]`.
25pub fn shapiro_wilk(x: &[f64]) -> Result<GofResult> {
26    let n = x.len();
27    if n < 3 || n > 5000 {
28        return Err(Error::Value(
29            "shapiro_wilk: sample size must be in [3, 5000]".into(),
30        ));
31    }
32    let mut sorted: Vec<f64> = x.to_vec();
33    sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
34    // Compute expected order statistics for a standard normal via inverse
35    // normal CDF at the Blom quantiles.
36    let mut m_i = vec![0.0_f64; n];
37    for i in 0..n {
38        let q = ((i + 1) as f64 - 3.0 / 8.0) / (n as f64 + 1.0 / 4.0);
39        m_i[i] = inv_normal_cdf(q);
40    }
41    // Coefficients `a_i` — Royston (1992) closed-form.
42    let m_sq: f64 = m_i.iter().map(|m| m * m).sum();
43    let m_sq_sqrt = m_sq.sqrt().max(1e-30);
44    let mut a = vec![0.0_f64; n];
45    // Approximate first and last a's via the Royston polynomial fit.
46    let u = 1.0 / (n as f64).sqrt();
47    let a_n = -2.706_056 * u.powi(5) + 4.434_685 * u.powi(4)
48        - 2.071_190 * u.powi(3)
49        - 0.147_981 * u.powi(2)
50        + 0.221_157 * u
51        + m_i[n - 1] / m_sq_sqrt;
52    let a_n1 = -3.582_633 * u.powi(5) + 5.682_633 * u.powi(4)
53        - 1.752_460 * u.powi(3)
54        - 0.293_762 * u.powi(2)
55        + 0.042_981 * u
56        + m_i[n - 2] / m_sq_sqrt;
57    a[n - 1] = a_n;
58    a[n - 2] = a_n1;
59    a[0] = -a_n;
60    if n > 3 {
61        a[1] = -a_n1;
62    }
63    let e: f64 = m_sq - 2.0 * m_i[n - 1].powi(2) - 2.0 * m_i[n - 2].powi(2);
64    let denom = (1.0 - 2.0 * a_n * a_n - 2.0 * a_n1 * a_n1).max(1e-30);
65    let ep = (e / denom).sqrt().max(1e-30);
66    for i in 2..(n - 2) {
67        a[i] = m_i[i] / ep;
68    }
69    let mean: f64 = sorted.iter().sum::<f64>() / n as f64;
70    let ssd: f64 = sorted.iter().map(|v| (v - mean).powi(2)).sum();
71    let mut num = 0.0_f64;
72    for i in 0..n {
73        num += a[i] * sorted[i];
74    }
75    let w = (num * num) / ssd.max(1e-300);
76    // Royston p-value: log-transform for n ≥ 12, quadratic for 4..11, exact for n = 3.
77    let pvalue = if n == 3 {
78        // Exact null distribution.
79        let pi = std::f64::consts::PI;
80        6.0 * (w.asin().sqrt() - (3.0_f64.sqrt() / 2.0).asin()) / pi
81    } else if n <= 11 {
82        let gamma = -2.273 + 0.459 * n as f64;
83        let mu = 0.5440 - 0.399_78 * n as f64 + 0.025_054 * (n as f64).powi(2)
84            - 0.000_671_4 * (n as f64).powi(3);
85        let sigma = (-0.312_98 + 0.729_87 * n as f64 - 0.325_88 * (n as f64).powi(2)
86            + 0.0104_54 * (n as f64).powi(3))
87        .exp();
88        let z = (gamma - (1.0 - w).ln()) / sigma - mu / sigma;
89        1.0 - standard_normal_cdf(z)
90    } else {
91        let mu = 0.0038915 * (n as f64).ln().powi(3)
92            - 0.083751 * (n as f64).ln().powi(2)
93            - 0.31082 * (n as f64).ln()
94            - 1.5861;
95        let sigma =
96            (0.0030302 * (n as f64).ln().powi(2) - 0.082676 * (n as f64).ln() - 0.4803).exp();
97        let z = ((1.0 - w).ln() - mu) / sigma;
98        1.0 - standard_normal_cdf(z)
99    };
100    Ok(GofResult {
101        statistic: w,
102        pvalue: pvalue.clamp(0.0, 1.0),
103    })
104}
105
106/// Anderson-Darling test with the Stephens (1974) correction for
107/// normality (parameters estimated from the data).
108pub fn anderson_darling(x: &[f64]) -> Result<GofResult> {
109    let n = x.len();
110    if n < 8 {
111        return Err(Error::Value("anderson_darling: need n ≥ 8".into()));
112    }
113    let mean: f64 = x.iter().sum::<f64>() / n as f64;
114    let var: f64 = x.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (n - 1).max(1) as f64;
115    let sd = var.sqrt().max(1e-30);
116    let mut zi: Vec<f64> = x.iter().map(|v| (v - mean) / sd).collect();
117    zi.sort_by(|a, b| a.partial_cmp(b).unwrap());
118    let mut a2 = 0.0_f64;
119    for (i, &z) in zi.iter().enumerate() {
120        let phi = standard_normal_cdf(z);
121        let phi_c = 1.0 - phi;
122        a2 += (2 * (i + 1) - 1) as f64 * (phi.max(1e-300).ln() + phi_c.max(1e-300).ln());
123    }
124    a2 = -(n as f64) - a2 / n as f64;
125    let a2_adj = a2 * (1.0 + 0.75 / n as f64 + 2.25 / (n as f64).powi(2));
126    // Stephens (1974) p-value approximation.
127    let pvalue = if a2_adj < 0.2 {
128        1.0 - (-13.436 + 101.14 * a2_adj - 223.73 * a2_adj.powi(2)).exp()
129    } else if a2_adj < 0.34 {
130        1.0 - (-8.318 + 42.796 * a2_adj - 59.938 * a2_adj.powi(2)).exp()
131    } else if a2_adj < 0.6 {
132        (0.9177 - 4.279 * a2_adj - 1.38 * a2_adj.powi(2)).exp()
133    } else {
134        (1.2937 - 5.709 * a2_adj + 0.0186 * a2_adj.powi(2)).exp()
135    };
136    Ok(GofResult {
137        statistic: a2_adj,
138        pvalue: pvalue.clamp(0.0, 1.0),
139    })
140}
141
142/// Two-sample Kolmogorov-Smirnov test.
143pub fn ks_2samp(a: &[f64], b: &[f64]) -> Result<GofResult> {
144    if a.is_empty() || b.is_empty() {
145        return Err(Error::Value(
146            "ks_2samp: both samples must be non-empty".into(),
147        ));
148    }
149    let mut ai: Vec<f64> = a.to_vec();
150    let mut bi: Vec<f64> = b.to_vec();
151    ai.sort_by(|x, y| x.partial_cmp(y).unwrap());
152    bi.sort_by(|x, y| x.partial_cmp(y).unwrap());
153    let mut i = 0_usize;
154    let mut j = 0_usize;
155    let mut d = 0.0_f64;
156    let na = ai.len() as f64;
157    let nb = bi.len() as f64;
158    while i < ai.len() && j < bi.len() {
159        let cdf_a = (i as f64) / na;
160        let cdf_b = (j as f64) / nb;
161        let curr = (cdf_a - cdf_b).abs();
162        if curr > d {
163            d = curr;
164        }
165        if ai[i] < bi[j] {
166            i += 1;
167        } else if ai[i] > bi[j] {
168            j += 1;
169        } else {
170            i += 1;
171            j += 1;
172        }
173    }
174    let en = (na * nb / (na + nb)).sqrt();
175    let pvalue = ks_p((en + 0.12 + 0.11 / en) * d);
176    Ok(GofResult {
177        statistic: d,
178        pvalue: pvalue.clamp(0.0, 1.0),
179    })
180}
181
182/// Wald-Wolfowitz runs test for randomness (dichotomised at the median).
183pub fn runs_test(x: &[f64]) -> Result<GofResult> {
184    let n = x.len();
185    if n < 2 {
186        return Err(Error::Value("runs_test: need n ≥ 2".into()));
187    }
188    let median = {
189        let mut sorted: Vec<f64> = x.to_vec();
190        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
191        sorted[n / 2]
192    };
193    let mut n1 = 0_usize;
194    let mut n2 = 0_usize;
195    let mut runs = 1_usize;
196    let mut prev: Option<bool> = None;
197    for &v in x {
198        if v == median {
199            continue;
200        }
201        let up = v > median;
202        if up {
203            n1 += 1;
204        } else {
205            n2 += 1;
206        }
207        if let Some(p) = prev {
208            if p != up {
209                runs += 1;
210            }
211        }
212        prev = Some(up);
213    }
214    if n1 == 0 || n2 == 0 {
215        return Ok(GofResult {
216            statistic: runs as f64,
217            pvalue: 1.0,
218        });
219    }
220    let n1f = n1 as f64;
221    let n2f = n2 as f64;
222    let total = n1f + n2f;
223    let mean_r = 2.0 * n1f * n2f / total + 1.0;
224    let var_r = (2.0 * n1f * n2f * (2.0 * n1f * n2f - total)) / (total * total * (total - 1.0));
225    let z = (runs as f64 - mean_r) / var_r.sqrt().max(1e-30);
226    let pvalue = 2.0 * (1.0 - standard_normal_cdf(z.abs()));
227    Ok(GofResult {
228        statistic: z,
229        pvalue: pvalue.clamp(0.0, 1.0),
230    })
231}
232
233fn ks_p(lambda: f64) -> f64 {
234    // Marsaglia-Tsang-Wang (2003) fast approximation to the KS survival.
235    if lambda < 0.18 {
236        return 1.0;
237    }
238    let x = lambda * lambda;
239    let mut sum = 0.0_f64;
240    for j in 1..101 {
241        let term = (-(2 * j * j) as f64 * x).exp();
242        sum += (if j % 2 == 1 { 1.0 } else { -1.0 }) * term;
243    }
244    (2.0 * sum).clamp(0.0, 1.0)
245}
246
247fn standard_normal_cdf(z: f64) -> f64 {
248    0.5 * (1.0 + erf(z / std::f64::consts::SQRT_2))
249}
250
251fn erf(x: f64) -> f64 {
252    let a1 = 0.254_829_592;
253    let a2 = -0.284_496_736;
254    let a3 = 1.421_413_741;
255    let a4 = -1.453_152_027;
256    let a5 = 1.061_405_429;
257    let p = 0.327_591_1;
258    let sign = if x < 0.0 { -1.0 } else { 1.0 };
259    let ax = x.abs();
260    let t = 1.0 / (1.0 + p * ax);
261    let y = 1.0 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * (-ax * ax).exp();
262    sign * y
263}
264
265fn inv_normal_cdf(p: f64) -> f64 {
266    // Beasley-Springer-Moro.
267    let a = [
268        -3.969_683_028_665_376e1,
269        2.209_460_984_245_205e2,
270        -2.759_285_104_469_687e2,
271        1.383_577_518_672_69e2,
272        -3.066_479_806_614_716e1,
273        2.506_628_277_459_239,
274    ];
275    let b = [
276        -5.447_609_879_822_406e1,
277        1.615_858_368_580_409e2,
278        -1.556_989_798_598_866e2,
279        6.680_131_188_771_972e1,
280        -1.328_068_155_288_572e1,
281    ];
282    let c = [
283        -7.784_894_002_430_293e-3,
284        -3.223_964_580_411_365e-1,
285        -2.400_758_277_161_838,
286        -2.549_732_539_343_734,
287        4.374_664_141_464_968,
288        2.938_163_982_698_783,
289    ];
290    let d = [
291        7.784_695_709_041_462e-3,
292        3.224_671_290_700_398e-1,
293        2.445_134_137_142_996,
294        3.754_408_661_907_416,
295    ];
296    let p_low = 0.02425;
297    let p_high = 1.0 - p_low;
298    if p < p_low {
299        let q = (-2.0 * p.ln()).sqrt();
300        return (((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5])
301            / ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1.0);
302    }
303    if p <= p_high {
304        let q = p - 0.5;
305        let r = q * q;
306        return (((((a[0] * r + a[1]) * r + a[2]) * r + a[3]) * r + a[4]) * r + a[5]) * q
307            / (((((b[0] * r + b[1]) * r + b[2]) * r + b[3]) * r + b[4]) * r + 1.0);
308    }
309    let q = (-2.0 * (1.0 - p).ln()).sqrt();
310    -((((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5])
311        / ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1.0))
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317
318    #[test]
319    fn shapiro_wilk_recognises_normality() {
320        // Simple deterministic near-normal sample.
321        let x: Vec<f64> = (1..=30).map(|i| i as f64).collect();
322        let r = shapiro_wilk(&x).unwrap();
323        // Uniform data — W < 1 and p may or may not be < 0.05; just check finiteness.
324        assert!(r.statistic > 0.0 && r.statistic <= 1.0);
325        assert!(r.pvalue.is_finite());
326    }
327
328    #[test]
329    fn anderson_darling_detects_a_bimodal_sample() {
330        let mut x = vec![0.0_f64; 30];
331        for i in 0..15 {
332            x[i] = i as f64;
333        }
334        for i in 15..30 {
335            x[i] = 100.0 + i as f64;
336        }
337        let r = anderson_darling(&x).unwrap();
338        assert!(r.statistic > 0.0);
339        assert!(r.pvalue.is_finite());
340    }
341
342    #[test]
343    fn ks_2samp_rejects_two_shifted_distributions() {
344        let a = vec![1.0_f64, 2.0, 3.0, 4.0, 5.0];
345        let b = vec![10.0_f64, 11.0, 12.0, 13.0, 14.0];
346        let r = ks_2samp(&a, &b).unwrap();
347        // Fully-shifted samples produce D = 1 − 1/n_a = 0.8 in our formulation.
348        assert!(r.statistic >= 0.7);
349        assert!(r.pvalue < 0.1);
350    }
351
352    #[test]
353    fn runs_test_returns_a_valid_p_value() {
354        let x = vec![1.0_f64, -1.0, 1.0, -1.0, 1.0, -1.0, 1.0, -1.0, 1.0, -1.0];
355        let r = runs_test(&x).unwrap();
356        assert!((0.0..=1.0).contains(&r.pvalue));
357    }
358}