Skip to main content

forge_core/
indicators.rs

1//! Quality indicators for multi-objective fronts.
2//!
3//! The standard toolkit for measuring how good an approximation front is
4//! (minimization convention throughout, matching [`MultiProblem`]):
5//!
6//! - [`hypervolume`] — the dominated volume w.r.t. a reference point
7//!   (Zitzler & Thiele 1999): the only strictly Pareto-compliant unary
8//!   indicator. Exact `O(n log n)` sweep in 2D; WFG exclusive decomposition
9//!   (While, Bradstreet & Barone 2012) for 3+ objectives.
10//! - [`hv_contributions`] — each point's exclusive hypervolume (what
11//!   [`SmsEmoa`] selects on).
12//! - [`gd`] / [`igd`] — (inverted) generational distance to a reference
13//!   front (Van Veldhuizen 1999; Coello & Reyes-Sierra 2004).
14//! - [`gd_plus`] / [`igd_plus`] — the weakly-Pareto-compliant `+` variants
15//!   (Ishibuchi, Masuda, Tanigaki & Nojima, EMO 2015), which replace the
16//!   Euclidean distance with the *dominance-aware* distance
17//!   `d⁺(z, a) = ‖max(a − z, 0)‖` so that dominating a reference point never
18//!   reads as an error. Prefer IGD+ over plain IGD when comparing algorithms.
19//!
20//! All functions are pure, deterministic, and dependency-free. Reference
21//! fronts are ordinary `&[Vec<f64>]` slices, so analytical fronts, sampled
22//! fronts, or another run's [`ParetoFront::objective_vectors`] all work.
23//!
24//! [`MultiProblem`]: crate::problem::MultiProblem
25//! [`SmsEmoa`]: crate::algo::SmsEmoa
26//! [`ParetoFront::objective_vectors`]: crate::solution::ParetoFront::objective_vectors
27
28/// Exact dominated hypervolume of `points` w.r.t. the reference point `r`
29/// (minimization: every point should weakly dominate `r`; components beyond
30/// `r` contribute zero). Duplicates and dominated points are handled
31/// correctly (they simply add no exclusive volume).
32///
33/// 2D uses an `O(n log n)` sweep; 3+ objectives use the WFG exclusive
34/// decomposition — exact but exponential in the worst case, intended for the
35/// front sizes typical of population-based MOEAs (≤ a few hundred points,
36/// 2–4 objectives).
37pub fn hypervolume(points: &[Vec<f64>], r: &[f64]) -> f64 {
38    if points.is_empty() {
39        return 0.0;
40    }
41    if r.len() == 2 {
42        return hypervolume_2d(points, r);
43    }
44    wfg(points, r)
45}
46
47/// The exclusive hypervolume contribution of every point: `contribution[i] =
48/// HV(points) − HV(points \ {i})`. Dominated or duplicate points get 0.
49///
50/// 2D is computed in one `O(n log n)` sweep; 3+ objectives fall back to
51/// leave-one-out WFG (`n + 1` hypervolume evaluations).
52pub fn hv_contributions(points: &[Vec<f64>], r: &[f64]) -> Vec<f64> {
53    if points.is_empty() {
54        return Vec::new();
55    }
56    if r.len() == 2 {
57        return contributions_2d(points, r);
58    }
59    let total = wfg(points, r);
60    (0..points.len())
61        .map(|i| {
62            let without: Vec<Vec<f64>> = points
63                .iter()
64                .enumerate()
65                .filter(|&(j, _)| j != i)
66                .map(|(_, p)| p.clone())
67                .collect();
68            (total - wfg(&without, r)).max(0.0)
69        })
70        .collect()
71}
72
73/// Generational Distance: the mean Euclidean distance from each point of
74/// `front` to its nearest neighbor in `reference` (Van Veldhuizen 1999).
75/// Measures convergence only; `NaN` for an empty front.
76pub fn gd(front: &[Vec<f64>], reference: &[Vec<f64>]) -> f64 {
77    mean_min_distance(front, reference, euclidean)
78}
79
80/// Inverted Generational Distance: the mean Euclidean distance from each
81/// *reference* point to its nearest neighbor in `front`. Measures both
82/// convergence and coverage; `NaN` for an empty reference set. Sensitive to
83/// the reference-front resolution — prefer [`igd_plus`] for algorithm
84/// comparisons.
85pub fn igd(front: &[Vec<f64>], reference: &[Vec<f64>]) -> f64 {
86    mean_min_distance(reference, front, euclidean)
87}
88
89/// GD⁺ (Ishibuchi et al. 2015): like [`gd`] but with the dominance-aware
90/// distance `‖max(a − z, 0)‖` from front point `a` to reference point `z`,
91/// so components where the front point is *better* than the reference do not
92/// count as error.
93pub fn gd_plus(front: &[Vec<f64>], reference: &[Vec<f64>]) -> f64 {
94    mean_min_distance(front, reference, d_plus)
95}
96
97/// IGD⁺ (Ishibuchi et al. 2015): like [`igd`] but with the dominance-aware
98/// distance `‖max(a − z, 0)‖` from each reference point `z` to front point
99/// `a`. Weakly Pareto-compliant, unlike plain IGD — the recommended default
100/// for comparing fronts against a reference.
101pub fn igd_plus(front: &[Vec<f64>], reference: &[Vec<f64>]) -> f64 {
102    mean_min_distance(reference, front, |z, a| d_plus(a, z))
103}
104
105/// Mean over `from` of the minimum `dist(p, q)` over `to`.
106fn mean_min_distance(
107    from: &[Vec<f64>],
108    to: &[Vec<f64>],
109    dist: impl Fn(&[f64], &[f64]) -> f64,
110) -> f64 {
111    if from.is_empty() || to.is_empty() {
112        return f64::NAN;
113    }
114    let total: f64 = from
115        .iter()
116        .map(|p| to.iter().map(|q| dist(p, q)).fold(f64::INFINITY, f64::min))
117        .sum();
118    total / from.len() as f64
119}
120
121fn euclidean(a: &[f64], b: &[f64]) -> f64 {
122    a.iter()
123        .zip(b)
124        .map(|(x, y)| (x - y) * (x - y))
125        .sum::<f64>()
126        .sqrt()
127}
128
129/// Dominance-aware distance from front point `a` to reference point `z`
130/// (minimization): `‖max(a − z, 0)‖`.
131fn d_plus(a: &[f64], z: &[f64]) -> f64 {
132    a.iter()
133        .zip(z)
134        .map(|(ai, zi)| (ai - zi).max(0.0).powi(2))
135        .sum::<f64>()
136        .sqrt()
137}
138
139/// 2D hypervolume by a sweep over the non-dominated staircase.
140fn hypervolume_2d(points: &[Vec<f64>], r: &[f64]) -> f64 {
141    // Sort by f1 ascending, f2 ascending; walk keeping the running best f2.
142    let mut sorted: Vec<&Vec<f64>> = points.iter().collect();
143    sorted.sort_by(|a, b| {
144        a[0].partial_cmp(&b[0])
145            .unwrap_or(std::cmp::Ordering::Equal)
146            .then(a[1].partial_cmp(&b[1]).unwrap_or(std::cmp::Ordering::Equal))
147    });
148    let mut vol = 0.0;
149    let mut best_f2 = r[1];
150    for p in sorted {
151        if p[1] < best_f2 && p[0] < r[0] {
152            vol += (r[0] - p[0]) * (best_f2 - p[1]);
153            best_f2 = p[1];
154        }
155    }
156    vol
157}
158
159/// 2D exclusive contributions in one sweep over the sorted staircase.
160fn contributions_2d(points: &[Vec<f64>], r: &[f64]) -> Vec<f64> {
161    let k = points.len();
162    let mut order: Vec<usize> = (0..k).collect();
163    order.sort_by(|&a, &b| {
164        points[a][0]
165            .partial_cmp(&points[b][0])
166            .unwrap_or(std::cmp::Ordering::Equal)
167            .then(
168                points[a][1]
169                    .partial_cmp(&points[b][1])
170                    .unwrap_or(std::cmp::Ordering::Equal),
171            )
172    });
173    // Keep only the staircase (strictly improving f2 as f1 grows); everything
174    // else is dominated or duplicate and contributes 0.
175    let mut contr = vec![0.0; k];
176    let mut stair: Vec<usize> = Vec::with_capacity(k);
177    let mut best_f2 = f64::INFINITY;
178    for &i in &order {
179        if points[i][1] < best_f2 {
180            stair.push(i);
181            best_f2 = points[i][1];
182        }
183    }
184    for (pos, &i) in stair.iter().enumerate() {
185        let x_next = if pos + 1 < stair.len() {
186            points[stair[pos + 1]][0]
187        } else {
188            r[0]
189        };
190        let y_prev = if pos > 0 {
191            points[stair[pos - 1]][1]
192        } else {
193            r[1]
194        };
195        contr[i] = ((x_next - points[i][0]) * (y_prev - points[i][1])).max(0.0);
196    }
197    contr
198}
199
200/// WFG exclusive-decomposition hypervolume (While et al. 2012):
201/// `HV(S) = Σᵢ [incl(pᵢ) − HV(nd(limit(pᵢ, p_{i+1..})))]`.
202fn wfg(points: &[Vec<f64>], r: &[f64]) -> f64 {
203    if points.is_empty() {
204        return 0.0;
205    }
206    let mut vol = 0.0;
207    for i in 0..points.len() {
208        let incl: f64 = r
209            .iter()
210            .zip(&points[i])
211            .map(|(rj, pj)| (rj - pj).max(0.0))
212            .product();
213        // Limit set: the overlap of each later point with points[i].
214        let limited: Vec<Vec<f64>> = points[i + 1..]
215            .iter()
216            .map(|q| (0..r.len()).map(|j| points[i][j].max(q[j])).collect())
217            .collect();
218        let nd = non_dominated(&limited);
219        vol += incl - wfg(&nd, r);
220    }
221    vol
222}
223
224/// Keeps the non-dominated subset (minimization).
225fn non_dominated(set: &[Vec<f64>]) -> Vec<Vec<f64>> {
226    let mut out = Vec::new();
227    for (i, p) in set.iter().enumerate() {
228        let dominated = set.iter().enumerate().any(|(j, q)| {
229            i != j && q.iter().zip(p).all(|(a, b)| a <= b) && q.iter().zip(p).any(|(a, b)| a < b)
230        });
231        if !dominated {
232            out.push(p.clone());
233        }
234    }
235    out
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    #[test]
243    fn hypervolume_known_2d_and_3d() {
244        // Two non-dominated 2D points vs r=(3,4): union area = 3.
245        let pts = vec![vec![1.0, 3.0], vec![2.0, 2.0]];
246        assert!((hypervolume(&pts, &[3.0, 4.0]) - 3.0).abs() < 1e-9);
247        // A dominated third point must not change the volume.
248        let with_dominated = vec![vec![1.0, 3.0], vec![2.0, 2.0], vec![2.5, 3.5]];
249        assert!((hypervolume(&with_dominated, &[3.0, 4.0]) - 3.0).abs() < 1e-9);
250        // Single 3D point: box volume.
251        let one = vec![vec![1.0, 1.0, 1.0]];
252        assert!((hypervolume(&one, &[2.0, 3.0, 4.0]) - 6.0).abs() < 1e-9);
253    }
254
255    #[test]
256    fn wfg_and_2d_sweep_agree() {
257        // Same 2D front evaluated by both paths must match exactly.
258        let pts = vec![
259            vec![0.1, 0.9],
260            vec![0.4, 0.5],
261            vec![0.4, 0.5], // duplicate
262            vec![0.8, 0.2],
263            vec![0.9, 0.8], // dominated
264        ];
265        let r = [1.1, 1.1];
266        let sweep = hypervolume_2d(&pts, &r);
267        let recursive = wfg(&pts, &r);
268        assert!(
269            (sweep - recursive).abs() < 1e-12,
270            "sweep {sweep} vs wfg {recursive}"
271        );
272    }
273
274    #[test]
275    fn contributions_match_leave_one_out() {
276        let pts = vec![vec![0.1, 0.9], vec![0.4, 0.5], vec![0.8, 0.2]];
277        let r = [1.0, 1.0];
278        let fast = hv_contributions(&pts, &r);
279        let total = hypervolume(&pts, &r);
280        for (i, f) in fast.iter().enumerate() {
281            let without: Vec<Vec<f64>> = pts
282                .iter()
283                .enumerate()
284                .filter(|&(j, _)| j != i)
285                .map(|(_, p)| p.clone())
286                .collect();
287            let slow = total - hypervolume(&without, &r);
288            assert!(
289                (f - slow).abs() < 1e-12,
290                "point {i}: fast {f} vs slow {slow}"
291            );
292        }
293        // Duplicates and dominated points contribute zero.
294        let with_extra = vec![
295            vec![0.1, 0.9],
296            vec![0.1, 0.9],
297            vec![0.5, 0.95], // dominated
298        ];
299        let c = hv_contributions(&with_extra, &r);
300        assert_eq!(c[1], 0.0);
301        assert_eq!(c[2], 0.0);
302    }
303
304    #[test]
305    fn igd_plus_ignores_dominating_deviation() {
306        // A front point that DOMINATES the reference point: plain IGD reads a
307        // positive error, IGD+ correctly reads zero.
308        let reference = vec![vec![0.5, 0.5]];
309        let dominating_front = vec![vec![0.4, 0.4]];
310        assert!(igd(&dominating_front, &reference) > 0.0);
311        assert_eq!(igd_plus(&dominating_front, &reference), 0.0);
312        // A front point dominated BY the reference reads the same in both.
313        let dominated_front = vec![vec![0.6, 0.6]];
314        let d_igd = igd(&dominated_front, &reference);
315        let d_plusv = igd_plus(&dominated_front, &reference);
316        assert!((d_igd - d_plusv).abs() < 1e-12);
317    }
318
319    #[test]
320    fn gd_igd_basics() {
321        let front = vec![vec![0.0, 1.0], vec![1.0, 0.0]];
322        // Front == reference: all indicators are zero.
323        assert_eq!(gd(&front, &front), 0.0);
324        assert_eq!(igd(&front, &front), 0.0);
325        assert_eq!(gd_plus(&front, &front), 0.0);
326        assert_eq!(igd_plus(&front, &front), 0.0);
327        // Empty inputs give NaN, not a panic.
328        assert!(gd(&[], &front).is_nan());
329        assert!(igd(&front, &[]).is_nan());
330    }
331}