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}