diff --git a/src/algorithms/mod.rs b/src/algorithms/mod.rs index 2952f7a..6e584c3 100644 --- a/src/algorithms/mod.rs +++ b/src/algorithms/mod.rs @@ -5,8 +5,10 @@ pub mod nsga2; pub mod paes; pub(crate) mod parallel_eval; pub mod random_search; +pub mod spea2; pub use differential_evolution::*; pub use nsga2::*; pub use paes::*; pub use random_search::*; +pub use spea2::*; diff --git a/src/algorithms/spea2.rs b/src/algorithms/spea2.rs new file mode 100644 index 0000000..c4bcdf1 --- /dev/null +++ b/src/algorithms/spea2.rs @@ -0,0 +1,379 @@ +//! SPEA2 — Strength Pareto Evolutionary Algorithm 2 (Zitzler, Laumanns, Thiele 2001). + +use rand::Rng as _; + +use crate::algorithms::parallel_eval::evaluate_batch; +use crate::core::candidate::Candidate; +use crate::core::objective::ObjectiveSpace; +use crate::core::population::Population; +use crate::core::problem::Problem; +use crate::core::result::OptimizationResult; +use crate::core::rng::{Rng, rng_from_seed}; +use crate::pareto::dominance::{Dominance, pareto_compare}; +use crate::pareto::front::{best_candidate, pareto_front}; +use crate::traits::{Initializer, Optimizer, Variation}; + +/// Configuration for [`Spea2`]. +#[derive(Debug, Clone)] +pub struct Spea2Config { + /// Constant population size carried across generations. + pub population_size: usize, + /// Constant archive size; SPEA2 grows or shrinks it to this exact target. + pub archive_size: usize, + /// Number of generations to run. + pub generations: usize, + /// Seed for the deterministic RNG. + pub seed: u64, +} + +impl Default for Spea2Config { + fn default() -> Self { + Self { population_size: 100, archive_size: 100, generations: 250, seed: 42 } + } +} + +/// SPEA2 optimizer. +#[derive(Debug, Clone)] +pub struct Spea2 { + /// Algorithm configuration. + pub config: Spea2Config, + /// Initial-decision sampler. + pub initializer: I, + /// Offspring-producing variation operator. + pub variation: V, +} + +impl Spea2 { + /// Construct a `Spea2` optimizer. + pub fn new(config: Spea2Config, initializer: I, variation: V) -> Self { + Self { config, initializer, variation } + } +} + +impl Optimizer

for Spea2 +where + P: Problem + Sync, + P::Decision: Send, + I: Initializer, + V: Variation, +{ + fn run(&mut self, problem: &P) -> OptimizationResult { + assert!( + self.config.population_size > 0, + "Spea2 population_size must be greater than 0", + ); + assert!( + self.config.archive_size > 0, + "Spea2 archive_size must be greater than 0", + ); + let n_pop = self.config.population_size; + let n_arc = self.config.archive_size; + let objectives = problem.objectives(); + let mut rng = rng_from_seed(self.config.seed); + + // Initial population. + let initial_decisions = self.initializer.initialize(n_pop, &mut rng); + assert_eq!( + initial_decisions.len(), + n_pop, + "SPEA2 initializer must return exactly population_size decisions", + ); + let mut population: Vec> = + evaluate_batch(problem, initial_decisions); + let mut evaluations = population.len(); + let mut archive: Vec> = Vec::new(); + + for _ in 0..self.config.generations { + // --- Combine pool, compute fitness --- + let mut pool: Vec> = + Vec::with_capacity(population.len() + archive.len()); + pool.extend(population.drain(..)); + pool.extend(archive.drain(..)); + let fitness = compute_fitness(&pool, &objectives); + + // --- Build the next archive --- + archive = build_archive(&pool, &fitness, &objectives, n_arc); + + // --- Generate offspring from the archive (mating pool) --- + let archive_fitness = compute_fitness(&archive, &objectives); + let mut offspring_decisions: Vec = Vec::with_capacity(n_pop); + while offspring_decisions.len() < n_pop { + let p1 = binary_tournament(&archive_fitness, &mut rng); + let p2 = binary_tournament(&archive_fitness, &mut rng); + let parents = vec![archive[p1].decision.clone(), archive[p2].decision.clone()]; + let children = self.variation.vary(&parents, &mut rng); + assert!(!children.is_empty(), "SPEA2 variation returned no children"); + for child_decision in children { + if offspring_decisions.len() >= n_pop { + break; + } + offspring_decisions.push(child_decision); + } + } + let new_population = evaluate_batch(problem, offspring_decisions); + evaluations += new_population.len(); + population = new_population; + } + + let front = pareto_front(&archive, &objectives); + let best = best_candidate(&archive, &objectives); + OptimizationResult::new( + Population::new(archive), + front, + best, + evaluations, + self.config.generations, + ) + } +} + +/// SPEA2 fitness: `R(i) + D(i)`, where lower is better. +/// +/// `R(i)` is the sum of `S(j)` over all `j` that dominate `i`. `S(j)` is the +/// count of members `j` dominates. `D(i) = 1 / (σ_k + 2)` where `σ_k` is the +/// distance to the k-th nearest neighbor (k = floor(sqrt(N))) in +/// minimization-oriented objective space. +fn compute_fitness(pool: &[Candidate], objectives: &ObjectiveSpace) -> Vec { + let n = pool.len(); + if n == 0 { + return Vec::new(); + } + let oriented: Vec> = pool + .iter() + .map(|c| objectives.as_minimization(&c.evaluation.objectives)) + .collect(); + + // Strength S(i) = number of members i dominates. + let mut strength = vec![0_usize; n]; + let mut dominators_of: Vec> = vec![Vec::new(); n]; + for i in 0..n { + for j in 0..n { + if i == j { + continue; + } + if matches!( + pareto_compare(&pool[i].evaluation, &pool[j].evaluation, objectives), + Dominance::Dominates + ) { + strength[i] += 1; + dominators_of[j].push(i); + } + } + } + + // Raw fitness R(i) = sum of S(j) over j that dominate i. + let raw: Vec = (0..n) + .map(|i| dominators_of[i].iter().map(|&j| strength[j] as f64).sum()) + .collect(); + + // Density D(i) = 1 / (σ_k + 2). Use kth_nearest distances. + let k = (n as f64).sqrt() as usize; + let density: Vec = (0..n) + .map(|i| { + let mut dists: Vec = (0..n) + .filter(|&j| j != i) + .map(|j| euclidean(&oriented[i], &oriented[j])) + .collect(); + dists.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + // SPEA2's σ_k is the distance to the k-th nearest neighbor (1-indexed). + // With k = floor(sqrt(N)), use index (k-1).clamp(0, len-1). + let idx = if dists.is_empty() { + return 0.0; + } else { + k.saturating_sub(1).min(dists.len() - 1) + }; + 1.0 / (dists[idx] + 2.0) + }) + .collect(); + + raw.into_iter().zip(density).map(|(r, d)| r + d).collect() +} + +fn euclidean(a: &[f64], b: &[f64]) -> f64 { + a.iter().zip(b.iter()).map(|(x, y)| (x - y).powi(2)).sum::().sqrt() +} + +/// Build the next archive of exactly `target_size` members. +/// +/// All non-dominated members of the pool (`fitness < 1.0`) are taken first. +/// If too many, prune by iteratively removing the member with the smallest +/// distance to its nearest neighbor (ties broken by next-nearest, etc.). If +/// too few, fill from the rest sorted by fitness ascending. +fn build_archive( + pool: &[Candidate], + fitness: &[f64], + objectives: &ObjectiveSpace, + target_size: usize, +) -> Vec> { + let mut nondom: Vec = (0..pool.len()).filter(|&i| fitness[i] < 1.0).collect(); + + if nondom.len() == target_size { + return nondom.into_iter().map(|i| pool[i].clone()).collect(); + } + + if nondom.len() < target_size { + // Fill from dominated members ordered by ascending fitness. + let mut dominated: Vec = + (0..pool.len()).filter(|&i| fitness[i] >= 1.0).collect(); + dominated.sort_by(|&a, &b| { + fitness[a].partial_cmp(&fitness[b]).unwrap_or(std::cmp::Ordering::Equal) + }); + let needed = target_size - nondom.len(); + nondom.extend(dominated.into_iter().take(needed)); + return nondom.into_iter().map(|i| pool[i].clone()).collect(); + } + + // Truncation: while too large, drop the member with the smallest distance + // to its nearest neighbor in the current archive. + let oriented: Vec> = nondom + .iter() + .map(|&i| objectives.as_minimization(&pool[i].evaluation.objectives)) + .collect(); + let mut alive: Vec = vec![true; nondom.len()]; + let mut alive_count = nondom.len(); + while alive_count > target_size { + // Compute per-member sorted distances to other alive members. + let mut neighbor_dists: Vec> = vec![Vec::new(); nondom.len()]; + for i in 0..nondom.len() { + if !alive[i] { + continue; + } + for j in 0..nondom.len() { + if !alive[j] || i == j { + continue; + } + neighbor_dists[i].push(euclidean(&oriented[i], &oriented[j])); + } + neighbor_dists[i] + .sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + } + // Find the alive member whose neighbor-distance vector is lex-smallest. + let mut victim = usize::MAX; + for i in 0..nondom.len() { + if !alive[i] { + continue; + } + if victim == usize::MAX { + victim = i; + continue; + } + // Lex-compare neighbor distances. + let cmp = neighbor_dists[i] + .iter() + .zip(neighbor_dists[victim].iter()) + .find_map(|(a, b)| { + let c = a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal); + if c != std::cmp::Ordering::Equal { Some(c) } else { None } + }) + .unwrap_or(std::cmp::Ordering::Equal); + if cmp == std::cmp::Ordering::Less { + victim = i; + } + } + alive[victim] = false; + alive_count -= 1; + } + + nondom + .into_iter() + .enumerate() + .filter_map(|(local, idx)| if alive[local] { Some(pool[idx].clone()) } else { None }) + .collect() +} + +fn binary_tournament(fitness: &[f64], rng: &mut Rng) -> usize { + let n = fitness.len(); + let a = rng.random_range(0..n); + let b = rng.random_range(0..n); + if fitness[a] < fitness[b] { + a + } else if fitness[a] > fitness[b] { + b + } else if rng.random_bool(0.5) { + a + } else { + b + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::operators::{GaussianMutation, RealBounds}; + use crate::tests_support::SchafferN1; + + #[test] + fn produces_pareto_front() { + let mut opt = Spea2::new( + Spea2Config { + population_size: 30, + archive_size: 30, + generations: 10, + seed: 1, + }, + RealBounds::new(vec![(-5.0, 5.0)]), + GaussianMutation { sigma: 0.3 }, + ); + let r = opt.run(&SchafferN1); + assert!(!r.pareto_front.is_empty()); + assert_eq!(r.population.len(), 30); + assert_eq!(r.generations, 10); + } + + #[test] + fn archive_size_respected() { + let mut opt = Spea2::new( + Spea2Config { + population_size: 40, + archive_size: 20, + generations: 15, + seed: 2, + }, + RealBounds::new(vec![(-5.0, 5.0)]), + GaussianMutation { sigma: 0.3 }, + ); + let r = opt.run(&SchafferN1); + assert_eq!(r.population.len(), 20); + } + + #[test] + fn deterministic_with_same_seed() { + let make = || { + Spea2::new( + Spea2Config { + population_size: 20, + archive_size: 20, + generations: 10, + seed: 99, + }, + RealBounds::new(vec![(-5.0, 5.0)]), + GaussianMutation { sigma: 0.2 }, + ) + }; + let mut a = make(); + let mut b = make(); + let ra = a.run(&SchafferN1); + let rb = b.run(&SchafferN1); + let oa: Vec> = + ra.pareto_front.iter().map(|c| c.evaluation.objectives.clone()).collect(); + let ob: Vec> = + rb.pareto_front.iter().map(|c| c.evaluation.objectives.clone()).collect(); + assert_eq!(oa, ob); + } + + #[test] + #[should_panic(expected = "population_size must be greater than 0")] + fn zero_population_size_panics() { + let mut opt = Spea2::new( + Spea2Config { + population_size: 0, + archive_size: 10, + generations: 1, + seed: 0, + }, + RealBounds::new(vec![(-1.0, 1.0)]), + GaussianMutation { sigma: 0.1 }, + ); + let _ = opt.run(&SchafferN1); + } +} diff --git a/src/prelude.rs b/src/prelude.rs index a924679..a4e9fe5 100644 --- a/src/prelude.rs +++ b/src/prelude.rs @@ -23,5 +23,5 @@ pub use crate::operators::{ pub use crate::algorithms::{ DifferentialEvolution, DifferentialEvolutionConfig, Nsga2, Nsga2Config, Paes, PaesConfig, - RandomSearch, RandomSearchConfig, + RandomSearch, RandomSearchConfig, Spea2, Spea2Config, };