use std::fmt::Debug;
use scirs2_core::num_traits::{Float, FromPrimitive};
use crate::error::{OptimizeError, OptimizeResult};
#[derive(Debug, Clone)]
pub struct ColumnGenerationConfig {
pub max_iter: usize,
pub tol: f64,
pub stabilization_rho: f64,
pub max_columns_per_iter: usize,
pub dual_step_size: f64,
pub dual_max_iter: usize,
}
impl Default for ColumnGenerationConfig {
fn default() -> Self {
Self {
max_iter: 100,
tol: 1e-6,
stabilization_rho: 0.0,
max_columns_per_iter: 5,
dual_step_size: 0.1,
dual_max_iter: 500,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum ConstraintSense {
Eq,
Le,
Ge,
}
#[derive(Debug, Clone)]
pub struct Column<F> {
pub coefficients: Vec<F>,
pub cost: F,
pub label: String,
}
impl<F: Clone + Debug> Column<F> {
pub fn new(coefficients: Vec<F>, cost: F, label: impl Into<String>) -> Self {
Self {
coefficients,
cost,
label: label.into(),
}
}
}
#[derive(Debug, Clone)]
pub struct MasterLp<F> {
pub n_constraints: usize,
pub columns: Vec<Column<F>>,
pub rhs: Vec<F>,
pub constraint_sense: Vec<ConstraintSense>,
}
impl<F: Float + FromPrimitive + Debug + Clone> MasterLp<F> {
pub fn new(
n_constraints: usize,
rhs: Vec<F>,
constraint_sense: Vec<ConstraintSense>,
) -> OptimizeResult<Self> {
if rhs.len() != n_constraints {
return Err(OptimizeError::InvalidInput(format!(
"rhs length {} != n_constraints {}",
rhs.len(),
n_constraints
)));
}
if constraint_sense.len() != n_constraints {
return Err(OptimizeError::InvalidInput(format!(
"constraint_sense length {} != n_constraints {}",
constraint_sense.len(),
n_constraints
)));
}
Ok(Self {
n_constraints,
columns: Vec::new(),
rhs,
constraint_sense,
})
}
pub fn add_column(&mut self, col: Column<F>) -> OptimizeResult<()> {
if col.coefficients.len() != self.n_constraints {
return Err(OptimizeError::InvalidInput(format!(
"column has {} coefficients but master has {} constraints",
col.coefficients.len(),
self.n_constraints
)));
}
self.columns.push(col);
Ok(())
}
pub fn n_columns(&self) -> usize {
self.columns.len()
}
}
pub trait ColumnGenerationProblem<F: Float + FromPrimitive + Debug + Clone> {
fn n_constraints(&self) -> usize;
fn solve_pricing(&self, dual_vars: &[F]) -> Option<Column<F>>;
fn initial_columns(&self) -> Vec<Column<F>>;
fn initial_rhs(&self) -> Vec<F> {
vec![F::one(); self.n_constraints()]
}
fn initial_senses(&self) -> Vec<ConstraintSense> {
vec![ConstraintSense::Eq; self.n_constraints()]
}
}
#[derive(Debug, Clone)]
pub struct ColumnGenerationResult<F> {
pub objective: F,
pub dual_vars: Vec<F>,
pub n_columns_added: usize,
pub n_iters: usize,
pub optimal: bool,
pub primal: Vec<F>,
}
fn solve_rmp_dual<F>(
master: &MasterLp<F>,
config: &ColumnGenerationConfig,
) -> (Vec<F>, Vec<F>, F)
where
F: Float + FromPrimitive + Debug + Clone + std::ops::AddAssign + std::ops::MulAssign,
{
let m = master.n_constraints;
let step = F::from_f64(config.dual_step_size).unwrap_or(F::epsilon());
let mut pi: Vec<F> = vec![F::zero(); m];
let max_iter = config.dual_max_iter;
let tol = F::from_f64(config.tol).unwrap_or(F::epsilon());
for _it in 0..max_iter {
let mut subgrad: Vec<F> = master.rhs.clone();
for col in &master.columns {
let pi_dot_a: F = pi
.iter()
.zip(col.coefficients.iter())
.fold(F::zero(), |acc, (&pi_i, &a_ij)| acc + pi_i * a_ij);
let rc = col.cost - pi_dot_a;
if rc <= F::zero() {
for (i, &a_ij) in col.coefficients.iter().enumerate() {
if i < subgrad.len() {
subgrad[i] = subgrad[i] - a_ij;
}
}
}
}
let norm_sq: F = subgrad.iter().fold(F::zero(), |a, &g| a + g * g);
if norm_sq < tol * tol {
break;
}
for (pi_i, &g_i) in pi.iter_mut().zip(subgrad.iter()) {
*pi_i = *pi_i + step * g_i;
}
for (i, sense) in master.constraint_sense.iter().enumerate() {
if i < m {
match sense {
ConstraintSense::Le => {
if pi[i] > F::zero() {
pi[i] = F::zero();
}
}
ConstraintSense::Ge => {
if pi[i] < F::zero() {
pi[i] = F::zero();
}
}
ConstraintSense::Eq => {} _ => {}
}
}
}
}
let n_cols = master.columns.len();
let mut primal = vec![F::zero(); n_cols];
let rc_vals: Vec<F> = master
.columns
.iter()
.map(|col| {
let pi_dot_a: F = pi
.iter()
.zip(col.coefficients.iter())
.fold(F::zero(), |acc, (&pi_i, &a_ij)| acc + pi_i * a_ij);
col.cost - pi_dot_a
})
.collect();
let min_rc = rc_vals.iter().fold(F::infinity(), |a, &b| if b < a { b } else { a });
let zero = F::zero();
let rc_tol = F::from_f64(1e-8).unwrap_or(zero);
let active: Vec<usize> = rc_vals
.iter()
.enumerate()
.filter_map(|(j, &rc)| {
if (rc - min_rc).abs() < rc_tol {
Some(j)
} else {
None
}
})
.collect();
if !active.is_empty() {
let weight = F::one() / F::from_usize(active.len()).unwrap_or(F::one());
for j in active {
primal[j] = weight;
}
}
let obj: F = primal
.iter()
.zip(master.columns.iter())
.fold(F::zero(), |acc, (&lam, col)| acc + lam * col.cost);
(pi, primal, obj)
}
pub fn column_generation<F, P>(
problem: &P,
config: &ColumnGenerationConfig,
) -> OptimizeResult<ColumnGenerationResult<F>>
where
F: Float
+ FromPrimitive
+ Debug
+ Clone
+ std::ops::AddAssign
+ std::ops::MulAssign,
P: ColumnGenerationProblem<F>,
{
let m = problem.n_constraints();
if m == 0 {
return Err(OptimizeError::InvalidInput(
"problem must have at least one constraint".into(),
));
}
let init_cols = problem.initial_columns();
if init_cols.is_empty() {
return Err(OptimizeError::InvalidInput(
"initial_columns() returned no columns; at least one is required".into(),
));
}
let rhs: Vec<F> = problem.initial_rhs();
let senses: Vec<ConstraintSense> = problem.initial_senses();
let mut master = MasterLp::new(m, rhs, senses)?;
let mut n_columns_added = 0usize;
for col in init_cols {
master.add_column(col)?;
}
let tol = config.tol;
let mut prev_obj = F::infinity();
let mut optimal = false;
for iter in 0..config.max_iter {
let (dual, primal, obj) = solve_rmp_dual(&master, config);
let _ = prev_obj;
prev_obj = obj;
let mut added_this_iter = 0usize;
let mut new_col_opt = problem.solve_pricing(&dual);
while let Some(new_col) = new_col_opt {
let rc: F = dual
.iter()
.zip(new_col.coefficients.iter())
.fold(new_col.cost, |acc, (&pi_i, &a_ij)| acc - pi_i * a_ij);
let rc_tol = F::from_f64(tol).unwrap_or(F::zero());
if rc >= -rc_tol {
break;
}
master.add_column(new_col)?;
n_columns_added += 1;
added_this_iter += 1;
if added_this_iter >= config.max_columns_per_iter {
break;
}
new_col_opt = if added_this_iter < config.max_columns_per_iter {
problem.solve_pricing(&dual)
} else {
None
};
}
if added_this_iter == 0 {
optimal = true;
let (final_dual, final_primal, final_obj) = solve_rmp_dual(&master, config);
return Ok(ColumnGenerationResult {
objective: final_obj,
dual_vars: final_dual,
n_columns_added,
n_iters: iter + 1,
optimal: true,
primal: final_primal,
});
}
}
let (final_dual, final_primal, final_obj) = solve_rmp_dual(&master, config);
Ok(ColumnGenerationResult {
objective: final_obj,
dual_vars: final_dual,
n_columns_added,
n_iters: config.max_iter,
optimal,
primal: final_primal,
})
}
#[cfg(test)]
mod tests {
use super::*;
type F = f64;
struct SingleConstraintProblem;
impl ColumnGenerationProblem<F> for SingleConstraintProblem {
fn n_constraints(&self) -> usize { 1 }
fn solve_pricing(&self, dual_vars: &[F]) -> Option<Column<F>> {
let pi = dual_vars.first().copied().unwrap_or(0.0);
let rc = 1.0 - pi * 1.0; if rc < -1e-8 {
Some(Column::new(vec![1.0_f64], 1.0, "extra"))
} else {
None
}
}
fn initial_columns(&self) -> Vec<Column<F>> {
vec![Column::new(vec![1.0_f64], 1.0, "x1")]
}
}
struct OnePricingIterProblem {
called: std::cell::Cell<usize>,
}
impl OnePricingIterProblem {
fn new() -> Self { Self { called: std::cell::Cell::new(0) } }
}
impl ColumnGenerationProblem<F> for OnePricingIterProblem {
fn n_constraints(&self) -> usize { 2 }
fn solve_pricing(&self, _dual_vars: &[F]) -> Option<Column<F>> {
let c = self.called.get();
self.called.set(c + 1);
if c == 0 {
Some(Column::new(vec![1.0_f64, 0.0], -100.0, "cheap"))
} else {
None
}
}
fn initial_columns(&self) -> Vec<Column<F>> {
vec![
Column::new(vec![1.0_f64, 0.0], 1.0, "slack1"),
Column::new(vec![0.0_f64, 1.0], 1.0, "slack2"),
]
}
fn initial_rhs(&self) -> Vec<F> { vec![1.0, 1.0] }
fn initial_senses(&self) -> Vec<ConstraintSense> {
vec![ConstraintSense::Eq, ConstraintSense::Eq]
}
}
#[test]
fn test_config_defaults() {
let cfg = ColumnGenerationConfig::default();
assert_eq!(cfg.max_iter, 100);
assert!((cfg.tol - 1e-6).abs() < 1e-12);
assert!((cfg.stabilization_rho).abs() < 1e-12);
assert_eq!(cfg.max_columns_per_iter, 5);
}
#[test]
fn test_master_lp_add_column() {
let mut master = MasterLp::<F>::new(
2,
vec![1.0, 1.0],
vec![ConstraintSense::Eq, ConstraintSense::Eq],
)
.unwrap();
assert_eq!(master.n_columns(), 0);
master.add_column(Column::new(vec![1.0, 0.0], 2.0, "col1")).unwrap();
assert_eq!(master.n_columns(), 1);
}
#[test]
fn test_master_lp_wrong_size_rejected() {
let mut master = MasterLp::<F>::new(
2,
vec![1.0, 1.0],
vec![ConstraintSense::Eq, ConstraintSense::Eq],
)
.unwrap();
let result = master.add_column(Column::new(vec![1.0], 2.0, "bad"));
assert!(result.is_err());
}
#[test]
fn test_single_constraint_lp() {
let problem = SingleConstraintProblem;
let cfg = ColumnGenerationConfig {
max_iter: 50,
dual_max_iter: 1000,
dual_step_size: 0.01,
..Default::default()
};
let res = column_generation(&problem, &cfg).unwrap();
assert!(res.optimal, "should be optimal");
assert_eq!(res.dual_vars.len(), 1);
}
#[test]
fn test_column_added_on_pricing() {
let problem = OnePricingIterProblem::new();
let cfg = ColumnGenerationConfig {
max_iter: 10,
dual_max_iter: 200,
dual_step_size: 0.05,
..Default::default()
};
let res = column_generation(&problem, &cfg).unwrap();
assert!(res.n_columns_added >= 1, "should add at least one column");
}
#[test]
fn test_stops_at_optimality() {
let problem = SingleConstraintProblem;
let cfg = ColumnGenerationConfig::default();
let res = column_generation(&problem, &cfg).unwrap();
assert!(res.optimal);
}
#[test]
fn test_dual_vars_length() {
let problem = OnePricingIterProblem::new();
let cfg = ColumnGenerationConfig {
max_iter: 5,
dual_max_iter: 100,
dual_step_size: 0.05,
..Default::default()
};
let res = column_generation(&problem, &cfg).unwrap();
assert_eq!(res.dual_vars.len(), problem.n_constraints());
}
#[test]
fn test_n_columns_added_tracked() {
let problem = OnePricingIterProblem::new();
let cfg = ColumnGenerationConfig {
max_iter: 10,
dual_max_iter: 200,
dual_step_size: 0.05,
..Default::default()
};
let res = column_generation(&problem, &cfg).unwrap();
assert_eq!(res.n_columns_added, 1);
}
#[test]
fn test_optimal_flag_set() {
let problem = SingleConstraintProblem;
let cfg = ColumnGenerationConfig {
max_iter: 50,
dual_max_iter: 500,
dual_step_size: 0.01,
..Default::default()
};
let res = column_generation(&problem, &cfg).unwrap();
assert!(res.optimal, "should reach optimality");
}
#[test]
fn test_initial_columns_present() {
let problem = OnePricingIterProblem::new();
let cfg = ColumnGenerationConfig {
max_iter: 1,
dual_max_iter: 10,
dual_step_size: 0.01,
..Default::default()
};
let res = column_generation(&problem, &cfg).unwrap();
assert!(res.n_columns_added <= 5); }
#[test]
fn test_primal_length_matches_columns() {
let problem = OnePricingIterProblem::new();
let cfg = ColumnGenerationConfig {
max_iter: 5,
dual_max_iter: 100,
dual_step_size: 0.05,
..Default::default()
};
let res = column_generation(&problem, &cfg).unwrap();
let expected_len = 2 + res.n_columns_added; assert_eq!(res.primal.len(), expected_len);
}
}