Skip to main content

dml_core/
stats.rs

1//! Standard normal distribution helpers for Wald inference (no external deps).
2//!
3//! Accurate to near machine precision, so p-values and critical values match
4//! SciPy (and therefore Python DoubleML) to ~1e-14.
5
6use std::f64::consts::{PI, SQRT_2};
7
8const FRAC_1_SQRT_PI: f64 = 0.564_189_583_547_756_3;
9
10/// Complementary error function `erfc(x) = 1 − erf(x)`.
11///
12/// Uses the everywhere-positive series
13/// `erf(x) = 2/√π · e^{−x²} · Σ 2ⁿ x^{2n+1} / (1·3·…·(2n+1))` for `|x| < 2`
14/// and the Laplace continued fraction (modified Lentz) for `|x| ≥ 2`,
15/// where the series would lose relative precision in the tail.
16pub fn erfc(x: f64) -> f64 {
17    if x.is_nan() {
18        return f64::NAN;
19    }
20    if x < 0.0 {
21        return 2.0 - erfc(-x);
22    }
23    if x < 2.0 {
24        let x2 = x * x;
25        let mut term = x;
26        let mut sum = x;
27        let mut n = 0.0;
28        while term > sum * f64::EPSILON {
29            n += 1.0;
30            term *= 2.0 * x2 / (2.0 * n + 1.0);
31            sum += term;
32        }
33        return 1.0 - 2.0 * FRAC_1_SQRT_PI * (-x2).exp() * sum;
34    }
35    if x > 27.3 {
36        return 0.0; // below the smallest subnormal
37    }
38    // erfc(x) = e^{−x²}/√π · 1 / (x + (1/2)/(x + 1/(x + (3/2)/(x + …))))
39    let tiny = 1e-300;
40    let mut f = x;
41    let mut c = x;
42    let mut d = 0.0;
43    for k in 1..1000 {
44        let a = k as f64 / 2.0;
45        d = x + a * d;
46        d = if d.abs() < tiny { 1.0 / tiny } else { 1.0 / d };
47        c = x + a / c;
48        if c.abs() < tiny {
49            c = tiny;
50        }
51        let delta = c * d;
52        f *= delta;
53        if (delta - 1.0).abs() < f64::EPSILON {
54            break;
55        }
56    }
57    FRAC_1_SQRT_PI * (-x * x).exp() / f
58}
59
60/// Standard normal CDF `Φ(x)`.
61pub fn norm_cdf(x: f64) -> f64 {
62    0.5 * erfc(-x / SQRT_2)
63}
64
65/// Standard normal survival function `1 − Φ(x)`, accurate in the upper tail.
66pub fn norm_sf(x: f64) -> f64 {
67    0.5 * erfc(x / SQRT_2)
68}
69
70/// Standard normal quantile `Φ⁻¹(p)` for `0 < p < 1`: Acklam's rational
71/// approximation followed by one Halley refinement step.
72// Coefficients are kept exactly as published.
73#[allow(clippy::excessive_precision)]
74pub fn norm_ppf(p: f64) -> f64 {
75    const A: [f64; 6] = [
76        -3.969_683_028_665_376e1,
77        2.209_460_984_245_205e2,
78        -2.759_285_104_469_687e2,
79        1.383_577_518_672_69e2,
80        -3.066_479_806_614_716e1,
81        2.506_628_277_459_239,
82    ];
83    const B: [f64; 5] = [
84        -5.447_609_879_822_406e1,
85        1.615_858_368_580_409e2,
86        -1.556_989_798_598_866e2,
87        6.680_131_188_771_972e1,
88        -1.328_068_155_288_572e1,
89    ];
90    const C: [f64; 6] = [
91        -7.784_894_002_430_293e-3,
92        -3.223_964_580_411_365e-1,
93        -2.400_758_277_161_838,
94        -2.549_732_539_343_734,
95        4.374_664_141_464_968,
96        2.938_163_982_698_783,
97    ];
98    const D: [f64; 4] = [
99        7.784_695_709_041_462e-3,
100        3.224_671_290_700_398e-1,
101        2.445_134_137_142_996,
102        3.754_408_661_907_416,
103    ];
104    const P_LOW: f64 = 0.024_25;
105
106    if p == 0.0 {
107        return f64::NEG_INFINITY;
108    }
109    if p == 1.0 {
110        return f64::INFINITY;
111    }
112    if !(p > 0.0 && p < 1.0) {
113        return f64::NAN;
114    }
115    let tail = |q: f64| {
116        (((((C[0] * q + C[1]) * q + C[2]) * q + C[3]) * q + C[4]) * q + C[5])
117            / ((((D[0] * q + D[1]) * q + D[2]) * q + D[3]) * q + 1.0)
118    };
119    let x = if p < P_LOW {
120        tail((-2.0 * p.ln()).sqrt())
121    } else if p > 1.0 - P_LOW {
122        -tail((-2.0 * (1.0 - p).ln()).sqrt())
123    } else {
124        let q = p - 0.5;
125        let r = q * q;
126        (((((A[0] * r + A[1]) * r + A[2]) * r + A[3]) * r + A[4]) * r + A[5]) * q
127            / (((((B[0] * r + B[1]) * r + B[2]) * r + B[3]) * r + B[4]) * r + 1.0)
128    };
129    // Halley step on Φ(x) − p; work in the smaller tail to keep precision.
130    let e = if p < 0.5 {
131        norm_cdf(x) - p
132    } else {
133        (1.0 - p) - norm_sf(x)
134    };
135    let u = e * (2.0 * PI).sqrt() * (0.5 * x * x).exp();
136    x - u / (1.0 + 0.5 * x * u)
137}
138
139/// Linear-interpolated sample quantile (NumPy's default `method="linear"`).
140/// Sorts `values` in place.
141pub(crate) fn quantile_mut(values: &mut [f64], q: f64) -> f64 {
142    values.sort_by(f64::total_cmp);
143    let pos = q * (values.len() - 1) as f64;
144    let lo = pos.floor() as usize;
145    let hi = pos.ceil() as usize;
146    values[lo] + (pos - lo as f64) * (values[hi] - values[lo])
147}
148
149/// Median (average of the two middle values for even length), as `np.median`.
150pub(crate) fn median(values: &[f64]) -> f64 {
151    quantile_mut(&mut values.to_vec(), 0.5)
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    fn rel(a: f64, b: f64) -> f64 {
159        ((a - b) / b).abs()
160    }
161
162    // Reference values from scipy.stats.norm.
163    #[test]
164    fn cdf_matches_scipy() {
165        assert_eq!(norm_cdf(0.0), 0.5);
166        assert!(rel(norm_cdf(-1.0), 0.158_655_253_931_457_07) < 1e-14);
167        assert!(rel(norm_cdf(1.96), 0.975_002_104_851_779_5) < 1e-14);
168        assert!(rel(norm_cdf(-2.5), 0.006_209_665_325_776_132) < 1e-13);
169        assert!(rel(norm_sf(5.0), 2.866_515_718_791_933e-7) < 1e-13);
170        assert!(rel(norm_sf(10.0), 7.619_853_024_160_47e-24) < 1e-12);
171    }
172
173    #[test]
174    fn ppf_matches_scipy_and_inverts_cdf() {
175        assert!(rel(norm_ppf(0.975), 1.959_963_984_540_054) < 1e-14);
176        assert!(rel(norm_ppf(0.995), 2.575_829_303_548_900_4) < 1e-14);
177        assert!(rel(norm_ppf(1e-10), -6.361_340_902_404_056) < 1e-13);
178        assert_eq!(norm_ppf(0.5), 0.0);
179        for &p in &[1e-6, 0.001, 0.02, 0.3, 0.7, 0.99, 0.999_999] {
180            assert!(rel(norm_cdf(norm_ppf(p)), p) < 1e-13, "p = {p}");
181        }
182    }
183
184    #[test]
185    fn quantile_and_median_match_numpy() {
186        assert_eq!(median(&[3.0, 1.0, 2.0]), 2.0);
187        assert_eq!(median(&[4.0, 1.0, 3.0, 2.0]), 2.5);
188        assert!((quantile_mut(&mut [1.0, 2.0, 3.0, 4.0, 5.0], 0.95) - 4.8).abs() < 1e-12);
189    }
190}