Skip to main content

ocas_eval/numeric/
vegas.rs

1//! Adaptive Monte Carlo integration (Vegas).
2//!
3//! Implements the classic Vegas algorithm of Lepage: a product grid over the
4//! unit hypercube whose bin boundaries are iteratively refined so that each
5//! bin captures an equal share of the integrand's variance. The estimate and
6//! its error are combined across iterations using inverse-variance weighting
7//! via [`StatisticsAccumulator`](super::statistics::StatisticsAccumulator).
8//!
9//! Integrands are closures `Fn(&[f64]) -> f64` taking a point in the unit
10//! hypercube; map physical bounds with a linear change of variables in the
11//! closure.
12
13use rand::{Rng, SeedableRng};
14use rand_xoshiro::Xoshiro256PlusPlus;
15
16use crate::numeric::statistics::StatisticsAccumulator;
17
18/// Result of a numerical integration: the estimate and its standard error.
19#[derive(Debug, Clone, Copy)]
20pub struct IntegrateResult {
21    /// Best estimate of the integral.
22    pub integral: f64,
23    /// Estimated standard error on `integral`.
24    pub error: f64,
25}
26
27/// A numerical integrator produces an [`IntegrateResult`] from a closure.
28pub trait Integrator {
29    /// Integrate `f` (which receives a point in the unit hypercube of the
30    /// integrator's native domain) and return the estimate and error.
31    fn integrate<F: Fn(&[f64]) -> f64>(&mut self, f: &F) -> IntegrateResult;
32}
33
34/// Tuning knobs for [`Vegas`].
35///
36/// # Example
37///
38/// ```
39/// use ocas_eval::numeric::VegasOptions;
40///
41/// let opts = VegasOptions {
42///     n_bins: 50,
43///     n_samples: 10_000,
44///     iterations: 5,
45///     learning_rate: 1.5,
46///     seed: 42,
47/// };
48/// ```
49#[derive(Debug, Clone, Copy)]
50pub struct VegasOptions {
51    /// Number of bins per dimension (the grid is a product of 1-D grids).
52    pub n_bins: usize,
53    /// Number of samples per iteration.
54    pub n_samples: usize,
55    /// Number of adaptive iterations.
56    pub iterations: usize,
57    /// Grid smoothing / learning rate (typical 1.0–2.0).
58    pub learning_rate: f64,
59    /// RNG seed (deterministic across runs).
60    pub seed: u64,
61}
62
63impl Default for VegasOptions {
64    fn default() -> Self {
65        Self {
66            n_bins: 64,
67            n_samples: 10_000,
68            iterations: 10,
69            learning_rate: 1.5,
70            seed: 0x0C45,
71        }
72    }
73}
74
75/// One-dimensional Vegas grid: bin boundaries on [0,1] plus a per-bin
76/// accumulator for the current iteration's importance estimate.
77#[derive(Debug, Clone)]
78struct GridAxis {
79    /// Bin boundary positions, `n_bins + 1` of them, in `[0,1]`. Starts uniform.
80    boundaries: Vec<f64>,
81    /// Per-bin accumulator of `f²·w` (the importance training signal).
82    bin_accum: Vec<f64>,
83}
84
85impl GridAxis {
86    fn new(n_bins: usize) -> Self {
87        let boundaries = (0..=n_bins).map(|i| i as f64 / n_bins as f64).collect();
88        Self {
89            boundaries,
90            bin_accum: vec![0.0; n_bins],
91        }
92    }
93
94    /// Sample a coordinate: pick a bin uniformly and map a uniform deviate
95    /// inside it. Returns `(x, jacobian)` where `jacobian = n_bins · bin_width`
96    /// is the inverse-pdf contribution from this axis.
97    fn sample<R: Rng>(&self, rng: &mut R) -> (f64, f64) {
98        let n = self.bin_accum.len();
99        let b = rng.random_range(0..n);
100        let lo = self.boundaries[b];
101        let hi = self.boundaries[b + 1];
102        let u = rng.random::<f64>();
103        let x = lo + (hi - lo) * u;
104        (x, (hi - lo) * n as f64)
105    }
106
107    /// Find the bin containing `x` and add `weight · f²` to that bin.
108    fn add_training(&mut self, x: f64, weight: f64, f2: f64) {
109        // Binary-search the bin boundaries for x.
110        let b = match self
111            .boundaries
112            .binary_search_by(|v| v.partial_cmp(&x).unwrap_or(std::cmp::Ordering::Equal))
113        {
114            Ok(i) => i.min(self.bin_accum.len().saturating_sub(1)),
115            Err(i) => i
116                .saturating_sub(1)
117                .min(self.bin_accum.len().saturating_sub(1)),
118        };
119        if b < self.bin_accum.len() {
120            self.bin_accum[b] += weight * f2;
121        }
122    }
123
124    /// Refine bin boundaries from the accumulated importance, using the Vegas
125    /// cumulative-arc-length update: bin boundaries are redistributed so that
126    /// each bin carries an equal share of the smoothed importance. The
127    /// `learning_rate` damps the update via `d^(1/lr)` (1.0 = full step).
128    fn update(&mut self, learning_rate: f64) {
129        let n = self.bin_accum.len();
130        if n == 0 {
131            return;
132        }
133        let total: f64 = self.bin_accum.iter().sum();
134        if total <= 0.0 {
135            return;
136        }
137        // Smoothed, average-normalised importance per bin (1.0 = average).
138        let avg = total / n as f64;
139        let mut d = vec![0.0; n];
140        for (i, d_slot) in d.iter_mut().enumerate() {
141            let prev = if i > 0 { self.bin_accum[i - 1] } else { 0.0 };
142            let next = if i + 1 < n {
143                self.bin_accum[i + 1]
144            } else {
145                0.0
146            };
147            let smooth = (prev + self.bin_accum[i] + next) / 3.0;
148            *d_slot = smooth / avg;
149        }
150        // Damp the update (learning_rate > 1 softens the grid change).
151        if (learning_rate - 1.0).abs() > 1e-12 {
152            for v in d.iter_mut() {
153                *v = v.max(1e-30).powf(1.0 / learning_rate);
154            }
155        }
156        // Cumulative arc length; redistribute boundaries at equal spacing.
157        let mut cum = vec![0.0; n + 1];
158        for i in 0..n {
159            cum[i + 1] = cum[i] + d[i];
160        }
161        let final_cum = cum[n];
162        if final_cum <= 0.0 {
163            return;
164        }
165        let mut new_boundaries = vec![0.0; n + 1];
166        new_boundaries[0] = 0.0;
167        new_boundaries[n] = 1.0;
168        let mut j = 0;
169        for (i, boundary) in new_boundaries.iter_mut().enumerate().take(n).skip(1) {
170            let target = i as f64 / n as f64 * final_cum;
171            while j < n && cum[j + 1] < target {
172                j += 1;
173            }
174            let lo = cum[j];
175            let hi = cum[j + 1];
176            let frac = if hi > lo {
177                (target - lo) / (hi - lo)
178            } else {
179                0.0
180            };
181            *boundary = (j as f64 + frac) / n as f64;
182        }
183        // Enforce monotone non-decreasing; clamp tiny regressions.
184        for i in 1..=n {
185            if new_boundaries[i] < new_boundaries[i - 1] {
186                new_boundaries[i] = new_boundaries[i - 1];
187            }
188        }
189        new_boundaries[n] = 1.0;
190        self.boundaries = new_boundaries;
191        self.bin_accum.fill(0.0);
192    }
193}
194
195/// Adaptive Monte Carlo integrator (Vegas) over the unit hypercube.
196pub struct Vegas {
197    opts: VegasOptions,
198    axes: Vec<GridAxis>,
199    accumulator: StatisticsAccumulator,
200}
201
202impl Vegas {
203    /// Create a Vegas integrator for `n_dims` dimensions with the given options.
204    pub fn new(n_dims: usize, opts: VegasOptions) -> Self {
205        let axes = (0..n_dims).map(|_| GridAxis::new(opts.n_bins)).collect();
206        Self {
207            opts,
208            axes,
209            accumulator: StatisticsAccumulator::new(),
210        }
211    }
212
213    /// Latest accumulated estimate and error after [`Integrator::integrate`].
214    pub fn result(&self) -> IntegrateResult {
215        IntegrateResult {
216            integral: self.accumulator.integral(),
217            error: self.accumulator.error(),
218        }
219    }
220
221    /// Number of completed iterations.
222    pub fn iterations(&self) -> usize {
223        self.accumulator.iterations()
224    }
225}
226
227impl Integrator for Vegas {
228    fn integrate<F: Fn(&[f64]) -> f64>(&mut self, f: &F) -> IntegrateResult {
229        let n_dims = self.axes.len();
230        let mut rng = Xoshiro256PlusPlus::seed_from_u64(self.opts.seed);
231        for _ in 0..self.opts.iterations {
232            for _ in 0..self.opts.n_samples {
233                // Sample each axis; collect x and the total jacobian.
234                let mut x = Vec::with_capacity(n_dims);
235                let mut jac = 1.0;
236                for axis in self.axes.iter_mut() {
237                    let (xi, wi) = axis.sample(&mut rng);
238                    x.push(xi);
239                    jac *= wi;
240                }
241                let fx = f(&x);
242                self.accumulator.add_sample(jac, fx);
243                let f2 = fx * fx;
244                for (i, xi) in x.iter().enumerate() {
245                    self.axes[i].add_training(*xi, jac, f2);
246                }
247            }
248            self.accumulator.finalize_iteration();
249            for axis in self.axes.iter_mut() {
250                axis.update(self.opts.learning_rate);
251            }
252        }
253        self.result()
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    #[test]
262    fn integrates_constant_exactly() {
263        // ∫₀¹ 7 dx = 7 with zero variance.
264        let mut v = Vegas::new(1, VegasOptions::default());
265        let r = v.integrate(&|_x: &[f64]| 7.0);
266        assert!((r.integral - 7.0).abs() < 1e-9, "got {}", r.integral);
267        assert!(r.error < 1e-6, "error {}", r.error);
268    }
269
270    #[test]
271    fn integrates_linear_to_one_percent() {
272        // ∫₀¹ x dx = 1/2.
273        let opts = VegasOptions {
274            n_samples: 20_000,
275            iterations: 8,
276            ..VegasOptions::default()
277        };
278        let mut v = Vegas::new(1, opts);
279        let r = v.integrate(&|x: &[f64]| x[0]);
280        assert!((r.integral - 0.5).abs() < 0.01, "got {}", r.integral);
281    }
282
283    #[test]
284    fn integrates_gaussian_peak() {
285        // ∫ exp(-50 (x-0.5)²) dx over [0,1] ≈ sqrt(π/50) ≈ 0.2507.
286        // Vegas' adaptive grid should resolve the narrow peak.
287        let opts = VegasOptions {
288            n_bins: 128,
289            n_samples: 20_000,
290            iterations: 12,
291            ..VegasOptions::default()
292        };
293        let mut v = Vegas::new(1, opts);
294        let r = v.integrate(&|x: &[f64]| (-50.0 * (x[0] - 0.5).powi(2)).exp());
295        let analytic = (std::f64::consts::PI / 50.0).sqrt();
296        assert!(
297            (r.integral - analytic).abs() < 0.02 * analytic,
298            "got {}, expected {}",
299            r.integral,
300            analytic
301        );
302    }
303
304    #[test]
305    fn integrates_two_dimensional_product() {
306        // ∫₀¹∫₀¹ x·y dx dy = 1/4.
307        let opts = VegasOptions {
308            n_samples: 20_000,
309            iterations: 8,
310            ..VegasOptions::default()
311        };
312        let mut v = Vegas::new(2, opts);
313        let r = v.integrate(&|x: &[f64]| x[0] * x[1]);
314        assert!((r.integral - 0.25).abs() < 0.01, "got {}", r.integral);
315    }
316
317    #[test]
318    fn deterministic_across_runs_with_same_seed() {
319        let opts = VegasOptions {
320            n_samples: 5000,
321            iterations: 4,
322            seed: 42,
323            ..VegasOptions::default()
324        };
325        let mut a = Vegas::new(1, opts);
326        let ra = a.integrate(&|x: &[f64]| x[0] * x[0]);
327        let mut b = Vegas::new(1, opts);
328        let rb = b.integrate(&|x: &[f64]| x[0] * x[0]);
329        assert_eq!(ra.integral, rb.integral);
330        assert_eq!(ra.error, rb.error);
331    }
332
333    #[test]
334    fn integrate_1d_over_physical_bounds() {
335        use super::super::integrate_1d;
336        // ∫₀² x dx = 2.
337        let r = integrate_1d(|x| x, 0.0, 2.0, Default::default());
338        assert!((r.integral - 2.0).abs() < 0.02, "got {}", r.integral);
339        // ∫₁² x² dx = 7/3.
340        let r2 = integrate_1d(|x| x * x, 1.0, 2.0, VegasOptions::default());
341        assert!(
342            (r2.integral - 7.0 / 3.0).abs() < 0.03,
343            "got {}",
344            r2.integral
345        );
346    }
347}