use crate::error::{OptimizeError, OptimizeResult};
use scirs2_core::ndarray::{Array1, Array2, ArrayView1};
#[derive(Debug, Clone)]
pub struct ScenarioResult {
pub scenario: Array1<f64>,
pub obj_value: f64,
pub feasible: bool,
pub violations: Vec<f64>,
}
#[derive(Debug, Clone)]
pub struct WorstCaseResult {
pub scenarios: Vec<ScenarioResult>,
pub worst_index: usize,
pub worst_obj: f64,
pub best_obj: f64,
pub avg_obj: f64,
pub std_obj: f64,
pub feasibility_rate: f64,
pub n_scenarios: usize,
}
pub fn worst_case_analysis<F, G>(
obj_fn: &F,
x: &ArrayView1<f64>,
scenarios: &[Array1<f64>],
constraint_fns: &[G],
) -> OptimizeResult<WorstCaseResult>
where
F: Fn(&ArrayView1<f64>, &ArrayView1<f64>) -> f64,
G: Fn(&ArrayView1<f64>, &ArrayView1<f64>) -> f64,
{
if scenarios.is_empty() {
return Err(OptimizeError::ValueError(
"scenarios must be non-empty".to_string(),
));
}
let n_scen = scenarios.len();
let mut results = Vec::with_capacity(n_scen);
let mut sum_obj = 0.0_f64;
let mut sum_sq = 0.0_f64;
let mut worst_val = f64::NEG_INFINITY;
let mut best_val = f64::INFINITY;
let mut worst_idx = 0_usize;
let mut n_feasible = 0_usize;
for (j, scenario) in scenarios.iter().enumerate() {
let obj = obj_fn(x, &scenario.view());
let violations: Vec<f64> = constraint_fns
.iter()
.map(|g| g(x, &scenario.view()))
.collect();
let feasible = violations.iter().all(|&v| v <= 0.0);
sum_obj += obj;
sum_sq += obj * obj;
if obj > worst_val {
worst_val = obj;
worst_idx = j;
}
if obj < best_val {
best_val = obj;
}
if feasible {
n_feasible += 1;
}
results.push(ScenarioResult {
scenario: scenario.clone(),
obj_value: obj,
feasible,
violations,
});
}
let avg = sum_obj / n_scen as f64;
let variance = (sum_sq / n_scen as f64 - avg * avg).max(0.0);
let std_dev = variance.sqrt();
let feasibility_rate = n_feasible as f64 / n_scen as f64;
Ok(WorstCaseResult {
scenarios: results,
worst_index: worst_idx,
worst_obj: worst_val,
best_obj: best_val,
avg_obj: avg,
std_obj: std_dev,
feasibility_rate,
n_scenarios: n_scen,
})
}
#[derive(Debug, Clone)]
pub struct AARCConfig {
pub recourse_dim: usize,
pub max_iter: usize,
pub tol: f64,
pub step_size: f64,
pub n_uncertainty_samples: usize,
pub fd_step: f64,
}
impl Default for AARCConfig {
fn default() -> Self {
Self {
recourse_dim: 1,
max_iter: 2_000,
tol: 1e-5,
step_size: 1e-3,
n_uncertainty_samples: 100,
fd_step: 1e-5,
}
}
}
#[derive(Debug, Clone)]
pub struct AARCResult {
pub x: Array1<f64>,
pub k_matrix: Array2<f64>,
pub l_matrix: Array2<f64>,
pub m_vector: Array1<f64>,
pub worst_obj: f64,
pub n_iter: usize,
pub converged: bool,
pub message: String,
}
pub fn affinely_adjustable<F>(
obj_fn: &F,
x0: &ArrayView1<f64>,
xi_samples: &[Array1<f64>],
xi_dim: usize,
config: &AARCConfig,
) -> OptimizeResult<AARCResult>
where
F: Fn(&ArrayView1<f64>, &ArrayView1<f64>, &ArrayView1<f64>) -> f64,
{
if xi_samples.is_empty() {
return Err(OptimizeError::ValueError(
"xi_samples must be non-empty".to_string(),
));
}
let x_dim = x0.len();
let y_dim = config.recourse_dim;
if y_dim == 0 {
return Err(OptimizeError::ValueError(
"recourse_dim must be positive".to_string(),
));
}
let mut k_matrix = Array2::<f64>::zeros((y_dim, x_dim));
let mut l_matrix = Array2::<f64>::zeros((y_dim, xi_dim));
let mut m_vector = Array1::<f64>::zeros(y_dim);
let mut x = x0.to_owned();
let h = config.fd_step;
let mut converged = false;
let eval_policy = |x_cur: &Array1<f64>,
k: &Array2<f64>,
l: &Array2<f64>,
m: &Array1<f64>,
xi: &Array1<f64>|
-> Array1<f64> {
let mut y = m.clone();
for i in 0..y_dim {
for j in 0..x_dim {
y[i] += k[[i, j]] * x_cur[j];
}
for j in 0..xi.len().min(xi_dim) {
y[i] += l[[i, j]] * xi[j];
}
}
y
};
let worst_obj_fn = |x_cur: &Array1<f64>,
k: &Array2<f64>,
l: &Array2<f64>,
m: &Array1<f64>|
-> f64 {
xi_samples
.iter()
.map(|xi| {
let y = eval_policy(x_cur, k, l, m, xi);
obj_fn(&x_cur.view(), &y.view(), &xi.view())
})
.fold(f64::NEG_INFINITY, f64::max)
};
for _ in 0..config.max_iter {
let f_curr = worst_obj_fn(&x, &k_matrix, &l_matrix, &m_vector);
let mut grad_x = Array1::<f64>::zeros(x_dim);
for j in 0..x_dim {
let mut x_fwd = x.clone();
x_fwd[j] += h;
let f_fwd = worst_obj_fn(&x_fwd, &k_matrix, &l_matrix, &m_vector);
grad_x[j] = (f_fwd - f_curr) / h;
}
let mut grad_k = Array2::<f64>::zeros((y_dim, x_dim));
for i in 0..y_dim {
for j in 0..x_dim {
let mut k_fwd = k_matrix.clone();
k_fwd[[i, j]] += h;
let f_fwd = worst_obj_fn(&x, &k_fwd, &l_matrix, &m_vector);
grad_k[[i, j]] = (f_fwd - f_curr) / h;
}
}
let mut grad_l = Array2::<f64>::zeros((y_dim, xi_dim));
for i in 0..y_dim {
for j in 0..xi_dim {
let mut l_fwd = l_matrix.clone();
l_fwd[[i, j]] += h;
let f_fwd = worst_obj_fn(&x, &k_matrix, &l_fwd, &m_vector);
grad_l[[i, j]] = (f_fwd - f_curr) / h;
}
}
let mut grad_m = Array1::<f64>::zeros(y_dim);
for i in 0..y_dim {
let mut m_fwd = m_vector.clone();
m_fwd[i] += h;
let f_fwd = worst_obj_fn(&x, &k_matrix, &l_matrix, &m_fwd);
grad_m[i] = (f_fwd - f_curr) / h;
}
let gx_norm = l2_norm_arr1(&grad_x);
let gk_norm = l2_norm_arr2(&grad_k);
let gl_norm = l2_norm_arr2(&grad_l);
let gm_norm = l2_norm_arr1(&grad_m);
let total_norm = gx_norm + gk_norm + gl_norm + gm_norm;
if total_norm < config.tol {
converged = true;
break;
}
let alpha = config.step_size;
for j in 0..x_dim {
x[j] -= alpha * grad_x[j];
}
for i in 0..y_dim {
for j in 0..x_dim {
k_matrix[[i, j]] -= alpha * grad_k[[i, j]];
}
for j in 0..xi_dim {
l_matrix[[i, j]] -= alpha * grad_l[[i, j]];
}
m_vector[i] -= alpha * grad_m[i];
}
}
let worst_obj = worst_obj_fn(&x, &k_matrix, &l_matrix, &m_vector);
Ok(AARCResult {
x,
k_matrix,
l_matrix,
m_vector,
worst_obj,
n_iter: config.max_iter,
converged,
message: if converged {
"AARC converged".to_string()
} else {
"AARC reached maximum iterations".to_string()
},
})
}
#[derive(Debug, Clone)]
pub struct ScenarioApproachConfig {
pub n_scenarios: usize,
pub confidence: f64,
pub max_iter: usize,
pub tol: f64,
pub step_size: f64,
pub fd_step: f64,
}
impl Default for ScenarioApproachConfig {
fn default() -> Self {
Self {
n_scenarios: 500,
confidence: 0.95,
max_iter: 2_000,
tol: 1e-5,
step_size: 1e-3,
fd_step: 1e-5,
}
}
}
#[derive(Debug, Clone)]
pub struct ScenarioApproachResult {
pub x: Array1<f64>,
pub fun: f64,
pub n_support_scenarios: usize,
pub feasibility_guarantee: f64,
pub n_scenarios: usize,
pub n_iter: usize,
pub converged: bool,
pub message: String,
}
pub fn scenario_approach<F, G, H>(
obj_fn: &F,
constraint_fn: &G,
sample_fn: &mut H,
x0: &ArrayView1<f64>,
config: &ScenarioApproachConfig,
) -> OptimizeResult<ScenarioApproachResult>
where
F: Fn(&ArrayView1<f64>) -> f64,
G: Fn(&ArrayView1<f64>, &ArrayView1<f64>) -> f64,
H: FnMut() -> Array1<f64>,
{
let n = x0.len();
if n == 0 {
return Err(OptimizeError::ValueError(
"x0 must be non-empty".to_string(),
));
}
if !(0.0 < config.confidence && config.confidence < 1.0) {
return Err(OptimizeError::ValueError(format!(
"confidence must be in (0,1), got {}",
config.confidence
)));
}
let scenarios: Vec<Array1<f64>> = (0..config.n_scenarios)
.map(|_| sample_fn())
.collect();
let h = config.fd_step;
let penalty = 1e3_f64;
let mut x = x0.to_owned();
let mut converged = false;
let penalized_obj = |x_cur: &ArrayView1<f64>| -> f64 {
let base = obj_fn(x_cur);
let viol: f64 = scenarios
.iter()
.map(|xi| constraint_fn(x_cur, &xi.view()).max(0.0).powi(2))
.sum();
base + penalty * viol
};
for _ in 0..config.max_iter {
let f0 = penalized_obj(&x.view());
let mut grad = Array1::<f64>::zeros(n);
let mut x_fwd = x.clone();
for j in 0..n {
x_fwd[j] += h;
let f_fwd = penalized_obj(&x_fwd.view());
grad[j] = (f_fwd - f0) / h;
x_fwd[j] = x[j];
}
let gn = l2_norm_arr1(&grad);
if gn < config.tol {
converged = true;
break;
}
for j in 0..n {
x[j] -= config.step_size * grad[j];
}
}
let fun = obj_fn(&x.view());
let tol_support = 1e-3;
let n_support = scenarios
.iter()
.filter(|xi| constraint_fn(&x.view(), &xi.view()).abs() < tol_support)
.count();
let beta = 1.0 - config.confidence;
let epsilon_cg = if config.n_scenarios > n {
2.0 * (beta.recip().ln() + n as f64) / config.n_scenarios as f64
} else {
1.0 };
let feasibility_guarantee = (1.0 - epsilon_cg).max(0.0).min(1.0);
Ok(ScenarioApproachResult {
x,
fun,
n_support_scenarios: n_support,
feasibility_guarantee,
n_scenarios: config.n_scenarios,
n_iter: config.max_iter,
converged,
message: if converged {
"Scenario approach inner optimization converged".to_string()
} else {
"Scenario approach reached maximum iterations".to_string()
},
})
}
#[derive(Debug, Clone)]
pub struct WassersteinDROConfig {
pub epsilon: f64,
pub p_order: u32,
pub lambda: f64,
pub max_iter: usize,
pub tol: f64,
pub step_size: f64,
pub fd_step: f64,
}
impl Default for WassersteinDROConfig {
fn default() -> Self {
Self {
epsilon: 0.1,
p_order: 1,
lambda: 10.0,
max_iter: 2_000,
tol: 1e-5,
step_size: 1e-3,
fd_step: 1e-5,
}
}
}
#[derive(Debug, Clone)]
pub struct WassersteinDROResult {
pub x: Array1<f64>,
pub worst_case_loss: f64,
pub empirical_loss: f64,
pub lipschitz_estimate: f64,
pub n_iter: usize,
pub converged: bool,
pub message: String,
}
pub fn distributionally_robust<F>(
loss_fn: &F,
x0: &ArrayView1<f64>,
scenarios: &[Array1<f64>],
config: &WassersteinDROConfig,
) -> OptimizeResult<WassersteinDROResult>
where
F: Fn(&ArrayView1<f64>, &ArrayView1<f64>) -> f64,
{
if scenarios.is_empty() {
return Err(OptimizeError::ValueError(
"scenarios must be non-empty".to_string(),
));
}
let n = x0.len();
if n == 0 {
return Err(OptimizeError::ValueError(
"x0 must be non-empty".to_string(),
));
}
if config.epsilon < 0.0 {
return Err(OptimizeError::ValueError(format!(
"epsilon must be non-negative, got {}",
config.epsilon
)));
}
let n_scen = scenarios.len();
let h = config.fd_step;
let mut x = x0.to_owned();
let mut converged = false;
let estimate_lipschitz = |x_cur: &Array1<f64>| -> f64 {
if n_scen < 2 {
return 1.0; }
let n_pairs = n_scen.min(20);
let mut max_lip = 0.0_f64;
for i in 0..n_pairs {
for j in (i + 1)..n_pairs.min(i + 5) {
let fi = loss_fn(&x_cur.view(), &scenarios[i].view());
let fj = loss_fn(&x_cur.view(), &scenarios[j].view());
let dist: f64 = if config.p_order == 1 {
scenarios[i]
.iter()
.zip(scenarios[j].iter())
.map(|(&a, &b)| (a - b).abs())
.sum()
} else {
scenarios[i]
.iter()
.zip(scenarios[j].iter())
.map(|(&a, &b)| (a - b).powi(2))
.sum::<f64>()
.sqrt()
};
if dist > 1e-12 {
max_lip = max_lip.max((fi - fj).abs() / dist);
}
}
}
max_lip.max(1e-3) };
let dro_objective = |x_cur: &Array1<f64>| -> f64 {
let empirical: f64 = scenarios
.iter()
.map(|xi| loss_fn(&x_cur.view(), &xi.view()))
.sum::<f64>()
/ n_scen as f64;
let lip = estimate_lipschitz(x_cur);
let reg: f64 = x_cur.iter().map(|xi| xi * xi).sum::<f64>();
empirical + config.epsilon * lip + config.lambda * 1e-4 * reg
};
for _ in 0..config.max_iter {
let f0 = dro_objective(&x);
let mut grad = Array1::<f64>::zeros(n);
let mut x_fwd = x.clone();
for j in 0..n {
x_fwd[j] += h;
let f_fwd = dro_objective(&x_fwd);
grad[j] = (f_fwd - f0) / h;
x_fwd[j] = x[j];
}
let gn = l2_norm_arr1(&grad);
if gn < config.tol {
converged = true;
break;
}
for j in 0..n {
x[j] -= config.step_size * grad[j];
}
}
let empirical_loss: f64 = scenarios
.iter()
.map(|xi| loss_fn(&x.view(), &xi.view()))
.sum::<f64>()
/ n_scen as f64;
let lip = estimate_lipschitz(&x);
let worst_case_loss = empirical_loss + config.epsilon * lip;
Ok(WassersteinDROResult {
x,
worst_case_loss,
empirical_loss,
lipschitz_estimate: lip,
n_iter: config.max_iter,
converged,
message: if converged {
"Wasserstein DRO converged".to_string()
} else {
"Wasserstein DRO reached maximum iterations".to_string()
},
})
}
fn l2_norm_arr1(v: &Array1<f64>) -> f64 {
v.iter().map(|vi| vi * vi).sum::<f64>().sqrt()
}
fn l2_norm_arr2(m: &Array2<f64>) -> f64 {
m.iter().map(|vi| vi * vi).sum::<f64>().sqrt()
}
#[cfg(test)]
mod tests {
use super::*;
use scirs2_core::ndarray::array;
fn quadratic_loss(x: &ArrayView1<f64>, xi: &ArrayView1<f64>) -> f64 {
(x[0] - xi[0]).powi(2)
}
fn make_uniform_scenarios(n: usize) -> Vec<Array1<f64>> {
(0..n)
.map(|i| array![(i as f64 + 0.5) / n as f64])
.collect()
}
#[test]
fn test_worst_case_analysis_basic() {
let x = array![0.5];
let scenarios = make_uniform_scenarios(10);
let obj_fn = |x: &ArrayView1<f64>, xi: &ArrayView1<f64>| (x[0] - xi[0]).powi(2);
let constraints: &[fn(&ArrayView1<f64>, &ArrayView1<f64>) -> f64] = &[];
let result = worst_case_analysis(&obj_fn, &x.view(), &scenarios, constraints).expect("failed to create result");
assert_eq!(result.n_scenarios, 10);
assert!(result.worst_obj >= result.best_obj);
assert!(result.avg_obj >= result.best_obj);
assert!((result.feasibility_rate - 1.0).abs() < 1e-9);
}
#[test]
fn test_worst_case_analysis_with_constraints() {
let x = array![0.5];
let scenarios = make_uniform_scenarios(5);
let obj_fn = |_x: &ArrayView1<f64>, xi: &ArrayView1<f64>| xi[0];
let constraints = vec![|_x: &ArrayView1<f64>, xi: &ArrayView1<f64>| xi[0] - 0.5];
let result = worst_case_analysis(&obj_fn, &x.view(), &scenarios, &constraints).expect("failed to create result");
assert!(result.feasibility_rate <= 1.0);
assert!(result.feasibility_rate >= 0.0);
}
#[test]
fn test_scenario_approach_basic() {
let obj_fn = |x: &ArrayView1<f64>| x[0].powi(2);
let constraint_fn =
|x: &ArrayView1<f64>, xi: &ArrayView1<f64>| (x[0] - xi[0]).powi(2) - 1.0;
let mut idx = 0_usize;
let grid: Vec<f64> = (0..50).map(|i| (i as f64) / 49.0).collect();
let mut sample_fn = || {
let v = grid[idx % grid.len()];
idx += 1;
array![v]
};
let x0 = array![2.0];
let config = ScenarioApproachConfig {
n_scenarios: 50,
confidence: 0.9,
max_iter: 1_000,
tol: 1e-4,
step_size: 1e-2,
..Default::default()
};
let result =
scenario_approach(&obj_fn, &constraint_fn, &mut sample_fn, &x0.view(), &config)
.expect("unexpected None or Err");
assert!(result.x[0].abs() <= 3.0, "x* should be bounded");
assert!(result.feasibility_guarantee >= 0.0);
assert!(result.feasibility_guarantee <= 1.0);
}
#[test]
fn test_distributionally_robust_basic() {
let scenarios = make_uniform_scenarios(20);
let x0 = array![0.0];
let config = WassersteinDROConfig {
epsilon: 0.05,
p_order: 1,
lambda: 1.0,
max_iter: 1_000,
tol: 1e-4,
step_size: 1e-2,
..Default::default()
};
let result =
distributionally_robust(&quadratic_loss, &x0.view(), &scenarios, &config).expect("unexpected None or Err");
assert!(
result.x[0] >= -0.5 && result.x[0] <= 1.5,
"DRO minimizer {} should be near [0,1]",
result.x[0]
);
assert!(result.worst_case_loss >= 0.0);
}
#[test]
fn test_affinely_adjustable_basic() {
let obj_fn = |x: &ArrayView1<f64>, y: &ArrayView1<f64>, xi: &ArrayView1<f64>| {
(x[0] + y[0] - xi[0]).powi(2)
};
let xi_samples = make_uniform_scenarios(10);
let x0 = array![0.0];
let config = AARCConfig {
recourse_dim: 1,
max_iter: 200,
tol: 1e-4,
step_size: 1e-3,
..Default::default()
};
let result = affinely_adjustable(&obj_fn, &x0.view(), &xi_samples, 1, &config).expect("failed to create result");
assert!(result.worst_obj >= 0.0);
assert_eq!(result.k_matrix.shape(), [1, 1]);
assert_eq!(result.l_matrix.shape(), [1, 1]);
}
#[test]
fn test_worst_case_empty_scenarios() {
let x = array![0.0];
let empty: Vec<Array1<f64>> = vec![];
let obj = |_x: &ArrayView1<f64>, _xi: &ArrayView1<f64>| 0.0;
let constraints: &[fn(&ArrayView1<f64>, &ArrayView1<f64>) -> f64] = &[];
assert!(worst_case_analysis(&obj, &x.view(), &empty, constraints).is_err());
}
#[test]
fn test_distributionally_robust_empty_scenarios() {
let x0 = array![0.0];
let config = WassersteinDROConfig::default();
let empty: Vec<Array1<f64>> = vec![];
assert!(distributionally_robust(&quadratic_loss, &x0.view(), &empty, &config).is_err());
}
}