Skip to main content

wm_simulation/
pce.rs

1//! Superforecaster pipeline — LHS sampling → polynomial chaos expansion
2//! (PCE) → Sobol' sensitivity indices → Bayesian optimization.
3//!
4//! Mirrors v26's `mc.superforecaster`: cheaply explore the parameter box
5//! with Latin Hypercube Sampling, build a polynomial surrogate (PCE) for
6//! variance-based sensitivity analysis, then refine the optimum with the
7//! GP/EI optimizer from [`crate::bayesian`].
8//!
9//! The PCE uses probabilists' Hermite polynomials (the orthogonal basis of
10//! the standard normal), so Sobol' indices are analytic in the coefficients:
11//! `S_i = Σ_{α: αᵢ>0} c_α² / Σ_α c_α²`.
12
13use crate::bayesian::{BayesianOptimizer, rand_u01};
14
15/// Latin Hypercube Sampling over a box.
16///
17/// Divides each dimension into `n` equal-probability strata and draws one
18/// point per stratum (stratified random sampling — far better coverage
19/// than plain random for small n).
20#[must_use]
21pub fn latin_hypercube(bounds: &[(f64, f64)], n: usize, seed: u64) -> Vec<Vec<f64>> {
22    let dim = bounds.len();
23    let mut rng = seed;
24    let mut points = Vec::with_capacity(n);
25    // Per-dimension stratum permutations: each stratum hit exactly once
26    let mut perms = Vec::with_capacity(dim);
27    for _ in 0..dim {
28        let mut perm: Vec<usize> = (0..n).collect();
29        // Fisher–Yates shuffle with the SplitMix64 PRNG
30        for i in (1..n).rev() {
31            let j = (rand_u01(&mut rng) * (i + 1) as f64).floor() as usize;
32            perm.swap(i, j);
33        }
34        perms.push(perm);
35    }
36    for (i, _) in (0..n).enumerate() {
37        let mut row = Vec::with_capacity(dim);
38        for (d, &(lo, hi)) in bounds.iter().enumerate() {
39            let stratum = perms[d][i];
40            let pos = rand_u01(&mut rng).clamp(1e-12, 1.0 - 1e-12);
41            let v = (stratum as f64 + pos) / n as f64;
42            row.push((hi - lo).mul_add(v, lo));
43        }
44        points.push(row);
45    }
46    points
47}
48
49/// Probabilists' Hermite polynomial H_n(x) evaluated at x.
50///
51/// H_0 = 1, H_1 = x, H_n = x·H_{n−1} − (n−1)·H_{n−2}.
52fn hermite(n: usize, x: f64) -> f64 {
53    if n == 0 {
54        return 1.0;
55    }
56    if n == 1 {
57        return x;
58    }
59    let mut h_prev2 = 1.0;
60    let mut h_prev1 = x;
61    let mut h = 0.0;
62    for k in 2..=n {
63        h = x.mul_add(h_prev1, -((k as f64 - 1.0) * h_prev2));
64        h_prev2 = h_prev1;
65        h_prev1 = h;
66    }
67    h
68}
69
70/// Multi-index of a PCE term: the Hermite degree per dimension.
71type MultiIndex = Vec<usize>;
72
73/// Generate all multi-indices with total degree ≤ `max_degree` in `dim`
74/// dimensions (lexicographic, truncated).
75fn multi_indices(dim: usize, max_degree: usize) -> Vec<MultiIndex> {
76    let mut out = Vec::new();
77    let mut idx = vec![0usize; dim];
78    loop {
79        if idx.iter().sum::<usize>() <= max_degree {
80            out.push(idx.clone());
81        }
82        // increment mixed-radix counter
83        let mut i = 0;
84        while i < dim {
85            idx[i] += 1;
86            if idx[i] <= max_degree {
87                break;
88            }
89            idx[i] = 0;
90            i += 1;
91        }
92        if i == dim {
93            break;
94        }
95    }
96    out
97}
98
99/// Polynomial chaos expansion — a Hermite-polynomial surrogate with
100/// analytic Sobol' sensitivity indices.
101pub struct Pce {
102    /// Multi-indices of the retained basis terms.
103    pub indices: Vec<MultiIndex>,
104    /// Regression coefficients per term.
105    pub coefficients: Vec<f64>,
106    /// Total variance of the surrogate (Σ c²).
107    pub total_variance: f64,
108    /// Sobol' main-effect indices per input dimension (first-order).
109    pub sobol_first_order: Vec<f64>,
110    /// Sobol' total-effect indices per input dimension.
111    pub sobol_total: Vec<f64>,
112    /// R² of the surrogate fit (determination coefficient).
113    pub r_squared: f64,
114}
115
116impl Pce {
117    /// Fit a PCE to the given samples.
118    ///
119    /// `x` inputs must be normalized to the box `bounds` (each column in
120    /// [lo, hi]); standardization to N(0,1) happens internally via
121    /// `ξ = 2·(x − lo)/(hi − lo) − 1` (an affine map — acceptable for
122    /// PCE on bounded boxes).
123    #[must_use]
124    pub fn fit(x: &[Vec<f64>], y: &[f64], bounds: &[(f64, f64)], max_degree: usize) -> Self {
125        let dim = bounds.len();
126        let indices = multi_indices(dim, max_degree.min(6));
127        let n = x.len();
128
129        // Design matrix: φ_j(x_i) = Π_d H_{α_jd}(ξ_d(x_i))
130        let mut design = vec![0.0_f64; n * indices.len()];
131        for (i, xi) in x.iter().enumerate() {
132            for (j, idx) in indices.iter().enumerate() {
133                let mut val = 1.0;
134                for (d, &deg) in idx.iter().enumerate() {
135                    let (lo, hi) = bounds[d];
136                    let xi_d = ((xi[d] - lo) / (hi - lo).max(1e-12))
137                        .mul_add(2.0, -1.0)
138                        .clamp(-1.0, 1.0);
139                    val *= hermite(deg, xi_d);
140                }
141                design[i * indices.len() + j] = val;
142            }
143        }
144
145        // Least squares via normal equations: (ΦᵀΦ)c = Φᵀy
146        let m = indices.len();
147        let mut at_a = vec![0.0_f64; m * m];
148        let mut at_y = vec![0.0_f64; m];
149        for i in 0..n {
150            for a in 0..m {
151                at_y[a] = design[i * m + a].mul_add(y[i], at_y[a]);
152                for b in 0..m {
153                    at_a[a * m + b] = design[i * m + a].mul_add(design[i * m + b], at_a[a * m + b]);
154                }
155            }
156        }
157        // Gaussian elimination with partial pivoting
158        let coeffs = solve_linear(&at_a, &at_y, m);
159
160        // Variance decomposition
161        let total_variance = coeffs.iter().skip(1).map(|c| c * c).sum::<f64>();
162        let mut sobol_first = vec![0.0; dim];
163        let mut sobol_total = vec![0.0; dim];
164        for (j, idx) in indices.iter().enumerate() {
165            let c2 = coeffs[j] * coeffs[j];
166            for (d, &deg) in idx.iter().enumerate() {
167                if deg > 0 {
168                    sobol_total[d] += c2;
169                    if idx.iter().filter(|&&k| k > 0).count() == 1 {
170                        sobol_first[d] += c2;
171                    }
172                }
173            }
174        }
175        let scale = |v: f64| {
176            if total_variance > 1e-300 {
177                v / total_variance
178            } else {
179                0.0
180            }
181        };
182        let sobol_first_order = sobol_first.iter().map(|&v| scale(v)).collect();
183        let sobol_total = sobol_total.iter().map(|&v| scale(v)).collect();
184
185        // R²: 1 − SSE/SST
186        let mean_y = y.iter().sum::<f64>() / n.max(1) as f64;
187        let mut sst = 0.0;
188        let mut sse = 0.0;
189        for (i, yi) in y.iter().enumerate() {
190            let mut pred = 0.0;
191            for (j, c) in coeffs.iter().enumerate() {
192                pred += c * design[i * m + j];
193            }
194            sst += (yi - mean_y).powi(2);
195            sse += (yi - pred).powi(2);
196        }
197        let r_squared = if sst > 1e-300 { 1.0 - sse / sst } else { 1.0 };
198
199        Self {
200            indices,
201            coefficients: coeffs,
202            total_variance,
203            sobol_first_order,
204            sobol_total,
205            r_squared,
206        }
207    }
208}
209
210/// Solve `A·c = b` for a symmetric positive-semidefinite matrix by Gaussian
211/// elimination with partial pivoting. Returns `c`.
212fn solve_linear(a: &[f64], b: &[f64], m: usize) -> Vec<f64> {
213    let mut aug = a.to_vec();
214    let mut rhs = b.to_vec();
215    for col in 0..m {
216        // partial pivot
217        let mut pivot = col;
218        let mut max_val = aug[col * m + col].abs();
219        for row in (col + 1)..m {
220            let v = aug[row * m + col].abs();
221            if v > max_val {
222                max_val = v;
223                pivot = row;
224            }
225        }
226        if max_val < 1e-12 {
227            // rank deficient — leave zeros (surrogate stays usable)
228            continue;
229        }
230        if pivot != col {
231            for k in 0..m {
232                aug.swap(col * m + k, pivot * m + k);
233            }
234            rhs.swap(col, pivot);
235        }
236        let pivot_val = aug[col * m + col];
237        for row in (col + 1)..m {
238            let factor = aug[row * m + col] / pivot_val;
239            if factor == 0.0 {
240                continue;
241            }
242            for k in col..m {
243                aug[row * m + k] = factor.mul_add(-aug[col * m + k], aug[row * m + k]);
244            }
245            rhs[row] = factor.mul_add(-rhs[col], rhs[row]);
246        }
247    }
248    // back substitution
249    let mut c = vec![0.0_f64; m];
250    for row in (0..m).rev() {
251        let mut sum = rhs[row];
252        for k in (row + 1)..m {
253            sum = aug[row * m + k].mul_add(-c[k], sum);
254        }
255        let diag = aug[row * m + row];
256        c[row] = if diag.abs() > 1e-300 { sum / diag } else { 0.0 };
257    }
258    c
259}
260
261/// Result of a superforecaster run.
262#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
263pub struct SuperforecasterResult {
264    /// Best parameters found.
265    pub best_params: Vec<f64>,
266    /// Best fitness value.
267    pub best_fitness: f64,
268    /// PCE surrogate diagnostics.
269    pub pce_r_squared: f64,
270    /// Sobol' first-order indices per dimension.
271    pub sobol_first_order: Vec<f64>,
272    /// Sobol' total-effect indices per dimension.
273    pub sobol_total: Vec<f64>,
274    /// Number of LHS initial samples.
275    pub n_initial: usize,
276    /// Number of BO iterations.
277    pub n_bo_iterations: usize,
278}
279
280/// Run the full superforecaster pipeline.
281///
282/// 1. LHS sample `n_initial` points in the box and evaluate the fitness.
283/// 2. Fit a PCE (degree `pce_degree`) → Sobol' indices (sensitivity).
284/// 3. Warm-start Bayesian optimization with the LHS evaluations and refine
285///    for `n_bo_iterations` iterations.
286pub fn superforecaster<F>(
287    bounds: &[(f64, f64)],
288    fitness: F,
289    n_initial: usize,
290    n_bo_iterations: usize,
291    pce_degree: usize,
292    seed: u64,
293) -> SuperforecasterResult
294where
295    F: Fn(&[f64]) -> f64 + Clone,
296{
297    // Phase 1: LHS exploration
298    let lhs = latin_hypercube(bounds, n_initial.max(2), seed);
299    let mut xs = Vec::with_capacity(n_initial + n_bo_iterations);
300    let mut ys = Vec::with_capacity(n_initial + n_bo_iterations);
301    for x in &lhs {
302        xs.push(x.clone());
303        ys.push(fitness(x));
304    }
305
306    // Phase 2: PCE surrogate + Sobol' indices
307    let pce = Pce::fit(&xs, &ys, bounds, pce_degree);
308
309    // Phase 3: Bayesian optimization from a fresh seed family (the LHS
310    // evaluations above are included in the best-point comparison)
311    let mut opt = BayesianOptimizer::new(fitness, seed.wrapping_add(1));
312    let (_, (best_params, best_fitness)) = opt
313        .optimize(bounds, n_initial, n_bo_iterations, 100, 0.01)
314        .unwrap_or_else(|_| {
315            // Fallback: best LHS point only
316            let mut best_idx = 0;
317            for (i, y) in ys.iter().enumerate() {
318                if y > &ys[best_idx] {
319                    best_idx = i;
320                }
321            }
322            (Vec::new(), (xs[best_idx].clone(), ys[best_idx]))
323        });
324
325    // Combine: the optimizer's best already covers the LHS phase (same
326    // n_initial random-init count), but prefer the LHS point if better.
327    let (best_params, best_fitness) = {
328        let (mut bp, mut bf) = (best_params, best_fitness);
329        for (x, y) in xs.iter().zip(ys.iter()) {
330            if *y > bf {
331                bp.clone_from(x);
332                bf = *y;
333            }
334        }
335        (bp, bf)
336    };
337
338    SuperforecasterResult {
339        best_params,
340        best_fitness,
341        pce_r_squared: pce.r_squared,
342        sobol_first_order: pce.sobol_first_order,
343        sobol_total: pce.sobol_total,
344        n_initial: n_initial.max(2),
345        n_bo_iterations,
346    }
347}
348
349#[cfg(test)]
350mod tests {
351    #![allow(clippy::suboptimal_flops)] // test data expressions, not hot paths
352    use super::*;
353
354    #[test]
355    fn lhs_has_stratified_coverage() {
356        let bounds = [(0.0, 10.0), (0.0, 10.0)];
357        let points = latin_hypercube(&bounds, 10, 1);
358        assert_eq!(points.len(), 10);
359        // Every stratum of dimension 0 must be hit exactly once
360        let mut strata: Vec<usize> = points
361            .iter()
362            .map(|p| ((p[0] / 10.0) * 10.0).floor() as usize)
363            .collect();
364        strata.sort_unstable();
365        assert_eq!(strata, vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
366    }
367
368    #[test]
369    fn pce_recovers_linear_surface() {
370        // y = 2x0 + 3x1 over [0,1]² — PCE should be near-perfect
371        let bounds = [(0.0, 1.0), (0.0, 1.0)];
372        let xs: Vec<Vec<f64>> = (0..50)
373            .map(|i| vec![f64::from(i % 10) / 9.0, f64::from(i / 10) / 4.0])
374            .collect();
375        let ys: Vec<f64> = xs.iter().map(|x| 2.0 * x[0] + 3.0 * x[1]).collect();
376        let pce = Pce::fit(&xs, &ys, &bounds, 2);
377        assert!(pce.r_squared > 0.99, "R² = {}", pce.r_squared);
378        // Sensitivity: both variables matter, similar magnitude
379        assert!(pce.sobol_first_order[0] > 0.1);
380        assert!(pce.sobol_first_order[1] > 0.1);
381    }
382
383    #[test]
384    fn pce_detects_dominant_variable() {
385        // y = 5x0 + tiny noise in x1 → Sobol should rank x0 first
386        let bounds = [(0.0, 1.0), (0.0, 1.0)];
387        let xs: Vec<Vec<f64>> = (0..40)
388            .map(|i| vec![f64::from(i % 8) / 7.0, f64::from(i / 8) / 4.0])
389            .collect();
390        let ys: Vec<f64> = xs.iter().map(|x| 5.0 * x[0] + 0.1 * x[1]).collect();
391        let pce = Pce::fit(&xs, &ys, &bounds, 2);
392        assert!(
393            pce.sobol_first_order[0] > pce.sobol_first_order[1],
394            "S0 = {}, S1 = {}",
395            pce.sobol_first_order[0],
396            pce.sobol_first_order[1]
397        );
398    }
399
400    #[test]
401    fn multi_indices_total_degree() {
402        let idx = multi_indices(2, 2);
403        // 1 + 2 + 3 = 6 terms for dim=2, degree ≤ 2
404        assert_eq!(idx.len(), 6);
405        assert!(idx.iter().all(|m| m.iter().sum::<usize>() <= 2));
406    }
407
408    #[test]
409    fn superforecaster_finds_optimum_and_sensitivities() {
410        let bounds = [(0.0, 10.0)];
411        let result = superforecaster(
412            &bounds,
413            |x: &[f64]| -(x[0] - 3.0).powi(2) + 5.0,
414            8,
415            10,
416            3,
417            42,
418        );
419        assert!(
420            (result.best_params[0] - 3.0).abs() < 0.5,
421            "best x = {}",
422            result.best_params[0]
423        );
424        assert!((result.best_fitness - 5.0).abs() < 0.5);
425        assert!(
426            result.sobol_first_order[0] > 0.9,
427            "S = {}",
428            result.sobol_first_order[0]
429        );
430        assert!(result.pce_r_squared > 0.9, "R² = {}", result.pce_r_squared);
431    }
432}