optirs_core/sensitivity_analysis/
sobol.rs1use 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
27const DEFAULT_SAMPLES: usize = 1024;
29
30#[derive(Debug)]
37pub struct SobolAnalyzer<F: Float + ScalarOperand + Debug> {
38 n_samples: usize,
40 compute_total_order: bool,
42 compute_second_order: bool,
44 rng: Random<scirs2_core::random::rngs::StdRng>,
46 seed: u64,
48 last_indices: Option<SensitivityIndices<F>>,
50}
51
52impl<F: Float + ScalarOperand + Debug> SobolAnalyzer<F> {
53 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 pub fn with_samples(mut self, n: usize) -> Self {
70 self.n_samples = n.max(2);
71 self
72 }
73
74 pub fn with_seed(mut self, seed: u64) -> Self {
76 self.rng = Random::seed(seed);
77 self.seed = seed;
78 self
79 }
80
81 pub fn with_total_order(mut self, flag: bool) -> Self {
83 self.compute_total_order = flag;
84 self
85 }
86
87 pub fn with_second_order(mut self, flag: bool) -> Self {
89 self.compute_second_order = flag;
90 self
91 }
92
93 pub fn n_samples(&self) -> usize {
95 self.n_samples
96 }
97
98 pub fn last_indices(&self) -> Option<&SensitivityIndices<F>> {
100 self.last_indices.as_ref()
101 }
102
103 pub fn seed(&self) -> u64 {
105 self.seed
106 }
107
108 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 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 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 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 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 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 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 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 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 let mut s_ij = vec![vec![F::zero(); k]; k];
280 let n_f = F::from(n).unwrap_or_else(F::one);
281 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 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 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 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 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 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 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 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}