feat(compare): add many-objective problems (DTLZ at 4, 10, 8 objectives)

Adds a many-objective section to the comparison harness, exercising the
regime where Pareto dominance stops discriminating: with enough
objectives almost every pair of solutions is mutually non-dominated.

- DTLZ2 4-objective: the entry point to many-objective.
- DTLZ2 10-objective: the curse of dimensionality in full.
- DTLZ1 8-objective: dominance collapse stacked on DTLZ1's deceptive
  multimodal g-term.

Implemented generically: the existing Dtlz1/Dtlz2 structs and distance
metrics are already objective-count agnostic, so a single `ManySpec` +
nine generic runners (RandomSearch, NSGA-II, NSGA-III, MOEA/D, RVEA,
GrEA, IBEA, HypE, AGE-MOEA) cover all three tables -- and any future M.

The results are a clean teaching story:
- NSGA-II collapses -- on DTLZ2-10 it finishes dead last, *worse than
  random search* (2.01 vs 0.63); its crowding distance actively
  misleads in 10-D.
- HypE / MOEA/D / GrEA / IBEA barely notice the 4 -> 10 jump.
- GrEA wins DTLZ1-8, consistent with the 3-objective DTLZ1 table.
- HypE reverses: #1 on both DTLZ2 tables, #6 on the deceptive DTLZ1-8.

Regenerated examples/compare-results.md with the three new sections.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 08:56:48 -06:00
co-authored by Claude Opus 4.7
parent a9d72b94f4
commit 398c82326a
2 changed files with 470 additions and 2 deletions
+376
View File
@@ -2847,6 +2847,320 @@ fn run_knapsack_comparison() {
print_table(&["algorithm", "hypervolume", "front", "ms"], &table);
}
// =============================================================================
// Many-objective problems (4+ objectives)
//
// The "curse of dimensionality" for multi-objective optimizers: as the
// objective count climbs, the fraction of mutually non-dominated solution
// pairs rushes toward 1, so Pareto rank alone stops discriminating.
// NSGA-II's whole population collapses into front 0 and only crowding
// distance is left to steer. Reference-point (NSGA-III), decomposition
// (MOEA/D), reference-vector (RVEA), grid (GrEA) and indicator (IBEA,
// HypE) methods are built to survive this regime.
//
// Both DTLZ structs above are already parameterised by objective count,
// and the DTLZ1/DTLZ2 distance metrics generalise to any M, so a single
// `ManySpec` + generic runners cover every objective count.
// =============================================================================
/// A DTLZ instance at an arbitrary objective count, plus the budget and
/// the reference-set sizing the many-objective algorithms need.
#[derive(Clone, Copy)]
struct ManySpec {
objectives: usize,
dim: usize,
budget: usize,
/// Population for the fixed-population algorithms; also the
/// Das-Dennis weight count MOEA/D derives from `reference_divisions`.
population: usize,
/// Das-Dennis divisions for NSGA-III / RVEA / MOEA/D reference sets.
reference_divisions: usize,
/// `true` = DTLZ1 (deceptive multimodal, linear-simplex front);
/// `false` = DTLZ2 (unit-hypersphere-octant front).
is_dtlz1: bool,
/// Per-axis HypE hypervolume reference coordinate.
hype_ref: f64,
}
/// One DTLZ problem type so the generic runners have a single `Problem`
/// to hand to `run` regardless of which front geometry is in play.
enum ManyDtlz {
D1(Dtlz1),
D2(Dtlz2),
}
impl Problem for ManyDtlz {
type Decision = Vec<f64>;
fn objectives(&self) -> ObjectiveSpace {
match self {
ManyDtlz::D1(p) => p.objectives(),
ManyDtlz::D2(p) => p.objectives(),
}
}
fn evaluate(&self, x: &Vec<f64>) -> Evaluation {
match self {
ManyDtlz::D1(p) => p.evaluate(x),
ManyDtlz::D2(p) => p.evaluate(x),
}
}
}
impl ManySpec {
fn problem(&self) -> ManyDtlz {
if self.is_dtlz1 {
ManyDtlz::D1(Dtlz1 {
num_objectives: self.objectives,
dim: self.dim,
})
} else {
ManyDtlz::D2(Dtlz2 {
num_objectives: self.objectives,
dim: self.dim,
})
}
}
fn bounds(&self) -> Vec<(f64, f64)> {
vec![(0.0, 1.0); self.dim]
}
fn variation(&self) -> CompositeVariation<SimulatedBinaryCrossover, PolynomialMutation> {
let b = self.bounds();
CompositeVariation {
crossover: SimulatedBinaryCrossover::new(b.clone(), 30.0, 1.0),
mutation: PolynomialMutation::new(b, 20.0, 1.0 / self.dim as f64),
}
}
fn mean_dist(&self, front: &[Candidate<Vec<f64>>]) -> f64 {
if self.is_dtlz1 {
mean_distance_to_dtlz1_front(front)
} else {
mean_distance_to_dtlz2_front(front)
}
}
}
fn many_random(spec: ManySpec, seed: u64) -> MoRun {
let problem = spec.problem();
let mut opt = RandomSearch::new(
RandomSearchConfig {
iterations: spec.budget,
batch_size: 1,
seed,
},
RealBounds::new(spec.bounds()),
);
let t0 = Instant::now();
let result = opt.run(&problem);
MoRun {
front: result.pareto_front,
wall_ms: t0.elapsed().as_millis(),
}
}
fn many_nsga2(spec: ManySpec, seed: u64) -> MoRun {
let problem = spec.problem();
let mut opt = Nsga2::new(
Nsga2Config {
population_size: spec.population,
generations: spec.budget / spec.population,
seed,
},
RealBounds::new(spec.bounds()),
spec.variation(),
);
let t0 = Instant::now();
let result = opt.run(&problem);
MoRun {
front: result.pareto_front,
wall_ms: t0.elapsed().as_millis(),
}
}
fn many_nsga3(spec: ManySpec, seed: u64) -> MoRun {
let problem = spec.problem();
let mut opt = Nsga3::new(
Nsga3Config {
population_size: spec.population,
generations: spec.budget / spec.population,
reference_divisions: spec.reference_divisions,
seed,
},
RealBounds::new(spec.bounds()),
spec.variation(),
);
let t0 = Instant::now();
let result = opt.run(&problem);
MoRun {
front: result.pareto_front,
wall_ms: t0.elapsed().as_millis(),
}
}
fn many_moead(spec: ManySpec, seed: u64) -> MoRun {
let problem = spec.problem();
let mut opt = Moead::new(
MoeadConfig {
generations: spec.budget / spec.population,
reference_divisions: spec.reference_divisions,
neighborhood_size: 20,
seed,
},
RealBounds::new(spec.bounds()),
spec.variation(),
);
let t0 = Instant::now();
let result = opt.run(&problem);
MoRun {
front: result.pareto_front,
wall_ms: t0.elapsed().as_millis(),
}
}
fn many_rvea(spec: ManySpec, seed: u64) -> MoRun {
let problem = spec.problem();
let mut opt = Rvea::new(
RveaConfig {
population_size: spec.population,
generations: spec.budget / spec.population,
reference_divisions: spec.reference_divisions,
alpha: 2.0,
seed,
},
RealBounds::new(spec.bounds()),
spec.variation(),
);
let t0 = Instant::now();
let result = opt.run(&problem);
MoRun {
front: result.pareto_front,
wall_ms: t0.elapsed().as_millis(),
}
}
fn many_grea(spec: ManySpec, seed: u64) -> MoRun {
let problem = spec.problem();
let mut opt = Grea::new(
GreaConfig {
population_size: spec.population,
generations: spec.budget / spec.population,
grid_divisions: 10,
seed,
},
RealBounds::new(spec.bounds()),
spec.variation(),
);
let t0 = Instant::now();
let result = opt.run(&problem);
MoRun {
front: result.pareto_front,
wall_ms: t0.elapsed().as_millis(),
}
}
fn many_ibea(spec: ManySpec, seed: u64) -> MoRun {
let problem = spec.problem();
let mut opt = Ibea::new(
IbeaConfig {
population_size: spec.population,
generations: spec.budget / spec.population,
kappa: 0.05,
seed,
},
RealBounds::new(spec.bounds()),
spec.variation(),
);
let t0 = Instant::now();
let result = opt.run(&problem);
MoRun {
front: result.pareto_front,
wall_ms: t0.elapsed().as_millis(),
}
}
fn many_hype(spec: ManySpec, seed: u64) -> MoRun {
let problem = spec.problem();
let mut opt = Hype::new(
HypeConfig {
population_size: spec.population,
generations: spec.budget / spec.population,
reference_point: vec![spec.hype_ref; spec.objectives],
mc_samples: 1_000,
seed,
},
RealBounds::new(spec.bounds()),
spec.variation(),
);
let t0 = Instant::now();
let result = opt.run(&problem);
MoRun {
front: result.pareto_front,
wall_ms: t0.elapsed().as_millis(),
}
}
fn many_age_moea(spec: ManySpec, seed: u64) -> MoRun {
let problem = spec.problem();
let mut opt = AgeMoea::new(
AgeMoeaConfig {
population_size: spec.population,
generations: spec.budget / spec.population,
seed,
},
RealBounds::new(spec.bounds()),
spec.variation(),
);
let t0 = Instant::now();
let result = opt.run(&problem);
MoRun {
front: result.pareto_front,
wall_ms: t0.elapsed().as_millis(),
}
}
fn run_many_objective_comparison(title: &str, blurb: &[&str], spec: ManySpec) {
println!();
println!("== {title} ==");
for line in blurb {
println!("{line}");
}
println!("sorted best-first by mean dist to the true front (lower is better)");
println!();
type Runner = fn(ManySpec, u64) -> MoRun;
let runners: &[(&str, Runner)] = &[
("RandomSearch", many_random),
("NSGA-II", many_nsga2),
("NSGA-III", many_nsga3),
("MOEA/D", many_moead),
("RVEA", many_rvea),
("GrEA", many_grea),
("IBEA", many_ibea),
("HypE", many_hype),
("AGE-MOEA", many_age_moea),
];
let mut rows: Vec<(f64, Vec<String>)> = Vec::new();
for (name, runner) in runners {
let runs: Vec<MoRun> = (0..SEEDS).map(|s| runner(spec, s)).collect();
let dist: Vec<f64> = runs.iter().map(|r| spec.mean_dist(&r.front)).collect();
let fs: Vec<f64> = runs.iter().map(|r| r.front.len() as f64).collect();
let ms: Vec<f64> = runs.iter().map(|r| r.wall_ms as f64).collect();
let (d_m, d_s) = mean_std(&dist);
let (fs_m, _) = mean_std(&fs);
let (ms_m, _) = mean_std(&ms);
rows.push((
d_m,
vec![
name.to_string(),
format!("{d_m:.4}+/-{d_s:.4}"),
format!("{fs_m:.0}"),
format!("{ms_m:.0}"),
],
));
}
rows.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
let table: Vec<Vec<String>> = rows.into_iter().map(|(_, r)| r).collect();
print_table(&["algorithm", "mean dist", "front", "ms"], &table);
}
fn main() {
run_zdt1_comparison();
run_zdt3_comparison();
@@ -2858,4 +3172,66 @@ fn main() {
run_tsp_comparison();
run_jss_comparison();
run_knapsack_comparison();
// ---- Many-objective (4+) ----
run_many_objective_comparison(
"DTLZ2 4-objective (dim=13, 40000 evals/run × 10 seeds)",
&[
"DTLZ2 scaled to 4 objectives -- the entry point to many-objective.",
"Front is still the unit-hypersphere octant (Σf² = 1). Already hard:",
"with 4 objectives most random pairs of solutions are mutually",
"non-dominated, so Pareto rank alone barely discriminates. Optimum:",
"mean dist -> 0.",
],
ManySpec {
objectives: 4,
dim: 13,
budget: 40_000,
population: 56,
reference_divisions: 5,
is_dtlz1: false,
hype_ref: 3.0,
},
);
run_many_objective_comparison(
"DTLZ2 10-objective (dim=19, 40000 evals/run × 10 seeds)",
&[
"DTLZ2 scaled to 10 objectives -- the curse of dimensionality in full.",
"In 10-D objective space almost EVERY pair of solutions is mutually",
"non-dominated, so NSGA-II's whole population collapses into front 0",
"and crowding distance is the only signal left. Reference-point,",
"decomposition and indicator methods are built for exactly this.",
"Watch the 'front' column: it pins to the population size because",
"nothing dominates anything. Optimum: mean dist -> 0.",
],
ManySpec {
objectives: 10,
dim: 19,
budget: 40_000,
population: 55,
reference_divisions: 2,
is_dtlz1: false,
hype_ref: 3.0,
},
);
run_many_objective_comparison(
"DTLZ1 8-objective (dim=12, 40000 evals/run × 10 seeds)",
&[
"DTLZ1 scaled to 8 objectives -- the brutal one. Stacks the",
"many-objective dominance collapse on top of DTLZ1's deceptive",
"multimodal g-term (a huge number of local fronts). The true front",
"is the linear simplex Σf = 0.5; reaching it at all is the",
"achievement. Expect large mean-dist values and wide spreads -- this",
"is near the edge of what the catalogue does at this budget.",
],
ManySpec {
objectives: 8,
dim: 12,
budget: 40_000,
population: 120,
reference_divisions: 3,
is_dtlz1: true,
hype_ref: 1.0,
},
);
}