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}