From 33a927d86dec66808ae5e49cc51b90124968da37 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Mon, 4 May 2026 19:24:03 -0600 Subject: [PATCH] feat(algorithms): add NSGA-II MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standard (μ+λ) NSGA-II with binary tournament parent selection on (rank, crowding distance) and elitist survival selection on the combined parent + offspring population (spec §12.3): 1. Initialize population_size random decisions. 2. Each generation: select parents by binary tournament (rank ↑ then crowding ↓ then random), apply variation, evaluate offspring, combine, non_dominated_sort, fill the next population front-by-front trimming the partial last front by crowding distance descending. 3. Return final population, Pareto front, best (None for >1 objective), evaluation count, and generation count. Internal Nsga2Entry { candidate, rank, crowding_distance } stays private. Panics with clear messages on `population_size == 0` or empty `vary` output. Tests cover population length, evaluation count, non-empty front, and full determinism with the same seed (spec §18.4). --- src/algorithms/mod.rs | 2 + src/algorithms/nsga2.rs | 269 ++++++++++++++++++++++++++++++++++++++++ src/prelude.rs | 4 +- 3 files changed, 274 insertions(+), 1 deletion(-) create mode 100644 src/algorithms/nsga2.rs diff --git a/src/algorithms/mod.rs b/src/algorithms/mod.rs index 76e15a8..e1b4be9 100644 --- a/src/algorithms/mod.rs +++ b/src/algorithms/mod.rs @@ -1,7 +1,9 @@ //! Built-in reference optimizers. +pub mod nsga2; pub mod paes; pub mod random_search; +pub use nsga2::*; pub use paes::*; pub use random_search::*; diff --git a/src/algorithms/nsga2.rs b/src/algorithms/nsga2.rs new file mode 100644 index 0000000..2c62bbc --- /dev/null +++ b/src/algorithms/nsga2.rs @@ -0,0 +1,269 @@ +//! NSGA-II — the canonical Pareto-based evolutionary algorithm. + +use rand::Rng as _; + +use crate::core::candidate::Candidate; +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::crowding::crowding_distance; +use crate::pareto::front::{best_candidate, pareto_front}; +use crate::pareto::sort::non_dominated_sort; +use crate::traits::{Initializer, Optimizer, Variation}; + +/// Configuration for [`Nsga2`]. +#[derive(Debug, Clone)] +pub struct Nsga2Config { + /// Constant population size carried across generations. + pub population_size: usize, + /// Number of generations to run. + pub generations: usize, + /// Seed for the deterministic RNG. + pub seed: u64, +} + +impl Default for Nsga2Config { + fn default() -> Self { + Self { population_size: 100, generations: 250, seed: 42 } + } +} + +/// NSGA-II optimizer (spec §12.3). +#[derive(Debug, Clone)] +pub struct Nsga2 { + /// Algorithm configuration. + pub config: Nsga2Config, + /// Initial-decision sampler. + pub initializer: I, + /// Offspring-producing variation operator. + pub variation: V, +} + +impl Nsga2 { + /// Construct an `Nsga2` optimizer. + pub fn new(config: Nsga2Config, initializer: I, variation: V) -> Self { + Self { config, initializer, variation } + } +} + +/// Private bookkeeping for NSGA-II survival selection. +struct Nsga2Entry { + candidate: Candidate, + rank: usize, + crowding_distance: f64, +} + +impl Optimizer

for Nsga2 +where + P: Problem, + I: Initializer, + V: Variation, +{ + fn run(&mut self, problem: &P) -> OptimizationResult { + assert!( + self.config.population_size > 0, + "Nsga2 population_size must be greater than 0", + ); + let n = self.config.population_size; + let objectives = problem.objectives(); + let mut rng = rng_from_seed(self.config.seed); + + // Initial population. + let initial_decisions = self.initializer.initialize(n, &mut rng); + assert_eq!( + initial_decisions.len(), + n, + "NSGA-II initializer must return exactly population_size decisions", + ); + let mut population: Vec> = initial_decisions + .into_iter() + .map(|d| { + let e = problem.evaluate(&d); + Candidate::new(d, e) + }) + .collect(); + let mut evaluations = population.len(); + + // Annotate the starting population with rank and crowding so the first + // round of tournament selection has data to compare on. + let mut annotated = annotate(population, &objectives); + + for _ in 0..self.config.generations { + // --- Parent selection + offspring generation --- + let mut offspring: Vec> = Vec::with_capacity(n); + while offspring.len() < n { + let p1 = binary_tournament(&annotated, &mut rng); + let p2 = binary_tournament(&annotated, &mut rng); + let parents = vec![ + annotated[p1].candidate.decision.clone(), + annotated[p2].candidate.decision.clone(), + ]; + let children = self.variation.vary(&parents, &mut rng); + assert!( + !children.is_empty(), + "NSGA-II variation returned no children", + ); + for child_decision in children { + if offspring.len() >= n { + break; + } + let eval = problem.evaluate(&child_decision); + evaluations += 1; + offspring.push(Candidate::new(child_decision, eval)); + } + } + + // --- Combine + survival selection --- + let mut combined: Vec> = Vec::with_capacity(2 * n); + combined.extend(annotated.into_iter().map(|e| e.candidate)); + combined.extend(offspring); + + let fronts = non_dominated_sort(&combined, &objectives); + let mut next: Vec> = Vec::with_capacity(n); + for front in &fronts { + if next.len() + front.len() <= n { + for &idx in front { + next.push(combined[idx].clone()); + } + } else { + // Partial last front: keep the most diverse by crowding. + let dist = crowding_distance(&combined, front, &objectives); + let mut order: Vec = (0..front.len()).collect(); + order.sort_by(|&a, &b| { + dist[b].partial_cmp(&dist[a]).unwrap_or(std::cmp::Ordering::Equal) + }); + let needed = n - next.len(); + for &k in order.iter().take(needed) { + next.push(combined[front[k]].clone()); + } + break; + } + if next.len() == n { + break; + } + } + population = next; + annotated = annotate(population, &objectives); + } + + // Return final state. + let final_pop: Vec> = + annotated.into_iter().map(|e| e.candidate).collect(); + let front = pareto_front(&final_pop, &objectives); + let best = best_candidate(&final_pop, &objectives); + OptimizationResult::new( + Population::new(final_pop), + front, + best, + evaluations, + self.config.generations, + ) + } +} + +fn annotate( + population: Vec>, + objectives: &crate::core::objective::ObjectiveSpace, +) -> Vec> { + let n = population.len(); + let fronts = non_dominated_sort(&population, objectives); + let mut rank = vec![0usize; n]; + let mut dist = vec![0.0_f64; n]; + for (r, front) in fronts.iter().enumerate() { + let d = crowding_distance(&population, front, objectives); + for (k, &idx) in front.iter().enumerate() { + rank[idx] = r; + dist[idx] = d[k]; + } + } + population + .into_iter() + .enumerate() + .map(|(i, c)| Nsga2Entry { candidate: c, rank: rank[i], crowding_distance: dist[i] }) + .collect() +} + +fn binary_tournament(entries: &[Nsga2Entry], rng: &mut Rng) -> usize { + let n = entries.len(); + let a = rng.random_range(0..n); + let b = rng.random_range(0..n); + let ea = &entries[a]; + let eb = &entries[b]; + if ea.rank < eb.rank { + a + } else if ea.rank > eb.rank { + b + } else if ea.crowding_distance > eb.crowding_distance { + a + } else if ea.crowding_distance < eb.crowding_distance { + 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 final_population_has_expected_size() { + let mut opt = Nsga2::new( + Nsga2Config { population_size: 20, generations: 5, seed: 1 }, + RealBounds::new(vec![(-5.0, 5.0)]), + GaussianMutation { sigma: 0.3 }, + ); + let r = opt.run(&SchafferN1); + assert_eq!(r.population.len(), 20); + assert!(!r.pareto_front.is_empty()); + } + + #[test] + fn evaluation_count_at_least_initial_population() { + let mut opt = Nsga2::new( + Nsga2Config { population_size: 16, generations: 3, seed: 2 }, + RealBounds::new(vec![(-5.0, 5.0)]), + GaussianMutation { sigma: 0.3 }, + ); + let r = opt.run(&SchafferN1); + assert!(r.evaluations >= 16); + assert_eq!(r.generations, 3); + } + + #[test] + fn deterministic_with_same_seed() { + let mut a = Nsga2::new( + Nsga2Config { population_size: 16, generations: 5, seed: 99 }, + RealBounds::new(vec![(-5.0, 5.0)]), + GaussianMutation { sigma: 0.2 }, + ); + let mut b = Nsga2::new( + Nsga2Config { population_size: 16, generations: 5, seed: 99 }, + RealBounds::new(vec![(-5.0, 5.0)]), + GaussianMutation { sigma: 0.2 }, + ); + 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 = Nsga2::new( + Nsga2Config { population_size: 0, 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 a3cb6c6..7e8229d 100644 --- a/src/prelude.rs +++ b/src/prelude.rs @@ -18,4 +18,6 @@ pub use crate::pareto::{ pub use crate::operators::{BitFlipMutation, GaussianMutation, RealBounds, SwapMutation}; -pub use crate::algorithms::{Paes, PaesConfig, RandomSearch, RandomSearchConfig}; +pub use crate::algorithms::{ + Nsga2, Nsga2Config, Paes, PaesConfig, RandomSearch, RandomSearchConfig, +};