use std::sync::Arc;
use std::time::Instant;
use numra_core::Scalar;
use rand::rngs::StdRng;
use rand::SeedableRng;
use crate::error::OptimError;
use crate::problem::{ConstraintKind, OptimProblem};
type ParamFn<S> = Arc<dyn Fn(&[S], &[S]) -> S + Send + Sync>;
type DetFn<S> = Box<dyn Fn(&[S]) -> S + Send + Sync>;
pub struct StochasticParam<S: Scalar> {
pub name: String,
pub sampler: Box<dyn Fn(&mut StdRng) -> S>,
pub nominal: S,
}
#[derive(Clone, Debug)]
pub struct StochasticOptions {
pub n_samples: usize,
pub seed: u64,
pub max_iter: usize,
}
impl Default for StochasticOptions {
fn default() -> Self {
Self {
n_samples: 100,
seed: 42,
max_iter: 1000,
}
}
}
#[derive(Clone, Debug)]
pub struct StochasticResult<S: Scalar> {
pub x: Vec<S>,
pub f_mean: S,
pub f_std_error: S,
pub scenario_objectives: Vec<S>,
pub chance_satisfaction: Vec<S>,
pub converged: bool,
pub message: String,
pub iterations: usize,
pub wall_time_secs: f64,
}
struct DetConstraint<S: Scalar> {
func: DetFn<S>,
kind: ConstraintKind,
}
struct ChanceConstraint<S: Scalar> {
func: ParamFn<S>,
probability: S,
}
pub fn param_normal<S: Scalar>(name: &str, mean: S, std: S) -> StochasticParam<S> {
use rand_distr::{Distribution, Normal};
let mean_f64 = mean.to_f64();
let std_f64 = std.to_f64();
let dist = Normal::new(mean_f64, std_f64).unwrap();
StochasticParam {
name: name.to_string(),
sampler: Box::new(move |rng: &mut StdRng| S::from_f64(dist.sample(rng))),
nominal: mean,
}
}
pub fn param_sampled<S: Scalar>(
name: &str,
nominal: S,
sampler: impl Fn(&mut StdRng) -> S + 'static,
) -> StochasticParam<S> {
StochasticParam {
name: name.to_string(),
sampler: Box::new(sampler),
nominal,
}
}
pub fn param_uniform<S: Scalar>(name: &str, lo: S, hi: S) -> StochasticParam<S> {
use rand_distr::{Distribution, Uniform};
let lo_f64 = lo.to_f64();
let hi_f64 = hi.to_f64();
let dist = Uniform::new(lo_f64, hi_f64);
let two = S::from_f64(2.0);
StochasticParam {
name: name.to_string(),
sampler: Box::new(move |rng: &mut StdRng| S::from_f64(dist.sample(rng))),
nominal: (lo + hi) / two,
}
}
pub struct StochasticProblem<S: Scalar> {
n: usize,
x0: Option<Vec<S>>,
bounds: Vec<Option<(S, S)>>,
objective: Option<ParamFn<S>>,
deterministic_constraints: Vec<DetConstraint<S>>,
chance_constraints: Vec<ChanceConstraint<S>>,
params: Vec<StochasticParam<S>>,
options: StochasticOptions,
cvar_alpha: Option<S>,
}
impl<S: Scalar> StochasticProblem<S> {
pub fn new(n: usize) -> Self {
Self {
n,
x0: None,
bounds: vec![None; n],
objective: None,
deterministic_constraints: Vec::new(),
chance_constraints: Vec::new(),
params: Vec::new(),
options: StochasticOptions::default(),
cvar_alpha: None,
}
}
pub fn x0(mut self, x0: &[S]) -> Self {
self.x0 = Some(x0.to_vec());
self
}
pub fn bounds(mut self, i: usize, lo_hi: (S, S)) -> Self {
self.bounds[i] = Some(lo_hi);
self
}
pub fn all_bounds(mut self, bounds: &[(S, S)]) -> Self {
for (i, &b) in bounds.iter().enumerate() {
self.bounds[i] = Some(b);
}
self
}
pub fn objective<F>(mut self, f: F) -> Self
where
F: Fn(&[S], &[S]) -> S + Send + Sync + 'static,
{
self.objective = Some(Arc::new(f));
self
}
pub fn constraint_det_ineq<F>(mut self, f: F) -> Self
where
F: Fn(&[S]) -> S + Send + Sync + 'static,
{
self.deterministic_constraints.push(DetConstraint {
func: Box::new(f),
kind: ConstraintKind::Inequality,
});
self
}
pub fn constraint_det_eq<F>(mut self, f: F) -> Self
where
F: Fn(&[S]) -> S + Send + Sync + 'static,
{
self.deterministic_constraints.push(DetConstraint {
func: Box::new(f),
kind: ConstraintKind::Equality,
});
self
}
pub fn chance_constraint<F>(mut self, f: F, probability: S) -> Self
where
F: Fn(&[S], &[S]) -> S + Send + Sync + 'static,
{
self.chance_constraints.push(ChanceConstraint {
func: Arc::new(f),
probability,
});
self
}
pub fn param(mut self, p: StochasticParam<S>) -> Self {
self.params.push(p);
self
}
pub fn param_normal(mut self, name: &str, mean: S, std: S) -> Self {
self.params.push(param_normal(name, mean, std));
self
}
pub fn param_uniform(mut self, name: &str, lo: S, hi: S) -> Self {
self.params.push(param_uniform(name, lo, hi));
self
}
pub fn n_samples(mut self, n: usize) -> Self {
self.options.n_samples = n;
self
}
pub fn seed(mut self, s: u64) -> Self {
self.options.seed = s;
self
}
pub fn max_iter(mut self, n: usize) -> Self {
self.options.max_iter = n;
self
}
pub fn minimize_cvar(mut self, alpha: S) -> Self {
self.cvar_alpha = Some(alpha);
self
}
}
impl<S: Scalar + faer::SimpleEntity + faer::Conjugate<Canonical = S> + faer::ComplexField>
StochasticProblem<S>
{
pub fn solve(self) -> Result<StochasticResult<S>, OptimError> {
let start = Instant::now();
let obj = self.objective.ok_or(OptimError::NoObjective)?;
let x0 = self.x0.clone().ok_or(OptimError::NoInitialPoint)?;
if self.params.is_empty() {
return Err(OptimError::Other(
"at least one stochastic parameter is required".to_string(),
));
}
let n = self.n;
let n_samples = self.options.n_samples;
let cvar_alpha = self.cvar_alpha;
let mut rng = StdRng::seed_from_u64(self.options.seed);
let scenarios: Vec<Vec<S>> = (0..n_samples)
.map(|_| self.params.iter().map(|p| (p.sampler)(&mut rng)).collect())
.collect();
let scenarios = Arc::new(scenarios);
let obj_for_saa = Arc::clone(&obj);
let scenarios_for_saa = Arc::clone(&scenarios);
let chance_fns: Vec<ParamFn<S>> = self
.chance_constraints
.iter()
.map(|cc| Arc::clone(&cc.func))
.collect();
let chance_fns = Arc::new(chance_fns);
let chance_fns_for_saa = Arc::clone(&chance_fns);
let base_penalty = S::from_f64(1e4);
let chance_weights: Arc<Vec<S>> = Arc::new(
self.chance_constraints
.iter()
.map(|cc| base_penalty * cc.probability)
.collect(),
);
let chance_weights_for_saa = Arc::clone(&chance_weights);
let n_chance = self.chance_constraints.len();
let n_opt = if cvar_alpha.is_some() { n + 1 } else { n };
let mut x0_ext = x0.clone();
if cvar_alpha.is_some() {
let mut f_scenarios: Vec<S> = scenarios.iter().map(|s| obj(&x0, s)).collect();
f_scenarios.sort_by(|a, b| a.to_f64().partial_cmp(&b.to_f64()).unwrap());
let median_idx = f_scenarios.len() / 2;
x0_ext.push(f_scenarios[median_idx]);
}
let saa_objective = move |x_ext: &[S]| -> S {
let ns = scenarios_for_saa.len();
let inv_n = S::ONE / S::from_usize(ns);
match cvar_alpha {
Some(alpha) => {
let x = &x_ext[..x_ext.len() - 1];
let t = x_ext[x_ext.len() - 1];
let inv_one_minus_alpha = S::ONE / (S::ONE - alpha);
let eps = S::from_f64(1e-6);
let mut cvar_sum = S::ZERO;
for s in 0..ns {
let fs = obj_for_saa(x, &scenarios_for_saa[s]);
let z = fs - t;
let smooth_max = (z + (z * z + eps).sqrt()) * S::HALF;
cvar_sum += smooth_max;
}
let mut penalty = S::ZERO;
for c in 0..n_chance {
let w = chance_weights_for_saa[c];
for s in 0..ns {
let g = chance_fns_for_saa[c](x, &scenarios_for_saa[s]);
if g > S::ZERO {
penalty += w * g * g;
}
}
}
t + inv_one_minus_alpha * inv_n * cvar_sum + inv_n * penalty
}
None => {
let x = x_ext;
let mut f_sum = S::ZERO;
for s in 0..ns {
f_sum += obj_for_saa(x, &scenarios_for_saa[s]);
}
let mut penalty = S::ZERO;
for c in 0..n_chance {
let w = chance_weights_for_saa[c];
for s in 0..ns {
let g = chance_fns_for_saa[c](x, &scenarios_for_saa[s]);
if g > S::ZERO {
penalty += w * g * g;
}
}
}
inv_n * f_sum + inv_n * penalty
}
}
};
let mut problem = OptimProblem::<S>::new(n_opt)
.x0(&x0_ext)
.objective(saa_objective)
.max_iter(self.options.max_iter);
for (i, b) in self.bounds.iter().enumerate() {
if let Some(lo_hi) = b {
problem = problem.bounds(i, *lo_hi);
}
}
for dc in self.deterministic_constraints {
if cvar_alpha.is_some() {
let dc_n = n;
match dc.kind {
ConstraintKind::Inequality => {
let func = dc.func;
problem = problem.constraint_ineq(move |x_ext: &[S]| func(&x_ext[..dc_n]));
}
ConstraintKind::Equality => {
let func = dc.func;
problem = problem.constraint_eq(move |x_ext: &[S]| func(&x_ext[..dc_n]));
}
}
} else {
match dc.kind {
ConstraintKind::Inequality => {
problem = problem.constraint_ineq(dc.func);
}
ConstraintKind::Equality => {
problem = problem.constraint_eq(dc.func);
}
}
}
}
let result = problem.solve()?;
let x_star = if cvar_alpha.is_some() {
result.x[..n].to_vec()
} else {
result.x.clone()
};
let scenario_objectives: Vec<S> = scenarios.iter().map(|s| obj(&x_star, s)).collect();
let f_mean = scenario_objectives.iter().copied().sum::<S>() / S::from_usize(n_samples);
let f_std_error = if n_samples > 1 {
let variance = scenario_objectives
.iter()
.map(|&fi| (fi - f_mean) * (fi - f_mean))
.sum::<S>()
/ S::from_usize(n_samples - 1);
variance.sqrt() / S::from_usize(n_samples).sqrt()
} else {
S::ZERO
};
let chance_satisfaction: Vec<S> = self
.chance_constraints
.iter()
.map(|cc| {
let n_satisfied = scenarios
.iter()
.filter(|s| (cc.func)(&x_star, s) <= S::ZERO)
.count();
S::from_usize(n_satisfied) / S::from_usize(n_samples)
})
.collect();
Ok(StochasticResult {
x: x_star,
f_mean,
f_std_error,
scenario_objectives,
chance_satisfaction,
converged: result.converged,
message: result.message,
iterations: result.iterations,
wall_time_secs: start.elapsed().as_secs_f64(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use rand::SeedableRng;
#[test]
fn test_saa_expected_value() {
let result = StochasticProblem::new(1)
.x0(&[0.0])
.objective(|x: &[f64], p: &[f64]| (x[0] - p[0]) * (x[0] - p[0]))
.param_normal("xi", 5.0, 1.0)
.n_samples(200)
.solve()
.unwrap();
assert!(
(result.x[0] - 5.0).abs() < 0.5,
"x* = {}, expected ~5.0",
result.x[0]
);
assert!(result.converged, "solver should converge");
assert_eq!(result.scenario_objectives.len(), 200);
assert!(result.f_std_error > 0.0, "std error should be positive");
}
#[test]
fn test_saa_bounded() {
let result = StochasticProblem::new(1)
.x0(&[1.0])
.objective(|x: &[f64], p: &[f64]| (x[0] - p[0]) * (x[0] - p[0]))
.param_normal("xi", 5.0, 1.0)
.bounds(0, (0.0, 3.0))
.n_samples(200)
.solve()
.unwrap();
assert!(
(result.x[0] - 3.0).abs() < 0.1,
"x* = {}, expected ~3.0 (bound active)",
result.x[0]
);
}
#[test]
fn test_chance_constraint() {
let result = StochasticProblem::new(1)
.x0(&[5.0])
.objective(|x: &[f64], _p: &[f64]| -x[0])
.chance_constraint(
|x: &[f64], p: &[f64]| x[0] - p[0], 0.95,
)
.param_normal("xi", 10.0, 2.0)
.bounds(0, (0.0, 20.0))
.n_samples(500)
.max_iter(2000)
.solve()
.unwrap();
assert!(
result.x[0] < 10.0,
"x* = {}, expected < 10.0 (must be conservative)",
result.x[0]
);
assert!(result.x[0] > 0.0, "x* = {}, expected > 0.0", result.x[0]);
assert!(
result.chance_satisfaction.len() == 1,
"should have one chance constraint"
);
}
#[test]
fn test_param_normal_uniform_helpers() {
let mut rng = StdRng::seed_from_u64(123);
{
use rand_distr::{Distribution, Normal};
let dist = Normal::new(5.0, 1.0).unwrap();
let samples: Vec<f64> = (0..1000).map(|_| dist.sample(&mut rng)).collect();
let mean = samples.iter().sum::<f64>() / 1000.0;
let variance = samples
.iter()
.map(|&s| (s - mean) * (s - mean))
.sum::<f64>()
/ 999.0;
let std = variance.sqrt();
assert!(
(mean - 5.0).abs() < 0.3,
"Normal mean = {}, expected ~5.0",
mean
);
assert!(
(std - 1.0).abs() < 0.3,
"Normal std = {}, expected ~1.0",
std
);
}
{
use rand_distr::{Distribution, Uniform};
let dist = Uniform::new(0.0, 10.0);
let samples: Vec<f64> = (0..1000).map(|_| dist.sample(&mut rng)).collect();
let mean = samples.iter().sum::<f64>() / 1000.0;
assert!(
samples.iter().all(|&s| (0.0..10.0).contains(&s)),
"Uniform samples should be in [0, 10)"
);
assert!(
(mean - 5.0).abs() < 1.0,
"Uniform mean = {}, expected ~5.0",
mean
);
}
let problem = StochasticProblem::<f64>::new(1)
.param_normal("n1", 5.0, 1.0)
.param_uniform("u1", 0.0, 10.0);
assert_eq!(problem.params.len(), 2);
assert_eq!(problem.params[0].name, "n1");
assert!((problem.params[0].nominal - 5.0).abs() < 1e-15);
assert_eq!(problem.params[1].name, "u1");
assert!((problem.params[1].nominal - 5.0).abs() < 1e-15);
let mut rng2 = StdRng::seed_from_u64(456);
let normal_samples: Vec<f64> = (0..1000)
.map(|_| (problem.params[0].sampler)(&mut rng2))
.collect();
let n_mean = normal_samples.iter().sum::<f64>() / 1000.0;
assert!(
(n_mean - 5.0).abs() < 0.3,
"StochasticParam Normal mean = {}, expected ~5.0",
n_mean
);
let uniform_samples: Vec<f64> = (0..1000)
.map(|_| (problem.params[1].sampler)(&mut rng2))
.collect();
let u_mean = uniform_samples.iter().sum::<f64>() / 1000.0;
assert!(
uniform_samples.iter().all(|&s| (0.0..10.0).contains(&s)),
"StochasticParam Uniform samples should be in [0, 10)"
);
assert!(
(u_mean - 5.0).abs() < 1.0,
"StochasticParam Uniform mean = {}, expected ~5.0",
u_mean
);
}
#[test]
fn test_param_sampled() {
use rand_distr::{Distribution, Normal};
let dist = Normal::new(5.0_f64, 1.0).unwrap();
let p = param_sampled("xi", 5.0, move |rng: &mut StdRng| dist.sample(rng));
assert_eq!(p.name, "xi");
assert!((p.nominal - 5.0).abs() < 1e-15);
let mut rng = StdRng::seed_from_u64(42);
let samples: Vec<f64> = (0..1000).map(|_| (p.sampler)(&mut rng)).collect();
let mean = samples.iter().sum::<f64>() / 1000.0;
assert!((mean - 5.0).abs() < 0.3);
}
#[test]
fn test_param_sampled_in_problem() {
use rand_distr::{Distribution, Normal};
let dist = Normal::new(5.0_f64, 1.0).unwrap();
let result = StochasticProblem::new(1)
.x0(&[0.0])
.objective(|x: &[f64], p: &[f64]| (x[0] - p[0]) * (x[0] - p[0]))
.param(param_sampled("xi", 5.0, move |rng: &mut StdRng| {
dist.sample(rng)
}))
.n_samples(200)
.solve()
.unwrap();
assert!((result.x[0] - 5.0).abs() < 0.5);
assert!(result.converged);
}
#[test]
fn test_cvar_minimization() {
let result_cvar = StochasticProblem::new(1)
.x0(&[3.0])
.objective(|x: &[f64], p: &[f64]| (x[0] - p[0]) * (x[0] - p[0]))
.param_normal("xi", 5.0, 2.0)
.n_samples(500)
.max_iter(2000)
.minimize_cvar(0.9)
.solve()
.unwrap();
assert_eq!(result_cvar.x.len(), 1, "CVaR should return original dim");
assert!(
result_cvar.converged,
"CVaR should converge: {}",
result_cvar.message
);
assert!(
(result_cvar.x[0] - 5.0).abs() < 1.0,
"CVaR x* = {}, expected ~5.0",
result_cvar.x[0]
);
let result_ev = StochasticProblem::new(1)
.x0(&[0.0])
.objective(|x: &[f64], p: &[f64]| (x[0] - p[0]) * (x[0] - p[0]))
.param_normal("xi", 5.0, 2.0)
.n_samples(500)
.solve()
.unwrap();
assert!(
result_cvar.f_mean >= result_ev.f_mean * 0.5,
"CVaR f_mean={} should be comparable to EV f_mean={}",
result_cvar.f_mean,
result_ev.f_mean
);
}
}