use crate::error::{OptimizeError, OptimizeResult};
use scirs2_core::ndarray::{Array1, Array2, ArrayView1};
pub struct MinimaxProblem {
pub funcs: Vec<Box<dyn Fn(&ArrayView1<f64>) -> f64 + Send + Sync>>,
}
impl MinimaxProblem {
pub fn new(funcs: Vec<Box<dyn Fn(&ArrayView1<f64>) -> f64 + Send + Sync>>) -> Self {
Self { funcs }
}
pub fn eval_max(&self, x: &ArrayView1<f64>) -> f64 {
self.funcs
.iter()
.map(|f| f(x))
.fold(f64::NEG_INFINITY, f64::max)
}
pub fn eval_all(&self, x: &ArrayView1<f64>) -> Vec<f64> {
self.funcs.iter().map(|f| f(x)).collect()
}
pub fn argmax(&self, x: &ArrayView1<f64>) -> usize {
let vals = self.eval_all(x);
vals.iter()
.enumerate()
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
.map(|(i, _)| i)
.unwrap_or(0)
}
pub fn num_funcs(&self) -> usize {
self.funcs.len()
}
}
#[derive(Debug, Clone)]
pub struct MinimaxSolverConfig {
pub max_iter: usize,
pub tol: f64,
pub step_size: f64,
pub step_decay: f64,
pub fd_step: f64,
pub smoothing_mu: f64,
pub fictitious_play_iter: usize,
}
impl Default for MinimaxSolverConfig {
fn default() -> Self {
Self {
max_iter: 2_000,
tol: 1e-6,
step_size: 1e-2,
step_decay: 1.0, fd_step: 1e-5,
smoothing_mu: 0.1,
fictitious_play_iter: 1_000,
}
}
}
#[derive(Debug, Clone)]
pub struct MinimaxSolveResult {
pub x: Array1<f64>,
pub fun: f64,
pub active_index: usize,
pub n_iter: usize,
pub converged: bool,
pub message: String,
}
fn fd_gradient<F>(f: &F, x: &ArrayView1<f64>, h: f64) -> Array1<f64>
where
F: Fn(&ArrayView1<f64>) -> f64,
{
let n = x.len();
let f0 = f(x);
let mut g = Array1::<f64>::zeros(n);
let mut x_fwd = x.to_owned();
for i in 0..n {
x_fwd[i] += h;
g[i] = (f(&x_fwd.view()) - f0) / h;
x_fwd[i] = x[i];
}
g
}
#[inline]
fn l2_norm(v: &Array1<f64>) -> f64 {
v.iter().map(|vi| vi * vi).sum::<f64>().sqrt()
}
pub fn minimax_subgradient(
problem: &MinimaxProblem,
x0: &ArrayView1<f64>,
config: &MinimaxSolverConfig,
) -> OptimizeResult<MinimaxSolveResult> {
if problem.num_funcs() == 0 {
return Err(OptimizeError::ValueError(
"MinimaxProblem must contain at least one function".to_string(),
));
}
let n = x0.len();
if n == 0 {
return Err(OptimizeError::ValueError(
"x0 must be non-empty".to_string(),
));
}
let mut x = x0.to_owned();
let mut x_best = x.clone();
let mut val_best = problem.eval_max(&x.view());
let h = config.fd_step;
let mut converged = false;
for k in 0..config.max_iter {
let vals = problem.eval_all(&x.view());
let max_val = vals
.iter()
.cloned()
.fold(f64::NEG_INFINITY, f64::max);
let active_idx = vals
.iter()
.enumerate()
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
.map(|(i, _)| i)
.unwrap_or(0);
if max_val < val_best {
val_best = max_val;
x_best = x.clone();
}
let subgrad = fd_gradient(&|v: &ArrayView1<f64>| problem.funcs[active_idx](v), &x.view(), h);
let sg_norm = l2_norm(&subgrad);
if sg_norm < config.tol {
converged = true;
break;
}
let alpha = config.step_size / ((k as f64 + 1.0).sqrt());
for i in 0..n {
x[i] -= alpha * subgrad[i];
}
}
let active_idx = problem.argmax(&x_best.view());
Ok(MinimaxSolveResult {
fun: val_best,
active_index: active_idx,
n_iter: config.max_iter,
converged,
message: if converged {
"Subgradient descent converged".to_string()
} else {
"Subgradient descent reached maximum iterations".to_string()
},
x: x_best,
})
}
#[derive(Debug, Clone)]
struct BundleCut {
point: Array1<f64>,
value: f64,
subgrad: Array1<f64>,
}
pub fn minimax_bundle(
problem: &MinimaxProblem,
x0: &ArrayView1<f64>,
config: &MinimaxSolverConfig,
) -> OptimizeResult<MinimaxSolveResult> {
if problem.num_funcs() == 0 {
return Err(OptimizeError::ValueError(
"MinimaxProblem must contain at least one function".to_string(),
));
}
let n = x0.len();
if n == 0 {
return Err(OptimizeError::ValueError(
"x0 must be non-empty".to_string(),
));
}
let h = config.fd_step;
let max_bundle_size = 20_usize;
let prox_t = config.step_size;
let mut x = x0.to_owned();
let mut x_best = x.clone();
let mut val_best = problem.eval_max(&x.view());
let mut bundle: Vec<BundleCut> = Vec::with_capacity(max_bundle_size);
let mut converged = false;
let eval_model = |x_cur: &Array1<f64>, d: &Array1<f64>, cuts: &[BundleCut]| -> f64 {
cuts.iter()
.map(|cut| {
let diff: f64 = x_cur
.iter()
.zip(d.iter())
.zip(cut.point.iter())
.map(|((&xc, &dc), &yj)| xc + dc - yj)
.zip(cut.subgrad.iter())
.map(|(delta, &gj)| delta * gj)
.sum::<f64>();
cut.value + diff
})
.fold(f64::NEG_INFINITY, f64::max)
};
for k in 0..config.max_iter {
let vals = problem.eval_all(&x.view());
let max_val = vals.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
if max_val < val_best {
val_best = max_val;
x_best = x.clone();
}
let threshold = max_val - config.tol;
let new_cuts: Vec<BundleCut> = vals
.iter()
.enumerate()
.filter(|(_, &v)| v >= threshold)
.map(|(i, _)| {
let subgrad = fd_gradient(
&|v: &ArrayView1<f64>| problem.funcs[i](v),
&x.view(),
h,
);
BundleCut {
point: x.clone(),
value: vals[i],
subgrad,
}
})
.collect();
bundle.extend(new_cuts);
if bundle.len() > max_bundle_size {
let start = bundle.len() - max_bundle_size;
bundle.drain(..start);
}
if bundle.is_empty() {
break;
}
let mut d = Array1::<f64>::zeros(n);
let inner_steps = 100_usize;
let inner_step = prox_t * 0.5;
for _ in 0..inner_steps {
let active_cut = bundle
.iter()
.enumerate()
.max_by(|(_, a), (_, b)| {
let va = a.value
+ a.point
.iter()
.zip(d.iter())
.zip(x.iter())
.map(|((&yj, &dj), &xc)| a.subgrad[{
0
}] * (xc + dj - yj))
.sum::<f64>();
let vb = b.value
+ b.point
.iter()
.zip(d.iter())
.zip(x.iter())
.map(|((&yj, &dj), &xc)| b.subgrad[0] * (xc + dj - yj))
.sum::<f64>();
va.partial_cmp(&vb).unwrap_or(std::cmp::Ordering::Equal)
})
.map(|(i, _)| i)
.unwrap_or(0);
let active_idx = bundle
.iter()
.enumerate()
.max_by(|(_, ca), (_, cb)| {
let va: f64 = ca.value
+ ca.subgrad
.iter()
.zip(x.iter().zip(d.iter()).zip(ca.point.iter()))
.map(|(&gj, ((&xc, &dc), &yj))| gj * (xc + dc - yj))
.sum::<f64>();
let vb: f64 = cb.value
+ cb.subgrad
.iter()
.zip(x.iter().zip(d.iter()).zip(cb.point.iter()))
.map(|(&gj, ((&xc, &dc), &yj))| gj * (xc + dc - yj))
.sum::<f64>();
va.partial_cmp(&vb).unwrap_or(std::cmp::Ordering::Equal)
})
.map(|(i, _)| i)
.unwrap_or(active_cut);
let cut = &bundle[active_idx];
let grad_d: Array1<f64> = cut
.subgrad
.iter()
.zip(d.iter())
.map(|(&gj, &dj)| gj + dj / prox_t)
.collect();
let step_norm = l2_norm(&grad_d);
if step_norm < config.tol * 0.1 {
break;
}
for i in 0..n {
d[i] -= inner_step * grad_d[i];
}
}
let d_norm = l2_norm(&d);
if d_norm < config.tol {
converged = true;
break;
}
for i in 0..n {
x[i] += d[i];
}
let new_max = problem.eval_max(&x.view());
if (new_max - max_val).abs() < config.tol && k > 10 {
converged = true;
break;
}
}
let active_idx = problem.argmax(&x_best.view());
Ok(MinimaxSolveResult {
x: x_best,
fun: val_best,
active_index: active_idx,
n_iter: config.max_iter,
converged,
message: if converged {
"Bundle method converged".to_string()
} else {
"Bundle method reached maximum iterations".to_string()
},
})
}
pub fn smooth_minimax(
problem: &MinimaxProblem,
x0: &ArrayView1<f64>,
config: &MinimaxSolverConfig,
) -> OptimizeResult<MinimaxSolveResult> {
if problem.num_funcs() == 0 {
return Err(OptimizeError::ValueError(
"MinimaxProblem must contain at least one function".to_string(),
));
}
let n = x0.len();
if n == 0 {
return Err(OptimizeError::ValueError(
"x0 must be non-empty".to_string(),
));
}
let mu = config.smoothing_mu;
if mu <= 0.0 {
return Err(OptimizeError::ValueError(format!(
"smoothing_mu must be positive, got {}",
mu
)));
}
let k = problem.num_funcs() as f64;
let h = config.fd_step;
let smooth_obj = |x: &ArrayView1<f64>| -> f64 {
let vals = problem.eval_all(x);
let max_val = vals.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
if max_val.is_infinite() {
return max_val;
}
let sum_exp: f64 = vals.iter().map(|&v| ((v - max_val) / mu).exp()).sum();
mu * (sum_exp.ln() + (max_val / mu)) - mu * k.ln()
};
let mut x = x0.to_owned();
let mut x_best = x.clone();
let mut val_best = problem.eval_max(&x.view());
let mut converged = false;
let mut y = x.clone(); let mut t_k = 1.0_f64;
for _ in 0..config.max_iter {
let grad = fd_gradient(&smooth_obj, &y.view(), h);
let grad_norm = l2_norm(&grad);
if grad_norm < config.tol {
converged = true;
break;
}
let x_new: Array1<f64> = y
.iter()
.zip(grad.iter())
.map(|(&yi, &gi)| yi - config.step_size * gi)
.collect();
let t_new = (1.0 + (1.0 + 4.0 * t_k * t_k).sqrt()) / 2.0;
let mom = (t_k - 1.0) / t_new;
let y_new: Array1<f64> = x_new
.iter()
.zip(x.iter())
.map(|(&xn, &xo)| xn + mom * (xn - xo))
.collect();
let max_val = problem.eval_max(&x_new.view());
if max_val < val_best {
val_best = max_val;
x_best = x_new.clone();
}
x = x_new;
y = y_new;
t_k = t_new;
}
let active_idx = problem.argmax(&x_best.view());
Ok(MinimaxSolveResult {
x: x_best,
fun: val_best,
active_index: active_idx,
n_iter: config.max_iter,
converged,
message: if converged {
"Smooth minimax (Nesterov) converged".to_string()
} else {
"Smooth minimax reached maximum iterations".to_string()
},
})
}
#[derive(Debug, Clone)]
pub struct GameMinimaxResult {
pub x: Array1<f64>,
pub fun: f64,
pub maximizer_strategy: Array1<f64>,
pub n_iter: usize,
pub converged: bool,
pub message: String,
}
pub fn game_theoretic_minimax(
problem: &MinimaxProblem,
x0: &ArrayView1<f64>,
step_size: f64,
config: &MinimaxSolverConfig,
) -> OptimizeResult<GameMinimaxResult> {
if problem.num_funcs() == 0 {
return Err(OptimizeError::ValueError(
"MinimaxProblem must contain at least one function".to_string(),
));
}
let n = x0.len();
if n == 0 {
return Err(OptimizeError::ValueError(
"x0 must be non-empty".to_string(),
));
}
let k = problem.num_funcs();
let h = config.fd_step;
let max_fp_iter = config.fictitious_play_iter;
let mut counts = vec![0_usize; k];
let mut x = x0.to_owned();
let mut x_best = x.clone();
let mut val_best = problem.eval_max(&x.view());
let mut converged = false;
let mut cumulative_grad = Array1::<f64>::zeros(n);
let mut cumulative_count = 0_usize;
for iter in 0..max_fp_iter {
let vals = problem.eval_all(&x.view());
let active_i = vals
.iter()
.enumerate()
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
.map(|(i, _)| i)
.unwrap_or(0);
counts[active_i] += 1;
cumulative_count += 1;
let max_val = vals[active_i];
if max_val < val_best {
val_best = max_val;
x_best = x.clone();
}
let grad_i = fd_gradient(
&|v: &ArrayView1<f64>| problem.funcs[active_i](v),
&x.view(),
h,
);
for i in 0..n {
cumulative_grad[i] += grad_i[i];
}
let avg_grad_norm: f64 = cumulative_grad
.iter()
.map(|&g| g * g)
.sum::<f64>()
.sqrt()
/ cumulative_count as f64;
if avg_grad_norm < config.tol {
converged = true;
break;
}
let alpha = step_size / ((iter as f64 + 1.0).sqrt());
for i in 0..n {
x[i] -= alpha * cumulative_grad[i] / cumulative_count as f64;
}
if iter > 10 && (max_val - problem.eval_max(&x.view())).abs() < config.tol {
converged = true;
break;
}
}
let total = counts.iter().sum::<usize>().max(1) as f64;
let maximizer_strategy: Array1<f64> = counts.iter().map(|&c| c as f64 / total).collect();
Ok(GameMinimaxResult {
x: x_best,
fun: val_best,
maximizer_strategy,
n_iter: max_fp_iter,
converged,
message: if converged {
"Fictitious play converged".to_string()
} else {
"Fictitious play reached maximum iterations".to_string()
},
})
}
pub fn smooth_max_value<F>(funcs: &[F], x: &ArrayView1<f64>, mu: f64) -> OptimizeResult<f64>
where
F: Fn(&ArrayView1<f64>) -> f64,
{
if funcs.is_empty() {
return Err(OptimizeError::ValueError(
"funcs must be non-empty".to_string(),
));
}
if mu <= 0.0 {
return Err(OptimizeError::ValueError(format!(
"mu must be positive, got {}",
mu
)));
}
let vals: Vec<f64> = funcs.iter().map(|f| f(x)).collect();
let max_val = vals.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
if max_val.is_infinite() {
return Ok(max_val);
}
let sum_exp: f64 = vals.iter().map(|&v| ((v - max_val) / mu).exp()).sum();
let k = funcs.len() as f64;
Ok(mu * (sum_exp.ln() + (max_val / mu)) - mu * k.ln())
}
#[cfg(test)]
mod tests {
use super::*;
use scirs2_core::ndarray::array;
fn build_two_func_problem() -> MinimaxProblem {
MinimaxProblem::new(vec![
Box::new(|x: &ArrayView1<f64>| (x[0] - 1.0).powi(2)),
Box::new(|x: &ArrayView1<f64>| (x[0] + 1.0).powi(2)),
])
}
#[test]
fn test_minimax_problem_eval() {
let p = build_two_func_problem();
let x = array![0.0];
assert_eq!(p.eval_max(&x.view()), 1.0);
let x2 = array![1.0];
assert!((p.eval_max(&x2.view()) - 4.0).abs() < 1e-9);
}
#[test]
fn test_subgradient_basic() {
let p = build_two_func_problem();
let x0 = array![3.0];
let config = MinimaxSolverConfig {
max_iter: 3_000,
tol: 1e-4,
step_size: 0.5,
..Default::default()
};
let result = minimax_subgradient(&p, &x0.view(), &config).expect("failed to create result");
assert!(
result.fun <= 1.5,
"subgradient minimax value {} should be ≤ 1.5",
result.fun
);
assert!(
result.x[0].abs() < 1.0,
"subgradient minimizer {} should be near 0",
result.x[0]
);
}
#[test]
fn test_smooth_minimax_basic() {
let p = build_two_func_problem();
let x0 = array![3.0];
let config = MinimaxSolverConfig {
max_iter: 3_000,
tol: 1e-5,
step_size: 1e-2,
smoothing_mu: 0.05,
..Default::default()
};
let result = smooth_minimax(&p, &x0.view(), &config).expect("failed to create result");
assert!(
result.fun <= 2.0,
"smooth minimax value {} should be ≤ 2.0",
result.fun
);
}
#[test]
fn test_game_theoretic_minimax() {
let p = build_two_func_problem();
let x0 = array![2.0];
let config = MinimaxSolverConfig {
max_iter: 500,
tol: 1e-4,
fictitious_play_iter: 500,
..Default::default()
};
let result = game_theoretic_minimax(&p, &x0.view(), 0.1, &config).expect("failed to create result");
assert!(
result.x[0].abs() < 2.5,
"game theoretic minimizer {} should move toward 0",
result.x[0]
);
assert_eq!(result.maximizer_strategy.len(), 2);
let strat_sum: f64 = result.maximizer_strategy.iter().sum();
assert!((strat_sum - 1.0).abs() < 1e-9, "strategy should sum to 1");
}
#[test]
fn test_smooth_max_value() {
let funcs: Vec<Box<dyn Fn(&ArrayView1<f64>) -> f64>> = vec![
Box::new(|_x: &ArrayView1<f64>| 1.0),
Box::new(|_x: &ArrayView1<f64>| 2.0),
Box::new(|_x: &ArrayView1<f64>| 3.0),
];
let x = array![0.0];
let val = smooth_max_value(&funcs, &x.view(), 0.01).expect("failed to create val");
assert!((val - 3.0).abs() < 0.1, "smooth max ≈ 3.0, got {val}");
}
#[test]
fn test_bundle_method_basic() {
let p = build_two_func_problem();
let x0 = array![2.0];
let config = MinimaxSolverConfig {
max_iter: 500,
tol: 1e-4,
step_size: 0.5,
..Default::default()
};
let result = minimax_bundle(&p, &x0.view(), &config).expect("failed to create result");
assert!(
result.fun <= 5.0,
"bundle minimax value {} should be reasonable",
result.fun
);
}
#[test]
fn test_empty_problem_error() {
let p = MinimaxProblem::new(vec![]);
let x0 = array![1.0];
let config = MinimaxSolverConfig::default();
assert!(minimax_subgradient(&p, &x0.view(), &config).is_err());
assert!(smooth_minimax(&p, &x0.view(), &config).is_err());
}
#[test]
fn test_empty_x0_error() {
let p = build_two_func_problem();
let x0: Array1<f64> = Array1::zeros(0);
let config = MinimaxSolverConfig::default();
assert!(minimax_subgradient(&p, &x0.view(), &config).is_err());
}
}