Files
heuropt/examples/benchmarks.rs
T
swaits 5b1ee99a58 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.
2026-05-04 19:46:56 -06:00

152 lines
4.9 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Two canonical optimization benchmarks: ZDT1 and Rastrigin.
//!
//! - **ZDT1** (Zitzler, Deb, Thiele, 2000) is the standard 2-objective
//! continuous benchmark. The analytical Pareto front is
//! `f2 = 1 - sqrt(f1)` for `f1 ∈ [0, 1]`, which we use to score the
//! solution quality of a short NSGA-II run.
//! - **Rastrigin** is the textbook multimodal single-objective trap:
//! `f(x) = 10·n + Σ (x_i² 10·cos(2π·x_i))`, global minimum `f = 0`
//! at the origin. We solve it with DE.
//!
//! Run with:
//!
//! ```bash
//! cargo run --release --example benchmarks
//! ```
//!
//! Both problems are defined by simple public-domain math and have no
//! licensing concerns.
use std::f64::consts::PI;
use heuropt::prelude::*;
/// ZDT1: 30-dimensional, two minimization objectives.
struct Zdt1 {
dim: usize,
}
impl Problem for Zdt1 {
type Decision = Vec<f64>;
fn objectives(&self) -> ObjectiveSpace {
ObjectiveSpace::new(vec![Objective::minimize("f1"), Objective::minimize("f2")])
}
fn evaluate(&self, x: &Vec<f64>) -> Evaluation {
debug_assert_eq!(x.len(), self.dim);
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 f2 = g * (1.0 - (f1 / g).sqrt());
Evaluation::new(vec![f1, f2])
}
}
/// Rastrigin: n-dimensional, single minimization objective.
struct Rastrigin {
dim: usize,
}
impl Problem for Rastrigin {
type Decision = Vec<f64>;
fn objectives(&self) -> ObjectiveSpace {
ObjectiveSpace::new(vec![Objective::minimize("f")])
}
fn evaluate(&self, x: &Vec<f64>) -> Evaluation {
let n = self.dim as f64;
let value = 10.0 * n
+ x.iter().map(|v| v * v - 10.0 * (2.0 * PI * v).cos()).sum::<f64>();
Evaluation::new(vec![value])
}
}
/// Mean L2 distance from each front point to the nearest point on the
/// analytical ZDT1 Pareto front (`f2 = 1 - sqrt(f1)` for `f1 ∈ [0, 1]`).
///
/// Since `(f1, 1 - sqrt(f1))` is monotone in `f1`, the minimum-distance
/// projection on a fine sample of the curve is good enough for a
/// human-readable quality signal.
fn mean_distance_to_zdt1_front(front: &[Candidate<Vec<f64>>]) -> f64 {
let samples: Vec<(f64, f64)> = (0..=1000)
.map(|i| {
let f1 = i as f64 / 1000.0;
(f1, 1.0 - f1.sqrt())
})
.collect();
let mut total = 0.0;
for c in front {
let f1 = c.evaluation.objectives[0];
let f2 = c.evaluation.objectives[1];
let mut best = f64::INFINITY;
for &(rf1, rf2) in &samples {
let d = ((rf1 - f1).powi(2) + (rf2 - f2).powi(2)).sqrt();
if d < best {
best = d;
}
}
total += best;
}
total / front.len().max(1) as f64
}
fn run_zdt1() {
let dim = 30;
let problem = Zdt1 { dim };
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);
let dist = mean_distance_to_zdt1_front(&result.pareto_front);
println!("== ZDT1 (NSGA-II, dim={dim}) ==");
println!(" population: {}", result.population.len());
println!(" pareto front: {}", result.pareto_front.len());
println!(" evaluations: {}", result.evaluations);
println!(" mean L2 to true front: {dist:.5}");
println!(
" example front point: f1={:.4}, f2={:.4}",
result.pareto_front[0].evaluation.objectives[0],
result.pareto_front[0].evaluation.objectives[1],
);
}
fn run_rastrigin() {
let dim = 5;
let problem = Rastrigin { dim };
let bounds = RealBounds::new(vec![(-5.12, 5.12); dim]);
let config = DifferentialEvolutionConfig {
population_size: 100,
generations: 1000,
differential_weight: 0.5,
crossover_probability: 0.9,
seed: 1,
};
let mut optimizer = DifferentialEvolution::new(config, bounds);
let result = optimizer.run(&problem);
let best = result.best.expect("single-objective always has a best");
println!("== Rastrigin (DE, dim={dim}) ==");
println!(" evaluations: {}", result.evaluations);
println!(" best f: {:.6e}", best.evaluation.objectives[0]);
println!(" best x: {:?}", best.decision);
}
fn main() {
run_zdt1();
println!();
run_rastrigin();
}