comnum_sim/stats.rs
1//! Running statistics with confidence intervals.
2//!
3//! - [`ErrorRate`] counts errors among trials of a Bernoulli event (bit, symbol or block
4//! errors). Its estimate is $\hat{p} = k/n$ and its interval the **Wilson score
5//! interval** at confidence $1 - \alpha$,
6//!
7//! $$
8//! \frac{\hat{p} + \frac{z^{2}}{2n} \pm z \sqrt{\frac{\hat{p}(1 - \hat{p})}{n} + \frac{z^{2}}{4n^{2}}}}{1 + \frac{z^{2}}{n}},
9//! \qquad z = \Phi^{-1}(1 - \alpha/2),
10//! $$
11//!
12//! which, unlike the normal approximation, stays inside $[0, 1]$ and is informative
13//! with few or no errors (with $k = 0$ its upper end is $z^{2}/(n + z^{2})$). The other
14//! intervals of a proportion compared by Brown, Cai & DasGupta (2001) are available
15//! through [`ErrorRate::interval`] and [`BinomialInterval`]: Wald, Agresti–Coull,
16//! Jeffreys and Clopper–Pearson, with their exact [`coverage`] and
17//! [`expected_length`].
18//! - [`Mean`] accumulates real values with Welford's algorithm (numerically stable
19//! running mean and variance) and merges two accumulators exactly (Chan, Golub and
20//! LeVeque); its interval is the normal interval of the mean,
21//! $\bar{x} \pm z s/\sqrt{n}$.
22//! - [`Estimate`] is either of the two, with the same interface.
23//!
24//! # Examples
25//! ```
26//! use comnum_sim::stats::{ErrorRate, Mean, Estimate, z_score};
27//! let mut e = ErrorRate::default();
28//! e.add(3, 1000);
29//! e.add(7, 1000);
30//! assert_eq!(e.rate(), 5e-3);
31//! let (lo, hi) = e.wilson(0.95);
32//! assert!(lo < 5e-3 && 5e-3 < hi);
33//! let mut m = Mean::default();
34//! for v in [1.0, 2.0, 3.0, 4.0] { m.add(v); }
35//! assert_eq!((m.mean(), m.variance()), (2.5, 5.0 / 3.0));
36//! assert!((z_score(0.95) - 1.959964).abs() < 1e-5);
37//! let est = Estimate::Rate(e);
38//! assert_eq!(est.value(), 5e-3);
39//! ```
40
41//!
42//! # References
43//!
44//! 1. L. D. Brown, T. T. Cai, A. DasGupta, "Interval estimation for a binomial
45//! proportion," *Statistical Science* 16(2), 101–133, 2001: eqs. (1), (4), (5),
46//! (6)–(8) and Sec. 4.2.1 (the intervals), Sec. 2 (the coverage), Sec. 3.3 (the
47//! expected length).
48
49use comnum_core::{Error, Result};
50use comnum_math::{beta_inv, ln_binomial_pmf, qfuncinv};
51
52/// The quantile $z = \Phi^{-1}(1 - \alpha/2) = Q^{-1}(\alpha/2)$ of a two-sided
53/// confidence level $1 - \alpha$ (1.96 for 0.95); the level is clamped to $[0, 1)$ (the
54/// runner refuses a level outside $(0, 1)$, see
55/// [`check_confidence`](crate::stopping::check_confidence)).
56pub fn z_score(confidence: f64) -> f64 {
57 qfuncinv((1.0 - confidence.clamp(0.0, 1.0 - 1e-16)) / 2.0)
58}
59
60/// Errors among trials of a Bernoulli event.
61#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
62pub struct ErrorRate {
63 /// Number of errors $k$.
64 pub errors: u64,
65 /// Number of trials $n$.
66 pub total: u64,
67}
68
69impl ErrorRate {
70 /// Add `errors` errors among `total` new trials.
71 pub fn add(&mut self, errors: u64, total: u64) {
72 self.errors += errors;
73 self.total += total;
74 }
75
76 /// Merge another count.
77 pub fn merge(&mut self, o: &ErrorRate) {
78 self.add(o.errors, o.total);
79 }
80
81 /// $k/n$ (`NaN` without trials).
82 pub fn rate(&self) -> f64 {
83 if self.total == 0 { f64::NAN } else { self.errors as f64 / self.total as f64 }
84 }
85
86 /// Wilson score interval at the given confidence (`(0, 1)` without trials): eq. (4)
87 /// of Brown et al. (2001), [`BinomialInterval::Wilson`].
88 pub fn wilson(&self, confidence: f64) -> (f64, f64) {
89 if self.total == 0 {
90 return (0.0, 1.0);
91 }
92 let n = self.total as f64;
93 let p = self.rate();
94 let z = z_score(confidence);
95 let z2 = z * z;
96 let centre = (p + z2 / (2.0 * n)) / (1.0 + z2 / n);
97 let half = z * (p * (1.0 - p) / n + z2 / (4.0 * n * n)).sqrt() / (1.0 + z2 / n);
98 // without errors the lower end is exactly 0: `centre - half` would leave a rounding residue
99 let lower = if self.errors == 0 { 0.0 } else { (centre - half).max(0.0) };
100 (lower, (centre + half).min(1.0))
101 }
102
103 /// The interval of the rate by `method` at the given confidence (`(0, 1)` without
104 /// trials; see [`BinomialInterval`]: the Wald and Agresti–Coull ends may leave
105 /// $[0, 1]$, as the paper defines them).
106 ///
107 /// # Examples
108 /// ```
109 /// use comnum_sim::stats::{BinomialInterval, ErrorRate};
110 /// let r = ErrorRate { errors: 3, total: 7 };
111 /// // Table 5 of Brown et al. (2001): the Jeffreys interval of 3 successes out of 7
112 /// let (lo, hi) = r.interval(BinomialInterval::Jeffreys, 0.95);
113 /// assert!((lo - 0.139).abs() < 1e-3 && (hi - 0.766).abs() < 1e-3);
114 /// // Clopper-Pearson contains Jeffreys, Agresti-Coull contains Wilson
115 /// let (cl, ch) = r.interval(BinomialInterval::ClopperPearson, 0.95);
116 /// assert!(cl < lo && hi < ch);
117 /// ```
118 pub fn interval(&self, method: BinomialInterval, confidence: f64) -> (f64, f64) {
119 if self.total == 0 {
120 return (0.0, 1.0);
121 }
122 let (k, n) = (self.errors as f64, self.total as f64);
123 let alpha = 1.0 - confidence;
124 let z = z_score(confidence);
125 match method {
126 BinomialInterval::Wald => {
127 let p = k / n;
128 let half = z * (p * (1.0 - p) / n).sqrt();
129 (p - half, p + half)
130 }
131 BinomialInterval::Wilson => self.wilson(confidence),
132 BinomialInterval::AgrestiCoull => {
133 let nt = n + z * z;
134 let pt = (k + z * z / 2.0) / nt;
135 let half = z * (pt * (1.0 - pt) / nt).sqrt();
136 (pt - half, pt + half)
137 }
138 BinomialInterval::Jeffreys => {
139 let lo = if self.errors == 0 { 0.0 } else { beta_inv(alpha / 2.0, k + 0.5, n - k + 0.5) };
140 let hi =
141 if self.errors == self.total { 1.0 } else { beta_inv(1.0 - alpha / 2.0, k + 0.5, n - k + 0.5) };
142 (lo, hi)
143 }
144 BinomialInterval::ClopperPearson => {
145 let lo = if self.errors == 0 { 0.0 } else { beta_inv(alpha / 2.0, k, n - k + 1.0) };
146 let hi = if self.errors == self.total { 1.0 } else { beta_inv(1.0 - alpha / 2.0, k + 1.0, n - k) };
147 (lo, hi)
148 }
149 }
150 }
151}
152
153/// A confidence interval of a binomial proportion $p$ from $k$ successes among $n$
154/// trials, $\hat{p} = k/n$, $z = \Phi^{-1}(1 - \alpha/2)$ (Brown, Cai & DasGupta 2001,
155/// with their equations):
156///
157/// - [`Wald`](Self::Wald), eq. (1): $\hat{p} \pm z \sqrt{\hat{p}(1 - \hat{p})/n}$, the
158/// textbook interval, whose coverage oscillates far below $1 - \alpha$ (a single
159/// point at $k = 0$ or $n$);
160/// - [`Wilson`](Self::Wilson), eq. (4): the score interval (the default of the runner);
161/// - [`AgrestiCoull`](Self::AgrestiCoull), eq. (5): the Wald interval of
162/// $\tilde{p} = (k + z^{2}/2)/(n + z^{2})$ with $\tilde{n} = n + z^{2}$, which contains
163/// the Wilson interval;
164/// - [`Jeffreys`](Self::Jeffreys), eqs. (6)–(8): the $\alpha/2$ and $1 - \alpha/2$
165/// quantiles of $\mathrm{Beta}(k + 1/2, n - k + 1/2)$, 0 at $k = 0$ and 1 at $k = n$;
166/// - [`ClopperPearson`](Self::ClopperPearson), Sec. 4.2.1: the quantiles of
167/// $\mathrm{Beta}(k, n - k + 1)$ and $\mathrm{Beta}(k + 1, n - k)$, the "exact"
168/// interval whose coverage never falls below $1 - \alpha$.
169///
170/// The Wald and Agresti–Coull intervals are returned as the paper defines them, not
171/// truncated to $[0, 1]$: their expected lengths are those of its Sec. 3.3 (Fig. 8), and
172/// truncating them would not change their coverage of $p \in (0, 1)$.
173#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
174pub enum BinomialInterval {
175 /// The standard interval, eq. (1).
176 Wald,
177 /// The score interval, eq. (4).
178 Wilson,
179 /// The Agresti–Coull interval, eq. (5).
180 AgrestiCoull,
181 /// The equal-tailed Jeffreys interval, eqs. (6)–(8).
182 Jeffreys,
183 /// The Clopper–Pearson interval, Sec. 4.2.1.
184 ClopperPearson,
185}
186
187impl BinomialInterval {
188 /// The five intervals.
189 pub const ALL: [BinomialInterval; 5] = [
190 BinomialInterval::Wald,
191 BinomialInterval::Wilson,
192 BinomialInterval::AgrestiCoull,
193 BinomialInterval::Jeffreys,
194 BinomialInterval::ClopperPearson,
195 ];
196
197 /// The name of the interval: `wald`, `wilson`, `agresti_coull`, `jeffreys` or
198 /// `clopper_pearson`.
199 pub fn name(self) -> &'static str {
200 match self {
201 BinomialInterval::Wald => "wald",
202 BinomialInterval::Wilson => "wilson",
203 BinomialInterval::AgrestiCoull => "agresti_coull",
204 BinomialInterval::Jeffreys => "jeffreys",
205 BinomialInterval::ClopperPearson => "clopper_pearson",
206 }
207 }
208}
209
210impl std::str::FromStr for BinomialInterval {
211 type Err = Error;
212
213 /// The interval of a [`BinomialInterval::name`] (any case, `-` for `_`).
214 fn from_str(s: &str) -> Result<Self> {
215 let key = s.to_ascii_lowercase().replace('-', "_");
216 BinomialInterval::ALL.into_iter().find(|m| m.name() == key).ok_or_else(|| {
217 let names: Vec<&str> = BinomialInterval::ALL.iter().map(|m| m.name()).collect();
218 Error::Config(format!("unknown binomial interval {s:?}: one of {}", names.join(", ")))
219 })
220 }
221}
222
223/// The intervals of $k = 0, \dots, n$ successes and their binomial probabilities.
224fn intervals_and_probabilities(method: BinomialInterval, n: u64, p: f64, confidence: f64) -> Vec<((f64, f64), f64)> {
225 let ln_pmf = ln_binomial_pmf(n as usize, p);
226 (0..=n)
227 .map(|k| (ErrorRate { errors: k, total: n }.interval(method, confidence), ln_pmf[k as usize].exp()))
228 .collect()
229}
230
231/// The exact coverage probability $C(p, n) = P_{p}(L(X) \le p \le U(X))$ of an interval
232/// for $X \sim \mathrm{Bin}(n, p)$: the sum of the binomial probabilities of the counts
233/// whose interval contains $p$ (Brown et al. 2001, Sec. 2), no simulation.
234///
235/// # Examples
236/// ```
237/// use comnum_sim::stats::{BinomialInterval, coverage};
238/// // Table 1 of Brown et al. (2001): the Wald interval covers p = 1/2 with 0.891 at n = 10
239/// let c = coverage(BinomialInterval::Wald, 10, 0.5, 0.95);
240/// assert!((c - 0.891).abs() < 5e-4);
241/// ```
242pub fn coverage(method: BinomialInterval, n: u64, p: f64, confidence: f64) -> f64 {
243 intervals_and_probabilities(method, n, p, confidence)
244 .into_iter()
245 .filter(|((lo, hi), _)| *lo <= p && p <= *hi)
246 .map(|(_, w)| w)
247 .sum()
248}
249
250/// The expected length $\sum_{k} (U(k) - L(k)) P_{p}(X = k)$ of an interval for
251/// $X \sim \mathrm{Bin}(n, p)$ (Brown et al. 2001, Sec. 3.3), with the ends of
252/// [`ErrorRate::interval`].
253pub fn expected_length(method: BinomialInterval, n: u64, p: f64, confidence: f64) -> f64 {
254 intervals_and_probabilities(method, n, p, confidence).into_iter().map(|((lo, hi), w)| (hi - lo) * w).sum()
255}
256
257/// Running mean and variance of real values (Welford).
258#[derive(Clone, Copy, Debug, Default, PartialEq)]
259#[allow(clippy::struct_field_names, reason = "the running mean of Welford's algorithm")]
260pub struct Mean {
261 n: u64,
262 mean: f64,
263 m2: f64,
264}
265
266impl Mean {
267 /// Add one value.
268 pub fn add(&mut self, x: f64) {
269 self.n += 1;
270 let d = x - self.mean;
271 self.mean += d / self.n as f64;
272 self.m2 += d * (x - self.mean);
273 }
274
275 /// Merge another accumulator (exact, Chan et al.).
276 pub fn merge(&mut self, o: &Mean) {
277 if o.n == 0 {
278 return;
279 }
280 if self.n == 0 {
281 *self = *o;
282 return;
283 }
284 let n = self.n + o.n;
285 let d = o.mean - self.mean;
286 self.mean += d * o.n as f64 / n as f64;
287 self.m2 += o.m2 + d * d * self.n as f64 * o.n as f64 / n as f64;
288 self.n = n;
289 }
290
291 /// Number of values.
292 pub fn count(&self) -> u64 {
293 self.n
294 }
295
296 /// Mean (`NaN` without values).
297 pub fn mean(&self) -> f64 {
298 if self.n == 0 { f64::NAN } else { self.mean }
299 }
300
301 /// Unbiased sample variance (`NaN` with fewer than two values).
302 pub fn variance(&self) -> f64 {
303 if self.n < 2 { f64::NAN } else { self.m2 / (self.n - 1) as f64 }
304 }
305
306 /// Sample standard deviation.
307 pub fn std(&self) -> f64 {
308 self.variance().sqrt()
309 }
310
311 /// Standard error of the mean $s/\sqrt{n}$.
312 pub fn sem(&self) -> f64 {
313 self.std() / (self.n as f64).sqrt()
314 }
315
316 /// Normal confidence interval of the mean, $\bar{x} \pm z s/\sqrt{n}$ (unbounded with fewer
317 /// than two values, so that a precision rule never stops on a single value).
318 pub fn interval(&self, confidence: f64) -> (f64, f64) {
319 if self.n < 2 {
320 return (f64::NEG_INFINITY, f64::INFINITY);
321 }
322 let h = z_score(confidence) * self.sem();
323 (self.mean - h, self.mean + h)
324 }
325}
326
327/// The estimate of one metric: an error rate or a mean.
328#[derive(Clone, Copy, Debug, PartialEq)]
329pub enum Estimate {
330 /// Error counts.
331 Rate(ErrorRate),
332 /// Values.
333 Mean(Mean),
334}
335
336impl Estimate {
337 /// The point estimate (rate or mean).
338 pub fn value(&self) -> f64 {
339 match self {
340 Estimate::Rate(r) => r.rate(),
341 Estimate::Mean(m) => m.mean(),
342 }
343 }
344
345 /// The confidence interval (Wilson or normal).
346 pub fn interval(&self, confidence: f64) -> (f64, f64) {
347 match self {
348 Estimate::Rate(r) => r.wilson(confidence),
349 Estimate::Mean(m) => m.interval(confidence),
350 }
351 }
352
353 /// Half-width of the interval relative to the estimate (`∞` for a zero estimate).
354 pub fn relative_precision(&self, confidence: f64) -> f64 {
355 let (lo, hi) = self.interval(confidence);
356 let v = self.value().abs();
357 if v == 0.0 || !v.is_finite() { f64::INFINITY } else { 0.5 * (hi - lo) / v }
358 }
359
360 /// Number of errors (rates) or of values (means).
361 pub fn count(&self) -> u64 {
362 match self {
363 Estimate::Rate(r) => r.errors,
364 Estimate::Mean(m) => m.count(),
365 }
366 }
367
368 /// Number of trials (rates) or of values (means).
369 pub fn total(&self) -> u64 {
370 match self {
371 Estimate::Rate(r) => r.total,
372 Estimate::Mean(m) => m.count(),
373 }
374 }
375
376 /// Merge an estimate of the same kind (a mismatch keeps `self` unchanged and
377 /// returns `false`).
378 pub fn merge(&mut self, o: &Estimate) -> bool {
379 match (self, o) {
380 (Estimate::Rate(a), Estimate::Rate(b)) => {
381 a.merge(b);
382 true
383 }
384 (Estimate::Mean(a), Estimate::Mean(b)) => {
385 a.merge(b);
386 true
387 }
388 _ => false,
389 }
390 }
391}
392
393#[cfg(test)]
394mod tests {
395 use super::*;
396
397 #[test]
398 fn the_intervals_nest_as_brown_cai_dasgupta_state() {
399 // Sec. 3.1.2: Agresti-Coull contains Wilson; Sec. 4.3: Clopper-Pearson contains Jeffreys
400 for n in 1..=60 {
401 for k in 0..=n {
402 let r = ErrorRate { errors: k, total: n };
403 let w = r.interval(BinomialInterval::Wilson, 0.95);
404 assert_eq!(w, r.wilson(0.95));
405 let ac = r.interval(BinomialInterval::AgrestiCoull, 0.95);
406 assert!(ac.0 <= w.0 + 1e-15 && w.1 <= ac.1 + 1e-15, "n {n} k {k}: {ac:?} {w:?}");
407 let j = r.interval(BinomialInterval::Jeffreys, 0.95);
408 let cp = r.interval(BinomialInterval::ClopperPearson, 0.95);
409 assert!(cp.0 <= j.0 && j.1 <= cp.1, "n {n} k {k}: {cp:?} {j:?}");
410 }
411 }
412 }
413
414 #[test]
415 fn clopper_pearson_covers_at_least_its_level() {
416 // Sec. 4.2.1: the coverage of Clopper-Pearson never falls below 1 - alpha
417 for n in [5, 17, 50] {
418 for i in 1..200 {
419 let p = f64::from(i) / 200.0;
420 let c = coverage(BinomialInterval::ClopperPearson, n, p, 0.95);
421 assert!(c >= 0.95 - 1e-12, "n {n} p {p}: {c}");
422 }
423 }
424 }
425
426 #[test]
427 fn the_names_parse_back_and_unknown_ones_are_errors() {
428 for m in BinomialInterval::ALL {
429 assert_eq!(m.name().parse::<BinomialInterval>().unwrap(), m);
430 }
431 assert_eq!("Clopper-Pearson".parse::<BinomialInterval>().unwrap(), BinomialInterval::ClopperPearson);
432 assert!("exact".parse::<BinomialInterval>().is_err());
433 assert_eq!(ErrorRate::default().interval(BinomialInterval::Wald, 0.95), (0.0, 1.0));
434 }
435
436 #[test]
437 fn wilson_with_no_error_and_merges() {
438 let r = ErrorRate { errors: 0, total: 1000 };
439 let (lo, hi) = r.wilson(0.95);
440 assert_eq!(lo, 0.0);
441 assert!((hi - 3.83e-3).abs() < 1e-4, "{hi}");
442 let mut a = Mean::default();
443 let mut b = Mean::default();
444 let mut all = Mean::default();
445 for i in 0..100 {
446 let v = (i as f64 * 0.37).sin();
447 if i < 30 {
448 a.add(v);
449 } else {
450 b.add(v);
451 }
452 all.add(v);
453 }
454 a.merge(&b);
455 assert!((a.mean() - all.mean()).abs() < 1e-14 && (a.variance() - all.variance()).abs() < 1e-14);
456 }
457}