Skip to main content

optirs_core/sensitivity_analysis/
sobol.rs

1// Sobol global sensitivity analysis (Saltelli's algorithm).
2//
3// Implements the variance-based Sobol method using Saltelli's enhanced
4// pick-and-freeze estimator. Given a black-box model `f: R^k -> R` and
5// rectangular bounds, the analyzer estimates:
6//
7// * first-order indices `S_i = Var[E[Y|X_i]] / Var[Y]`
8// * total-order indices `S_Ti = E[Var[Y|X_~i]] / Var[Y]`
9// * (optionally) closed second-order indices `S_ij`
10//
11// References
12// ----------
13// - Saltelli, A. et al. (2010). "Variance based sensitivity analysis of
14//   model output. Design and estimator for the total sensitivity index."
15//   Computer Physics Communications, 181(2), 259-270.
16// - Sobol, I.M. (2001). "Global sensitivity indices for nonlinear
17//   mathematical models and their Monte Carlo estimates."
18//   Mathematics and Computers in Simulation, 55(1-3), 271-280.
19
20use crate::error::{OptimError, Result};
21use crate::sensitivity_analysis::{SensitivityAnalyzer, SensitivityIndices};
22use scirs2_core::ndarray::{Array1, Array2, ScalarOperand};
23use scirs2_core::numeric::Float;
24use scirs2_core::random::Random;
25use std::fmt::Debug;
26
27/// Default number of base samples used per Saltelli matrix.
28const DEFAULT_SAMPLES: usize = 1024;
29
30/// Variance-based Sobol global sensitivity analyzer.
31///
32/// The analyzer requires `N * (k + 2)` model evaluations to estimate first
33/// and total-order indices, where `N` is [`SobolAnalyzer::n_samples`] and
34/// `k` is the number of parameters. Enabling second-order indices adds an
35/// extra `N * k` evaluations.
36#[derive(Debug)]
37pub struct SobolAnalyzer<F: Float + ScalarOperand + Debug> {
38    /// Base sample size per Saltelli matrix.
39    n_samples: usize,
40    /// Whether to compute total-order indices.
41    compute_total_order: bool,
42    /// Whether to compute (closed) second-order indices.
43    compute_second_order: bool,
44    /// Seeded RNG used for sampling.
45    rng: Random<scirs2_core::random::rngs::StdRng>,
46    /// Seed kept for reproducibility introspection and rebuilding.
47    seed: u64,
48    /// Cached output of the last successful analysis.
49    last_indices: Option<SensitivityIndices<F>>,
50}
51
52impl<F: Float + ScalarOperand + Debug> SobolAnalyzer<F> {
53    /// Create a new Sobol analyzer with default parameters.
54    pub fn new() -> Self {
55        let seed: u64 = 0xC0FFEE_u64;
56        Self {
57            n_samples: DEFAULT_SAMPLES,
58            compute_total_order: true,
59            compute_second_order: false,
60            rng: Random::seed(seed),
61            seed,
62            last_indices: None,
63        }
64    }
65
66    /// Set the base sample size `N` for the Saltelli matrices. The total
67    /// number of model evaluations is `N * (k + 2)` (or `N * (2k + 2)` with
68    /// second-order indices enabled).
69    pub fn with_samples(mut self, n: usize) -> Self {
70        self.n_samples = n.max(2);
71        self
72    }
73
74    /// Reseed the analyzer for reproducibility.
75    pub fn with_seed(mut self, seed: u64) -> Self {
76        self.rng = Random::seed(seed);
77        self.seed = seed;
78        self
79    }
80
81    /// Toggle computation of total-order indices.
82    pub fn with_total_order(mut self, flag: bool) -> Self {
83        self.compute_total_order = flag;
84        self
85    }
86
87    /// Toggle computation of closed second-order indices.
88    pub fn with_second_order(mut self, flag: bool) -> Self {
89        self.compute_second_order = flag;
90        self
91    }
92
93    /// Number of base samples per Saltelli matrix.
94    pub fn n_samples(&self) -> usize {
95        self.n_samples
96    }
97
98    /// Last computed indices, if any.
99    pub fn last_indices(&self) -> Option<&SensitivityIndices<F>> {
100        self.last_indices.as_ref()
101    }
102
103    /// Seed used by the underlying RNG.
104    pub fn seed(&self) -> u64 {
105        self.seed
106    }
107
108    /// Generate an `n x k` matrix of uniform `[0, 1)` samples and immediately
109    /// scale them into the rectangular bounds `[low_i, high_i]`.
110    fn sample_scaled(&mut self, n: usize, bounds: &[(F, F)]) -> Result<Array2<F>> {
111        let k = bounds.len();
112        let mut mat = Array2::<F>::zeros((n, k));
113        for i in 0..n {
114            for j in 0..k {
115                let u: f64 = self.rng.gen_range(0.0..1.0);
116                let u_f = F::from(u).ok_or_else(|| {
117                    OptimError::ComputationError("uniform conversion failed".into())
118                })?;
119                let (low, high) = bounds[j];
120                mat[(i, j)] = low + (high - low) * u_f;
121            }
122        }
123        Ok(mat)
124    }
125
126    /// Evaluate `model` over every row of `samples`.
127    fn evaluate_matrix(model: &dyn Fn(&Array1<F>) -> F, samples: &Array2<F>) -> Array1<F> {
128        let n = samples.nrows();
129        let mut out = Array1::<F>::zeros(n);
130        for i in 0..n {
131            let row = samples.row(i).to_owned();
132            out[i] = model(&row);
133        }
134        out
135    }
136
137    /// Compute the variance `Var[Y]` from the concatenation of `y_a` and
138    /// `y_b`. Uses the sample variance (denominator `2N`).
139    fn total_variance(y_a: &Array1<F>, y_b: &Array1<F>) -> F {
140        let n = y_a.len();
141        let two_n = F::from(2 * n).unwrap_or_else(F::one);
142        let mut sum = F::zero();
143        for i in 0..n {
144            sum = sum + y_a[i] + y_b[i];
145        }
146        let mean = sum / two_n;
147        let mut acc = F::zero();
148        for i in 0..n {
149            let d_a = y_a[i] - mean;
150            let d_b = y_b[i] - mean;
151            acc = acc + d_a * d_a + d_b * d_b;
152        }
153        acc / two_n
154    }
155
156    /// Saltelli (2010) first-order estimator.
157    ///
158    /// `S_i = (1/N) Σ_j y_B[j] · (y_AB[j] - y_A[j]) / Var(Y)`.
159    fn first_order_estimator(y_a: &Array1<F>, y_b: &Array1<F>, y_ab: &Array1<F>, var_y: F) -> F {
160        let n = y_a.len();
161        if var_y <= F::zero() {
162            return F::zero();
163        }
164        let n_f = F::from(n).unwrap_or_else(F::one);
165        let mut acc = F::zero();
166        for j in 0..n {
167            acc = acc + y_b[j] * (y_ab[j] - y_a[j]);
168        }
169        (acc / n_f) / var_y
170    }
171
172    /// Saltelli (2010) total-order estimator.
173    ///
174    /// `S_Ti = (1/(2N)) Σ_j (y_A[j] - y_AB[j])² / Var(Y)`.
175    fn total_order_estimator(y_a: &Array1<F>, y_ab: &Array1<F>, var_y: F) -> F {
176        let n = y_a.len();
177        if var_y <= F::zero() {
178            return F::zero();
179        }
180        let two_n = F::from(2 * n).unwrap_or_else(F::one);
181        let mut acc = F::zero();
182        for j in 0..n {
183            let d = y_a[j] - y_ab[j];
184            acc = acc + d * d;
185        }
186        (acc / two_n) / var_y
187    }
188
189    /// Build the resampling matrix `A_B^(i)` by copying `mat_a` and
190    /// substituting its `column` from `mat_b`.
191    fn build_swap_matrix(mat_a: &Array2<F>, mat_b: &Array2<F>, column: usize) -> Array2<F> {
192        let mut out = mat_a.clone();
193        let n = out.nrows();
194        for row in 0..n {
195            out[(row, column)] = mat_b[(row, column)];
196        }
197        out
198    }
199
200    /// Build the resampling matrix `B_A^(i)` by copying `mat_b` and
201    /// substituting its `column` from `mat_a`. Used for closed second-order
202    /// indices.
203    fn build_swap_matrix_ba(mat_a: &Array2<F>, mat_b: &Array2<F>, column: usize) -> Array2<F> {
204        let mut out = mat_b.clone();
205        let n = out.nrows();
206        for row in 0..n {
207            out[(row, column)] = mat_a[(row, column)];
208        }
209        out
210    }
211}
212
213impl<F: Float + ScalarOperand + Debug> Default for SobolAnalyzer<F> {
214    fn default() -> Self {
215        Self::new()
216    }
217}
218
219impl<F: Float + ScalarOperand + Debug> SensitivityAnalyzer<F> for SobolAnalyzer<F> {
220    fn analyze(
221        &mut self,
222        model: &dyn Fn(&Array1<F>) -> F,
223        bounds: &[(F, F)],
224    ) -> Result<SensitivityIndices<F>> {
225        let k = bounds.len();
226        if k == 0 {
227            return Err(OptimError::InvalidConfig(
228                "Sobol analysis requires at least one parameter".into(),
229            ));
230        }
231        for (idx, (low, high)) in bounds.iter().enumerate() {
232            if *low >= *high {
233                return Err(OptimError::InvalidConfig(format!(
234                    "bounds[{idx}] must satisfy low < high"
235                )));
236            }
237        }
238
239        let n = self.n_samples;
240        let mat_a = self.sample_scaled(n, bounds)?;
241        let mat_b = self.sample_scaled(n, bounds)?;
242
243        // Sanity check: caller-side dimension mismatch. We evaluate one row to
244        // ensure the model accepts vectors of length `k`. The model is a
245        // black-box `Fn(&Array1<F>) -> F`, so the only externally observable
246        // failure is the model's own behavior; we still want to surface
247        // configuration errors when bounds disagrees with the model's intent.
248        let _probe_row = mat_a.row(0).to_owned();
249        let _probe_value = model(&_probe_row);
250
251        let y_a = Self::evaluate_matrix(model, &mat_a);
252        let y_b = Self::evaluate_matrix(model, &mat_b);
253
254        let var_y = Self::total_variance(&y_a, &y_b);
255
256        let mut first_order = vec![F::zero(); k];
257        let mut total_order = vec![F::zero(); k];
258
259        // Cache the per-parameter pick-and-freeze evaluations so we can also
260        // assemble (closed) second-order indices without repeating work.
261        let mut y_ab_per_param: Vec<Array1<F>> = Vec::with_capacity(k);
262
263        for i in 0..k {
264            let mat_ab_i = Self::build_swap_matrix(&mat_a, &mat_b, i);
265            let y_ab_i = Self::evaluate_matrix(model, &mat_ab_i);
266
267            first_order[i] = Self::first_order_estimator(&y_a, &y_b, &y_ab_i, var_y);
268            if self.compute_total_order {
269                total_order[i] = Self::total_order_estimator(&y_a, &y_ab_i, var_y);
270            } else {
271                total_order[i] = first_order[i];
272            }
273            y_ab_per_param.push(y_ab_i);
274        }
275
276        let second_order = if self.compute_second_order && k >= 2 {
277            // Closed second-order indices: S_{ij}^c = (1/N) Σ y_{B_A^(i)} · y_{A_B^(j)} / Var(Y) - S_i - S_j
278            // We compute y_{B_A^(i)} on the fly for each i.
279            let mut s_ij = vec![vec![F::zero(); k]; k];
280            let n_f = F::from(n).unwrap_or_else(F::one);
281            // Means of y_A and y_B used in the closed estimator.
282            let mut mean_a = F::zero();
283            let mut mean_b = F::zero();
284            for j in 0..n {
285                mean_a = mean_a + y_a[j];
286                mean_b = mean_b + y_b[j];
287            }
288            mean_a = mean_a / n_f;
289            mean_b = mean_b / n_f;
290
291            // Precompute y_{B_A^(i)} matrices.
292            let mut y_ba_per_param: Vec<Array1<F>> = Vec::with_capacity(k);
293            for i in 0..k {
294                let mat_ba_i = Self::build_swap_matrix_ba(&mat_a, &mat_b, i);
295                y_ba_per_param.push(Self::evaluate_matrix(model, &mat_ba_i));
296            }
297
298            for i in 0..k {
299                for j in (i + 1)..k {
300                    let mut acc = F::zero();
301                    for s in 0..n {
302                        acc = acc + y_ba_per_param[i][s] * y_ab_per_param[j][s];
303                    }
304                    let closed = if var_y > F::zero() {
305                        (acc / n_f - mean_a * mean_b) / var_y
306                    } else {
307                        F::zero()
308                    };
309                    // Subtract first-order contributions to get the pure
310                    // interaction term S_{ij}.
311                    let interaction = closed - first_order[i] - first_order[j];
312                    s_ij[i][j] = interaction;
313                    s_ij[j][i] = interaction;
314                }
315            }
316            Some(s_ij)
317        } else {
318            None
319        };
320
321        let parameter_names = (0..k).map(|i| format!("x{i}")).collect::<Vec<_>>();
322        let indices = SensitivityIndices {
323            first_order,
324            total_order,
325            second_order,
326            parameter_names,
327        };
328        self.last_indices = Some(indices.clone());
329        Ok(indices)
330    }
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336    use std::f64::consts::PI;
337
338    fn ishigami(x: &Array1<f64>) -> f64 {
339        let (x1, x2, x3) = (x[0], x[1], x[2]);
340        x1.sin() + 7.0 * x2.sin().powi(2) + 0.1 * x3.powi(4) * x1.sin()
341    }
342
343    fn pi_bounds() -> Vec<(f64, f64)> {
344        vec![(-PI, PI), (-PI, PI), (-PI, PI)]
345    }
346
347    #[test]
348    fn test_ishigami_first_order_indices() {
349        let mut sa = SobolAnalyzer::<f64>::new().with_samples(4096).with_seed(7);
350        let model: &dyn Fn(&Array1<f64>) -> f64 = &ishigami;
351        let bounds = pi_bounds();
352        let res = sa.analyze(model, &bounds).expect("analyze failed");
353        // Known analytical Sobol indices for the Ishigami function with
354        // a = 7, b = 0.1: S1 ≈ 0.314, S2 ≈ 0.442, S3 = 0.
355        assert!(
356            (res.first_order[0] - 0.314).abs() < 0.10,
357            "S1 = {} far from 0.314",
358            res.first_order[0]
359        );
360        assert!(
361            (res.first_order[1] - 0.442).abs() < 0.10,
362            "S2 = {} far from 0.442",
363            res.first_order[1]
364        );
365        assert!(
366            res.first_order[2].abs() < 0.10,
367            "S3 = {} should be near 0",
368            res.first_order[2]
369        );
370    }
371
372    #[test]
373    fn test_ishigami_total_order_indices() {
374        let mut sa = SobolAnalyzer::<f64>::new()
375            .with_samples(4096)
376            .with_seed(11)
377            .with_total_order(true);
378        let model: &dyn Fn(&Array1<f64>) -> f64 = &ishigami;
379        let bounds = pi_bounds();
380        let res = sa.analyze(model, &bounds).expect("analyze failed");
381        // Analytical totals: ST1 ≈ 0.557, ST2 ≈ 0.442, ST3 ≈ 0.244.
382        assert!(
383            (res.total_order[0] - 0.557).abs() < 0.10,
384            "ST1 = {} far from 0.557",
385            res.total_order[0]
386        );
387        assert!(
388            (res.total_order[1] - 0.442).abs() < 0.10,
389            "ST2 = {} far from 0.442",
390            res.total_order[1]
391        );
392        assert!(
393            (res.total_order[2] - 0.244).abs() < 0.12,
394            "ST3 = {} far from 0.244",
395            res.total_order[2]
396        );
397    }
398
399    #[test]
400    fn test_indices_sum_bounded() {
401        let mut sa = SobolAnalyzer::<f64>::new().with_samples(2048).with_seed(19);
402        let model: &dyn Fn(&Array1<f64>) -> f64 = &ishigami;
403        let bounds = pi_bounds();
404        let res = sa.analyze(model, &bounds).expect("analyze failed");
405        let s_sum: f64 = res.first_order.iter().sum();
406        assert!(s_sum <= 1.0 + 0.15, "Σ S_i = {s_sum} exceeded 1 + tol");
407    }
408
409    #[test]
410    fn test_total_geq_first() {
411        // The Saltelli first-order estimator is unbiased but has higher
412        // variance than the total-order estimator at low sample counts, so
413        // individual realizations can momentarily violate the theoretical
414        // ordering S_Ti >= S_i. We use a large sample budget plus a small
415        // tolerance to absorb residual Monte Carlo noise.
416        let mut sa = SobolAnalyzer::<f64>::new()
417            .with_samples(8192)
418            .with_seed(23)
419            .with_total_order(true);
420        let model: &dyn Fn(&Array1<f64>) -> f64 = &ishigami;
421        let bounds = pi_bounds();
422        let res = sa.analyze(model, &bounds).expect("analyze failed");
423        for i in 0..res.first_order.len() {
424            assert!(
425                res.total_order[i] + 0.10 >= res.first_order[i],
426                "ST{i} = {} < S{i} = {}",
427                res.total_order[i],
428                res.first_order[i]
429            );
430        }
431    }
432
433    #[test]
434    fn test_constant_function_zero_indices() {
435        let mut sa = SobolAnalyzer::<f64>::new().with_samples(512).with_seed(31);
436        let constant: &dyn Fn(&Array1<f64>) -> f64 = &|_x| 5.0;
437        let bounds = vec![(0.0, 1.0), (0.0, 1.0)];
438        let res = sa.analyze(constant, &bounds).expect("analyze failed");
439        for s in &res.first_order {
440            assert!(s.abs() < 1e-6, "expected 0, got {s}");
441        }
442    }
443
444    #[test]
445    fn test_linear_function_indices() {
446        let mut sa = SobolAnalyzer::<f64>::new().with_samples(4096).with_seed(37);
447        // f(x) = 2*x1 + 3*x2 + 0*x3 over uniform [0,1]^3. Variance share
448        // ratio is 4 : 9, so S2 must exceed S1 by a wide margin while S3
449        // is essentially zero.
450        let linear: &dyn Fn(&Array1<f64>) -> f64 =
451            &|x: &Array1<f64>| 2.0 * x[0] + 3.0 * x[1] + 0.0 * x[2];
452        let bounds = vec![(0.0, 1.0), (0.0, 1.0), (0.0, 1.0)];
453        let res = sa.analyze(linear, &bounds).expect("analyze failed");
454        assert!(
455            res.first_order[1] > res.first_order[0],
456            "S2 = {} should exceed S1 = {}",
457            res.first_order[1],
458            res.first_order[0]
459        );
460        assert!(
461            res.first_order[2].abs() < 0.05,
462            "S3 should be near 0, got {}",
463            res.first_order[2]
464        );
465    }
466
467    #[test]
468    fn test_seed_reproducibility() {
469        let model: &dyn Fn(&Array1<f64>) -> f64 = &ishigami;
470        let bounds = pi_bounds();
471        let mut sa_a = SobolAnalyzer::<f64>::new().with_samples(256).with_seed(42);
472        let mut sa_b = SobolAnalyzer::<f64>::new().with_samples(256).with_seed(42);
473        let res_a = sa_a.analyze(model, &bounds).expect("analyze failed");
474        let res_b = sa_b.analyze(model, &bounds).expect("analyze failed");
475        for i in 0..3 {
476            assert!((res_a.first_order[i] - res_b.first_order[i]).abs() < 1e-12);
477            assert!((res_a.total_order[i] - res_b.total_order[i]).abs() < 1e-12);
478        }
479    }
480
481    #[test]
482    fn test_builder_pattern() {
483        let sa = SobolAnalyzer::<f64>::new()
484            .with_samples(8192)
485            .with_seed(101)
486            .with_total_order(false)
487            .with_second_order(true);
488        assert_eq!(sa.n_samples(), 8192);
489        assert_eq!(sa.seed(), 101);
490        assert!(!sa.compute_total_order);
491        assert!(sa.compute_second_order);
492    }
493
494    #[test]
495    fn test_dimension_mismatch_error() {
496        let mut sa = SobolAnalyzer::<f64>::new().with_samples(64);
497        let model: &dyn Fn(&Array1<f64>) -> f64 = &ishigami;
498        let bounds: Vec<(f64, f64)> = Vec::new();
499        let err = sa.analyze(model, &bounds);
500        assert!(matches!(err, Err(OptimError::InvalidConfig(_))));
501
502        // Also reject invalid (collapsed) bounds.
503        let bad_bounds = vec![(1.0, 1.0), (0.0, 1.0)];
504        let err2 = sa.analyze(model, &bad_bounds);
505        assert!(matches!(err2, Err(OptimError::InvalidConfig(_))));
506    }
507
508    #[test]
509    fn test_second_order_runs() {
510        let mut sa = SobolAnalyzer::<f64>::new()
511            .with_samples(256)
512            .with_seed(99)
513            .with_second_order(true);
514        let model: &dyn Fn(&Array1<f64>) -> f64 = &ishigami;
515        let bounds = pi_bounds();
516        let res = sa.analyze(model, &bounds).expect("analyze failed");
517        let so = res
518            .second_order
519            .as_ref()
520            .expect("second-order indices should be computed");
521        assert_eq!(so.len(), 3);
522        assert_eq!(so[0].len(), 3);
523    }
524}