docs(examples): switch ZDT1 to canonical NSGA-II operators (SBX + PolyMut)

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.
This commit is contained in:
2026-05-04 19:46:56 -06:00
parent cc1b44b34e
commit 5b1ee99a58
+12 -9
View File
@@ -35,13 +35,9 @@ impl Problem for Zdt1 {
fn evaluate(&self, x: &Vec<f64>) -> 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<Vec<f64>>]) -> 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);