use super::{utils, MultiObjectiveConfig, MultiObjectiveOptimizer};
use crate::error::OptimizeError;
use crate::multi_objective::solutions::{MultiObjectiveResult, MultiObjectiveSolution, Population};
use scirs2_core::ndarray::{Array1, ArrayView1};
use scirs2_core::random::rngs::StdRng;
use scirs2_core::random::{Rng, RngExt, SeedableRng};
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ScalarizationMethod {
Tchebycheff,
WeightedSum,
PBI,
}
impl Default for ScalarizationMethod {
fn default() -> Self {
ScalarizationMethod::Tchebycheff
}
}
pub struct MOEAD {
config: MultiObjectiveConfig,
n_objectives: usize,
n_variables: usize,
weight_vectors: Vec<Array1<f64>>,
neighborhoods: Vec<Vec<usize>>,
neighborhood_size: usize,
solutions: Vec<MultiObjectiveSolution>,
ideal_point: Array1<f64>,
scalarization: ScalarizationMethod,
population: Population,
generation: usize,
n_evaluations: usize,
rng: StdRng,
cr: f64,
f_scale: f64,
delta: f64,
nr: usize,
convergence_history: Vec<f64>,
}
impl MOEAD {
pub fn new(population_size: usize, n_objectives: usize, n_variables: usize) -> Self {
let config = MultiObjectiveConfig {
population_size,
..Default::default()
};
Self::with_config(
config,
n_objectives,
n_variables,
ScalarizationMethod::Tchebycheff,
)
}
pub fn with_config(
config: MultiObjectiveConfig,
n_objectives: usize,
n_variables: usize,
scalarization: ScalarizationMethod,
) -> Self {
let neighborhood_size = config.neighborhood_size.unwrap_or(20);
let neighborhood_size = neighborhood_size.min(config.population_size);
let seed = config.random_seed.unwrap_or_else(|| {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(42)
});
let rng = StdRng::seed_from_u64(seed);
let weight_vectors = generate_uniform_weight_vectors(n_objectives, config.population_size);
let actual_pop_size = weight_vectors.len();
let neighborhoods = compute_neighborhoods(&weight_vectors, neighborhood_size);
Self {
config: MultiObjectiveConfig {
population_size: actual_pop_size,
..config
},
n_objectives,
n_variables,
weight_vectors,
neighborhoods,
neighborhood_size,
solutions: Vec::new(),
ideal_point: Array1::from_elem(n_objectives, f64::INFINITY),
scalarization,
population: Population::new(),
generation: 0,
n_evaluations: 0,
rng,
cr: 1.0, f_scale: 0.5, delta: 0.9, nr: 2, convergence_history: Vec::new(),
}
}
fn evaluate_individual<F>(
&mut self,
variables: &Array1<f64>,
objective_function: &F,
) -> Result<Array1<f64>, OptimizeError>
where
F: Fn(&ArrayView1<f64>) -> Array1<f64> + Send + Sync,
{
self.n_evaluations += 1;
if let Some(max_evals) = self.config.max_evaluations {
if self.n_evaluations > max_evals {
return Err(OptimizeError::MaxEvaluationsReached);
}
}
let objectives = objective_function(&variables.view());
if objectives.len() != self.n_objectives {
return Err(OptimizeError::InvalidInput(format!(
"Expected {} objectives, got {}",
self.n_objectives,
objectives.len()
)));
}
Ok(objectives)
}
fn update_ideal_point(&mut self, objectives: &Array1<f64>) {
for (i, &obj) in objectives.iter().enumerate() {
if obj < self.ideal_point[i] {
self.ideal_point[i] = obj;
}
}
}
fn scalar_value(&self, objectives: &Array1<f64>, weight: &Array1<f64>) -> f64 {
match self.scalarization {
ScalarizationMethod::Tchebycheff => {
objectives
.iter()
.zip(weight.iter())
.zip(self.ideal_point.iter())
.map(|((obj, w), ideal)| {
let w_eff = if *w < 1e-10 { 1e-10 } else { *w };
w_eff * (obj - ideal).abs()
})
.fold(0.0_f64, f64::max)
}
ScalarizationMethod::WeightedSum => {
objectives
.iter()
.zip(weight.iter())
.map(|(obj, w)| w * obj)
.sum()
}
ScalarizationMethod::PBI => {
let theta = 5.0;
let diff: Array1<f64> = objectives - &self.ideal_point;
let w_norm = weight.dot(weight).sqrt();
if w_norm < 1e-30 {
return diff.dot(&diff).sqrt();
}
let w_unit = weight / w_norm;
let d1 = diff.dot(&w_unit).abs();
let proj = d1 * &w_unit;
let perp = &diff - &proj;
let d2 = perp.dot(&perp).sqrt();
d1 + theta * d2
}
}
}
fn generate_offspring(&mut self, subproblem_idx: usize) -> Array1<f64> {
let pool = if self.rng.random::<f64>() < self.delta {
&self.neighborhoods[subproblem_idx]
} else {
&self.neighborhoods[subproblem_idx]
};
if pool.len() < 3 || self.solutions.len() < 3 {
let current = &self.solutions[subproblem_idx].variables;
let mut child = current.clone();
let bounds: Vec<(f64, f64)> = if let Some((lower, upper)) = &self.config.bounds {
lower
.iter()
.zip(upper.iter())
.map(|(&l, &u)| (l, u))
.collect()
} else {
vec![(-1.0, 1.0); self.n_variables]
};
for j in 0..self.n_variables {
if self.rng.random::<f64>() < 0.1 {
let (lb, ub) = bounds[j];
child[j] = self.rng.random_range(lb..ub);
}
}
return child;
}
let mut indices = Vec::with_capacity(3);
let mut attempts = 0;
while indices.len() < 3 && attempts < 100 {
let idx = pool[self.rng.random_range(0..pool.len())];
if idx < self.solutions.len() && !indices.contains(&idx) && idx != subproblem_idx {
indices.push(idx);
}
attempts += 1;
}
if indices.len() < 3 {
let current = &self.solutions[subproblem_idx].variables;
return current.clone();
}
let x1 = &self.solutions[indices[0]].variables;
let x2 = &self.solutions[indices[1]].variables;
let x3 = &self.solutions[indices[2]].variables;
let current = &self.solutions[subproblem_idx].variables;
let mut child = Array1::zeros(self.n_variables);
let j_rand = self.rng.random_range(0..self.n_variables);
for j in 0..self.n_variables {
if self.rng.random::<f64>() < self.cr || j == j_rand {
child[j] = x1[j] + self.f_scale * (x2[j] - x3[j]);
} else {
child[j] = current[j];
}
}
if let Some((lower, upper)) = &self.config.bounds {
for j in 0..self.n_variables {
child[j] = child[j].max(lower[j]).min(upper[j]);
}
}
child
}
fn update_neighbors(&mut self, subproblem_idx: usize, offspring: &MultiObjectiveSolution) {
let neighbors = self.neighborhoods[subproblem_idx].clone();
let mut count = 0;
for &neighbor_idx in &neighbors {
if count >= self.nr {
break;
}
if neighbor_idx >= self.solutions.len() || neighbor_idx >= self.weight_vectors.len() {
continue;
}
let w = &self.weight_vectors[neighbor_idx];
let new_scalar = self.scalar_value(&offspring.objectives, w);
let old_scalar = self.scalar_value(&self.solutions[neighbor_idx].objectives, w);
if new_scalar < old_scalar {
self.solutions[neighbor_idx] = offspring.clone();
count += 1;
}
}
}
fn calculate_metrics(&mut self) {
if let Some(ref_point) = &self.config.reference_point {
let pop = Population::from_solutions(self.solutions.clone());
let pareto_front = pop.extract_pareto_front();
let hv = utils::calculate_hypervolume(&pareto_front, ref_point);
self.convergence_history.push(hv);
}
}
}
impl MultiObjectiveOptimizer for MOEAD {
fn optimize<F>(&mut self, objective_function: F) -> Result<MultiObjectiveResult, OptimizeError>
where
F: Fn(&ArrayView1<f64>) -> Array1<f64> + Send + Sync,
{
self.initialize_population()?;
let pop_size = self.weight_vectors.len();
let initial_vars =
utils::generate_random_population(pop_size, self.n_variables, &self.config.bounds);
self.solutions.clear();
for vars in initial_vars {
let objs = self.evaluate_individual(&vars, &objective_function)?;
self.update_ideal_point(&objs);
self.solutions.push(MultiObjectiveSolution::new(vars, objs));
}
while self.generation < self.config.max_generations {
if self.check_convergence() {
break;
}
self.evolve_generation(&objective_function)?;
}
let pop = Population::from_solutions(self.solutions.clone());
let pareto_front = pop.extract_pareto_front();
let hypervolume = self
.config
.reference_point
.as_ref()
.map(|rp| utils::calculate_hypervolume(&pareto_front, rp));
let mut result = MultiObjectiveResult::new(
pareto_front,
self.solutions.clone(),
self.n_evaluations,
self.generation,
);
result.hypervolume = hypervolume;
result.metrics.convergence_history = self.convergence_history.clone();
Ok(result)
}
fn evolve_generation<F>(&mut self, objective_function: &F) -> Result<(), OptimizeError>
where
F: Fn(&ArrayView1<f64>) -> Array1<f64> + Send + Sync,
{
let pop_size = self.solutions.len();
for i in 0..pop_size {
let child_vars = self.generate_offspring(i);
let child_objs = self.evaluate_individual(&child_vars, objective_function)?;
self.update_ideal_point(&child_objs);
let offspring = MultiObjectiveSolution::new(child_vars, child_objs);
self.update_neighbors(i, &offspring);
}
self.generation += 1;
self.population = Population::from_solutions(self.solutions.clone());
self.calculate_metrics();
Ok(())
}
fn initialize_population(&mut self) -> Result<(), OptimizeError> {
self.solutions.clear();
self.population.clear();
self.generation = 0;
self.n_evaluations = 0;
self.ideal_point = Array1::from_elem(self.n_objectives, f64::INFINITY);
self.convergence_history.clear();
Ok(())
}
fn check_convergence(&self) -> bool {
if let Some(max_evals) = self.config.max_evaluations {
if self.n_evaluations >= max_evals {
return true;
}
}
if self.convergence_history.len() >= 10 {
let recent = &self.convergence_history[self.convergence_history.len() - 10..];
let max_hv = recent.iter().fold(f64::NEG_INFINITY, |a, &b| a.max(b));
let min_hv = recent.iter().fold(f64::INFINITY, |a, &b| a.min(b));
if (max_hv - min_hv) < self.config.tolerance {
return true;
}
}
false
}
fn get_population(&self) -> &Population {
&self.population
}
fn get_generation(&self) -> usize {
self.generation
}
fn get_evaluations(&self) -> usize {
self.n_evaluations
}
fn name(&self) -> &str {
"MOEA/D"
}
}
fn generate_uniform_weight_vectors(n_objectives: usize, target_size: usize) -> Vec<Array1<f64>> {
if n_objectives == 0 {
return vec![];
}
if n_objectives == 1 {
return vec![Array1::from_elem(1, 1.0); target_size.max(1)];
}
let mut h = 1;
loop {
let n_points = binomial_coefficient(h + n_objectives - 1, n_objectives - 1);
if n_points >= target_size {
break;
}
h += 1;
if h > 100 {
break;
}
}
let mut vectors = Vec::new();
generate_weight_recursive(
&mut vectors,
Array1::zeros(n_objectives),
0,
n_objectives,
h,
h,
);
for v in &mut vectors {
let sum: f64 = v.sum();
if sum > 0.0 {
*v /= sum;
}
}
if vectors.len() > target_size {
vectors.truncate(target_size);
}
vectors
}
fn generate_weight_recursive(
vectors: &mut Vec<Array1<f64>>,
mut current: Array1<f64>,
index: usize,
n_objectives: usize,
h: usize,
remaining: usize,
) {
if index == n_objectives - 1 {
current[index] = remaining as f64;
vectors.push(current);
} else {
for i in 0..=remaining {
current[index] = i as f64;
generate_weight_recursive(
vectors,
current.clone(),
index + 1,
n_objectives,
h,
remaining - i,
);
}
}
}
fn binomial_coefficient(n: usize, k: usize) -> usize {
if k > n {
return 0;
}
if k == 0 || k == n {
return 1;
}
let k = k.min(n - k);
let mut result = 1usize;
for i in 0..k {
result = result.saturating_mul(n - i);
result /= i + 1;
}
result
}
fn compute_neighborhoods(
weight_vectors: &[Array1<f64>],
neighborhood_size: usize,
) -> Vec<Vec<usize>> {
let n = weight_vectors.len();
let t = neighborhood_size.min(n);
let mut neighborhoods = Vec::with_capacity(n);
for i in 0..n {
let mut distances: Vec<(usize, f64)> = (0..n)
.map(|j| {
let diff = &weight_vectors[i] - &weight_vectors[j];
let dist = diff.dot(&diff).sqrt();
(j, dist)
})
.collect();
distances.sort_by(|a, b| a.1.total_cmp(&b.1));
let neighbors: Vec<usize> = distances.iter().take(t).map(|(idx, _)| *idx).collect();
neighborhoods.push(neighbors);
}
neighborhoods
}
#[cfg(test)]
mod tests {
use super::*;
use scirs2_core::ndarray::{array, s};
fn zdt1(x: &ArrayView1<f64>) -> Array1<f64> {
let f1 = x[0];
let g = 1.0 + 9.0 * x.slice(s![1..]).sum() / (x.len() - 1) as f64;
let f2 = g * (1.0 - (f1 / g).sqrt());
array![f1, f2]
}
#[test]
fn test_moead_creation() {
let moead = MOEAD::new(50, 2, 3);
assert_eq!(moead.n_objectives, 2);
assert_eq!(moead.n_variables, 3);
assert!(!moead.weight_vectors.is_empty());
assert_eq!(moead.generation, 0);
}
#[test]
fn test_moead_with_config() {
let config = MultiObjectiveConfig {
population_size: 30,
neighborhood_size: Some(10),
max_generations: 10,
random_seed: Some(42),
..Default::default()
};
let moead = MOEAD::with_config(config, 2, 3, ScalarizationMethod::Tchebycheff);
assert_eq!(moead.neighborhood_size, 10);
}
#[test]
fn test_moead_optimize_zdt1() {
let config = MultiObjectiveConfig {
max_generations: 10,
population_size: 20,
bounds: Some((Array1::zeros(3), Array1::ones(3))),
random_seed: Some(42),
neighborhood_size: Some(5),
..Default::default()
};
let mut moead = MOEAD::with_config(config, 2, 3, ScalarizationMethod::Tchebycheff);
let result = moead.optimize(zdt1);
assert!(result.is_ok());
let res = result.expect("should succeed");
assert!(res.success);
assert!(!res.pareto_front.is_empty());
assert!(res.n_evaluations > 0);
}
#[test]
fn test_moead_weighted_sum() {
let config = MultiObjectiveConfig {
max_generations: 10,
population_size: 20,
bounds: Some((Array1::zeros(3), Array1::ones(3))),
random_seed: Some(42),
..Default::default()
};
let mut moead = MOEAD::with_config(config, 2, 3, ScalarizationMethod::WeightedSum);
let result = moead.optimize(zdt1);
assert!(result.is_ok());
let res = result.expect("should succeed");
assert!(res.success);
}
#[test]
fn test_moead_pbi() {
let config = MultiObjectiveConfig {
max_generations: 10,
population_size: 20,
bounds: Some((Array1::zeros(3), Array1::ones(3))),
random_seed: Some(42),
..Default::default()
};
let mut moead = MOEAD::with_config(config, 2, 3, ScalarizationMethod::PBI);
let result = moead.optimize(zdt1);
assert!(result.is_ok());
let res = result.expect("should succeed");
assert!(res.success);
}
#[test]
fn test_moead_max_evaluations() {
let config = MultiObjectiveConfig {
max_generations: 1000,
max_evaluations: Some(50),
population_size: 10,
bounds: Some((Array1::zeros(3), Array1::ones(3))),
random_seed: Some(42),
..Default::default()
};
let mut moead = MOEAD::with_config(config, 2, 3, ScalarizationMethod::Tchebycheff);
let result = moead.optimize(zdt1);
assert!(result.is_ok());
let res = result.expect("should succeed");
assert!(res.n_evaluations <= 70); }
#[test]
fn test_weight_vector_generation() {
let vectors = generate_uniform_weight_vectors(2, 10);
assert!(!vectors.is_empty());
for v in &vectors {
let sum: f64 = v.sum();
assert!(
(sum - 1.0).abs() < 1e-10,
"Weight sum should be 1.0, got {}",
sum
);
}
for v in &vectors {
for &w in v.iter() {
assert!(w >= -1e-10, "Weight should be >= 0, got {}", w);
}
}
}
#[test]
fn test_weight_vector_3d() {
let vectors = generate_uniform_weight_vectors(3, 20);
assert!(!vectors.is_empty());
for v in &vectors {
assert_eq!(v.len(), 3);
let sum: f64 = v.sum();
assert!((sum - 1.0).abs() < 1e-10);
}
}
#[test]
fn test_neighborhood_computation() {
let vectors = vec![array![1.0, 0.0], array![0.5, 0.5], array![0.0, 1.0]];
let neighborhoods = compute_neighborhoods(&vectors, 2);
assert_eq!(neighborhoods.len(), 3);
for n in &neighborhoods {
assert_eq!(n.len(), 2);
}
}
#[test]
fn test_scalarization_tchebycheff() {
let config = MultiObjectiveConfig {
population_size: 10,
..Default::default()
};
let moead = MOEAD::with_config(config, 2, 2, ScalarizationMethod::Tchebycheff);
let objectives = array![2.0, 3.0];
let weight = array![0.5, 0.5];
let val = moead.scalar_value(&objectives, &weight);
assert!(val.is_finite() || val.is_infinite()); }
#[test]
fn test_moead_name() {
let moead = MOEAD::new(50, 2, 3);
assert_eq!(moead.name(), "MOEA/D");
}
#[test]
fn test_binomial_coefficient() {
assert_eq!(binomial_coefficient(5, 2), 10);
assert_eq!(binomial_coefficient(10, 3), 120);
assert_eq!(binomial_coefficient(4, 0), 1);
assert_eq!(binomial_coefficient(0, 0), 1);
}
#[test]
fn test_moead_weight_vectors_span_front() {
let config = MultiObjectiveConfig {
max_generations: 30,
population_size: 30,
bounds: Some((Array1::zeros(3), Array1::ones(3))),
random_seed: Some(77),
neighborhood_size: Some(10),
..Default::default()
};
let mut moead = MOEAD::with_config(config, 2, 3, ScalarizationMethod::Tchebycheff);
let result = moead.optimize(zdt1).expect("should succeed");
assert!(result.success);
assert!(!result.pareto_front.is_empty());
let all_f1: Vec<f64> = result.population.iter().map(|s| s.objectives[0]).collect();
let unique: std::collections::HashSet<u64> = all_f1.iter().map(|v| v.to_bits()).collect();
assert!(unique.len() > 1, "Solutions should have varied f1 values");
}
#[test]
fn test_moead_tchebycheff_vs_weighted_sum() {
let make_config = || MultiObjectiveConfig {
max_generations: 15,
population_size: 20,
bounds: Some((Array1::zeros(3), Array1::ones(3))),
random_seed: Some(42),
neighborhood_size: Some(5),
..Default::default()
};
let mut moead_tch =
MOEAD::with_config(make_config(), 2, 3, ScalarizationMethod::Tchebycheff);
let result_tch = moead_tch
.optimize(zdt1)
.expect("Tchebycheff should succeed");
let mut moead_ws =
MOEAD::with_config(make_config(), 2, 3, ScalarizationMethod::WeightedSum);
let result_ws = moead_ws.optimize(zdt1).expect("WeightedSum should succeed");
assert!(!result_tch.pareto_front.is_empty());
assert!(!result_ws.pareto_front.is_empty());
assert!(result_tch.success);
assert!(result_ws.success);
}
#[test]
fn test_moead_ideal_point_tracking() {
let config = MultiObjectiveConfig {
max_generations: 10,
population_size: 20,
bounds: Some((Array1::zeros(3), Array1::ones(3))),
random_seed: Some(42),
neighborhood_size: Some(5),
..Default::default()
};
let mut moead = MOEAD::with_config(config, 2, 3, ScalarizationMethod::Tchebycheff);
let _result = moead.optimize(zdt1).expect("should succeed");
assert!(
moead.ideal_point[0].is_finite(),
"Ideal point[0] should be finite after optimization"
);
assert!(
moead.ideal_point[1].is_finite(),
"Ideal point[1] should be finite after optimization"
);
}
#[test]
fn test_moead_deterministic_evaluation_count() {
let config = MultiObjectiveConfig {
max_generations: 5,
population_size: 15,
bounds: Some((Array1::zeros(3), Array1::ones(3))),
random_seed: Some(42),
neighborhood_size: Some(5),
..Default::default()
};
let mut moead = MOEAD::with_config(config, 2, 3, ScalarizationMethod::Tchebycheff);
let result = moead.optimize(zdt1).expect("should succeed");
assert!(result.n_evaluations > 10, "Should have > 10 evaluations");
assert!(result.success);
assert!(!result.pareto_front.is_empty());
}
#[test]
fn test_moead_scalarization_values() {
let config = MultiObjectiveConfig {
population_size: 10,
..Default::default()
};
let mut moead = MOEAD::with_config(config, 2, 2, ScalarizationMethod::WeightedSum);
moead.ideal_point = array![0.0, 0.0];
let objectives = array![2.0, 3.0];
let weight = array![0.5, 0.5];
let val = moead.scalar_value(&objectives, &weight);
assert!(
(val - 2.5).abs() < 1e-10,
"WeightedSum should be 2.5, got {}",
val
);
}
#[test]
fn test_moead_tchebycheff_scalarization_value() {
let config = MultiObjectiveConfig {
population_size: 10,
..Default::default()
};
let mut moead = MOEAD::with_config(config, 2, 2, ScalarizationMethod::Tchebycheff);
moead.ideal_point = array![0.0, 0.0];
let objectives = array![2.0, 3.0];
let weight = array![0.5, 0.5];
let val = moead.scalar_value(&objectives, &weight);
assert!(
(val - 1.5).abs() < 1e-10,
"Tchebycheff should be 1.5, got {}",
val
);
}
}