Skip to main content

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}