From 5b1ee99a5809418b6c26702498c2d2d2b2038c9d Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Mon, 4 May 2026 19:45:08 -0600 Subject: [PATCH] docs(examples): switch ZDT1 to canonical NSGA-II operators (SBX + PolyMut) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the v0.1 `GaussianMutation` + clamp-inside-`evaluate` setup with the canonical NSGA-II operator pair: SBX (η_c=15, per-var prob 0.5) followed by PolynomialMutation (η_m=20, per-var prob 1/dim), composed via `CompositeVariation`. Both are bounds-aware on their own, so the in-evaluate clamping is dropped. Result on ZDT1 (dim=30, pop=100, gens=1000, seed=42): mean L2 distance to the analytical Pareto front is 0.00152 — comfortably within the published NSGA-II range for this benchmark. Note on the previous number: the v0.1 setup reported 0.00072 at 40k evals, but that was an artifact of clamping inside `evaluate`. Out-of- bounds Gaussian mutations on `x[0]` were snapping to 0, which coincides with the ZDT1 Pareto-front extreme (f1=0). The new operator pair has no such free lunch — it runs the actual NSGA-II algorithm — and the new measurement is what honest convergence on ZDT1 actually looks like. Generations bumped from 400 to 1000 (40k → 100k evaluations) to give the operators headroom; matches the budget DE uses for Rastrigin so the example feels balanced. --- examples/benchmarks.rs | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/examples/benchmarks.rs b/examples/benchmarks.rs index 23ba775..05f8861 100644 --- a/examples/benchmarks.rs +++ b/examples/benchmarks.rs @@ -35,13 +35,9 @@ impl Problem for Zdt1 { fn evaluate(&self, x: &Vec) -> Evaluation { debug_assert_eq!(x.len(), self.dim); - // GaussianMutation does not enforce bounds (spec §11.2); clamp here so - // the example stays well-defined on a bounded benchmark. This is the - // spec-recommended pattern for handling bounds in v1. - let x0 = x[0].clamp(0.0, 1.0); - let tail_sum: f64 = x[1..].iter().map(|v| v.clamp(0.0, 1.0)).sum(); + let f1 = x[0]; + let tail_sum: f64 = x[1..].iter().sum(); let g = 1.0 + 9.0 * tail_sum / (self.dim as f64 - 1.0); - let f1 = x0; let f2 = g * (1.0 - (f1 / g).sqrt()); Evaluation::new(vec![f1, f2]) } @@ -99,9 +95,16 @@ fn mean_distance_to_zdt1_front(front: &[Candidate>]) -> f64 { fn run_zdt1() { let dim = 30; let problem = Zdt1 { dim }; - let initializer = RealBounds::new(vec![(0.0, 1.0); dim]); - let variation = GaussianMutation { sigma: 0.05 }; - let config = Nsga2Config { population_size: 100, generations: 400, seed: 42 }; + let bounds = vec![(0.0, 1.0); dim]; + let initializer = RealBounds::new(bounds.clone()); + // Canonical NSGA-II operator pair: SBX (η_c=15) + polynomial mutation + // (η_m=20, per-var prob 1/dim). Both are bounds-aware so children stay + // feasible without any clamping inside `evaluate`. + let variation = CompositeVariation { + crossover: SimulatedBinaryCrossover::new(bounds.clone(), 15.0, 0.5), + mutation: PolynomialMutation::new(bounds, 20.0, 1.0 / dim as f64), + }; + let config = Nsga2Config { population_size: 100, generations: 1000, seed: 42 }; let mut optimizer = Nsga2::new(config, initializer, variation); let result = optimizer.run(&problem);