optirs_core/sensitivity_analysis/
oat.rs1use 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#[derive(Debug, Clone)]
21pub struct OatResult<F: Float> {
22 pub central_gradient: Vec<F>,
24 pub forward_gradient: Vec<F>,
26 pub baseline_value: F,
28 pub perturbation_size: Vec<F>,
30 pub parameter_names: Vec<String>,
32}
33
34impl<F: Float> OatResult<F> {
35 pub fn num_parameters(&self) -> usize {
37 self.central_gradient.len()
38 }
39}
40
41#[derive(Debug, Clone)]
43pub struct OatAnalyzer<F: Float + Debug> {
44 perturbation_fraction: F,
47 baseline: Option<Array1<F>>,
49 last_result: Option<OatResult<F>>,
51}
52
53impl<F: Float + Debug> OatAnalyzer<F> {
54 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 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 pub fn with_baseline(mut self, baseline: Array1<F>) -> Self {
84 self.baseline = Some(baseline);
85 self
86 }
87
88 pub fn perturbation_fraction(&self) -> F {
90 self.perturbation_fraction
91 }
92
93 pub fn last_result(&self) -> Option<&OatResult<F>> {
95 self.last_result.as_ref()
96 }
97
98 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 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 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 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 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 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 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 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 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 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 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 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}