Skip to main content

ggplot_rs/stat/
dist.rs

1//! Student-t quantile used for confidence-interval multipliers.
2//!
3//! With the `regression` feature the quantile is computed by
4//! anofox-regression (`statrs::StudentsT`). Without it, a self-contained
5//! inversion of the t CDF (regularised incomplete beta, bisection) gives the
6//! same value to ~1e-10 — before #414 this fell back to the normal 1.96 for
7//! every `p` and `df`, so small-sample intervals were too narrow.
8
9/// Standard-normal 0.975 quantile — the fallback if statrs can't build the
10/// t distribution (the `regression` path).
11#[cfg(feature = "regression")]
12pub(crate) const Z_975: f64 = 1.959_963_984_540_054;
13
14/// Student-t quantile: the `t` with `P(T ≤ t) = p` for `df` degrees of freedom
15/// (R's `qt(p, df)`). `df` may be non-integer (e.g. loess effective df).
16///
17/// With the `regression` feature this delegates to anofox-regression's
18/// `StudentsT` (statrs); without it, [`qt_native`] computes it.
19#[cfg(feature = "regression")]
20pub fn qt(p: f64, df: f64) -> f64 {
21    use anofox_regression::distributions::{ContinuousCDF, StudentsT};
22    if df <= 0.0 {
23        return f64::NAN;
24    }
25    StudentsT::new(0.0, 1.0, df)
26        .map(|d| d.inverse_cdf(p))
27        .unwrap_or(Z_975)
28}
29
30/// See the `regression`-enabled variant above.
31#[cfg(not(feature = "regression"))]
32pub fn qt(p: f64, df: f64) -> f64 {
33    qt_native(p, df)
34}
35
36#[cfg_attr(feature = "regression", allow(dead_code))]
37/// Continued fraction for the regularised incomplete beta (Numerical Recipes'
38/// `betacf`, modified Lentz).
39fn beta_cf(a: f64, b: f64, x: f64) -> f64 {
40    const TINY: f64 = 1e-300;
41    let (qab, qap, qam) = (a + b, a + 1.0, a - 1.0);
42    let mut c = 1.0;
43    let mut d = 1.0 - qab * x / qap;
44    if d.abs() < TINY {
45        d = TINY;
46    }
47    d = 1.0 / d;
48    let mut h = d;
49    for m in 1..=300 {
50        let m = m as f64;
51        let m2 = 2.0 * m;
52        let aa = m * (b - m) * x / ((qam + m2) * (a + m2));
53        d = 1.0 + aa * d;
54        d = if d.abs() < TINY { TINY } else { d };
55        c = 1.0 + aa / c;
56        c = if c.abs() < TINY { TINY } else { c };
57        d = 1.0 / d;
58        h *= d * c;
59        let aa = -(a + m) * (qab + m) * x / ((a + m2) * (qap + m2));
60        d = 1.0 + aa * d;
61        d = if d.abs() < TINY { TINY } else { d };
62        c = 1.0 + aa / c;
63        c = if c.abs() < TINY { TINY } else { c };
64        d = 1.0 / d;
65        let del = d * c;
66        h *= del;
67        if (del - 1.0).abs() < 1e-15 {
68            break;
69        }
70    }
71    h
72}
73
74#[cfg_attr(feature = "regression", allow(dead_code))]
75/// Regularised incomplete beta `I_x(a, b)`.
76fn inc_beta(a: f64, b: f64, x: f64) -> f64 {
77    use crate::stat::distribution::ln_gamma;
78    if x <= 0.0 {
79        return 0.0;
80    }
81    if x >= 1.0 {
82        return 1.0;
83    }
84    let front =
85        (ln_gamma(a + b) - ln_gamma(a) - ln_gamma(b) + a * x.ln() + b * (1.0 - x).ln()).exp();
86    if x < (a + 1.0) / (a + b + 2.0) {
87        front * beta_cf(a, b, x) / a
88    } else {
89        1.0 - front * beta_cf(b, a, 1.0 - x) / b
90    }
91}
92
93#[cfg_attr(feature = "regression", allow(dead_code))]
94/// Student-t CDF `P(T ≤ t)` for `df` degrees of freedom.
95pub(crate) fn pt_native(t: f64, df: f64) -> f64 {
96    let tail = 0.5 * inc_beta(df / 2.0, 0.5, df / (df + t * t));
97    if t >= 0.0 {
98        1.0 - tail
99    } else {
100        tail
101    }
102}
103
104#[cfg_attr(feature = "regression", allow(dead_code))]
105/// Student-t quantile by bisection on [`pt_native`] (any `df > 0`).
106pub(crate) fn qt_native(p: f64, df: f64) -> f64 {
107    if df.is_nan() || df <= 0.0 || !(0.0..=1.0).contains(&p) {
108        return f64::NAN;
109    }
110    if p == 0.0 {
111        return f64::NEG_INFINITY;
112    }
113    if p == 1.0 {
114        return f64::INFINITY;
115    }
116    let (mut lo, mut hi) = (-1.0, 1.0);
117    while pt_native(lo, df) > p {
118        lo *= 2.0;
119    }
120    while pt_native(hi, df) < p {
121        hi *= 2.0;
122    }
123    for _ in 0..200 {
124        let mid = 0.5 * (lo + hi);
125        if pt_native(mid, df) < p {
126            lo = mid;
127        } else {
128            hi = mid;
129        }
130        if hi - lo < 1e-12 * hi.abs().max(1.0) {
131            break;
132        }
133    }
134    0.5 * (lo + hi)
135}
136
137/// Standard-normal quantile (probit), R's `qnorm(p)`.
138///
139/// With the `regression` feature this delegates to anofox-regression's `Normal`
140/// (statrs) for an exact value; without it, it uses the Abramowitz & Stegun
141/// 26.2.23 rational approximation (|error| < ~4.5e-4 in z).
142#[cfg(feature = "regression")]
143pub fn qnorm(p: f64) -> f64 {
144    use anofox_regression::distributions::{ContinuousCDF, Normal};
145    if p <= 0.0 {
146        return f64::NEG_INFINITY;
147    }
148    if p >= 1.0 {
149        return f64::INFINITY;
150    }
151    Normal::new(0.0, 1.0)
152        .map(|d| d.inverse_cdf(p))
153        .unwrap_or(0.0)
154}
155
156/// See the `regression`-enabled variant; this is the Abramowitz & Stegun
157/// rational approximation used when no distribution library is available.
158#[cfg(not(feature = "regression"))]
159pub fn qnorm(p: f64) -> f64 {
160    if p <= 0.0 {
161        return f64::NEG_INFINITY;
162    }
163    if p >= 1.0 {
164        return f64::INFINITY;
165    }
166    // A&S 26.2.23, using symmetry about p = 0.5.
167    fn rational_approx(t: f64) -> f64 {
168        let (c0, c1, c2) = (2.515_517, 0.802_853, 0.010_328);
169        let (d1, d2, d3) = (1.432_788, 0.189_269, 0.001_308);
170        t - (c0 + c1 * t + c2 * t * t) / (1.0 + d1 * t + d2 * t * t + d3 * t * t * t)
171    }
172    if p < 0.5 {
173        -rational_approx((-2.0 * p.ln()).sqrt())
174    } else if p > 0.5 {
175        rational_approx((-2.0 * (1.0 - p).ln()).sqrt())
176    } else {
177        0.0
178    }
179}
180
181/// Radius scaling for a bivariate confidence ellipse at `level` from `n` points,
182/// matching ggplot2's `stat_ellipse`: `sqrt(2 · F⁻¹(level; 2, n−1))`.
183///
184/// With the `regression` feature the F quantile comes from anofox-regression's
185/// `FisherSnedecor` (statrs). Without it, we fall back to the large-sample
186/// limit `sqrt(-2·ln(1−level))` (the χ²₂ quantile, exact as n → ∞).
187#[cfg(feature = "regression")]
188pub fn ellipse_radius(level: f64, n: usize) -> f64 {
189    use anofox_regression::distributions::{ContinuousCDF, FisherSnedecor};
190    let dfd = (n as f64 - 1.0).max(1.0);
191    match FisherSnedecor::new(2.0, dfd) {
192        Ok(f) => (2.0 * f.inverse_cdf(level)).max(0.0).sqrt(),
193        Err(_) => (-2.0 * (1.0 - level).ln()).sqrt(),
194    }
195}
196
197/// See the `regression`-enabled variant; this uses the χ²₂ closed form.
198#[cfg(not(feature = "regression"))]
199pub fn ellipse_radius(level: f64, _n: usize) -> f64 {
200    (-2.0 * (1.0 - level).ln()).sqrt()
201}
202
203#[cfg(all(test, feature = "regression"))]
204mod tests {
205    use super::{ellipse_radius, qnorm, qt};
206
207    #[test]
208    fn qnorm_matches_r() {
209        // R: qnorm(c(0.025, 0.25, 0.5, 0.75, 0.975)).
210        let cases = [
211            (0.025, -1.959_963_984_540_054),
212            (0.25, -0.674_489_750_196_082),
213            (0.5, 0.0),
214            (0.75, 0.674_489_750_196_082),
215            (0.975, 1.959_963_984_540_054),
216        ];
217        for (p, want) in cases {
218            assert!((qnorm(p) - want).abs() < 1e-9, "qnorm({p})");
219        }
220    }
221
222    #[test]
223    fn ellipse_radius_is_monotonic_and_finite() {
224        let small = ellipse_radius(0.5, 30);
225        let big = ellipse_radius(0.99, 30);
226        assert!(big > small && small > 0.0 && big.is_finite());
227    }
228
229    // Reference values from R's qt(0.975, df).
230    #[test]
231    fn qt_matches_r() {
232        let cases = [
233            (1.0, 12.706_204_736_432_1),
234            (2.0, 4.302_652_729_912_0),
235            (5.0, 2.570_581_835_636_1),
236            (10.0, 2.228_138_851_986_3),
237            (30.0, 2.042_272_456_301_4),
238            (100.0, 1.983_971_518_449_5),
239        ];
240        for (df, want) in cases {
241            let got = qt(0.975, df);
242            assert!(
243                (got - want).abs() < 1e-6,
244                "qt(0.975,{df}) = {got}, want {want}"
245            );
246        }
247    }
248}
249
250#[cfg(test)]
251mod native_t_tests {
252    use super::qt_native;
253
254    #[test]
255    fn qt_matches_r() {
256        // R: qt(c(.975, .95, .995, .025, .9), df)
257        for (p, df, want) in [
258            (0.975, 6.0, 2.446911851),
259            (0.95, 6.0, 1.943180281),
260            (0.995, 3.0, 5.840909310),
261            (0.025, 10.0, -2.228138852),
262            (0.9, 1.0, 3.077683537),
263            (0.975, 1000.0, 1.962339081),
264            (0.975, 2.5, 3.574654842),
265        ] {
266            let got = qt_native(p, df);
267            assert!((got - want).abs() < 1e-7, "qt({p}, {df}) = {got}, R {want}");
268        }
269    }
270}