feat(algorithms): add EpsilonMoea (ε-dominance MOEA, Deb et al. 2003)

Replaces strict Pareto dominance with ε-dominance: A ε-dominates B when
`floor(A_i / ε) ≤ floor(B_i / ε)` for every objective and strictly
less in at least one (minimization frame). The result is a regular
discretization of objective space — at most one archive member per
ε-box — so the front spreads out automatically and the archive size
self-limits without truncation tricks.

Steady-state design: each generation samples one parent from the main
population and one from the ε-archive, applies variation, evaluates
the child, and offers it to both archives. Every member's
ε-coordinates and the box-tie rules are precomputed each insertion.

Tests: produces a front on Schaffer N.1 with reasonable spread,
deterministic reruns, panic on `epsilon[i] <= 0.0` and on
`epsilon.len() != objectives.len()`.
This commit is contained in:
2026-05-05 09:51:11 -06:00
parent f8fd3880ac
commit 4fa8250c24
3 changed files with 358 additions and 1 deletions
+355
View File
@@ -0,0 +1,355 @@
//! `EpsilonMoea` — Deb, Mohan & Mishra 2003 ε-dominance MOEA.
use rand::Rng as _;
use crate::core::candidate::Candidate;
use crate::core::evaluation::Evaluation;
use crate::core::objective::ObjectiveSpace;
use crate::core::population::Population;
use crate::core::problem::Problem;
use crate::core::result::OptimizationResult;
use crate::core::rng::rng_from_seed;
use crate::pareto::dominance::{Dominance, pareto_compare};
use crate::pareto::front::{best_candidate, pareto_front};
use crate::traits::{Initializer, Optimizer, Variation};
/// Configuration for [`EpsilonMoea`].
#[derive(Debug, Clone)]
pub struct EpsilonMoeaConfig {
/// Internal population size.
pub population_size: usize,
/// Number of evaluations to perform (steady-state: one offspring per gen).
pub evaluations: usize,
/// ε for each objective. Must have one entry per objective; controls
/// the resolution of the regular box-grid the archive lives on.
pub epsilon: Vec<f64>,
/// Seed for the deterministic RNG.
pub seed: u64,
}
impl Default for EpsilonMoeaConfig {
fn default() -> Self {
Self {
population_size: 50,
evaluations: 25_000,
epsilon: vec![0.05, 0.05],
seed: 42,
}
}
}
/// ε-dominance MOEA.
#[derive(Debug, Clone)]
pub struct EpsilonMoea<I, V> {
/// Algorithm configuration.
pub config: EpsilonMoeaConfig,
/// Initial-decision sampler.
pub initializer: I,
/// Offspring-producing variation operator.
pub variation: V,
}
impl<I, V> EpsilonMoea<I, V> {
/// Construct an `EpsilonMoea`.
pub fn new(config: EpsilonMoeaConfig, initializer: I, variation: V) -> Self {
Self { config, initializer, variation }
}
}
impl<P, I, V> Optimizer<P> for EpsilonMoea<I, V>
where
P: Problem + Sync,
P::Decision: Send,
I: Initializer<P::Decision>,
V: Variation<P::Decision>,
{
fn run(&mut self, problem: &P) -> OptimizationResult<P::Decision> {
assert!(self.config.population_size > 0, "EpsilonMoea population_size must be > 0");
let n = self.config.population_size;
let objectives = problem.objectives();
assert_eq!(
self.config.epsilon.len(),
objectives.len(),
"EpsilonMoea epsilon.len() must equal number of objectives",
);
for (i, &e) in self.config.epsilon.iter().enumerate() {
assert!(e > 0.0, "EpsilonMoea epsilon[{i}] must be > 0.0");
}
let epsilon = self.config.epsilon.clone();
let mut rng = rng_from_seed(self.config.seed);
// Internal population.
let initial_decisions = self.initializer.initialize(n, &mut rng);
let mut population: Vec<Candidate<P::Decision>> = initial_decisions
.into_iter()
.map(|d| {
let e = problem.evaluate(&d);
Candidate::new(d, e)
})
.collect();
let mut evaluations = population.len();
// ε-archive.
let mut archive: Vec<Candidate<P::Decision>> = Vec::new();
for c in &population {
insert_into_epsilon_archive(&mut archive, c.clone(), &objectives, &epsilon);
}
let total_evals = self.config.evaluations.max(evaluations);
while evaluations < total_evals {
// Pick one parent from the population, one from the archive
// (when non-empty; else two from the population).
let p1_idx = rng.random_range(0..population.len());
let parent_a = population[p1_idx].decision.clone();
let parent_b = if !archive.is_empty() {
let j = rng.random_range(0..archive.len());
archive[j].decision.clone()
} else {
let j = rng.random_range(0..population.len());
population[j].decision.clone()
};
let parents = vec![parent_a, parent_b];
let children = self.variation.vary(&parents, &mut rng);
assert!(!children.is_empty(), "EpsilonMoea variation returned no children");
let child_decision = children.into_iter().next().unwrap();
let child_eval = problem.evaluate(&child_decision);
evaluations += 1;
let child = Candidate::new(child_decision, child_eval);
// Update population: child replaces a Pareto-dominated random member,
// or any random member if non-dominated wrt every population member.
update_population(&mut population, &child, &objectives, &mut rng);
// Update ε-archive.
insert_into_epsilon_archive(&mut archive, child, &objectives, &epsilon);
}
let final_pop: Vec<Candidate<P::Decision>> = if !archive.is_empty() {
archive.clone()
} else {
population
};
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.evaluations,
)
}
}
/// Standard ε-MOEA population update: if the child is dominated by some
/// member, drop it; if it dominates a member, replace that member; if
/// non-dominated wrt all, replace a random member.
fn update_population<D: Clone>(
population: &mut [Candidate<D>],
child: &Candidate<D>,
objectives: &ObjectiveSpace,
rng: &mut crate::core::rng::Rng,
) {
let mut dominated_indices: Vec<usize> = Vec::new();
for (i, c) in population.iter().enumerate() {
match pareto_compare(&child.evaluation, &c.evaluation, objectives) {
Dominance::DominatedBy => return, // child dominated → discard
Dominance::Dominates => dominated_indices.push(i),
_ => {}
}
}
if !dominated_indices.is_empty() {
let pick = dominated_indices[rng.random_range(0..dominated_indices.len())];
population[pick] = child.clone();
} else {
let pick = rng.random_range(0..population.len());
population[pick] = child.clone();
}
}
/// Insert `child` into the ε-archive following Deb's standard rule:
///
/// - Translate every objective vector into ε-box coordinates
/// `b_i = floor(o_i / ε_i)` (in minimization frame).
/// - If `child`'s box is ε-dominated by an existing member → drop child.
/// - Else, drop existing members whose box is ε-dominated by `child`'s.
/// - Among members in the SAME box as `child`, keep the one closer to its
/// box's "ideal corner" (smallest L2 distance from box origin).
fn insert_into_epsilon_archive<D: Clone>(
archive: &mut Vec<Candidate<D>>,
child: Candidate<D>,
objectives: &ObjectiveSpace,
epsilon: &[f64],
) {
let child_box = box_coords(&child.evaluation, objectives, epsilon);
let child_corner_dist = corner_distance(&child.evaluation, objectives, epsilon, &child_box);
let mut to_drop: Vec<usize> = Vec::new();
let mut child_box_index: Option<usize> = None;
for (i, member) in archive.iter().enumerate() {
let member_box = box_coords(&member.evaluation, objectives, epsilon);
if box_dominates(&member_box, &child_box) {
// Child's box is ε-dominated; ignore the child.
return;
}
if box_dominates(&child_box, &member_box) {
to_drop.push(i);
} else if member_box == child_box {
child_box_index = Some(i);
}
}
// Drop ε-dominated members (in reverse order to keep indices valid).
to_drop.sort_unstable();
for i in to_drop.into_iter().rev() {
archive.swap_remove(i);
}
if let Some(idx) = child_box_index {
// Same box: keep whichever is closer to box's ideal corner.
let member_corner_dist = corner_distance(
&archive[idx].evaluation,
objectives,
epsilon,
&child_box,
);
if child_corner_dist < member_corner_dist {
archive[idx] = child;
}
} else {
archive.push(child);
}
}
fn box_coords(eval: &Evaluation, objectives: &ObjectiveSpace, epsilon: &[f64]) -> Vec<i64> {
let oriented = objectives.as_minimization(&eval.objectives);
oriented
.iter()
.zip(epsilon.iter())
.map(|(v, e)| (v / e).floor() as i64)
.collect()
}
fn corner_distance(
eval: &Evaluation,
objectives: &ObjectiveSpace,
epsilon: &[f64],
box_idx: &[i64],
) -> f64 {
let oriented = objectives.as_minimization(&eval.objectives);
let mut sq = 0.0;
for k in 0..oriented.len() {
let corner = box_idx[k] as f64 * epsilon[k];
let d = oriented[k] - corner;
sq += d * d;
}
sq.sqrt()
}
/// Box-A ε-dominates box-B iff every coordinate of A is ≤ B and at least
/// one is strictly less.
fn box_dominates(a: &[i64], b: &[i64]) -> bool {
let mut strictly_less = false;
for (x, y) in a.iter().zip(b.iter()) {
if x > y {
return false;
}
if x < y {
strictly_less = true;
}
}
strictly_less
}
#[cfg(test)]
mod tests {
use super::*;
use crate::operators::{
CompositeVariation, PolynomialMutation, RealBounds, SimulatedBinaryCrossover,
};
use crate::tests_support::SchafferN1;
fn make_optimizer(
seed: u64,
) -> EpsilonMoea<RealBounds, CompositeVariation<SimulatedBinaryCrossover, PolynomialMutation>>
{
let bounds = vec![(-5.0, 5.0)];
let initializer = RealBounds::new(bounds.clone());
let variation = CompositeVariation {
crossover: SimulatedBinaryCrossover::new(bounds.clone(), 15.0, 0.5),
mutation: PolynomialMutation::new(bounds, 20.0, 1.0),
};
EpsilonMoea::new(
EpsilonMoeaConfig {
population_size: 20,
evaluations: 1_000,
epsilon: vec![0.05, 0.05],
seed,
},
initializer,
variation,
)
}
#[test]
fn produces_pareto_front() {
let mut opt = make_optimizer(1);
let r = opt.run(&SchafferN1);
assert!(!r.pareto_front.is_empty());
}
#[test]
fn deterministic_with_same_seed() {
let mut a = make_optimizer(99);
let mut b = make_optimizer(99);
let ra = a.run(&SchafferN1);
let rb = b.run(&SchafferN1);
let oa: Vec<Vec<f64>> =
ra.pareto_front.iter().map(|c| c.evaluation.objectives.clone()).collect();
let ob: Vec<Vec<f64>> =
rb.pareto_front.iter().map(|c| c.evaluation.objectives.clone()).collect();
assert_eq!(oa, ob);
}
#[test]
#[should_panic(expected = "epsilon.len() must equal number of objectives")]
fn dim_mismatch_panics() {
let bounds = vec![(0.0, 1.0)];
let initializer = RealBounds::new(bounds.clone());
let variation = CompositeVariation {
crossover: SimulatedBinaryCrossover::new(bounds.clone(), 15.0, 0.5),
mutation: PolynomialMutation::new(bounds, 20.0, 1.0),
};
let mut opt = EpsilonMoea::new(
EpsilonMoeaConfig {
population_size: 4,
evaluations: 100,
epsilon: vec![0.1, 0.1, 0.1],
seed: 0,
},
initializer,
variation,
);
let _ = opt.run(&SchafferN1);
}
#[test]
#[should_panic(expected = "must be > 0.0")]
fn zero_epsilon_panics() {
let bounds = vec![(0.0, 1.0)];
let initializer = RealBounds::new(bounds.clone());
let variation = CompositeVariation {
crossover: SimulatedBinaryCrossover::new(bounds.clone(), 15.0, 0.5),
mutation: PolynomialMutation::new(bounds, 20.0, 1.0),
};
let mut opt = EpsilonMoea::new(
EpsilonMoeaConfig {
population_size: 4,
evaluations: 100,
epsilon: vec![0.0, 0.1],
seed: 0,
},
initializer,
variation,
);
let _ = opt.run(&SchafferN1);
}
}