1use crate::error::{OptimizeError, OptimizeResult};
34use scirs2_core::ndarray::{Array1, Array2, ArrayView1};
35
36#[derive(Debug, Clone)]
40pub struct ScenarioResult {
41 pub scenario: Array1<f64>,
43 pub obj_value: f64,
45 pub feasible: bool,
47 pub violations: Vec<f64>,
49}
50
51#[derive(Debug, Clone)]
53pub struct WorstCaseResult {
54 pub scenarios: Vec<ScenarioResult>,
56 pub worst_index: usize,
58 pub worst_obj: f64,
60 pub best_obj: f64,
62 pub avg_obj: f64,
64 pub std_obj: f64,
66 pub feasibility_rate: f64,
68 pub n_scenarios: usize,
70}
71
72pub fn worst_case_analysis<F, G>(
88 obj_fn: &F,
89 x: &ArrayView1<f64>,
90 scenarios: &[Array1<f64>],
91 constraint_fns: &[G],
92) -> OptimizeResult<WorstCaseResult>
93where
94 F: Fn(&ArrayView1<f64>, &ArrayView1<f64>) -> f64,
95 G: Fn(&ArrayView1<f64>, &ArrayView1<f64>) -> f64,
96{
97 if scenarios.is_empty() {
98 return Err(OptimizeError::ValueError(
99 "scenarios must be non-empty".to_string(),
100 ));
101 }
102
103 let n_scen = scenarios.len();
104 let mut results = Vec::with_capacity(n_scen);
105 let mut sum_obj = 0.0_f64;
106 let mut sum_sq = 0.0_f64;
107 let mut worst_val = f64::NEG_INFINITY;
108 let mut best_val = f64::INFINITY;
109 let mut worst_idx = 0_usize;
110 let mut n_feasible = 0_usize;
111
112 for (j, scenario) in scenarios.iter().enumerate() {
113 let obj = obj_fn(x, &scenario.view());
114 let violations: Vec<f64> = constraint_fns
115 .iter()
116 .map(|g| g(x, &scenario.view()))
117 .collect();
118 let feasible = violations.iter().all(|&v| v <= 0.0);
119
120 sum_obj += obj;
121 sum_sq += obj * obj;
122 if obj > worst_val {
123 worst_val = obj;
124 worst_idx = j;
125 }
126 if obj < best_val {
127 best_val = obj;
128 }
129 if feasible {
130 n_feasible += 1;
131 }
132
133 results.push(ScenarioResult {
134 scenario: scenario.clone(),
135 obj_value: obj,
136 feasible,
137 violations,
138 });
139 }
140
141 let avg = sum_obj / n_scen as f64;
142 let variance = (sum_sq / n_scen as f64 - avg * avg).max(0.0);
143 let std_dev = variance.sqrt();
144 let feasibility_rate = n_feasible as f64 / n_scen as f64;
145
146 Ok(WorstCaseResult {
147 scenarios: results,
148 worst_index: worst_idx,
149 worst_obj: worst_val,
150 best_obj: best_val,
151 avg_obj: avg,
152 std_obj: std_dev,
153 feasibility_rate,
154 n_scenarios: n_scen,
155 })
156}
157
158#[derive(Debug, Clone)]
162pub struct AARCConfig {
163 pub recourse_dim: usize,
165 pub max_iter: usize,
167 pub tol: f64,
169 pub step_size: f64,
171 pub n_uncertainty_samples: usize,
173 pub fd_step: f64,
175}
176
177impl Default for AARCConfig {
178 fn default() -> Self {
179 Self {
180 recourse_dim: 1,
181 max_iter: 2_000,
182 tol: 1e-5,
183 step_size: 1e-3,
184 n_uncertainty_samples: 100,
185 fd_step: 1e-5,
186 }
187 }
188}
189
190#[derive(Debug, Clone)]
192pub struct AARCResult {
193 pub x: Array1<f64>,
195 pub k_matrix: Array2<f64>,
198 pub l_matrix: Array2<f64>,
199 pub m_vector: Array1<f64>,
200 pub worst_obj: f64,
202 pub n_iter: usize,
204 pub converged: bool,
206 pub message: String,
208}
209
210pub fn affinely_adjustable<F>(
245 obj_fn: &F,
246 x0: &ArrayView1<f64>,
247 xi_samples: &[Array1<f64>],
248 xi_dim: usize,
249 config: &AARCConfig,
250) -> OptimizeResult<AARCResult>
251where
252 F: Fn(&ArrayView1<f64>, &ArrayView1<f64>, &ArrayView1<f64>) -> f64,
253{
254 if xi_samples.is_empty() {
255 return Err(OptimizeError::ValueError(
256 "xi_samples must be non-empty".to_string(),
257 ));
258 }
259 let x_dim = x0.len();
260 let y_dim = config.recourse_dim;
261 if y_dim == 0 {
262 return Err(OptimizeError::ValueError(
263 "recourse_dim must be positive".to_string(),
264 ));
265 }
266
267 let mut k_matrix = Array2::<f64>::zeros((y_dim, x_dim));
269 let mut l_matrix = Array2::<f64>::zeros((y_dim, xi_dim));
270 let mut m_vector = Array1::<f64>::zeros(y_dim);
271 let mut x = x0.to_owned();
272 let h = config.fd_step;
273
274 let mut converged = false;
275
276 let eval_policy = |x_cur: &Array1<f64>,
278 k: &Array2<f64>,
279 l: &Array2<f64>,
280 m: &Array1<f64>,
281 xi: &Array1<f64>|
282 -> Array1<f64> {
283 let mut y = m.clone();
284 for i in 0..y_dim {
285 for j in 0..x_dim {
286 y[i] += k[[i, j]] * x_cur[j];
287 }
288 for j in 0..xi.len().min(xi_dim) {
289 y[i] += l[[i, j]] * xi[j];
290 }
291 }
292 y
293 };
294
295 let worst_obj_fn =
297 |x_cur: &Array1<f64>, k: &Array2<f64>, l: &Array2<f64>, m: &Array1<f64>| -> f64 {
298 xi_samples
299 .iter()
300 .map(|xi| {
301 let y = eval_policy(x_cur, k, l, m, xi);
302 obj_fn(&x_cur.view(), &y.view(), &xi.view())
303 })
304 .fold(f64::NEG_INFINITY, f64::max)
305 };
306
307 for _ in 0..config.max_iter {
308 let f_curr = worst_obj_fn(&x, &k_matrix, &l_matrix, &m_vector);
309
310 let mut grad_x = Array1::<f64>::zeros(x_dim);
312 for j in 0..x_dim {
313 let mut x_fwd = x.clone();
314 x_fwd[j] += h;
315 let f_fwd = worst_obj_fn(&x_fwd, &k_matrix, &l_matrix, &m_vector);
316 grad_x[j] = (f_fwd - f_curr) / h;
317 }
318
319 let mut grad_k = Array2::<f64>::zeros((y_dim, x_dim));
321 for i in 0..y_dim {
322 for j in 0..x_dim {
323 let mut k_fwd = k_matrix.clone();
324 k_fwd[[i, j]] += h;
325 let f_fwd = worst_obj_fn(&x, &k_fwd, &l_matrix, &m_vector);
326 grad_k[[i, j]] = (f_fwd - f_curr) / h;
327 }
328 }
329
330 let mut grad_l = Array2::<f64>::zeros((y_dim, xi_dim));
332 for i in 0..y_dim {
333 for j in 0..xi_dim {
334 let mut l_fwd = l_matrix.clone();
335 l_fwd[[i, j]] += h;
336 let f_fwd = worst_obj_fn(&x, &k_matrix, &l_fwd, &m_vector);
337 grad_l[[i, j]] = (f_fwd - f_curr) / h;
338 }
339 }
340
341 let mut grad_m = Array1::<f64>::zeros(y_dim);
343 for i in 0..y_dim {
344 let mut m_fwd = m_vector.clone();
345 m_fwd[i] += h;
346 let f_fwd = worst_obj_fn(&x, &k_matrix, &l_matrix, &m_fwd);
347 grad_m[i] = (f_fwd - f_curr) / h;
348 }
349
350 let gx_norm = l2_norm_arr1(&grad_x);
352 let gk_norm = l2_norm_arr2(&grad_k);
353 let gl_norm = l2_norm_arr2(&grad_l);
354 let gm_norm = l2_norm_arr1(&grad_m);
355 let total_norm = gx_norm + gk_norm + gl_norm + gm_norm;
356
357 if total_norm < config.tol {
358 converged = true;
359 break;
360 }
361
362 let alpha = config.step_size;
364 for j in 0..x_dim {
365 x[j] -= alpha * grad_x[j];
366 }
367 for i in 0..y_dim {
368 for j in 0..x_dim {
369 k_matrix[[i, j]] -= alpha * grad_k[[i, j]];
370 }
371 for j in 0..xi_dim {
372 l_matrix[[i, j]] -= alpha * grad_l[[i, j]];
373 }
374 m_vector[i] -= alpha * grad_m[i];
375 }
376 }
377
378 let worst_obj = worst_obj_fn(&x, &k_matrix, &l_matrix, &m_vector);
379
380 Ok(AARCResult {
381 x,
382 k_matrix,
383 l_matrix,
384 m_vector,
385 worst_obj,
386 n_iter: config.max_iter,
387 converged,
388 message: if converged {
389 "AARC converged".to_string()
390 } else {
391 "AARC reached maximum iterations".to_string()
392 },
393 })
394}
395
396#[derive(Debug, Clone)]
400pub struct ScenarioApproachConfig {
401 pub n_scenarios: usize,
403 pub confidence: f64,
405 pub max_iter: usize,
407 pub tol: f64,
409 pub step_size: f64,
411 pub fd_step: f64,
413}
414
415impl Default for ScenarioApproachConfig {
416 fn default() -> Self {
417 Self {
418 n_scenarios: 500,
419 confidence: 0.95,
420 max_iter: 2_000,
421 tol: 1e-5,
422 step_size: 1e-3,
423 fd_step: 1e-5,
424 }
425 }
426}
427
428#[derive(Debug, Clone)]
430pub struct ScenarioApproachResult {
431 pub x: Array1<f64>,
433 pub fun: f64,
435 pub n_support_scenarios: usize,
437 pub feasibility_guarantee: f64,
439 pub n_scenarios: usize,
441 pub n_iter: usize,
443 pub converged: bool,
445 pub message: String,
447}
448
449pub fn scenario_approach<F, G, H>(
486 obj_fn: &F,
487 constraint_fn: &G,
488 sample_fn: &mut H,
489 x0: &ArrayView1<f64>,
490 config: &ScenarioApproachConfig,
491) -> OptimizeResult<ScenarioApproachResult>
492where
493 F: Fn(&ArrayView1<f64>) -> f64,
494 G: Fn(&ArrayView1<f64>, &ArrayView1<f64>) -> f64,
495 H: FnMut() -> Array1<f64>,
496{
497 let n = x0.len();
498 if n == 0 {
499 return Err(OptimizeError::ValueError(
500 "x0 must be non-empty".to_string(),
501 ));
502 }
503 if !(0.0 < config.confidence && config.confidence < 1.0) {
504 return Err(OptimizeError::ValueError(format!(
505 "confidence must be in (0,1), got {}",
506 config.confidence
507 )));
508 }
509
510 let scenarios: Vec<Array1<f64>> = (0..config.n_scenarios).map(|_| sample_fn()).collect();
512
513 let h = config.fd_step;
514 let penalty = 1e3_f64; let mut x = x0.to_owned();
517 let mut converged = false;
518
519 let penalized_obj = |x_cur: &ArrayView1<f64>| -> f64 {
521 let base = obj_fn(x_cur);
522 let viol: f64 = scenarios
523 .iter()
524 .map(|xi| constraint_fn(x_cur, &xi.view()).max(0.0).powi(2))
525 .sum();
526 base + penalty * viol
527 };
528
529 for _ in 0..config.max_iter {
530 let f0 = penalized_obj(&x.view());
531
532 let mut grad = Array1::<f64>::zeros(n);
534 let mut x_fwd = x.clone();
535 for j in 0..n {
536 x_fwd[j] += h;
537 let f_fwd = penalized_obj(&x_fwd.view());
538 grad[j] = (f_fwd - f0) / h;
539 x_fwd[j] = x[j];
540 }
541
542 let gn = l2_norm_arr1(&grad);
543 if gn < config.tol {
544 converged = true;
545 break;
546 }
547
548 let mut alpha = config.step_size;
550 let mut x_trial = x.clone();
551 let armijo_c = 0.5_f64;
552 let backtrack_factor = 0.5_f64;
553 let max_backtracks = 30_usize;
554 for _ in 0..max_backtracks {
555 for j in 0..n {
556 x_trial[j] = x[j] - alpha * grad[j];
557 }
558 let f_trial = penalized_obj(&x_trial.view());
559 if f_trial <= f0 - armijo_c * alpha * gn * gn {
560 break;
561 }
562 alpha *= backtrack_factor;
563 }
564 x = x_trial;
565 }
566
567 let fun = obj_fn(&x.view());
568
569 let tol_support = 1e-3;
571 let n_support = scenarios
572 .iter()
573 .filter(|xi| constraint_fn(&x.view(), &xi.view()).abs() < tol_support)
574 .count();
575
576 let beta = 1.0 - config.confidence;
579 let epsilon_cg = if config.n_scenarios > n {
580 2.0 * (beta.recip().ln() + n as f64) / config.n_scenarios as f64
583 } else {
584 1.0 };
586 let feasibility_guarantee = (1.0 - epsilon_cg).max(0.0).min(1.0);
587
588 Ok(ScenarioApproachResult {
589 x,
590 fun,
591 n_support_scenarios: n_support,
592 feasibility_guarantee,
593 n_scenarios: config.n_scenarios,
594 n_iter: config.max_iter,
595 converged,
596 message: if converged {
597 "Scenario approach inner optimization converged".to_string()
598 } else {
599 "Scenario approach reached maximum iterations".to_string()
600 },
601 })
602}
603
604#[derive(Debug, Clone)]
608pub struct WassersteinDROConfig {
609 pub epsilon: f64,
611 pub p_order: u32,
613 pub lambda: f64,
615 pub max_iter: usize,
617 pub tol: f64,
619 pub step_size: f64,
621 pub fd_step: f64,
623}
624
625impl Default for WassersteinDROConfig {
626 fn default() -> Self {
627 Self {
628 epsilon: 0.1,
629 p_order: 1,
630 lambda: 10.0,
631 max_iter: 2_000,
632 tol: 1e-5,
633 step_size: 1e-3,
634 fd_step: 1e-5,
635 }
636 }
637}
638
639#[derive(Debug, Clone)]
641pub struct WassersteinDROResult {
642 pub x: Array1<f64>,
644 pub worst_case_loss: f64,
646 pub empirical_loss: f64,
648 pub lipschitz_estimate: f64,
650 pub n_iter: usize,
652 pub converged: bool,
654 pub message: String,
656}
657
658pub fn distributionally_robust<F>(
697 loss_fn: &F,
698 x0: &ArrayView1<f64>,
699 scenarios: &[Array1<f64>],
700 config: &WassersteinDROConfig,
701) -> OptimizeResult<WassersteinDROResult>
702where
703 F: Fn(&ArrayView1<f64>, &ArrayView1<f64>) -> f64,
704{
705 if scenarios.is_empty() {
706 return Err(OptimizeError::ValueError(
707 "scenarios must be non-empty".to_string(),
708 ));
709 }
710 let n = x0.len();
711 if n == 0 {
712 return Err(OptimizeError::ValueError(
713 "x0 must be non-empty".to_string(),
714 ));
715 }
716 if config.epsilon < 0.0 {
717 return Err(OptimizeError::ValueError(format!(
718 "epsilon must be non-negative, got {}",
719 config.epsilon
720 )));
721 }
722
723 let n_scen = scenarios.len();
724 let h = config.fd_step;
725 let mut x = x0.to_owned();
726 let mut converged = false;
727
728 let estimate_lipschitz = |x_cur: &Array1<f64>| -> f64 {
730 if n_scen < 2 {
731 return 1.0; }
733 let n_pairs = n_scen.min(20);
735 let mut max_lip = 0.0_f64;
736 for i in 0..n_pairs {
737 for j in (i + 1)..n_pairs.min(i + 5) {
738 let fi = loss_fn(&x_cur.view(), &scenarios[i].view());
739 let fj = loss_fn(&x_cur.view(), &scenarios[j].view());
740 let dist: f64 = if config.p_order == 1 {
741 scenarios[i]
742 .iter()
743 .zip(scenarios[j].iter())
744 .map(|(&a, &b)| (a - b).abs())
745 .sum()
746 } else {
747 scenarios[i]
748 .iter()
749 .zip(scenarios[j].iter())
750 .map(|(&a, &b)| (a - b).powi(2))
751 .sum::<f64>()
752 .sqrt()
753 };
754 if dist > 1e-12 {
755 max_lip = max_lip.max((fi - fj).abs() / dist);
756 }
757 }
758 }
759 max_lip.max(1e-3) };
761
762 let dro_objective = |x_cur: &Array1<f64>| -> f64 {
765 let empirical: f64 = scenarios
766 .iter()
767 .map(|xi| loss_fn(&x_cur.view(), &xi.view()))
768 .sum::<f64>()
769 / n_scen as f64;
770 let lip = estimate_lipschitz(x_cur);
771 let reg: f64 = x_cur.iter().map(|xi| xi * xi).sum::<f64>();
772 empirical + config.epsilon * lip + config.lambda * 1e-4 * reg
773 };
774
775 for _ in 0..config.max_iter {
776 let f0 = dro_objective(&x);
777
778 let mut grad = Array1::<f64>::zeros(n);
780 let mut x_fwd = x.clone();
781 for j in 0..n {
782 x_fwd[j] += h;
783 let f_fwd = dro_objective(&x_fwd);
784 grad[j] = (f_fwd - f0) / h;
785 x_fwd[j] = x[j];
786 }
787
788 let gn = l2_norm_arr1(&grad);
789 if gn < config.tol {
790 converged = true;
791 break;
792 }
793
794 for j in 0..n {
795 x[j] -= config.step_size * grad[j];
796 }
797 }
798
799 let empirical_loss: f64 = scenarios
800 .iter()
801 .map(|xi| loss_fn(&x.view(), &xi.view()))
802 .sum::<f64>()
803 / n_scen as f64;
804 let lip = estimate_lipschitz(&x);
805 let worst_case_loss = empirical_loss + config.epsilon * lip;
806
807 Ok(WassersteinDROResult {
808 x,
809 worst_case_loss,
810 empirical_loss,
811 lipschitz_estimate: lip,
812 n_iter: config.max_iter,
813 converged,
814 message: if converged {
815 "Wasserstein DRO converged".to_string()
816 } else {
817 "Wasserstein DRO reached maximum iterations".to_string()
818 },
819 })
820}
821
822fn l2_norm_arr1(v: &Array1<f64>) -> f64 {
825 v.iter().map(|vi| vi * vi).sum::<f64>().sqrt()
826}
827
828fn l2_norm_arr2(m: &Array2<f64>) -> f64 {
829 m.iter().map(|vi| vi * vi).sum::<f64>().sqrt()
830}
831
832#[cfg(test)]
835mod tests {
836 use super::*;
837 use scirs2_core::ndarray::array;
838
839 fn quadratic_loss(x: &ArrayView1<f64>, xi: &ArrayView1<f64>) -> f64 {
840 (x[0] - xi[0]).powi(2)
841 }
842
843 fn make_uniform_scenarios(n: usize) -> Vec<Array1<f64>> {
844 (0..n)
846 .map(|i| array![(i as f64 + 0.5) / n as f64])
847 .collect()
848 }
849
850 #[test]
851 fn test_worst_case_analysis_basic() {
852 let x = array![0.5];
853 let scenarios = make_uniform_scenarios(10);
854 let obj_fn = |x: &ArrayView1<f64>, xi: &ArrayView1<f64>| (x[0] - xi[0]).powi(2);
855 let constraints: &[fn(&ArrayView1<f64>, &ArrayView1<f64>) -> f64] = &[];
856 let result = worst_case_analysis(&obj_fn, &x.view(), &scenarios, constraints)
857 .expect("failed to create result");
858
859 assert_eq!(result.n_scenarios, 10);
860 assert!(result.worst_obj >= result.best_obj);
861 assert!(result.avg_obj >= result.best_obj);
862 assert!((result.feasibility_rate - 1.0).abs() < 1e-9);
863 }
864
865 #[test]
866 fn test_worst_case_analysis_with_constraints() {
867 let x = array![0.5];
868 let scenarios = make_uniform_scenarios(5);
869 let obj_fn = |_x: &ArrayView1<f64>, xi: &ArrayView1<f64>| xi[0];
870 let constraints = vec![|_x: &ArrayView1<f64>, xi: &ArrayView1<f64>| xi[0] - 0.5];
872 let result = worst_case_analysis(&obj_fn, &x.view(), &scenarios, &constraints)
873 .expect("failed to create result");
874 assert!(result.feasibility_rate <= 1.0);
876 assert!(result.feasibility_rate >= 0.0);
877 }
878
879 #[test]
880 fn test_scenario_approach_basic() {
881 let obj_fn = |x: &ArrayView1<f64>| x[0].powi(2);
883 let constraint_fn =
885 |x: &ArrayView1<f64>, xi: &ArrayView1<f64>| (x[0] - xi[0]).powi(2) - 1.0;
886
887 let mut idx = 0_usize;
888 let grid: Vec<f64> = (0..50).map(|i| (i as f64) / 49.0).collect();
889 let mut sample_fn = || {
890 let v = grid[idx % grid.len()];
891 idx += 1;
892 array![v]
893 };
894
895 let x0 = array![2.0];
896 let config = ScenarioApproachConfig {
897 n_scenarios: 50,
898 confidence: 0.9,
899 max_iter: 1_000,
900 tol: 1e-4,
901 step_size: 1e-2,
902 ..Default::default()
903 };
904 let result =
905 scenario_approach(&obj_fn, &constraint_fn, &mut sample_fn, &x0.view(), &config)
906 .expect("unexpected None or Err");
907
908 assert!(result.x[0].abs() <= 3.0, "x* should be bounded");
909 assert!(result.feasibility_guarantee >= 0.0);
910 assert!(result.feasibility_guarantee <= 1.0);
911 }
912
913 #[test]
914 fn test_distributionally_robust_basic() {
915 let scenarios = make_uniform_scenarios(20);
917 let x0 = array![0.0];
918 let config = WassersteinDROConfig {
919 epsilon: 0.05,
920 p_order: 1,
921 lambda: 1.0,
922 max_iter: 1_000,
923 tol: 1e-4,
924 step_size: 1e-2,
925 ..Default::default()
926 };
927 let result = distributionally_robust(&quadratic_loss, &x0.view(), &scenarios, &config)
928 .expect("unexpected None or Err");
929
930 assert!(
932 result.x[0] >= -0.5 && result.x[0] <= 1.5,
933 "DRO minimizer {} should be near [0,1]",
934 result.x[0]
935 );
936 assert!(result.worst_case_loss >= 0.0);
937 }
938
939 #[test]
940 fn test_affinely_adjustable_basic() {
941 let obj_fn = |x: &ArrayView1<f64>, y: &ArrayView1<f64>, xi: &ArrayView1<f64>| {
944 (x[0] + y[0] - xi[0]).powi(2)
945 };
946 let xi_samples = make_uniform_scenarios(10);
947 let x0 = array![0.0];
948 let config = AARCConfig {
949 recourse_dim: 1,
950 max_iter: 200,
951 tol: 1e-4,
952 step_size: 1e-3,
953 ..Default::default()
954 };
955 let result = affinely_adjustable(&obj_fn, &x0.view(), &xi_samples, 1, &config)
956 .expect("failed to create result");
957 assert!(result.worst_obj >= 0.0);
958 assert_eq!(result.k_matrix.shape(), [1, 1]);
959 assert_eq!(result.l_matrix.shape(), [1, 1]);
960 }
961
962 #[test]
963 fn test_worst_case_empty_scenarios() {
964 let x = array![0.0];
965 let empty: Vec<Array1<f64>> = vec![];
966 let obj = |_x: &ArrayView1<f64>, _xi: &ArrayView1<f64>| 0.0;
967 let constraints: &[fn(&ArrayView1<f64>, &ArrayView1<f64>) -> f64] = &[];
968 assert!(worst_case_analysis(&obj, &x.view(), &empty, constraints).is_err());
969 }
970
971 #[test]
972 fn test_distributionally_robust_empty_scenarios() {
973 let x0 = array![0.0];
974 let config = WassersteinDROConfig::default();
975 let empty: Vec<Array1<f64>> = vec![];
976 assert!(distributionally_robust(&quadratic_loss, &x0.view(), &empty, &config).is_err());
977 }
978}