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