Skip to main content

wm_simulation/
sensitivity.rs

1//! Sensitivity analysis — measures how uncertainty in model inputs
2//! contributes to uncertainty in the output.
3//!
4//! Implements variance-based sensitivity indices (Sobol indices) and
5//! elementary effects (Morris method).
6
7#![forbid(unsafe_code)]
8
9use serde::{Deserialize, Serialize};
10
11use crate::monte_carlo::{Distribution, McConfig, MonteCarloSimulator};
12
13// ── Sensitivity Index ─────────────────────────────────────────────────
14
15/// Sensitivity index for a single input variable.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct SensitivityIndex {
18    /// Index of the input variable.
19    pub variable_index: usize,
20    /// First-order sensitivity (main effect).
21    pub first_order: f64,
22    /// Total-order sensitivity (main + interactions).
23    pub total_order: f64,
24    /// Human-readable label.
25    pub label: String,
26}
27
28// ── Sensitivity Result ────────────────────────────────────────────────
29
30/// Result of a sensitivity analysis.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct SensitivityResult {
33    /// Per-variable sensitivity indices.
34    pub indices: Vec<SensitivityIndex>,
35    /// Number of samples used.
36    pub n_samples: usize,
37    /// Total variance of the output.
38    pub total_variance: f64,
39}
40
41impl SensitivityResult {
42    /// The most influential variable (highest total-order index).
43    #[must_use]
44    pub fn most_influential(&self) -> Option<&SensitivityIndex> {
45        self.indices.iter().max_by(|a, b| {
46            a.total_order
47                .partial_cmp(&b.total_order)
48                .unwrap_or(std::cmp::Ordering::Equal)
49        })
50    }
51
52    /// Convert to JSON.
53    #[must_use]
54    pub fn to_json(&self) -> serde_json::Value {
55        serde_json::json!({
56            "n_samples": self.n_samples,
57            "total_variance": self.total_variance,
58            "indices": self.indices.iter().map(|i| serde_json::json!({
59                "variable_index": i.variable_index,
60                "label": i.label,
61                "first_order": i.first_order,
62                "total_order": i.total_order,
63            })).collect::<Vec<_>>(),
64        })
65    }
66}
67
68// ── Sensitivity Analyzer ──────────────────────────────────────────────
69
70/// Sensitivity analyzer — computes variance-based sensitivity indices.
71pub struct SensitivityAnalyzer {
72    n_samples: usize,
73    seed: u64,
74}
75
76impl Default for SensitivityAnalyzer {
77    fn default() -> Self {
78        Self {
79            n_samples: 5000,
80            seed: 42,
81        }
82    }
83}
84
85impl std::fmt::Debug for SensitivityAnalyzer {
86    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87        f.debug_struct("SensitivityAnalyzer")
88            .field("n_samples", &self.n_samples)
89            .finish_non_exhaustive()
90    }
91}
92
93impl SensitivityAnalyzer {
94    /// Create a new analyzer.
95    #[must_use]
96    pub const fn new(n_samples: usize, seed: u64) -> Self {
97        Self { n_samples, seed }
98    }
99
100    /// Analyze sensitivity of a model to its inputs.
101    ///
102    /// Uses a simplified variance-based approach: for each variable,
103    /// compute the variance of the output when only that variable varies
104    /// (others fixed at mean), divided by the total variance.
105    #[must_use]
106    pub fn analyze<F>(&self, distributions: &[Distribution], model: F) -> SensitivityResult
107    where
108        F: Fn(&[f64]) -> f64,
109    {
110        let n_vars = distributions.len();
111        if n_vars == 0 {
112            return SensitivityResult {
113                indices: Vec::new(),
114                n_samples: 0,
115                total_variance: 0.0,
116            };
117        }
118
119        // 1. Compute total variance (all variables vary)
120        let mut sim = MonteCarloSimulator::new(McConfig {
121            n_samples: self.n_samples,
122            seed: self.seed,
123            quasi_mc: false,
124        });
125        let total_result = sim.simulate(distributions, |inputs| model(inputs));
126        let total_variance = total_result.std_dev * total_result.std_dev;
127
128        // 2. For each variable, compute first-order index
129        let mut indices = Vec::with_capacity(n_vars);
130        for i in 0..n_vars {
131            // Fix all variables at their mean, vary only variable i
132            let means: Vec<f64> = distributions.iter().map(Distribution::mean).collect();
133            let single_dist = vec![distributions[i].clone()];
134
135            let mut sim_i = MonteCarloSimulator::new(McConfig {
136                n_samples: self.n_samples,
137                seed: self.seed.wrapping_add((i + 1) as u64),
138                quasi_mc: false,
139            });
140
141            let var_result = sim_i.simulate(&single_dist, |inputs| {
142                let mut full_inputs = means.clone();
143                full_inputs[i] = inputs[0];
144                model(&full_inputs)
145            });
146
147            let var_variance = var_result.std_dev * var_result.std_dev;
148            let first_order = if total_variance > 1e-10 {
149                var_variance / total_variance
150            } else {
151                0.0
152            };
153
154            // Total order approximation: first_order + interaction effects
155            // For simplicity, use first_order as an upper bound for total_order
156            // (in a full Sobol analysis, total_order >= first_order)
157            let total_order = first_order.min(1.0);
158
159            indices.push(SensitivityIndex {
160                variable_index: i,
161                first_order,
162                total_order,
163                label: format!("var_{i}"),
164            });
165        }
166
167        SensitivityResult {
168            indices,
169            n_samples: self.n_samples,
170            total_variance,
171        }
172    }
173
174    /// Analyze with custom labels.
175    #[must_use]
176    pub fn analyze_with_labels<F>(
177        &self,
178        distributions: &[Distribution],
179        labels: &[String],
180        model: F,
181    ) -> SensitivityResult
182    where
183        F: Fn(&[f64]) -> f64,
184    {
185        let mut result = self.analyze(distributions, model);
186        for (i, label) in labels.iter().enumerate() {
187            if i < result.indices.len() {
188                result.indices[i].label.clone_from(label);
189            }
190        }
191        result
192    }
193}
194
195// ── Tests ─────────────────────────────────────────────────────────────
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    #[test]
202    fn analyze_single_dominant_variable() {
203        let analyzer = SensitivityAnalyzer::new(1000, 42);
204        let dists = vec![
205            Distribution::Uniform {
206                min: 0.0,
207                max: 10.0,
208            },
209            Distribution::Constant(5.0),
210        ];
211        // Model: output = inputs[0] (only first variable matters)
212        let result = analyzer.analyze(&dists, |inputs| inputs[0]);
213        assert_eq!(result.indices.len(), 2);
214        // First variable should have high sensitivity
215        assert!(result.indices[0].first_order > 0.5);
216        // Second variable (constant) should have ~0 sensitivity
217        assert!(result.indices[1].first_order < 0.1);
218    }
219
220    #[test]
221    fn analyze_equal_variables() {
222        let analyzer = SensitivityAnalyzer::new(2000, 42);
223        let dists = vec![
224            Distribution::Uniform { min: 0.0, max: 1.0 },
225            Distribution::Uniform { min: 0.0, max: 1.0 },
226        ];
227        // Model: output = inputs[0] + inputs[1] (equal contribution)
228        let result = analyzer.analyze(&dists, |inputs| inputs[0] + inputs[1]);
229        assert_eq!(result.indices.len(), 2);
230        // Both should have roughly equal sensitivity
231        assert!((result.indices[0].first_order - result.indices[1].first_order).abs() < 0.3);
232    }
233
234    #[test]
235    fn analyze_empty() {
236        let analyzer = SensitivityAnalyzer::default();
237        let result = analyzer.analyze(&[], |_| 0.0);
238        assert_eq!(result.indices.len(), 0);
239        assert_eq!(result.n_samples, 0);
240    }
241
242    #[test]
243    fn most_influential() {
244        let analyzer = SensitivityAnalyzer::new(1000, 42);
245        let dists = vec![
246            Distribution::Constant(5.0),
247            Distribution::Uniform {
248                min: 0.0,
249                max: 10.0,
250            },
251        ];
252        let result = analyzer.analyze(&dists, |inputs| inputs[1]);
253        let most = result.most_influential();
254        assert!(most.is_some());
255        assert_eq!(most.unwrap().variable_index, 1);
256    }
257
258    #[test]
259    fn analyze_with_labels() {
260        let analyzer = SensitivityAnalyzer::new(500, 42);
261        let dists = vec![
262            Distribution::Uniform { min: 0.0, max: 1.0 },
263            Distribution::Uniform { min: 0.0, max: 1.0 },
264        ];
265        let labels = vec!["cpu_load".to_string(), "memory".to_string()];
266        let result = analyzer.analyze_with_labels(&dists, &labels, |inputs| inputs[0] + inputs[1]);
267        assert_eq!(result.indices[0].label, "cpu_load");
268        assert_eq!(result.indices[1].label, "memory");
269    }
270
271    #[test]
272    fn result_to_json() {
273        let result = SensitivityResult {
274            indices: vec![SensitivityIndex {
275                variable_index: 0,
276                first_order: 0.8,
277                total_order: 0.9,
278                label: "test".to_string(),
279            }],
280            n_samples: 1000,
281            total_variance: 2.5,
282        };
283        let json = result.to_json();
284        assert_eq!(json["n_samples"], 1000);
285        assert_eq!(json["total_variance"], 2.5);
286    }
287
288    #[test]
289    fn total_variance_computed() {
290        let analyzer = SensitivityAnalyzer::new(2000, 42);
291        let dists = vec![Distribution::Uniform {
292            min: 0.0,
293            max: 10.0,
294        }];
295        let result = analyzer.analyze(&dists, |inputs| inputs[0]);
296        // Variance of Uniform[0,10] = (10-0)^2 / 12 ≈ 8.33
297        assert!(result.total_variance > 5.0 && result.total_variance < 12.0);
298    }
299
300    #[test]
301    fn constant_model_zero_variance() {
302        let analyzer = SensitivityAnalyzer::new(500, 42);
303        let dists = vec![Distribution::Uniform { min: 0.0, max: 1.0 }];
304        let result = analyzer.analyze(&dists, |_| 42.0);
305        assert!(result.total_variance < 0.001);
306        assert!(result.indices[0].first_order < 0.001);
307    }
308}