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}