optirs_core/sensitivity_analysis/
morris.rs1use crate::error::{OptimError, Result};
17use crate::sensitivity_analysis::{SensitivityAnalyzer, SensitivityIndices};
18use scirs2_core::ndarray::Array1;
19use scirs2_core::numeric::Float;
20use scirs2_core::random::Random;
21use std::fmt::Debug;
22
23const DEFAULT_TRAJECTORIES: usize = 10;
25const DEFAULT_LEVELS: usize = 4;
27
28#[derive(Debug, Clone)]
32pub struct MorrisIndices<F: Float> {
33 pub mu: Vec<F>,
35 pub mu_star: Vec<F>,
37 pub sigma: Vec<F>,
39 pub parameter_names: Vec<String>,
41}
42
43impl<F: Float> MorrisIndices<F> {
44 pub fn num_parameters(&self) -> usize {
46 self.mu_star.len()
47 }
48}
49
50#[derive(Debug)]
52pub struct MorrisAnalyzer<F: Float + Debug> {
53 n_trajectories: usize,
55 n_levels: usize,
57 rng: Random<scirs2_core::random::rngs::StdRng>,
59 seed: u64,
61 last_indices: Option<MorrisIndices<F>>,
63}
64
65impl<F: Float + Debug> MorrisAnalyzer<F> {
66 pub fn new() -> Self {
68 let seed: u64 = 0xCAFEBABE_u64;
69 Self {
70 n_trajectories: DEFAULT_TRAJECTORIES,
71 n_levels: DEFAULT_LEVELS,
72 rng: Random::seed(seed),
73 seed,
74 last_indices: None,
75 }
76 }
77
78 pub fn with_trajectories(mut self, r: usize) -> Self {
80 self.n_trajectories = r.max(1);
81 self
82 }
83
84 pub fn with_levels(mut self, p: usize) -> Self {
86 let mut levels = p.max(2);
87 if !levels.is_multiple_of(2) {
88 levels += 1;
89 }
90 self.n_levels = levels;
91 self
92 }
93
94 pub fn with_seed(mut self, seed: u64) -> Self {
96 self.rng = Random::seed(seed);
97 self.seed = seed;
98 self
99 }
100
101 pub fn n_trajectories(&self) -> usize {
103 self.n_trajectories
104 }
105
106 pub fn n_levels(&self) -> usize {
108 self.n_levels
109 }
110
111 pub fn last_indices(&self) -> Option<&MorrisIndices<F>> {
113 self.last_indices.as_ref()
114 }
115
116 pub fn seed(&self) -> u64 {
118 self.seed
119 }
120
121 pub fn analyze_morris(
123 &mut self,
124 model: &dyn Fn(&Array1<F>) -> F,
125 bounds: &[(F, F)],
126 ) -> Result<MorrisIndices<F>> {
127 let k = bounds.len();
128 if k == 0 {
129 return Err(OptimError::InvalidConfig(
130 "Morris analysis requires at least one parameter".into(),
131 ));
132 }
133 for (idx, (low, high)) in bounds.iter().enumerate() {
134 if *low >= *high {
135 return Err(OptimError::InvalidConfig(format!(
136 "bounds[{idx}] must satisfy low < high"
137 )));
138 }
139 }
140
141 let p_f = F::from(self.n_levels).ok_or_else(|| {
143 OptimError::ComputationError("failed to convert n_levels to F".into())
144 })?;
145 let denom = F::from(2 * (self.n_levels - 1)).ok_or_else(|| {
146 OptimError::ComputationError("failed to convert level denominator".into())
147 })?;
148 let delta = p_f / denom;
149
150 let mut effects: Vec<Vec<F>> = vec![Vec::with_capacity(self.n_trajectories); k];
152
153 for _traj in 0..self.n_trajectories {
154 let mut x = Array1::<F>::zeros(k);
156 for j in 0..k {
157 let u: f64 = self.rng.gen_range(0.0..1.0);
158 let u_f = F::from(u).ok_or_else(|| {
159 OptimError::ComputationError("uniform conversion failed".into())
160 })?;
161 let one_minus_delta = F::one() - delta;
163 x[j] = u_f * one_minus_delta;
164 }
165
166 let mut order: Vec<usize> = (0..k).collect();
168 for idx in (1..k).rev() {
169 let swap_to: usize = self.rng.gen_range(0..(idx + 1));
170 order.swap(idx, swap_to);
171 }
172
173 let mut direction = vec![F::one(); k];
175 for dir in direction.iter_mut().take(k) {
176 let s: f64 = self.rng.gen_range(0.0..1.0);
177 *dir = if s < 0.5 { -F::one() } else { F::one() };
178 }
179
180 let f_current = model(&Self::scale_to_bounds(&x, bounds));
182 let mut f_prev = f_current;
183 for ¶m_idx in &order {
184 let mut x_new = x.clone();
186 let mut step = direction[param_idx] * delta;
187 let candidate = x_new[param_idx] + step;
188 if candidate > F::one() || candidate < F::zero() {
189 step = -step;
191 direction[param_idx] = -direction[param_idx];
192 }
193 x_new[param_idx] = x_new[param_idx] + step;
194 let f_next = model(&Self::scale_to_bounds(&x_new, bounds));
195
196 let ee = (f_next - f_prev) / step;
201 effects[param_idx].push(ee);
202
203 f_prev = f_next;
204 x = x_new;
205 }
206 }
207
208 let mut mu = vec![F::zero(); k];
210 let mut mu_star = vec![F::zero(); k];
211 let mut sigma = vec![F::zero(); k];
212 for j in 0..k {
213 let ees = &effects[j];
214 if ees.is_empty() {
215 continue;
216 }
217 let n_f = F::from(ees.len()).unwrap_or_else(F::one);
218 let mut sum = F::zero();
219 let mut abs_sum = F::zero();
220 for &v in ees {
221 sum = sum + v;
222 abs_sum = abs_sum + v.abs();
223 }
224 mu[j] = sum / n_f;
225 mu_star[j] = abs_sum / n_f;
226 if ees.len() > 1 {
228 let denom = F::from(ees.len() - 1).unwrap_or_else(F::one);
229 let mean = mu[j];
230 let mut acc = F::zero();
231 for &v in ees {
232 let d = v - mean;
233 acc = acc + d * d;
234 }
235 sigma[j] = (acc / denom).sqrt();
236 } else {
237 sigma[j] = F::zero();
238 }
239 }
240
241 let parameter_names = (0..k).map(|i| format!("x{i}")).collect::<Vec<_>>();
242 let indices = MorrisIndices {
243 mu,
244 mu_star,
245 sigma,
246 parameter_names,
247 };
248 self.last_indices = Some(indices.clone());
249 Ok(indices)
250 }
251
252 fn scale_to_bounds(point: &Array1<F>, bounds: &[(F, F)]) -> Array1<F> {
255 let k = bounds.len();
256 let mut out = Array1::<F>::zeros(k);
257 for j in 0..k {
258 let (low, high) = bounds[j];
259 out[j] = low + (high - low) * point[j];
260 }
261 out
262 }
263}
264
265impl<F: Float + Debug> Default for MorrisAnalyzer<F> {
266 fn default() -> Self {
267 Self::new()
268 }
269}
270
271impl<F: Float + Debug> SensitivityAnalyzer<F> for MorrisAnalyzer<F> {
272 fn analyze(
273 &mut self,
274 model: &dyn Fn(&Array1<F>) -> F,
275 bounds: &[(F, F)],
276 ) -> Result<SensitivityIndices<F>> {
277 let morris = self.analyze_morris(model, bounds)?;
278 Ok(SensitivityIndices {
283 first_order: morris.mu_star.clone(),
284 total_order: morris.sigma.clone(),
285 second_order: None,
286 parameter_names: morris.parameter_names.clone(),
287 })
288 }
289}
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294
295 #[test]
296 fn test_linear_function_mu_star() {
297 let mut ma = MorrisAnalyzer::<f64>::new()
298 .with_trajectories(40)
299 .with_levels(4)
300 .with_seed(1);
301 let model: &dyn Fn(&Array1<f64>) -> f64 =
303 &|x: &Array1<f64>| 2.0 * x[0] + 3.0 * x[1] + 0.0 * x[2];
304 let bounds = vec![(0.0, 1.0), (0.0, 1.0), (0.0, 1.0)];
305 let idx = ma.analyze_morris(model, &bounds).expect("analyze failed");
306 assert!(
307 (idx.mu_star[0] - 2.0).abs() < 0.5,
308 "μ*₁ = {} far from 2",
309 idx.mu_star[0]
310 );
311 assert!(
312 (idx.mu_star[1] - 3.0).abs() < 0.5,
313 "μ*₂ = {} far from 3",
314 idx.mu_star[1]
315 );
316 assert!(
317 idx.mu_star[2].abs() < 0.5,
318 "μ*₃ = {} should be near 0",
319 idx.mu_star[2]
320 );
321 }
322
323 #[test]
324 fn test_constant_function_mu_star_zero() {
325 let mut ma = MorrisAnalyzer::<f64>::new()
326 .with_trajectories(20)
327 .with_seed(2);
328 let constant: &dyn Fn(&Array1<f64>) -> f64 = &|_x| 5.0;
329 let bounds = vec![(0.0, 1.0), (0.0, 1.0)];
330 let idx = ma
331 .analyze_morris(constant, &bounds)
332 .expect("analyze failed");
333 for &v in &idx.mu_star {
334 assert!(v.abs() < 1e-8, "μ* = {v} should be 0");
335 }
336 }
337
338 #[test]
339 fn test_sigma_zero_for_linear() {
340 let mut ma = MorrisAnalyzer::<f64>::new()
341 .with_trajectories(20)
342 .with_seed(3);
343 let model: &dyn Fn(&Array1<f64>) -> f64 = &|x: &Array1<f64>| 2.0 * x[0] + 3.0 * x[1];
344 let bounds = vec![(0.0, 1.0), (0.0, 1.0)];
345 let idx = ma.analyze_morris(model, &bounds).expect("analyze failed");
346 for &s in &idx.sigma {
347 assert!(s < 1e-8, "linear σ = {s} should be 0");
348 }
349 }
350
351 #[test]
352 fn test_sigma_nonzero_for_nonlinear() {
353 let mut ma = MorrisAnalyzer::<f64>::new()
354 .with_trajectories(40)
355 .with_seed(4);
356 let model: &dyn Fn(&Array1<f64>) -> f64 = &|x: &Array1<f64>| x[0] * x[0];
358 let bounds = vec![(0.0, 1.0)];
359 let idx = ma.analyze_morris(model, &bounds).expect("analyze failed");
360 assert!(
361 idx.sigma[0] > 1e-3,
362 "σ = {} should be strictly positive",
363 idx.sigma[0]
364 );
365 }
366
367 #[test]
368 fn test_seed_reproducibility() {
369 let model: &dyn Fn(&Array1<f64>) -> f64 = &|x: &Array1<f64>| x[0].sin() + 2.0 * x[1];
370 let bounds = vec![(0.0, 1.0), (0.0, 1.0)];
371 let mut a = MorrisAnalyzer::<f64>::new()
372 .with_trajectories(8)
373 .with_seed(123);
374 let mut b = MorrisAnalyzer::<f64>::new()
375 .with_trajectories(8)
376 .with_seed(123);
377 let res_a = a.analyze_morris(model, &bounds).expect("analyze failed");
378 let res_b = b.analyze_morris(model, &bounds).expect("analyze failed");
379 for j in 0..2 {
380 assert!((res_a.mu_star[j] - res_b.mu_star[j]).abs() < 1e-12);
381 assert!((res_a.sigma[j] - res_b.sigma[j]).abs() < 1e-12);
382 }
383 }
384
385 #[test]
386 fn test_builder_pattern() {
387 let ma = MorrisAnalyzer::<f64>::new()
388 .with_trajectories(25)
389 .with_levels(6)
390 .with_seed(456);
391 assert_eq!(ma.n_trajectories(), 25);
392 assert_eq!(ma.n_levels(), 6);
393 assert_eq!(ma.seed(), 456);
394 }
395
396 #[test]
397 fn test_trajectories_correct_count() {
398 let mut ma = MorrisAnalyzer::<f64>::new()
399 .with_trajectories(10)
400 .with_seed(789);
401 let model: &dyn Fn(&Array1<f64>) -> f64 = &|x: &Array1<f64>| x[0] + x[1] + x[2];
402 let bounds = vec![(0.0, 1.0), (0.0, 1.0), (0.0, 1.0)];
403 let idx = ma.analyze_morris(model, &bounds).expect("analyze failed");
404 assert_eq!(idx.mu_star.len(), 3);
406 assert_eq!(idx.sigma.len(), 3);
407 assert_eq!(idx.mu.len(), 3);
408 assert_eq!(idx.parameter_names.len(), 3);
409 }
410
411 #[test]
412 fn test_invalid_bounds_error() {
413 let mut ma = MorrisAnalyzer::<f64>::new();
414 let model: &dyn Fn(&Array1<f64>) -> f64 = &|x| x[0];
415 let err = ma.analyze_morris(model, &[]);
416 assert!(matches!(err, Err(OptimError::InvalidConfig(_))));
417 let err2 = ma.analyze_morris(model, &[(1.0, 1.0)]);
418 assert!(matches!(err2, Err(OptimError::InvalidConfig(_))));
419 }
420}