feat: v0.6.0 — observer / stop-conditions / tracing / IGD / R2
Theme: production lifecycle. heuropt becomes deployable for long- running, real-world workloads. No breaking changes — Optimizer trait gains a default-impl run_with method that falls back to run. Adds: - src/observer/ module: Snapshot, Observer trait, ControlFlow, plus built-in MaxTime / MaxIterations / TargetFitness / Stagnation / Periodic / AnyOf / AllOf and a closure impl. - Optimizer::run_with(problem, observer): default-impl on the trait, overridden for full per-gen visibility on Nsga2, RandomSearch, and DifferentialEvolution. Other algorithms inherit a final-only notification — full per-gen support follows incrementally. - New 'tracing' optional feature plus TracingObserver that emits structured debug! events per generation. - src/metrics/igd.rs: IGD + IGD+ performance indicators against a reference set. - src/metrics/r2.rs: R2 indicator using the weighted Tchebycheff utility; pair with das_dennis for the canonical weight set. - examples/constrained.rs: BNH constrained 2-objective problem solved with NSGA-II + observer composition (MaxTime.or(Periodic)). Bumps Cargo.toml to 0.6.0; CHANGELOG entry consolidates the above. Existing 247 unit + 38 doctest + 32 algorithm-property + property / metric / numerical-stability tests all pass; bit-identical compare output verified post-DE refactor.
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
//! Inverted Generational Distance (IGD) and IGD+ performance indicators.
|
||||
//!
|
||||
//! Both quantify how well an approximation set covers a reference set
|
||||
//! (typically the true Pareto front). Smaller values are better.
|
||||
|
||||
use crate::core::candidate::Candidate;
|
||||
use crate::core::evaluation::Evaluation;
|
||||
use crate::core::objective::ObjectiveSpace;
|
||||
|
||||
/// Inverted Generational Distance.
|
||||
///
|
||||
/// For each point in the `reference` set, compute the Euclidean distance
|
||||
/// to its nearest neighbor in the `approximation` set (in minimization-
|
||||
/// oriented objective space), then average:
|
||||
///
|
||||
/// ```text
|
||||
/// IGD(A) = (1 / |R|) · Σ_{r ∈ R} min_{a ∈ A} ‖a − r‖₂
|
||||
/// ```
|
||||
///
|
||||
/// Lower is better. IGD captures both convergence (close to the front)
|
||||
/// and spread (the approximation must cover the reference).
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// If `reference` is empty.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use heuropt::prelude::*;
|
||||
/// use heuropt::metrics::igd::igd;
|
||||
///
|
||||
/// let space = ObjectiveSpace::new(vec![
|
||||
/// Objective::minimize("f1"),
|
||||
/// Objective::minimize("f2"),
|
||||
/// ]);
|
||||
/// // Approximation: a sparse 2-point front.
|
||||
/// let approx = [
|
||||
/// Candidate::new((), Evaluation::new(vec![0.0, 1.0])),
|
||||
/// Candidate::new((), Evaluation::new(vec![1.0, 0.0])),
|
||||
/// ];
|
||||
/// // Reference: a dense 3-point sample of the true front.
|
||||
/// let reference = [
|
||||
/// Evaluation::new(vec![0.0, 1.0]),
|
||||
/// Evaluation::new(vec![0.5, 0.5]),
|
||||
/// Evaluation::new(vec![1.0, 0.0]),
|
||||
/// ];
|
||||
/// let v = igd(&approx, &reference, &space);
|
||||
/// // The middle reference point is unfortunately distance √(0.5²+0.5²) = 0.707
|
||||
/// // from each approximation point; the boundary points are 0 away.
|
||||
/// // IGD = (0 + 0.707 + 0) / 3 ≈ 0.236.
|
||||
/// assert!((v - 0.2357).abs() < 1e-3);
|
||||
/// ```
|
||||
pub fn igd<D>(
|
||||
approximation: &[Candidate<D>],
|
||||
reference: &[Evaluation],
|
||||
objectives: &ObjectiveSpace,
|
||||
) -> f64 {
|
||||
assert!(
|
||||
!reference.is_empty(),
|
||||
"igd: reference set must not be empty"
|
||||
);
|
||||
let approx_oriented: Vec<Vec<f64>> = approximation
|
||||
.iter()
|
||||
.map(|c| objectives.as_minimization(&c.evaluation.objectives))
|
||||
.collect();
|
||||
if approx_oriented.is_empty() {
|
||||
return f64::INFINITY;
|
||||
}
|
||||
let mut total = 0.0_f64;
|
||||
for r in reference {
|
||||
let r_oriented = objectives.as_minimization(&r.objectives);
|
||||
let mut min_d = f64::INFINITY;
|
||||
for a in &approx_oriented {
|
||||
let d: f64 = a
|
||||
.iter()
|
||||
.zip(r_oriented.iter())
|
||||
.map(|(x, y)| (x - y).powi(2))
|
||||
.sum::<f64>()
|
||||
.sqrt();
|
||||
if d < min_d {
|
||||
min_d = d;
|
||||
}
|
||||
}
|
||||
total += min_d;
|
||||
}
|
||||
total / reference.len() as f64
|
||||
}
|
||||
|
||||
/// IGD+ — a dominance-respecting variant of IGD.
|
||||
///
|
||||
/// For each reference point `r`, the distance to an approximation
|
||||
/// point `a` is computed only on objectives where `a` is *worse than*
|
||||
/// `r` — i.e. on the "violation" component of the gap. This makes
|
||||
/// IGD+ a Pareto-compliant indicator: adding a dominated point to the
|
||||
/// approximation never improves the score.
|
||||
///
|
||||
/// ```text
|
||||
/// IGD+(A) = (1 / |R|) · Σ_{r ∈ R} min_{a ∈ A} ‖max(a − r, 0)‖₂
|
||||
/// ```
|
||||
///
|
||||
/// Lower is better.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// If `reference` is empty.
|
||||
pub fn igd_plus<D>(
|
||||
approximation: &[Candidate<D>],
|
||||
reference: &[Evaluation],
|
||||
objectives: &ObjectiveSpace,
|
||||
) -> f64 {
|
||||
assert!(
|
||||
!reference.is_empty(),
|
||||
"igd_plus: reference set must not be empty"
|
||||
);
|
||||
let approx_oriented: Vec<Vec<f64>> = approximation
|
||||
.iter()
|
||||
.map(|c| objectives.as_minimization(&c.evaluation.objectives))
|
||||
.collect();
|
||||
if approx_oriented.is_empty() {
|
||||
return f64::INFINITY;
|
||||
}
|
||||
let mut total = 0.0_f64;
|
||||
for r in reference {
|
||||
let r_oriented = objectives.as_minimization(&r.objectives);
|
||||
let mut min_d = f64::INFINITY;
|
||||
for a in &approx_oriented {
|
||||
let d: f64 = a
|
||||
.iter()
|
||||
.zip(r_oriented.iter())
|
||||
.map(|(x, y)| (x - y).max(0.0).powi(2))
|
||||
.sum::<f64>()
|
||||
.sqrt();
|
||||
if d < min_d {
|
||||
min_d = d;
|
||||
}
|
||||
}
|
||||
total += min_d;
|
||||
}
|
||||
total / reference.len() as f64
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::core::objective::Objective;
|
||||
|
||||
fn space_min2() -> ObjectiveSpace {
|
||||
ObjectiveSpace::new(vec![Objective::minimize("f1"), Objective::minimize("f2")])
|
||||
}
|
||||
|
||||
fn cand(obj: Vec<f64>) -> Candidate<()> {
|
||||
Candidate::new((), Evaluation::new(obj))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn igd_perfect_match_is_zero() {
|
||||
let s = space_min2();
|
||||
let approx = [cand(vec![0.0, 1.0]), cand(vec![1.0, 0.0])];
|
||||
let reference = [
|
||||
Evaluation::new(vec![0.0, 1.0]),
|
||||
Evaluation::new(vec![1.0, 0.0]),
|
||||
];
|
||||
let v = igd(&approx, &reference, &s);
|
||||
assert!(v < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn igd_known_value() {
|
||||
let s = space_min2();
|
||||
let approx = [cand(vec![0.0, 0.0])];
|
||||
let reference = [Evaluation::new(vec![1.0, 1.0])];
|
||||
let v = igd(&approx, &reference, &s);
|
||||
assert!((v - 2.0_f64.sqrt()).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn igd_plus_dominated_point_does_not_improve() {
|
||||
let s = space_min2();
|
||||
let reference = [
|
||||
Evaluation::new(vec![0.0, 1.0]),
|
||||
Evaluation::new(vec![1.0, 0.0]),
|
||||
];
|
||||
let base = vec![cand(vec![0.5, 0.5])];
|
||||
let with_dominated = vec![cand(vec![0.5, 0.5]), cand(vec![1.0, 1.0])];
|
||||
let v_base = igd_plus(&base, &reference, &s);
|
||||
let v_with = igd_plus(&with_dominated, &reference, &s);
|
||||
// Adding a dominated point should not improve the score.
|
||||
assert!(v_with >= v_base - 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn igd_empty_approximation_is_infinity() {
|
||||
let s = space_min2();
|
||||
let approx: [Candidate<()>; 0] = [];
|
||||
let reference = [Evaluation::new(vec![0.0, 1.0])];
|
||||
assert!(igd(&approx, &reference, &s).is_infinite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "reference set must not be empty")]
|
||||
fn igd_empty_reference_panics() {
|
||||
let s = space_min2();
|
||||
let approx = [cand(vec![0.0, 1.0])];
|
||||
let _ = igd::<()>(&approx, &[], &s);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
//! Quality metrics for Pareto fronts.
|
||||
|
||||
pub mod hypervolume;
|
||||
pub mod igd;
|
||||
pub mod r2;
|
||||
pub mod spacing;
|
||||
|
||||
pub use hypervolume::*;
|
||||
pub use igd::{igd, igd_plus};
|
||||
pub use r2::r2;
|
||||
pub use spacing::*;
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
//! R2 indicator — a unary quality measure for Pareto fronts.
|
||||
//!
|
||||
//! For each weight vector `λ` in a user-supplied set, find the
|
||||
//! best (smallest) weighted Tchebycheff value across the front;
|
||||
//! average over all weight vectors. Lower is better.
|
||||
|
||||
use crate::core::candidate::Candidate;
|
||||
use crate::core::objective::ObjectiveSpace;
|
||||
|
||||
/// R2 indicator using the weighted Tchebycheff utility.
|
||||
///
|
||||
/// ```text
|
||||
/// R2(A) = (1 / |Λ|) · Σ_{λ ∈ Λ} min_{a ∈ A} max_i { λ_i · |a_i − z*_i| }
|
||||
/// ```
|
||||
///
|
||||
/// where `z*` is the ideal point (per-axis minimum across the
|
||||
/// approximation, in minimization-oriented coordinates) and `Λ` is
|
||||
/// a set of unit-simplex weight vectors. Lower is better.
|
||||
///
|
||||
/// Use [`das_dennis`](crate::pareto::das_dennis) to generate the
|
||||
/// canonical structured weight set.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// If the approximation is empty, or any weight vector has wrong
|
||||
/// length / negative entries / zero sum.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use heuropt::prelude::*;
|
||||
/// use heuropt::metrics::r2::r2;
|
||||
///
|
||||
/// let space = ObjectiveSpace::new(vec![
|
||||
/// Objective::minimize("f1"),
|
||||
/// Objective::minimize("f2"),
|
||||
/// ]);
|
||||
/// let approx = [
|
||||
/// Candidate::new((), Evaluation::new(vec![0.0, 1.0])),
|
||||
/// Candidate::new((), Evaluation::new(vec![1.0, 0.0])),
|
||||
/// ];
|
||||
/// // Two weight vectors: (1, 0) and (0, 1) — extreme directions.
|
||||
/// let weights = [vec![1.0, 0.0], vec![0.0, 1.0]];
|
||||
/// let v = r2(&approx, &weights, &space);
|
||||
/// // For each direction, the best front member matches that axis exactly.
|
||||
/// // R2 = 0 since the ideal point is achieved on each direction.
|
||||
/// assert!(v < 1e-12);
|
||||
/// ```
|
||||
pub fn r2<D>(
|
||||
approximation: &[Candidate<D>],
|
||||
weights: &[Vec<f64>],
|
||||
objectives: &ObjectiveSpace,
|
||||
) -> f64 {
|
||||
assert!(
|
||||
!approximation.is_empty(),
|
||||
"r2: approximation must not be empty"
|
||||
);
|
||||
assert!(!weights.is_empty(), "r2: weight set must not be empty");
|
||||
let m = objectives.len();
|
||||
for (i, w) in weights.iter().enumerate() {
|
||||
assert_eq!(
|
||||
w.len(),
|
||||
m,
|
||||
"r2: weight {i} has wrong length ({} vs {m})",
|
||||
w.len()
|
||||
);
|
||||
assert!(
|
||||
w.iter().all(|&v| v >= 0.0),
|
||||
"r2: weight {i} has a negative entry"
|
||||
);
|
||||
assert!(w.iter().sum::<f64>() > 0.0, "r2: weight {i} has zero sum");
|
||||
}
|
||||
|
||||
// Convert all approximation members to minimization orientation once.
|
||||
let oriented: Vec<Vec<f64>> = approximation
|
||||
.iter()
|
||||
.map(|c| objectives.as_minimization(&c.evaluation.objectives))
|
||||
.collect();
|
||||
|
||||
// Ideal point z* (per-axis minimum).
|
||||
let mut z_star = vec![f64::INFINITY; m];
|
||||
for o in &oriented {
|
||||
for k in 0..m {
|
||||
if o[k] < z_star[k] {
|
||||
z_star[k] = o[k];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut total = 0.0_f64;
|
||||
for w in weights {
|
||||
let mut best = f64::INFINITY;
|
||||
for o in &oriented {
|
||||
// Weighted Tchebycheff: max_i { w_i · |o_i − z*_i| }
|
||||
let mut t = 0.0_f64;
|
||||
for k in 0..m {
|
||||
let dk = (o[k] - z_star[k]).abs() * w[k];
|
||||
if dk > t {
|
||||
t = dk;
|
||||
}
|
||||
}
|
||||
if t < best {
|
||||
best = t;
|
||||
}
|
||||
}
|
||||
total += best;
|
||||
}
|
||||
total / weights.len() as f64
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::core::evaluation::Evaluation;
|
||||
use crate::core::objective::Objective;
|
||||
use crate::pareto::das_dennis;
|
||||
|
||||
fn space_min2() -> ObjectiveSpace {
|
||||
ObjectiveSpace::new(vec![Objective::minimize("f1"), Objective::minimize("f2")])
|
||||
}
|
||||
|
||||
fn cand(obj: Vec<f64>) -> Candidate<()> {
|
||||
Candidate::new((), Evaluation::new(obj))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn r2_extremes_are_perfect_at_endpoints() {
|
||||
let s = space_min2();
|
||||
let front = [cand(vec![0.0, 1.0]), cand(vec![1.0, 0.0])];
|
||||
let weights = [vec![1.0, 0.0], vec![0.0, 1.0]];
|
||||
assert!(r2(&front, &weights, &s) < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn r2_dense_dasdennis_finite_for_uniform_front() {
|
||||
let s = space_min2();
|
||||
let weights = das_dennis(2, 5);
|
||||
let front: Vec<Candidate<()>> = (0..=10)
|
||||
.map(|i| {
|
||||
let t = i as f64 / 10.0;
|
||||
cand(vec![t, 1.0 - t])
|
||||
})
|
||||
.collect();
|
||||
let v = r2(&front, &weights, &s);
|
||||
assert!(v.is_finite());
|
||||
assert!(v >= 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "approximation must not be empty")]
|
||||
fn r2_empty_approximation_panics() {
|
||||
let s = space_min2();
|
||||
let weights = vec![vec![1.0, 0.0]];
|
||||
let _: f64 = r2::<()>(&[], &weights, &s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "weight set must not be empty")]
|
||||
fn r2_empty_weights_panics() {
|
||||
let s = space_min2();
|
||||
let front = [cand(vec![0.0, 1.0])];
|
||||
let _ = r2(&front, &[], &s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "wrong length")]
|
||||
fn r2_wrong_dim_weight_panics() {
|
||||
let s = space_min2();
|
||||
let front = [cand(vec![0.0, 1.0])];
|
||||
let weights = vec![vec![1.0, 0.0, 0.0]];
|
||||
let _ = r2(&front, &weights, &s);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user