Skip to main content

optirs_core/sensitivity_analysis/
oat.rs

1// One-At-A-Time (OAT) local sensitivity analysis.
2//
3// Computes both central- and forward-difference gradients of a model around a
4// user-supplied baseline. OAT is the cheapest analysis technique offered in
5// this module and is best suited to interrogating a known good operating
6// point (for example, the optimum returned by a Sobol/Morris-guided search).
7//
8// References
9// ----------
10// - Saltelli, A. et al. (2008). *Global Sensitivity Analysis: The Primer.*
11//   Chapter on local methods, sections 1.2-1.3.
12
13use crate::error::{OptimError, Result};
14use crate::sensitivity_analysis::{SensitivityAnalyzer, SensitivityIndices};
15use scirs2_core::ndarray::Array1;
16use scirs2_core::numeric::Float;
17use std::fmt::Debug;
18
19/// Outcome of a One-At-a-Time local sensitivity analysis.
20#[derive(Debug, Clone)]
21pub struct OatResult<F: Float> {
22    /// Central-difference gradient `[ (f(x+ε e_i) - f(x-ε e_i)) / (2ε) ]_i`.
23    pub central_gradient: Vec<F>,
24    /// Forward-difference gradient `[ (f(x+ε e_i) - f(x)) / ε ]_i`.
25    pub forward_gradient: Vec<F>,
26    /// Value of the model at the baseline point.
27    pub baseline_value: F,
28    /// Actual (absolute) perturbation size used per parameter.
29    pub perturbation_size: Vec<F>,
30    /// Parameter labels.
31    pub parameter_names: Vec<String>,
32}
33
34impl<F: Float> OatResult<F> {
35    /// Number of parameters.
36    pub fn num_parameters(&self) -> usize {
37        self.central_gradient.len()
38    }
39}
40
41/// One-At-a-Time local sensitivity analyzer.
42#[derive(Debug, Clone)]
43pub struct OatAnalyzer<F: Float + Debug> {
44    /// Perturbation size expressed as a fraction of the bound width
45    /// (default: 1 %).
46    perturbation_fraction: F,
47    /// Optional baseline. If `None`, the midpoint of `bounds` is used.
48    baseline: Option<Array1<F>>,
49    /// Cached result from the last successful analysis.
50    last_result: Option<OatResult<F>>,
51}
52
53impl<F: Float + Debug> OatAnalyzer<F> {
54    /// Construct a new analyzer with the default 1 % perturbation.
55    pub fn new() -> Self {
56        let default_eps = F::from(0.01_f64).unwrap_or_else(F::one);
57        Self {
58            perturbation_fraction: default_eps,
59            baseline: None,
60            last_result: None,
61        }
62    }
63
64    /// Override the relative perturbation size. Values are clamped to a
65    /// strictly positive range smaller than 0.5 so that `x ± ε` stays inside
66    /// the bounds when the baseline lies in the interior.
67    pub fn with_perturbation(mut self, eps: F) -> Self {
68        let half = F::from(0.5_f64).unwrap_or_else(F::one);
69        let tiny = F::from(1e-12_f64).unwrap_or_else(F::epsilon);
70        let mut clamped = eps;
71        if clamped <= F::zero() {
72            clamped = tiny;
73        }
74        if clamped >= half {
75            clamped = half - tiny;
76        }
77        self.perturbation_fraction = clamped;
78        self
79    }
80
81    /// Override the baseline point. If unset the midpoint of `bounds` is
82    /// used.
83    pub fn with_baseline(mut self, baseline: Array1<F>) -> Self {
84        self.baseline = Some(baseline);
85        self
86    }
87
88    /// Currently configured relative perturbation size.
89    pub fn perturbation_fraction(&self) -> F {
90        self.perturbation_fraction
91    }
92
93    /// Cached result, if any.
94    pub fn last_result(&self) -> Option<&OatResult<F>> {
95        self.last_result.as_ref()
96    }
97
98    /// Run the analysis around the configured baseline (or the midpoint).
99    pub fn analyze_oat(
100        &mut self,
101        model: &dyn Fn(&Array1<F>) -> F,
102        bounds: &[(F, F)],
103    ) -> Result<OatResult<F>> {
104        let k = bounds.len();
105        if k == 0 {
106            return Err(OptimError::InvalidConfig(
107                "OAT analysis requires at least one parameter".into(),
108            ));
109        }
110        for (idx, (low, high)) in bounds.iter().enumerate() {
111            if *low >= *high {
112                return Err(OptimError::InvalidConfig(format!(
113                    "bounds[{idx}] must satisfy low < high"
114                )));
115            }
116        }
117
118        // Determine baseline: explicit override or midpoint.
119        let baseline = match &self.baseline {
120            Some(b) => {
121                if b.len() != k {
122                    return Err(OptimError::InvalidConfig(format!(
123                        "baseline has length {} but bounds have length {}",
124                        b.len(),
125                        k
126                    )));
127                }
128                b.clone()
129            }
130            None => {
131                let mut mid = Array1::<F>::zeros(k);
132                let two = F::from(2.0_f64).unwrap_or_else(F::one);
133                for (j, slot) in mid.iter_mut().enumerate() {
134                    *slot = (bounds[j].0 + bounds[j].1) / two;
135                }
136                mid
137            }
138        };
139
140        // Per-parameter absolute perturbation, derived from the bound width.
141        let mut perturbation_size = Vec::with_capacity(k);
142        for &(low, high) in bounds.iter() {
143            let width = high - low;
144            perturbation_size.push(width * self.perturbation_fraction);
145        }
146
147        let baseline_value = model(&baseline);
148
149        let mut central_gradient = Vec::with_capacity(k);
150        let mut forward_gradient = Vec::with_capacity(k);
151
152        for j in 0..k {
153            let eps = perturbation_size[j];
154            if eps <= F::zero() {
155                central_gradient.push(F::zero());
156                forward_gradient.push(F::zero());
157                continue;
158            }
159            let mut x_plus = baseline.clone();
160            let mut x_minus = baseline.clone();
161            x_plus[j] = x_plus[j] + eps;
162            x_minus[j] = x_minus[j] - eps;
163
164            // Project back into bounds so that the user cannot inadvertently
165            // sample outside the rectangular domain when the baseline lies on
166            // a boundary.
167            x_plus[j] = if x_plus[j] > bounds[j].1 {
168                bounds[j].1
169            } else {
170                x_plus[j]
171            };
172            x_minus[j] = if x_minus[j] < bounds[j].0 {
173                bounds[j].0
174            } else {
175                x_minus[j]
176            };
177
178            let f_plus = model(&x_plus);
179            let f_minus = model(&x_minus);
180
181            // Central difference uses the *actual* span after clamping.
182            let span = x_plus[j] - x_minus[j];
183            let central = if span > F::zero() {
184                (f_plus - f_minus) / span
185            } else {
186                F::zero()
187            };
188            central_gradient.push(central);
189
190            let forward_span = x_plus[j] - baseline[j];
191            let forward = if forward_span > F::zero() {
192                (f_plus - baseline_value) / forward_span
193            } else {
194                F::zero()
195            };
196            forward_gradient.push(forward);
197        }
198
199        let parameter_names = (0..k).map(|i| format!("x{i}")).collect::<Vec<_>>();
200        let result = OatResult {
201            central_gradient,
202            forward_gradient,
203            baseline_value,
204            perturbation_size,
205            parameter_names,
206        };
207        self.last_result = Some(result.clone());
208        Ok(result)
209    }
210}
211
212impl<F: Float + Debug> Default for OatAnalyzer<F> {
213    fn default() -> Self {
214        Self::new()
215    }
216}
217
218impl<F: Float + Debug> SensitivityAnalyzer<F> for OatAnalyzer<F> {
219    fn analyze(
220        &mut self,
221        model: &dyn Fn(&Array1<F>) -> F,
222        bounds: &[(F, F)],
223    ) -> Result<SensitivityIndices<F>> {
224        let result = self.analyze_oat(model, bounds)?;
225        // Surface |central gradient| as the "first-order" importance proxy and
226        // the forward-difference magnitude as the "total" component so that
227        // OAT plugs into the common analyzer interface.
228        let first_order: Vec<F> = result.central_gradient.iter().map(|g| g.abs()).collect();
229        let total_order: Vec<F> = result.forward_gradient.iter().map(|g| g.abs()).collect();
230        Ok(SensitivityIndices {
231            first_order,
232            total_order,
233            second_order: None,
234            parameter_names: result.parameter_names.clone(),
235        })
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    #[test]
244    fn test_linear_function_gradient() {
245        let mut oa = OatAnalyzer::<f64>::new();
246        // f(x) = 2 x1 + 3 x2.
247        let model: &dyn Fn(&Array1<f64>) -> f64 = &|x: &Array1<f64>| 2.0 * x[0] + 3.0 * x[1];
248        let bounds = vec![(0.0, 1.0), (0.0, 1.0)];
249        let res = oa.analyze_oat(model, &bounds).expect("analyze failed");
250        // Linear functions: the central difference recovers the slope
251        // *exactly* in floating point (subject to round-off).
252        assert!((res.central_gradient[0] - 2.0).abs() < 1e-9);
253        assert!((res.central_gradient[1] - 3.0).abs() < 1e-9);
254    }
255
256    #[test]
257    fn test_quadratic_function_gradient() {
258        let baseline = Array1::from(vec![1.0, 0.0]);
259        let mut oa = OatAnalyzer::<f64>::new()
260            .with_perturbation(0.001)
261            .with_baseline(baseline);
262        // f(x) = x1^2.
263        let model: &dyn Fn(&Array1<f64>) -> f64 = &|x: &Array1<f64>| x[0] * x[0];
264        let bounds = vec![(0.0, 2.0), (-1.0, 1.0)];
265        let res = oa.analyze_oat(model, &bounds).expect("analyze failed");
266        assert!(
267            (res.central_gradient[0] - 2.0).abs() < 1e-4,
268            "∂f/∂x₁ ≈ 2, got {}",
269            res.central_gradient[0]
270        );
271        assert!(
272            res.central_gradient[1].abs() < 1e-9,
273            "∂f/∂x₂ should be 0, got {}",
274            res.central_gradient[1]
275        );
276    }
277
278    #[test]
279    fn test_forward_difference_matches_for_linear() {
280        let mut oa = OatAnalyzer::<f64>::new();
281        let model: &dyn Fn(&Array1<f64>) -> f64 = &|x: &Array1<f64>| 1.5 * x[0] - 4.0 * x[1];
282        let bounds = vec![(-1.0, 1.0), (-1.0, 1.0)];
283        let res = oa.analyze_oat(model, &bounds).expect("analyze failed");
284        for (c, f) in res.central_gradient.iter().zip(res.forward_gradient.iter()) {
285            assert!(
286                (c - f).abs() < 1e-9,
287                "central {c} disagrees with forward {f}"
288            );
289        }
290    }
291
292    #[test]
293    fn test_central_vs_forward_consistency_for_smooth() {
294        let mut oa = OatAnalyzer::<f64>::new().with_perturbation(0.0005);
295        // Smooth f(x) = sin(x1) + x2^2.
296        let model: &dyn Fn(&Array1<f64>) -> f64 = &|x: &Array1<f64>| x[0].sin() + x[1] * x[1];
297        let bounds = vec![(0.0, 1.0), (0.0, 1.0)];
298        let res = oa.analyze_oat(model, &bounds).expect("analyze failed");
299        for (c, f) in res.central_gradient.iter().zip(res.forward_gradient.iter()) {
300            assert!(
301                (c - f).abs() < 5e-3,
302                "central {c} vs forward {f} differ by too much"
303            );
304        }
305    }
306
307    #[test]
308    fn test_perturbation_size_changes_result() {
309        let model: &dyn Fn(&Array1<f64>) -> f64 = &|x: &Array1<f64>| x[0].powi(3);
310        let bounds = vec![(0.0, 1.0)];
311        let mut a = OatAnalyzer::<f64>::new().with_perturbation(0.001);
312        let mut b = OatAnalyzer::<f64>::new().with_perturbation(0.2);
313        let res_a = a.analyze_oat(model, &bounds).expect("analyze failed");
314        let res_b = b.analyze_oat(model, &bounds).expect("analyze failed");
315        // Larger ε must change the cached perturbation size and, for a
316        // non-linear function, the forward-difference estimate.
317        assert!(
318            (res_a.perturbation_size[0] - res_b.perturbation_size[0]).abs() > 1e-6,
319            "perturbation sizes did not change"
320        );
321        assert!(
322            (res_a.forward_gradient[0] - res_b.forward_gradient[0]).abs() > 1e-3,
323            "forward gradient should differ for cubic with different ε"
324        );
325    }
326
327    #[test]
328    fn test_baseline_value_recorded() {
329        let baseline = Array1::from(vec![0.5, 0.25]);
330        let mut oa = OatAnalyzer::<f64>::new().with_baseline(baseline);
331        let model: &dyn Fn(&Array1<f64>) -> f64 = &|x: &Array1<f64>| 3.0 * x[0] + x[1] * x[1];
332        let bounds = vec![(0.0, 1.0), (0.0, 1.0)];
333        let res = oa.analyze_oat(model, &bounds).expect("analyze failed");
334        // 3 * 0.5 + 0.25^2 = 1.5625.
335        assert!((res.baseline_value - 1.5625).abs() < 1e-9);
336    }
337
338    #[test]
339    fn test_invalid_bounds_error() {
340        let mut oa = OatAnalyzer::<f64>::new();
341        let model: &dyn Fn(&Array1<f64>) -> f64 = &|x| x[0];
342        let err = oa.analyze_oat(model, &[]);
343        assert!(matches!(err, Err(OptimError::InvalidConfig(_))));
344        let err2 = oa.analyze_oat(model, &[(1.0, 1.0)]);
345        assert!(matches!(err2, Err(OptimError::InvalidConfig(_))));
346    }
347}