Skip to main content

ocas_eval/numeric/
statistics.rs

1//! Running statistics accumulator for Monte Carlo integration.
2//!
3//! Tracks the mean, variance, and χ² across independent iterations so the
4//! integrator can combine stratified / multi-channel estimates with correct
5//! weighting (the inverse-variance weight, as in the original Vegas paper).
6
7use std::f64;
8
9/// Accumulator for a single iteration's samples and for the cross-iteration
10/// weighted average.
11#[derive(Debug, Clone)]
12pub struct StatisticsAccumulator {
13    /// Σ weight over samples in the current iteration.
14    sum_w: f64,
15    /// Σ weight·f over samples in the current iteration.
16    sum_wf: f64,
17    /// Σ weight·f² over samples in the current iteration.
18    sum_wf2: f64,
19    /// Best estimate of the integral accumulated over iterations.
20    integral: f64,
21    /// Standard error of `integral`.
22    error: f64,
23    /// χ² across iterations (goodness of stratification).
24    chi_square: f64,
25    /// Number of completed iterations contributing to the average.
26    iterations: usize,
27}
28
29impl StatisticsAccumulator {
30    /// Create a fresh accumulator.
31    pub fn new() -> Self {
32        Self {
33            sum_w: 0.0,
34            sum_wf: 0.0,
35            sum_wf2: 0.0,
36            integral: 0.0,
37            error: f64::INFINITY,
38            chi_square: 0.0,
39            iterations: 0,
40        }
41    }
42
43    /// Add a sample with the given Vegas weight (1/pdf). The contribution to
44    /// the integral estimate is `weight · f(xs)`.
45    pub fn add_sample(&mut self, weight: f64, f: f64) {
46        self.sum_w += weight;
47        self.sum_wf += weight * f;
48        self.sum_wf2 += weight * f * f;
49    }
50
51    /// Number of samples in the current (not-yet-finalised) iteration.
52    pub fn samples(&self) -> usize {
53        // We don't track count directly; derive from sum_w when weights are 1.
54        // Vegas weights are Jacobians, so this is approximate — callers should
55        // not rely on it for sample-count bookkeeping.
56        self.sum_w as usize
57    }
58
59    /// Finalise the current iteration: fold its mean and variance into the
60    /// cross-iteration weighted average, then reset per-iteration accumulators.
61    pub fn finalize_iteration(&mut self) {
62        if self.sum_w <= 0.0 || self.sum_wf2 < 0.0 {
63            // Degenerate iteration (no samples or numerical issue): skip but
64            // still reset.
65            self.reset_iteration();
66            return;
67        }
68        let mean = self.sum_wf / self.sum_w;
69        // Unbiased variance estimate of the weighted mean: <f²>/<w> − <f>².
70        let var = (self.sum_wf2 / self.sum_w) - mean * mean;
71        let sig2 = if var > 0.0 { var } else { 0.0 };
72        // Per-iteration standard error of the mean estimate.
73        let iter_err = sig2.sqrt();
74        self.combine_iteration(mean, iter_err);
75        self.reset_iteration();
76    }
77
78    /// Combine one iteration's (mean, error) into the cross-iteration average
79    /// using inverse-variance weighting, and update χ².
80    fn combine_iteration(&mut self, mean: f64, err: f64) {
81        // Clamp the error away from zero so the inverse-variance weight does
82        // not blow up to infinity (a zero-variance iteration would otherwise
83        // square to a subnormal that underflows in the divisor). 1e-150
84        // squares to 1e-300, still representable.
85        let err = if err > 1e-150 { err } else { 1e-150 };
86        let w = 1.0 / (err * err);
87        if self.iterations == 0 {
88            self.integral = mean;
89            self.error = err;
90            self.chi_square = 0.0;
91        } else {
92            let prev_w = 1.0 / (self.error * self.error);
93            let new_w = prev_w + w;
94            let new_integral = (prev_w * self.integral + w * mean) / new_w;
95            // χ² contribution: Σ wᵢ (meanᵢ − combined)².
96            let delta_prev = self.integral - new_integral;
97            let delta_cur = mean - new_integral;
98            self.chi_square += prev_w * delta_prev * delta_prev + w * delta_cur * delta_cur;
99            self.integral = new_integral;
100            self.error = new_w.sqrt().recip();
101        }
102        self.iterations += 1;
103    }
104
105    /// Reset per-iteration accumulators (called after [`Self::finalize_iteration`]).
106    fn reset_iteration(&mut self) {
107        self.sum_w = 0.0;
108        self.sum_wf = 0.0;
109        self.sum_wf2 = 0.0;
110    }
111
112    /// Current best estimate of the integral.
113    pub fn integral(&self) -> f64 {
114        self.integral
115    }
116
117    /// Standard error on [`Self::integral`].
118    pub fn error(&self) -> f64 {
119        self.error
120    }
121
122    /// χ² across iterations; values near `iterations − 1` indicate consistent
123    /// estimates.
124    pub fn chi_square(&self) -> f64 {
125        self.chi_square
126    }
127
128    /// Number of completed iterations.
129    pub fn iterations(&self) -> usize {
130        self.iterations
131    }
132}
133
134impl Default for StatisticsAccumulator {
135    fn default() -> Self {
136        Self::new()
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn constant_integrand_converges_to_constant() {
146        // ∫ 5 over [0,1] with uniform pdf (weight=1) → 5 with zero variance.
147        let mut acc = StatisticsAccumulator::new();
148        for _ in 0..1000 {
149            acc.add_sample(1.0, 5.0);
150        }
151        acc.finalize_iteration();
152        assert!((acc.integral() - 5.0).abs() < 1e-12);
153        assert!(acc.error().abs() < 1e-9);
154        assert!(acc.chi_square().abs() < 1e-9);
155    }
156
157    #[test]
158    fn linear_integrand_matches_analytic() {
159        // ∫₀¹ x dx = 1/2; with 50 000 uniform samples the mean ≈ 0.5 within
160        // a few standard errors.
161        let mut acc = StatisticsAccumulator::new();
162        let n = 50_000u32;
163        // Deterministic lattice to avoid pulling rand into this unit test.
164        for i in 0..n {
165            let x = (i as f64 + 0.5) / n as f64;
166            acc.add_sample(1.0, x);
167        }
168        acc.finalize_iteration();
169        assert!(
170            (acc.integral() - 0.5).abs() < 1e-3,
171            "got {}",
172            acc.integral()
173        );
174    }
175
176    #[test]
177    fn combine_two_iterations_uses_inverse_variance_weighting() {
178        let mut acc = StatisticsAccumulator::new();
179        for _ in 0..1000 {
180            acc.add_sample(1.0, 1.0);
181        }
182        acc.finalize_iteration();
183        for _ in 0..1000 {
184            acc.add_sample(1.0, 3.0);
185        }
186        acc.finalize_iteration();
187        // Two consistent-internal iterations averaging 1 and 3 → near 2.
188        assert!((acc.integral() - 2.0).abs() < 1e-9);
189        assert_eq!(acc.iterations(), 2);
190    }
191}