Skip to main content

stats_claw/distributions/symmetric/
normal.rs

1//! Normal (Gaussian) distribution numerics, for the [`NormalDistribution`] parameter struct.
2//!
3//! Equivalent to `scipy.stats.norm(loc=mean, scale=standard_deviation)`: the
4//! density is the closed-form Gaussian, the CDF is `½(1 + erf(z))`, and the
5//! quantile inverts it via a Newton-refined inverse error function. Sampling uses
6//! the RNG's cached Box–Muller `standard_normal`.
7//!
8//! # Examples
9//!
10//! ```
11//! use stats_claw::distributions::Pdf;
12//! use stats_claw::distributions::NormalDistribution;
13//!
14//! let n = NormalDistribution { mean: 0.0, standard_deviation: 1.0, ..Default::default() };
15//! // Peak density of the standard normal is 1/sqrt(2π) ≈ 0.398_942.
16//! assert!((n.pdf(0.0) - 0.398_942_28).abs() < 1e-6, "peak was {}", n.pdf(0.0));
17//! ```
18
19use super::super::{Cdf, LogCdf, Moments, Pdf, Quantile, Sample};
20use crate::distributions::NormalDistribution;
21use crate::rng::SplitMix64;
22use crate::special::{erf, ln_erfc};
23use std::f64::consts::{LN_2, PI, SQRT_2};
24
25/// `1/√(2π)`, the standard-normal density's normalization constant, precomputed
26/// so the batch `pdf` hot path needs no `sqrt` per element.
27const INV_SQRT_2PI: f64 = 0.398_942_280_401_432_7;
28
29impl NormalDistribution {
30    /// Evaluates the density at every point of `xs`, writing the results into
31    /// `out`, using the fastest available native-SIMD path (NEON / AVX2 / scalar).
32    ///
33    /// This is the dense batch hot path for evaluating many densities at once. It
34    /// is numerically identical to calling [`Pdf::pdf`] per
35    /// element (the SIMD `exp` agrees with the scalar `exp` to ≤ 1e-12 relative),
36    /// so callers needing a single value should still use [`Pdf::pdf`].
37    ///
38    /// # Arguments
39    ///
40    /// * `xs` — the evaluation grid.
41    /// * `out` — the output buffer; should have the same length as `xs`. If the
42    ///   lengths differ, only the leading `min(xs.len(), out.len())` entries are
43    ///   written.
44    ///
45    /// # Examples
46    ///
47    /// ```
48    /// use stats_claw::distributions::Pdf;
49    /// use stats_claw::distributions::NormalDistribution;
50    ///
51    /// let n = NormalDistribution { mean: 0.0, standard_deviation: 1.0, ..Default::default() };
52    /// let xs = [-1.0, 0.0, 1.0];
53    /// let mut out = [0.0; 3];
54    /// n.pdf_batch(&xs, &mut out);
55    /// assert!((out[1] - n.pdf(0.0)).abs() < 1e-12);
56    /// ```
57    pub fn pdf_batch(&self, xs: &[f64], out: &mut [f64]) {
58        super::super::simd::normal_pdf_into(self, xs, out);
59    }
60
61    /// Evaluates the CDF at every point of `xs`, writing the results into `out`.
62    ///
63    /// Numerically identical to calling [`Cdf::cdf`] per element; provided as the
64    /// batch-layout counterpart of [`Self::pdf_batch`]. The `erf` evaluation stays
65    /// scalar (Cody rational), so this is a convenience over the dense grid rather
66    /// than a vectorized kernel.
67    ///
68    /// # Arguments
69    ///
70    /// * `xs` — the evaluation grid.
71    /// * `out` — the output buffer; same-length contract as [`Self::pdf_batch`].
72    ///
73    /// # Examples
74    ///
75    /// ```
76    /// use stats_claw::distributions::Cdf;
77    /// use stats_claw::distributions::NormalDistribution;
78    ///
79    /// let n = NormalDistribution { mean: 0.0, standard_deviation: 1.0, ..Default::default() };
80    /// let xs = [0.0];
81    /// let mut out = [0.0; 1];
82    /// n.cdf_batch(&xs, &mut out);
83    /// assert!((out[0] - 0.5).abs() < 1e-12);
84    /// ```
85    pub fn cdf_batch(&self, xs: &[f64], out: &mut [f64]) {
86        super::super::simd::normal_cdf_into(self, xs, out);
87    }
88
89    /// Draws `out.len()` variates into `out` using the native-SIMD batch ziggurat
90    /// (NEON / AVX2 / scalar), the dense batch sampling hot path.
91    ///
92    /// This is the batch counterpart of [`Sample::sample`]. Where `sample` draws one
93    /// variate by cached Box–Muller (and is the path the equivalence/GoF fixtures
94    /// pin), `sample_batch` fills a whole buffer by the Marsaglia–Tsang ziggurat,
95    /// vectorized per CPU — the workload that races numpy's `norm.rvs`. Both are
96    /// statistically N(mean, σ²); they do **not** produce the same stream for a given
97    /// seed (different algorithms consume the RNG differently), but each is itself
98    /// reproducible for a fixed seed and CPU path.
99    ///
100    /// # Arguments
101    ///
102    /// * `rng` — the deterministic generator; advanced in a fixed per-path order so
103    ///   a fixed seed yields a reproducible buffer on a given CPU.
104    /// * `out` — the output buffer; every element is overwritten with a draw.
105    ///
106    /// # Examples
107    ///
108    /// ```
109    /// use stats_claw::distributions::NormalDistribution;
110    /// use stats_claw::rng::SplitMix64;
111    ///
112    /// let n = NormalDistribution { mean: 0.0, standard_deviation: 1.0, ..Default::default() };
113    /// let mut rng = SplitMix64::new(7);
114    /// let mut out = [0.0; 4];
115    /// n.sample_batch(&mut rng, &mut out);
116    /// // Same seed, same CPU path → identical buffer.
117    /// let mut rng2 = SplitMix64::new(7);
118    /// let mut out2 = [0.0; 4];
119    /// n.sample_batch(&mut rng2, &mut out2);
120    /// assert_eq!(out, out2);
121    /// ```
122    pub fn sample_batch(&self, rng: &mut SplitMix64, out: &mut [f64]) {
123        super::super::ziggurat::normal_sample_into(self.mean, self.standard_deviation, rng, out);
124    }
125}
126
127impl Pdf for NormalDistribution {
128    fn pdf(&self, x: f64) -> f64 {
129        // Hoist the reciprocal scale and normalization out of the transcendental:
130        // one `mul`, one `exp`, two `mul` — no per-call `sqrt` or division.
131        let inv_sigma = 1.0 / self.standard_deviation;
132        let z = (x - self.mean) * inv_sigma;
133        (-0.5 * z * z).exp() * (INV_SQRT_2PI * inv_sigma)
134    }
135}
136
137impl Cdf for NormalDistribution {
138    fn cdf(&self, x: f64) -> f64 {
139        // Fold the 1/(σ√2) scale into a single reciprocal-multiply so the hot
140        // path is one `mul` plus the `erf` rational eval — no per-call division.
141        let inv_scale = 1.0 / (self.standard_deviation * SQRT_2);
142        let z = (x - self.mean) * inv_scale;
143        0.5 * (1.0 + erf(z))
144    }
145}
146
147impl LogCdf for NormalDistribution {
148    fn logsf(&self, x: f64) -> f64 {
149        // sf(x) = erfc(z)/2 with z = (x-μ)/(σ√2); ln sf = ln erfc(z) − ln 2.
150        let z = (x - self.mean) / (self.standard_deviation * SQRT_2);
151        ln_erfc(z) - LN_2
152    }
153    fn logcdf(&self, x: f64) -> f64 {
154        // cdf(x) = sf(2μ − x) by symmetry; equivalently ln erfc(−z) − ln 2.
155        let z = (x - self.mean) / (self.standard_deviation * SQRT_2);
156        ln_erfc(-z) - LN_2
157    }
158}
159
160impl Quantile for NormalDistribution {
161    fn quantile(&self, p: f64) -> f64 {
162        let erf_target = 2.0f64.mul_add(p, -1.0);
163        self.standard_deviation
164            .mul_add(SQRT_2 * inv_erf(erf_target), self.mean)
165    }
166}
167
168impl Moments for NormalDistribution {
169    fn mean(&self) -> Option<f64> {
170        Some(self.mean)
171    }
172    fn variance(&self) -> Option<f64> {
173        Some(self.standard_deviation * self.standard_deviation)
174    }
175}
176
177impl Sample for NormalDistribution {
178    fn sample(&self, rng: &mut SplitMix64) -> f64 {
179        self.standard_deviation
180            .mul_add(rng.standard_normal(), self.mean)
181    }
182}
183
184/// Inverse error function via a rational approximation refined by Newton steps on
185/// `erf`.
186///
187/// The seed is Giles' polynomial approximation; three Newton iterations using the
188/// exact derivative `erf'(x) = 2/√π · e^(−x²)` then drive the residual to ~1e-15
189/// over the open interval `(-1, 1)`.
190///
191/// # Arguments
192///
193/// * `y` — the target erf value in `[-1, 1]`; the closed endpoints map to `±∞`.
194///
195/// # Returns
196///
197/// The `x` with `erf(x) = y`.
198fn inv_erf(y: f64) -> f64 {
199    if y <= -1.0 {
200        return f64::NEG_INFINITY;
201    }
202    if y >= 1.0 {
203        return f64::INFINITY;
204    }
205    let w = -((1.0 - y) * (1.0 + y)).ln();
206    let mut x = if w < 5.0 {
207        let w = w - 2.5;
208        let mut p: f64 = 2.810_226_36e-08;
209        for c in [
210            3.432_739_39e-07,
211            -3.523_387_7e-06,
212            -4.391_506_54e-06,
213            0.000_218_580_87,
214            -0.001_253_725_03,
215            -0.004_177_681_64,
216            0.246_640_727,
217            1.501_409_41,
218        ] {
219            p = p.mul_add(w, c);
220        }
221        p * y
222    } else {
223        let w = w.sqrt() - 3.0;
224        let mut p: f64 = -0.000_200_214_257;
225        for c in [
226            0.000_100_950_558,
227            0.001_349_343_22,
228            -0.003_673_428_44,
229            0.005_739_507_73,
230            -0.007_622_461_3,
231            0.009_438_870_47,
232            1.001_674_06,
233            2.832_976_82,
234        ] {
235            p = p.mul_add(w, c);
236        }
237        p * y
238    };
239    let two_over_sqrt_pi = 2.0 / PI.sqrt();
240    for _ in 0..3 {
241        let err = erf(x) - y;
242        x -= err / (two_over_sqrt_pi * (-x * x).exp());
243    }
244    x
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    /// The standard normal peaks at `1/√(2π) ≈ 0.398_942` at the mean.
252    #[test]
253    fn standard_normal_peak_density() {
254        let n = NormalDistribution {
255            mean: 0.0,
256            standard_deviation: 1.0,
257            ..Default::default()
258        };
259        assert!(
260            (n.pdf(0.0) - 0.398_942_28).abs() < 1e-6,
261            "peak density was {}",
262            n.pdf(0.0)
263        );
264    }
265
266    /// The shared behaviour traits still let us use it polymorphically.
267    #[test]
268    fn density_decreases_away_from_mean() {
269        fn density_at(d: &impl Pdf, x: f64) -> f64 {
270            d.pdf(x)
271        }
272        let n = NormalDistribution {
273            mean: 2.0,
274            standard_deviation: 0.5,
275            ..Default::default()
276        };
277        assert!(
278            density_at(&n, 2.0) > density_at(&n, 3.0),
279            "density should fall off from the mean"
280        );
281    }
282
283    /// `logsf`/`logcdf` agree with the log of the linear `cdf` in the body and
284    /// stay finite (matching scipy) in the deep tail where `1 - cdf` underflows.
285    #[test]
286    fn logsf_matches_scipy_and_stays_finite() {
287        let n = NormalDistribution {
288            mean: 0.0,
289            standard_deviation: 1.0,
290            ..Default::default()
291        };
292        // Body: logsf(0.5) == ln(1 - cdf(0.5)).
293        let body = n.logsf(0.5);
294        assert!(
295            ((body - (1.0 - n.cdf(0.5)).ln()) / body.abs()).abs() < 1e-10,
296            "logsf body was {body}"
297        );
298        // Deep tail: linear sf underflows but logsf matches scipy norm.logsf(40).
299        let tail = n.logsf(40.0);
300        let want = -804.608_442_013_753_9;
301        assert!(tail.is_finite(), "logsf(40) was {tail}");
302        assert!(
303            ((tail - want) / want).abs() < 1e-9,
304            "logsf(40) = {tail}, want {want}"
305        );
306    }
307}