Skip to main content

optirs_core/sensitivity_analysis/
morris.rs

1// Morris Elementary Effects (EE) screening method.
2//
3// The Morris method is a one-step-at-a-time global screening technique that
4// identifies parameters with negligible, linear, or nonlinear/interacting
5// influence on the output. It is typically used as a cheap preliminary step
6// before a full variance-based analysis (such as Sobol).
7//
8// References
9// ----------
10// - Morris, M.D. (1991). "Factorial sampling plans for preliminary
11//   computational experiments." Technometrics, 33(2), 161-174.
12// - Campolongo, F., Cariboni, J., Saltelli, A. (2007). "An effective
13//   screening design for sensitivity analysis of large models."
14//   Environmental Modelling & Software, 22(10), 1509-1518.
15
16use 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
23/// Default trajectory count `r`.
24const DEFAULT_TRAJECTORIES: usize = 10;
25/// Default number of levels `p`.
26const DEFAULT_LEVELS: usize = 4;
27
28/// Aggregated Morris sensitivity measures.
29///
30/// All vectors have length `k` (one entry per parameter).
31#[derive(Debug, Clone)]
32pub struct MorrisIndices<F: Float> {
33    /// Mean of the **signed** elementary effects.
34    pub mu: Vec<F>,
35    /// Mean of the **absolute** elementary effects (Campolongo's μ*).
36    pub mu_star: Vec<F>,
37    /// Standard deviation of the elementary effects.
38    pub sigma: Vec<F>,
39    /// Human-readable parameter labels.
40    pub parameter_names: Vec<String>,
41}
42
43impl<F: Float> MorrisIndices<F> {
44    /// Number of parameters described by the indices.
45    pub fn num_parameters(&self) -> usize {
46        self.mu_star.len()
47    }
48}
49
50/// Morris Elementary Effects analyzer.
51#[derive(Debug)]
52pub struct MorrisAnalyzer<F: Float + Debug> {
53    /// Number of trajectories `r` (each contributes one EE per parameter).
54    n_trajectories: usize,
55    /// Number of grid levels `p`.
56    n_levels: usize,
57    /// Seeded RNG.
58    rng: Random<scirs2_core::random::rngs::StdRng>,
59    /// Stored seed.
60    seed: u64,
61    /// Cached Morris indices from the last successful call.
62    last_indices: Option<MorrisIndices<F>>,
63}
64
65impl<F: Float + Debug> MorrisAnalyzer<F> {
66    /// Construct a new analyzer with default settings.
67    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    /// Set the number of independent trajectories `r`.
79    pub fn with_trajectories(mut self, r: usize) -> Self {
80        self.n_trajectories = r.max(1);
81        self
82    }
83
84    /// Set the grid resolution `p`. Must be even and `>= 2`.
85    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    /// Reseed the analyzer for reproducibility.
95    pub fn with_seed(mut self, seed: u64) -> Self {
96        self.rng = Random::seed(seed);
97        self.seed = seed;
98        self
99    }
100
101    /// Number of trajectories used per analysis.
102    pub fn n_trajectories(&self) -> usize {
103        self.n_trajectories
104    }
105
106    /// Grid resolution `p`.
107    pub fn n_levels(&self) -> usize {
108        self.n_levels
109    }
110
111    /// Cached indices from the last analysis.
112    pub fn last_indices(&self) -> Option<&MorrisIndices<F>> {
113        self.last_indices.as_ref()
114    }
115
116    /// Sampled seed.
117    pub fn seed(&self) -> u64 {
118        self.seed
119    }
120
121    /// Run the analysis and store the result on the analyzer.
122    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        // Compute the step Δ = p / (2 (p - 1)) in unit-cube coordinates.
142        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        // Storage for per-parameter elementary effects across all trajectories.
151        let mut effects: Vec<Vec<F>> = vec![Vec::with_capacity(self.n_trajectories); k];
152
153        for _traj in 0..self.n_trajectories {
154            // 1. Random base point in [0, 1 - Δ]^k so that x + Δ stays inside [0, 1].
155            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                // Restrict to [0, 1 - Δ] so a forward step keeps us in the cube.
162                let one_minus_delta = F::one() - delta;
163                x[j] = u_f * one_minus_delta;
164            }
165
166            // 2. Random permutation of parameter order via Fisher-Yates.
167            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            // 3. Random direction (+ or -) per parameter.
174            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            // 4. Walk along the trajectory, computing one EE per dimension.
181            let f_current = model(&Self::scale_to_bounds(&x, bounds));
182            let mut f_prev = f_current;
183            for &param_idx in &order {
184                // Step ±Δ in unit-cube coords.
185                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                    // Reflect the step so we remain inside the unit cube.
190                    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                // Elementary effect in unit-cube coordinates. The conversion
197                // back to the user-scaled domain cancels out because the
198                // perturbation is proportional to (high - low) on both sides
199                // of the finite difference.
200                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        // 5. Aggregate per-parameter statistics.
209        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            // Sample standard deviation (denominator n - 1 when possible).
227            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    /// Convert a unit-cube point `[0, 1]^k` into the user-provided
253    /// rectangular domain.
254    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        // Map Morris quantities into the common SensitivityIndices envelope so
279        // callers can switch analyzers without changing downstream code:
280        //   * `first_order`  ← μ* (importance magnitude)
281        //   * `total_order`  ← σ  (interaction/non-linearity proxy)
282        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        // f(x) = 2 x1 + 3 x2 + 0 x3 on [0, 1]^3.
302        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        // f(x) = x1^2 on [0, 1].
357        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        // One mu*/sigma per parameter, not per EE.
405        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}