scirs2_optimize/multiobjective/indicators.rs
1//! Performance indicators for multi-objective optimization
2//!
3//! Provides metrics to evaluate the quality of Pareto fronts:
4//! - Hypervolume (exact 2D sweep, Monte Carlo for higher dimensions)
5//! - Inverted Generational Distance (IGD)
6//! - Generational Distance (GD)
7//! - Additive epsilon indicator
8//! - Spread / diversity indicator
9//! - Domination utilities and non-dominated sorting
10//!
11//! # References
12//!
13//! - Zitzler, Thiele (1998). Multiobjective Optimization Using Evolutionary Algorithms —
14//! A Comparative Case Study.
15//! - Deb et al. (2002). A fast and elitist multiobjective genetic algorithm: NSGA-II.
16//! - Van Veldhuizen & Lamont (1998). Evolutionary Computation and Convergence to a Pareto Front.
17
18use crate::error::OptimizeError;
19use scirs2_core::random::rngs::StdRng;
20use scirs2_core::random::{Rng, SeedableRng};
21use scirs2_core::RngExt;
22
23// ─────────────────────────────────────────────────────────────────────────────
24// Domination utilities
25// ─────────────────────────────────────────────────────────────────────────────
26
27/// Returns `true` if vector `a` Pareto-dominates vector `b`.
28///
29/// `a` dominates `b` when:
30/// - `a[i] <= b[i]` for every objective `i`, AND
31/// - `a[j] < b[j]` for at least one objective `j`.
32///
33/// # Examples
34/// ```
35/// use scirs2_optimize::multiobjective::indicators::dominates;
36/// assert!(dominates(&[1.0, 2.0], &[2.0, 3.0]));
37/// assert!(!dominates(&[1.0, 3.0], &[2.0, 2.0]));
38/// ```
39pub fn dominates(a: &[f64], b: &[f64]) -> bool {
40 debug_assert_eq!(a.len(), b.len(), "objective vectors must have equal length");
41 let mut any_strictly_better = false;
42 for (ai, bi) in a.iter().zip(b.iter()) {
43 if ai > bi {
44 return false;
45 }
46 if ai < bi {
47 any_strictly_better = true;
48 }
49 }
50 any_strictly_better
51}
52
53/// Fast non-dominated sorting.
54///
55/// Returns a list of fronts, where each front is a `Vec<usize>` of indices
56/// into `points`. Front 0 contains the non-dominated set; front 1 contains
57/// points dominated only by front 0, and so on.
58///
59/// This implements the O(MN²) algorithm from Deb et al. (2002).
60///
61/// # Arguments
62/// * `points` - Slice of objective vectors. Each inner `Vec<f64>` must have
63/// the same length (number of objectives).
64///
65/// # Returns
66/// Vector of fronts; `result[0]` = Pareto-optimal indices, etc.
67///
68/// # Examples
69/// ```
70/// use scirs2_optimize::multiobjective::indicators::non_dominated_sort;
71/// let pts = vec![vec![1.0,2.0], vec![2.0,1.0], vec![3.0,3.0]];
72/// let fronts = non_dominated_sort(&pts);
73/// assert_eq!(fronts[0].len(), 2); // (1,2) and (2,1) are non-dominated
74/// assert_eq!(fronts[1].len(), 1); // (3,3) dominated
75/// ```
76pub fn non_dominated_sort(points: &[Vec<f64>]) -> Vec<Vec<usize>> {
77 let n = points.len();
78 if n == 0 {
79 return vec![];
80 }
81
82 // For each point i: list of points it dominates, and count of points that dominate it
83 let mut dominated_by_count = vec![0usize; n];
84 let mut dominates_list: Vec<Vec<usize>> = vec![vec![]; n];
85
86 for i in 0..n {
87 for j in 0..n {
88 if i == j {
89 continue;
90 }
91 if dominates(&points[i], &points[j]) {
92 dominates_list[i].push(j);
93 } else if dominates(&points[j], &points[i]) {
94 dominated_by_count[i] += 1;
95 }
96 }
97 }
98
99 let mut fronts: Vec<Vec<usize>> = Vec::new();
100 let mut current_front: Vec<usize> = (0..n).filter(|&i| dominated_by_count[i] == 0).collect();
101
102 while !current_front.is_empty() {
103 let mut next_front: Vec<usize> = Vec::new();
104 for &i in ¤t_front {
105 for &j in &dominates_list[i] {
106 dominated_by_count[j] -= 1;
107 if dominated_by_count[j] == 0 {
108 next_front.push(j);
109 }
110 }
111 }
112 fronts.push(current_front);
113 current_front = next_front;
114 }
115
116 fronts
117}
118
119// ─────────────────────────────────────────────────────────────────────────────
120// Hypervolume
121// ─────────────────────────────────────────────────────────────────────────────
122
123/// Exact 2-D hypervolume indicator.
124///
125/// Computes the area of the objective space dominated by `pareto_front` and
126/// bounded by `reference_point`. All objective values are assumed to be
127/// minimised.
128///
129/// # Arguments
130/// * `pareto_front` - Non-dominated objective vectors (2-D each).
131/// * `reference_point` - Reference point with 2 components; must be greater
132/// than all front points on both axes to yield a non-zero result.
133///
134/// # Panics
135/// Panics in debug mode if any point does not have exactly 2 objectives.
136///
137/// # Examples
138/// ```
139/// use scirs2_optimize::multiobjective::indicators::hypervolume_2d;
140/// // Triangle: (0,2), (2,0), reference (3,3) => area = 5
141/// let front = vec![vec![0.0,2.0], vec![2.0,0.0]];
142/// let hv = hypervolume_2d(&front, &[3.0, 3.0]);
143/// assert!((hv - 5.0).abs() < 1e-10);
144/// ```
145pub fn hypervolume_2d(pareto_front: &[Vec<f64>], reference_point: &[f64]) -> f64 {
146 if pareto_front.is_empty() {
147 return 0.0;
148 }
149 debug_assert_eq!(reference_point.len(), 2);
150
151 // Filter points that are dominated by the reference point on both axes
152 let mut pts: Vec<(f64, f64)> = pareto_front
153 .iter()
154 .filter(|p| p[0] < reference_point[0] && p[1] < reference_point[1])
155 .map(|p| (p[0], p[1]))
156 .collect();
157
158 if pts.is_empty() {
159 return 0.0;
160 }
161
162 // Build 2-D Pareto front: sort by f1 ascending, keep only strictly
163 // decreasing f2 subsequence (i.e., remove dominated points in 2D).
164 pts.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
165
166 let mut pareto_2d: Vec<(f64, f64)> = Vec::new();
167 let mut min_f2 = f64::INFINITY;
168 for (f1, f2) in &pts {
169 if *f2 < min_f2 {
170 min_f2 = *f2;
171 pareto_2d.push((*f1, *f2));
172 }
173 }
174
175 // Right-to-left sweep: accumulate rectangles
176 let ref_x = reference_point[0];
177 let ref_y = reference_point[1];
178 let np = pareto_2d.len();
179 let mut volume = 0.0;
180 for i in (0..np).rev() {
181 let x_start = pareto_2d[i].0;
182 let x_end = if i + 1 < np {
183 pareto_2d[i + 1].0
184 } else {
185 ref_x
186 };
187 let y = pareto_2d[i].1;
188 volume += (x_end - x_start) * (ref_y - y);
189 }
190
191 volume
192}
193
194/// Monte-Carlo hypervolume approximation for arbitrary number of objectives.
195///
196/// Estimates the hypervolume by sampling random points uniformly in the
197/// bounding box `[ideal_point, reference_point]` and checking whether each
198/// sample is dominated by at least one point in `pareto_front`.
199///
200/// For 2-D problems prefer [`hypervolume_2d`] which gives an exact result.
201///
202/// # Arguments
203/// * `pareto_front` - Objective vectors (each of equal length).
204/// * `reference_point` - Upper bound; must have the same length as each point.
205/// * `n_samples` - Number of Monte-Carlo samples (higher → more accurate).
206/// * `seed` - RNG seed for reproducibility.
207pub fn hypervolume_mc(
208 pareto_front: &[Vec<f64>],
209 reference_point: &[f64],
210 n_samples: usize,
211 seed: u64,
212) -> f64 {
213 if pareto_front.is_empty() || n_samples == 0 {
214 return 0.0;
215 }
216
217 let n_obj = reference_point.len();
218 let mut rng = StdRng::seed_from_u64(seed);
219
220 // Compute ideal (lower) point as component-wise minimum of the front
221 let mut ideal = vec![f64::INFINITY; n_obj];
222 for pt in pareto_front {
223 for (i, &v) in pt.iter().enumerate() {
224 if v < ideal[i] {
225 ideal[i] = v;
226 }
227 }
228 }
229
230 // Compute total volume of the sampling bounding box
231 let box_volume: f64 = (0..n_obj)
232 .map(|i| (reference_point[i] - ideal[i]).max(0.0))
233 .product();
234
235 if box_volume == 0.0 {
236 return 0.0;
237 }
238
239 let mut dominated_count = 0usize;
240
241 for _ in 0..n_samples {
242 // Sample a random point within the bounding box
243 let sample: Vec<f64> = (0..n_obj)
244 .map(|i| ideal[i] + rng.random::<f64>() * (reference_point[i] - ideal[i]))
245 .collect();
246
247 // Check if the sample is dominated by any front point
248 'outer: for front_pt in pareto_front {
249 let mut pt_dominates_sample = true;
250 for j in 0..n_obj {
251 if front_pt[j] >= sample[j] {
252 pt_dominates_sample = false;
253 break;
254 }
255 }
256 if pt_dominates_sample {
257 dominated_count += 1;
258 break 'outer;
259 }
260 }
261 }
262
263 box_volume * (dominated_count as f64 / n_samples as f64)
264}
265
266// ─────────────────────────────────────────────────────────────────────────────
267// Convergence / diversity indicators
268// ─────────────────────────────────────────────────────────────────────────────
269
270/// Inverted Generational Distance (IGD).
271///
272/// For each point in `true_front`, finds the nearest point in `approx_front`
273/// and averages the distances. A value of 0 means `approx_front` covers every
274/// point in `true_front` exactly.
275///
276/// # Arguments
277/// * `true_front` - Reference (true) Pareto front.
278/// * `approx_front` - Approximated Pareto front being evaluated.
279///
280/// # Returns
281/// IGD ∈ [0, ∞). Returns `f64::INFINITY` if either input is empty.
282///
283/// # Examples
284/// ```
285/// use scirs2_optimize::multiobjective::indicators::igd;
286/// let true_front = vec![vec![0.0, 1.0], vec![1.0, 0.0]];
287/// let approx = vec![vec![0.0, 1.0], vec![1.0, 0.0]];
288/// assert!(igd(&true_front, &approx) < 1e-10);
289/// ```
290pub fn igd(true_front: &[Vec<f64>], approx_front: &[Vec<f64>]) -> f64 {
291 if true_front.is_empty() || approx_front.is_empty() {
292 return f64::INFINITY;
293 }
294
295 let sum: f64 = true_front
296 .iter()
297 .map(|tp| {
298 approx_front
299 .iter()
300 .map(|ap| euclidean_distance(tp, ap))
301 .fold(f64::INFINITY, f64::min)
302 })
303 .sum();
304
305 sum / true_front.len() as f64
306}
307
308/// Generational Distance (GD).
309///
310/// For each point in `approx_front`, finds the nearest point in `true_front`
311/// and averages the distances.
312///
313/// # Returns
314/// GD ∈ [0, ∞). Returns `f64::INFINITY` if either input is empty.
315pub fn generational_distance(true_front: &[Vec<f64>], approx_front: &[Vec<f64>]) -> f64 {
316 if true_front.is_empty() || approx_front.is_empty() {
317 return f64::INFINITY;
318 }
319
320 let sum: f64 = approx_front
321 .iter()
322 .map(|ap| {
323 true_front
324 .iter()
325 .map(|tp| euclidean_distance(ap, tp))
326 .fold(f64::INFINITY, f64::min)
327 })
328 .sum();
329
330 sum / approx_front.len() as f64
331}
332
333/// Additive epsilon indicator (I_ε+).
334///
335/// Returns the smallest scalar ε such that for every point p ∈ `true_front`
336/// there exists a point q ∈ `approx_front` with `q[i] - ε ≤ p[i]` for all i
337/// (i.e., q ε-dominates p).
338///
339/// Smaller values indicate that `approx_front` better covers `true_front`.
340/// A value ≤ 0 means `approx_front` weakly dominates all of `true_front`.
341///
342/// # Examples
343/// ```
344/// use scirs2_optimize::multiobjective::indicators::additive_epsilon_indicator;
345/// // Perfect coverage => eps = 0
346/// let tf = vec![vec![0.0,1.0], vec![1.0,0.0]];
347/// let af = vec![vec![0.0,1.0], vec![1.0,0.0]];
348/// assert!(additive_epsilon_indicator(&tf, &af).abs() < 1e-10);
349/// ```
350pub fn additive_epsilon_indicator(true_front: &[Vec<f64>], approx_front: &[Vec<f64>]) -> f64 {
351 if true_front.is_empty() || approx_front.is_empty() {
352 return f64::INFINITY;
353 }
354
355 // For each true_front point, find the minimum epsilon needed from any approx point
356 true_front
357 .iter()
358 .map(|tp| {
359 approx_front
360 .iter()
361 .map(|ap| {
362 // Minimum shift so that ap[i] - eps <= tp[i] for all i
363 // => eps >= ap[i] - tp[i] for all i
364 ap.iter()
365 .zip(tp.iter())
366 .map(|(a, t)| a - t)
367 .fold(f64::NEG_INFINITY, f64::max)
368 })
369 .fold(f64::INFINITY, f64::min)
370 })
371 .fold(f64::NEG_INFINITY, f64::max)
372}
373
374/// Spread / diversity indicator (Δ, Delta).
375///
376/// Measures the extent and uniformity of the Pareto front approximation.
377/// Based on Deb et al. (2002): computes the average nearest-neighbour
378/// distance for internal points and compares against extreme-point distances.
379///
380/// Returns a value ≥ 0; smaller means better distributed front.
381/// Returns 0 for fewer than 2 points.
382///
383/// # Notes
384/// This implementation uses the modified spread indicator where the value
385/// is computed as the standard deviation of nearest-neighbour distances,
386/// normalised by the mean distance.
387pub fn spread(pareto_front: &[Vec<f64>]) -> f64 {
388 let n = pareto_front.len();
389 if n < 2 {
390 return 0.0;
391 }
392
393 // Nearest-neighbour distances
394 let nn_dists: Vec<f64> = (0..n)
395 .map(|i| {
396 (0..n)
397 .filter(|&j| j != i)
398 .map(|j| euclidean_distance(&pareto_front[i], &pareto_front[j]))
399 .fold(f64::INFINITY, f64::min)
400 })
401 .collect();
402
403 let mean = nn_dists.iter().sum::<f64>() / n as f64;
404 if mean < f64::EPSILON {
405 return 0.0;
406 }
407
408 // Coefficient of variation as diversity measure
409 let variance = nn_dists.iter().map(|d| (d - mean).powi(2)).sum::<f64>() / n as f64;
410 variance.sqrt() / mean
411}
412
413// ─────────────────────────────────────────────────────────────────────────────
414// Internal helpers
415// ─────────────────────────────────────────────────────────────────────────────
416
417fn euclidean_distance(a: &[f64], b: &[f64]) -> f64 {
418 a.iter()
419 .zip(b.iter())
420 .map(|(x, y)| (x - y).powi(2))
421 .sum::<f64>()
422 .sqrt()
423}
424
425// ─────────────────────────────────────────────────────────────────────────────
426// Tests
427// ─────────────────────────────────────────────────────────────────────────────
428
429// ─────────────────────────────────────────────────────────────────────────────
430// Enhanced quality indicators
431// ─────────────────────────────────────────────────────────────────────────────
432
433/// Spacing metric (SP).
434///
435/// Measures the uniformity of spacing between solutions in the Pareto front.
436/// Based on Schott (1995): computes the standard deviation of nearest-neighbour
437/// distances. A value of 0 indicates perfectly uniform spacing.
438///
439/// # Returns
440/// SP ∈ [0, ∞). Returns 0 for fewer than 2 points.
441///
442/// # Examples
443/// ```
444/// use scirs2_optimize::multiobjective::indicators::spacing_metric;
445/// // Uniformly spaced front → near-zero spacing
446/// let front = vec![vec![0.0,1.0], vec![0.5,0.5], vec![1.0,0.0]];
447/// let sp = spacing_metric(&front);
448/// assert!(sp < 0.01);
449/// ```
450pub fn spacing_metric(front: &[Vec<f64>]) -> f64 {
451 let n = front.len();
452 if n < 2 {
453 return 0.0;
454 }
455
456 let dists: Vec<f64> = (0..n)
457 .map(|i| {
458 (0..n)
459 .filter(|&j| j != i)
460 .map(|j| euclidean_distance(&front[i], &front[j]))
461 .fold(f64::INFINITY, f64::min)
462 })
463 .collect();
464
465 let mean = dists.iter().sum::<f64>() / n as f64;
466 let variance = dists.iter().map(|d| (d - mean).powi(2)).sum::<f64>() / n as f64;
467 variance.sqrt()
468}
469
470/// Delta metric (Δ) — Deb's modified spread measure.
471///
472/// Combines spread (extent) of the front with uniformity of spacing.
473/// Formally: Δ = (d_f + d_l + Σ|d_i - d̄|) / (d_f + d_l + (n-1) * d̄)
474///
475/// where d_f, d_l are distances of the extreme approximation points to the
476/// corresponding true-front extremes (or 0 if `true_extremes` is empty),
477/// and d_i are nearest-neighbour distances between consecutive solutions.
478///
479/// # Arguments
480/// * `front` - Approximated Pareto front solutions.
481/// * `true_extremes` - Extreme points of the true Pareto front (may be empty).
482///
483/// # Returns
484/// Δ ∈ [0, ∞). Lower values indicate better spread and uniformity.
485///
486/// # Examples
487/// ```
488/// use scirs2_optimize::multiobjective::indicators::delta_metric;
489/// let front = vec![vec![0.0,1.0], vec![0.5,0.5], vec![1.0,0.0]];
490/// let delta = delta_metric(&front, &[]);
491/// assert!(delta >= 0.0);
492/// ```
493pub fn delta_metric(front: &[Vec<f64>], true_extremes: &[Vec<f64>]) -> f64 {
494 let n = front.len();
495 if n == 0 {
496 return f64::INFINITY;
497 }
498 if n == 1 {
499 return 0.0;
500 }
501
502 // Nearest-neighbour distances for all solutions
503 let dists: Vec<f64> = (0..n)
504 .map(|i| {
505 (0..n)
506 .filter(|&j| j != i)
507 .map(|j| euclidean_distance(&front[i], &front[j]))
508 .fold(f64::INFINITY, f64::min)
509 })
510 .collect();
511
512 let d_bar = dists.iter().sum::<f64>() / n as f64;
513
514 // Distance from extreme approximation points to their true-front counterparts
515 let (d_f, d_l) = if true_extremes.is_empty() {
516 (0.0_f64, 0.0_f64)
517 } else {
518 // Sort front by first objective
519 let mut sorted_front = front.to_vec();
520 sorted_front.sort_by(|a, b| a[0].partial_cmp(&b[0]).unwrap_or(std::cmp::Ordering::Equal));
521
522 let first = &sorted_front[0];
523 let last = &sorted_front[n - 1];
524
525 let df = true_extremes
526 .iter()
527 .map(|p| euclidean_distance(first, p))
528 .fold(f64::INFINITY, f64::min);
529 let dl = true_extremes
530 .iter()
531 .map(|p| euclidean_distance(last, p))
532 .fold(f64::INFINITY, f64::min);
533
534 (df.min(1e10), dl.min(1e10))
535 };
536
537 let sum_diff: f64 = dists.iter().map(|d| (d - d_bar).abs()).sum();
538 let denominator = d_f + d_l + (n as f64 - 1.0) * d_bar;
539
540 if denominator < f64::EPSILON {
541 0.0
542 } else {
543 (d_f + d_l + sum_diff) / denominator
544 }
545}
546
547/// Inverted Generational Distance Plus (IGD+).
548///
549/// IGD+ is a Pareto-compliant variant of IGD. It uses a modified distance
550/// d+(p, q) = sqrt(Σ_i max(q_i - p_i, 0)²) that only penalises objectives
551/// where the approximation point q is worse (larger) than the true point p.
552///
553/// For each point p ∈ `true_front`:
554/// IGD+(p, A) = min_{q ∈ A} d+(p, q)
555///
556/// IGD+ = (1/|true_front|) Σ_{p ∈ true_front} IGD+(p, A)
557///
558/// # Returns
559/// IGD+ ∈ [0, ∞). Returns `f64::INFINITY` if either input is empty.
560/// A value of 0 means the approximation set weakly dominates the entire true front.
561///
562/// # References
563/// - Ishibuchi et al. (2015). Evolutionary Computation, 26(3):411-440.
564///
565/// # Examples
566/// ```
567/// use scirs2_optimize::multiobjective::indicators::igd_plus;
568/// let tf = vec![vec![0.0,1.0], vec![1.0,0.0]];
569/// let af = tf.clone();
570/// assert!(igd_plus(&tf, &af) < 1e-10);
571/// ```
572pub fn igd_plus(true_front: &[Vec<f64>], approx_front: &[Vec<f64>]) -> f64 {
573 if true_front.is_empty() || approx_front.is_empty() {
574 return f64::INFINITY;
575 }
576
577 let sum: f64 = true_front
578 .iter()
579 .map(|tp| {
580 approx_front
581 .iter()
582 .map(|ap| igd_plus_dist(tp, ap))
583 .fold(f64::INFINITY, f64::min)
584 })
585 .sum();
586
587 sum / true_front.len() as f64
588}
589
590/// Modified distance for IGD+: d+(p, q) = sqrt(Σ max(q_i - p_i, 0)²).
591fn igd_plus_dist(true_point: &[f64], approx_point: &[f64]) -> f64 {
592 true_point
593 .iter()
594 .zip(approx_point.iter())
595 .map(|(p, q)| (q - p).max(0.0).powi(2))
596 .sum::<f64>()
597 .sqrt()
598}
599
600/// Scalarizing utility function type for the R2 indicator.
601#[derive(Debug, Clone, Copy, PartialEq, Eq)]
602pub enum R2Utility {
603 /// Weighted Tchebycheff scalarization:
604 /// u(f|λ,z*) = max_i { λ_i · |f_i - z*_i| }
605 Tchebycheff,
606 /// Weighted sum scalarization:
607 /// u(f|λ,z*) = Σ_i λ_i · (f_i - z*_i)
608 WeightedSum,
609 /// Penalty-Based Boundary Intersection (PBI) with penalty θ = 5:
610 /// PBI(f|λ,z*) = d₁ + θ · d₂
611 Pbi,
612}
613
614/// R2 quality indicator.
615///
616/// Evaluates a Pareto front approximation using a set of reference weight
617/// vectors and a scalarizing utility function. Defined as:
618///
619/// R2(A, Λ, z*) = (1/|Λ|) Σ_{λ ∈ Λ} min_{a ∈ A} u(a | λ, z*)
620///
621/// A lower R2 value indicates a better approximation (closer to ideal).
622///
623/// # Arguments
624/// * `approx_front` - Approximation set to evaluate.
625/// * `weight_vectors` - Reference weight vectors (each summing to ~1).
626/// * `reference_point` - Ideal point z* (minimization target; typically
627/// the component-wise minimum of the true Pareto front).
628/// * `utility` - Scalarizing function variant.
629///
630/// # Returns
631/// R2 value. Lower is better. Returns `f64::INFINITY` for empty inputs.
632///
633/// # Examples
634/// ```
635/// use scirs2_optimize::multiobjective::indicators::{r2_indicator, R2Utility};
636/// let front = vec![vec![0.0,1.0], vec![0.5,0.5], vec![1.0,0.0]];
637/// let weights = vec![vec![1.0,0.0], vec![0.5,0.5], vec![0.0,1.0]];
638/// let z_star = vec![0.0, 0.0];
639/// let r2 = r2_indicator(&front, &weights, &z_star, R2Utility::Tchebycheff);
640/// assert!(r2 >= 0.0);
641/// ```
642pub fn r2_indicator(
643 approx_front: &[Vec<f64>],
644 weight_vectors: &[Vec<f64>],
645 reference_point: &[f64],
646 utility: R2Utility,
647) -> f64 {
648 if approx_front.is_empty() || weight_vectors.is_empty() {
649 return f64::INFINITY;
650 }
651
652 let sum: f64 = weight_vectors
653 .iter()
654 .map(|w| {
655 approx_front
656 .iter()
657 .map(|a| r2_utility_value(a, w, reference_point, utility))
658 .fold(f64::INFINITY, f64::min)
659 })
660 .sum();
661
662 sum / weight_vectors.len() as f64
663}
664
665/// Compute the scalarizing utility value for the R2 indicator.
666fn r2_utility_value(f: &[f64], weights: &[f64], reference: &[f64], utility: R2Utility) -> f64 {
667 match utility {
668 R2Utility::Tchebycheff => f
669 .iter()
670 .zip(weights.iter())
671 .zip(reference.iter())
672 .map(|((fi, wi), zi)| wi * (fi - zi).abs())
673 .fold(f64::NEG_INFINITY, f64::max),
674
675 R2Utility::WeightedSum => f
676 .iter()
677 .zip(weights.iter())
678 .zip(reference.iter())
679 .map(|((fi, wi), zi)| wi * (fi - zi))
680 .sum(),
681
682 R2Utility::Pbi => {
683 let theta = 5.0_f64;
684
685 // Translate f by reference: f_shifted = f - z*
686 let f_shifted: Vec<f64> = f
687 .iter()
688 .zip(reference.iter())
689 .map(|(fi, zi)| fi - zi)
690 .collect();
691
692 // d1: distance along the weight vector direction
693 let w_norm_sq: f64 = weights.iter().map(|w| w * w).sum();
694 let dot: f64 = f_shifted
695 .iter()
696 .zip(weights.iter())
697 .map(|(a, b)| a * b)
698 .sum();
699
700 let d1 = if w_norm_sq < 1e-14 {
701 0.0
702 } else {
703 (dot / w_norm_sq.sqrt()).abs()
704 };
705
706 // d2: perpendicular distance from the weight vector direction
707 let w_proj: Vec<f64> = if w_norm_sq < 1e-14 {
708 vec![0.0; weights.len()]
709 } else {
710 weights.iter().map(|w| dot * w / w_norm_sq).collect()
711 };
712
713 let d2 = f_shifted
714 .iter()
715 .zip(w_proj.iter())
716 .map(|(fi, wp)| (fi - wp).powi(2))
717 .sum::<f64>()
718 .sqrt();
719
720 d1 + theta * d2
721 }
722 }
723}
724
725/// Hypervolume contribution of each point in a Pareto front.
726///
727/// The exclusive hypervolume contribution (HVC) of a point p is the reduction
728/// in total hypervolume when p is removed:
729///
730/// HVC(p) = HV(front) - HV(front \ {p})
731///
732/// Solutions with larger HVC are more critical for the overall coverage.
733///
734/// # Arguments
735/// * `front` - Non-dominated objective vectors.
736/// * `reference_point` - Upper bound (all objectives < reference for contribution > 0).
737/// * `mc_samples` - Monte Carlo samples for N-D hypervolume (≥ 3 objectives).
738/// * `seed` - RNG seed for Monte Carlo reproducibility.
739///
740/// # Returns
741/// Vector of contribution values, one per front point.
742/// Uses exact sweep for 2-D, Monte Carlo for higher dimensions.
743///
744/// # Examples
745/// ```
746/// use scirs2_optimize::multiobjective::indicators::hypervolume_contribution;
747/// let front = vec![vec![0.0,1.0], vec![0.5,0.5], vec![1.0,0.0]];
748/// let hvc = hypervolume_contribution(&front, &[2.0,2.0], 10_000, 42);
749/// assert_eq!(hvc.len(), 3);
750/// for v in &hvc { assert!(*v >= 0.0); }
751/// ```
752pub fn hypervolume_contribution(
753 front: &[Vec<f64>],
754 reference_point: &[f64],
755 mc_samples: usize,
756 seed: u64,
757) -> Vec<f64> {
758 let n = front.len();
759 if n == 0 {
760 return vec![];
761 }
762
763 let n_obj = reference_point.len();
764 let total_hv = if n_obj == 2 {
765 hypervolume_2d(front, reference_point)
766 } else {
767 hypervolume_mc(front, reference_point, mc_samples, seed)
768 };
769
770 (0..n)
771 .map(|i| {
772 let reduced: Vec<Vec<f64>> = front
773 .iter()
774 .enumerate()
775 .filter(|&(j, _)| j != i)
776 .map(|(_, p)| p.clone())
777 .collect();
778
779 let hv_without = if reduced.is_empty() {
780 0.0
781 } else if n_obj == 2 {
782 hypervolume_2d(&reduced, reference_point)
783 } else {
784 hypervolume_mc(&reduced, reference_point, mc_samples, seed)
785 };
786
787 (total_hv - hv_without).max(0.0)
788 })
789 .collect()
790}
791
792#[cfg(test)]
793mod tests {
794 use super::*;
795
796 // ── dominates ────────────────────────────────────────────────────────────
797
798 #[test]
799 fn test_dominates_basic_2d() {
800 assert!(dominates(&[1.0, 1.0], &[2.0, 2.0]));
801 assert!(dominates(&[1.0, 2.0], &[2.0, 2.0]));
802 assert!(!dominates(&[1.0, 3.0], &[2.0, 2.0]));
803 assert!(!dominates(&[2.0, 2.0], &[2.0, 2.0])); // equal: not dominating
804 }
805
806 #[test]
807 fn test_dominates_identical_points() {
808 assert!(!dominates(&[1.0, 2.0, 3.0], &[1.0, 2.0, 3.0]));
809 }
810
811 #[test]
812 fn test_dominates_three_objectives() {
813 assert!(dominates(&[1.0, 1.0, 1.0], &[2.0, 2.0, 2.0]));
814 assert!(!dominates(&[1.0, 2.0, 1.0], &[1.0, 1.0, 2.0])); // trade-off
815 }
816
817 // ── non_dominated_sort ───────────────────────────────────────────────────
818
819 #[test]
820 fn test_non_dominated_sort_trivial() {
821 let pts = vec![vec![1.0, 2.0], vec![2.0, 1.0], vec![3.0, 3.0]];
822 let fronts = non_dominated_sort(&pts);
823 assert_eq!(fronts.len(), 2);
824 assert_eq!(fronts[0].len(), 2); // (1,2) and (2,1) are non-dominated
825 assert_eq!(fronts[1].len(), 1); // (3,3)
826 assert!(fronts[1].contains(&2));
827 }
828
829 #[test]
830 fn test_non_dominated_sort_all_non_dominated() {
831 let pts = vec![vec![1.0, 3.0], vec![2.0, 2.0], vec![3.0, 1.0]];
832 let fronts = non_dominated_sort(&pts);
833 assert_eq!(fronts.len(), 1);
834 assert_eq!(fronts[0].len(), 3);
835 }
836
837 #[test]
838 fn test_non_dominated_sort_chain() {
839 // Strictly ordered: 0 dominates 1 dominates 2
840 let pts = vec![vec![1.0, 1.0], vec![2.0, 2.0], vec![3.0, 3.0]];
841 let fronts = non_dominated_sort(&pts);
842 assert_eq!(fronts.len(), 3);
843 assert_eq!(fronts[0], vec![0]);
844 assert_eq!(fronts[1], vec![1]);
845 assert_eq!(fronts[2], vec![2]);
846 }
847
848 #[test]
849 fn test_non_dominated_sort_empty() {
850 let fronts = non_dominated_sort(&[]);
851 assert!(fronts.is_empty());
852 }
853
854 // ── hypervolume_2d ───────────────────────────────────────────────────────
855
856 #[test]
857 fn test_hypervolume_2d_single_point() {
858 let front = vec![vec![1.0, 1.0]];
859 let hv = hypervolume_2d(&front, &[2.0, 2.0]);
860 assert!((hv - 1.0).abs() < 1e-10, "Expected 1.0, got {hv}");
861 }
862
863 #[test]
864 fn test_hypervolume_2d_two_points() {
865 // (0,2) and (2,0) with reference (3,3) → expected area = 5
866 let front = vec![vec![0.0, 2.0], vec![2.0, 0.0]];
867 let hv = hypervolume_2d(&front, &[3.0, 3.0]);
868 assert!((hv - 5.0).abs() < 1e-10, "Expected 5.0, got {hv}");
869 }
870
871 #[test]
872 fn test_hypervolume_2d_empty_front() {
873 let hv = hypervolume_2d(&[], &[2.0, 2.0]);
874 assert_eq!(hv, 0.0);
875 }
876
877 #[test]
878 fn test_hypervolume_2d_point_outside_reference() {
879 // Point at (5,5) does not dominate reference (2,2) → HV = 0
880 let front = vec![vec![5.0, 5.0]];
881 let hv = hypervolume_2d(&front, &[2.0, 2.0]);
882 assert_eq!(hv, 0.0);
883 }
884
885 #[test]
886 fn test_hypervolume_2d_with_dominated_point() {
887 // (1,1) dominates (2,2); effectively only (1,1) contributes
888 let front = vec![vec![1.0, 1.0], vec![2.0, 2.0]];
889 let hv = hypervolume_2d(&front, &[3.0, 3.0]);
890 // Expected: area covered by (1,1) up to (3,3) = 2*2 = 4
891 assert!((hv - 4.0).abs() < 1e-10, "Expected 4.0, got {hv}");
892 }
893
894 // ── hypervolume_mc ───────────────────────────────────────────────────────
895
896 #[test]
897 fn test_hypervolume_mc_approximation() {
898 // Single point at (1,1) with reference (2,2) → exact HV = 1
899 let front = vec![vec![1.0, 1.0]];
900 let hv = hypervolume_mc(&front, &[2.0, 2.0], 100_000, 42);
901 // With 100k samples, should be within 2% of exact value 1.0
902 assert!((hv - 1.0).abs() < 0.05, "MC approx too far from 1.0: {hv}");
903 }
904
905 #[test]
906 fn test_hypervolume_mc_empty() {
907 let hv = hypervolume_mc(&[], &[2.0, 2.0], 1000, 42);
908 assert_eq!(hv, 0.0);
909 }
910
911 // ── igd ──────────────────────────────────────────────────────────────────
912
913 #[test]
914 fn test_igd_perfect_coverage() {
915 let tf = vec![vec![0.0, 1.0], vec![0.5, 0.5], vec![1.0, 0.0]];
916 let af = tf.clone();
917 let val = igd(&tf, &af);
918 assert!(val < 1e-10, "IGD of identical fronts: {val}");
919 }
920
921 #[test]
922 fn test_igd_offset_front() {
923 // approx_front is shifted by (0.1, 0.1)
924 let tf = vec![vec![0.0, 0.0]];
925 let af = vec![vec![0.1, 0.1]];
926 let val = igd(&tf, &af);
927 let expected = (0.1f64.powi(2) + 0.1f64.powi(2)).sqrt();
928 assert!((val - expected).abs() < 1e-10);
929 }
930
931 #[test]
932 fn test_igd_empty_inputs() {
933 assert_eq!(igd(&[], &[vec![1.0, 1.0]]), f64::INFINITY);
934 assert_eq!(igd(&[vec![1.0, 1.0]], &[]), f64::INFINITY);
935 }
936
937 // ── generational_distance ────────────────────────────────────────────────
938
939 #[test]
940 fn test_gd_perfect_coverage() {
941 let tf = vec![vec![0.0, 1.0], vec![1.0, 0.0]];
942 let af = tf.clone();
943 let val = generational_distance(&tf, &af);
944 assert!(val < 1e-10, "GD of identical fronts: {val}");
945 }
946
947 // ── additive_epsilon_indicator ───────────────────────────────────────────
948
949 #[test]
950 fn test_epsilon_perfect_coverage() {
951 let tf = vec![vec![0.0, 1.0], vec![1.0, 0.0]];
952 let af = tf.clone();
953 let eps = additive_epsilon_indicator(&tf, &af);
954 assert!(eps.abs() < 1e-10, "Epsilon for identical fronts: {eps}");
955 }
956
957 #[test]
958 fn test_epsilon_offset_front() {
959 // approx is uniformly better by 0.5 on f1 only
960 let tf = vec![vec![1.0, 1.0]];
961 let af = vec![vec![0.5, 1.0]]; // a-t = (-0.5, 0.0) → max = 0.0
962 let eps = additive_epsilon_indicator(&tf, &af);
963 assert!(
964 eps <= 0.0,
965 "approx is better: eps should be <= 0, got {eps}"
966 );
967 }
968
969 #[test]
970 fn test_epsilon_worse_front() {
971 // approx is uniformly worse by 1.0
972 let tf = vec![vec![0.0, 0.0]];
973 let af = vec![vec![1.0, 1.0]]; // a - t = 1 for both
974 let eps = additive_epsilon_indicator(&tf, &af);
975 assert!((eps - 1.0).abs() < 1e-10, "Expected eps=1.0, got {eps}");
976 }
977
978 // ── spread ───────────────────────────────────────────────────────────────
979
980 #[test]
981 fn test_spread_uniform_distribution() {
982 // Uniformly spaced points should have zero spread (no variation)
983 let front: Vec<Vec<f64>> = (0..5)
984 .map(|i| {
985 let f1 = i as f64 * 0.25;
986 vec![f1, 1.0 - f1]
987 })
988 .collect();
989 let sp = spread(&front);
990 // Perfectly uniform → std dev of nn_dists is 0
991 assert!(
992 sp < 0.01,
993 "Uniform front should have near-zero spread: {sp}"
994 );
995 }
996
997 #[test]
998 fn test_spread_single_point() {
999 let front = vec![vec![0.5, 0.5]];
1000 assert_eq!(spread(&front), 0.0);
1001 }
1002
1003 // ── spacing_metric ───────────────────────────────────────────────────────
1004
1005 #[test]
1006 fn test_spacing_metric_uniform() {
1007 // Uniformly spaced front: all nn-distances are equal → std dev = 0
1008 let front: Vec<Vec<f64>> = (0..5)
1009 .map(|i| vec![i as f64 * 0.25, 1.0 - i as f64 * 0.25])
1010 .collect();
1011 let sp = spacing_metric(&front);
1012 assert!(sp < 1e-10, "uniform front spacing should be 0, got {sp}");
1013 }
1014
1015 #[test]
1016 fn test_spacing_metric_single_point() {
1017 assert_eq!(spacing_metric(&[vec![0.5, 0.5]]), 0.0);
1018 }
1019
1020 #[test]
1021 fn test_spacing_metric_non_uniform() {
1022 // Points with asymmetric nearest-neighbour distances yield sp > 0.
1023 // NNDs: d(0,1)=0.1, d(1,0)=0.1, d(2,3)=0.1, d(3,2)=0.1
1024 // but we need the gaps to differ; use an odd-one-out layout:
1025 // [0.0,1.0],[0.1,0.9],[0.5,0.5],[1.0,0.0]
1026 // NN: pt0→pt1≈0.141, pt1→pt0≈0.141, pt2→pt1≈0.566, pt3→pt2≈0.707
1027 // → mean≠individual dists, variance > 0
1028 let clustered = vec![
1029 vec![0.0, 1.0],
1030 vec![0.1, 0.9],
1031 vec![0.5, 0.5],
1032 vec![1.0, 0.0],
1033 ];
1034 let sp = spacing_metric(&clustered);
1035 assert!(
1036 sp > 0.0,
1037 "clustered front should have nonzero spacing, got {sp}"
1038 );
1039 }
1040
1041 // ── delta_metric ─────────────────────────────────────────────────────────
1042
1043 #[test]
1044 fn test_delta_metric_uniform_no_extremes() {
1045 let front = vec![vec![0.0, 1.0], vec![0.5, 0.5], vec![1.0, 0.0]];
1046 let d = delta_metric(&front, &[]);
1047 assert!(d >= 0.0, "delta metric should be non-negative, got {d}");
1048 }
1049
1050 #[test]
1051 fn test_delta_metric_empty() {
1052 assert_eq!(delta_metric(&[], &[]), f64::INFINITY);
1053 }
1054
1055 #[test]
1056 fn test_delta_metric_single_point() {
1057 assert_eq!(delta_metric(&[vec![0.5, 0.5]], &[]), 0.0);
1058 }
1059
1060 #[test]
1061 fn test_delta_metric_with_true_extremes() {
1062 let front = vec![vec![0.0, 1.0], vec![0.5, 0.5], vec![1.0, 0.0]];
1063 let true_extremes = vec![vec![0.0, 1.0], vec![1.0, 0.0]];
1064 let d = delta_metric(&front, &true_extremes);
1065 assert!(d >= 0.0);
1066 // With perfect coverage of extremes, d_f + d_l = 0 → delta ≈ 0
1067 assert!(d < 0.5, "well-spread front with covered extremes: {d}");
1068 }
1069
1070 // ── igd_plus ─────────────────────────────────────────────────────────────
1071
1072 #[test]
1073 fn test_igd_plus_identical_fronts() {
1074 let tf = vec![vec![0.0, 1.0], vec![0.5, 0.5], vec![1.0, 0.0]];
1075 let af = tf.clone();
1076 let val = igd_plus(&tf, &af);
1077 assert!(
1078 val < 1e-10,
1079 "IGD+ of identical fronts should be 0, got {val}"
1080 );
1081 }
1082
1083 #[test]
1084 fn test_igd_plus_empty_inputs() {
1085 assert_eq!(igd_plus(&[], &[vec![1.0]]), f64::INFINITY);
1086 assert_eq!(igd_plus(&[vec![1.0]], &[]), f64::INFINITY);
1087 }
1088
1089 #[test]
1090 fn test_igd_plus_dominating_approx() {
1091 // Approx dominates true: q_i < p_i for all i → d+ = 0
1092 let tf = vec![vec![1.0, 1.0]];
1093 let af = vec![vec![0.5, 0.5]]; // dominates tf
1094 let val = igd_plus(&tf, &af);
1095 assert!(
1096 val < 1e-10,
1097 "dominating approx should give IGD+=0, got {val}"
1098 );
1099 }
1100
1101 #[test]
1102 fn test_igd_plus_worse_approx() {
1103 // Approx is worse: q_i > p_i → d+ = Euclidean distance
1104 let tf = vec![vec![0.0, 0.0]];
1105 let af = vec![vec![1.0, 1.0]];
1106 let val = igd_plus(&tf, &af);
1107 let expected = (1.0f64.powi(2) + 1.0f64.powi(2)).sqrt();
1108 assert!(
1109 (val - expected).abs() < 1e-10,
1110 "Expected {expected}, got {val}"
1111 );
1112 }
1113
1114 #[test]
1115 fn test_igd_plus_less_or_equal_to_igd() {
1116 // IGD+ ≤ IGD because d+ ≤ Euclidean distance
1117 let tf = vec![vec![0.0, 1.0], vec![0.5, 0.5], vec![1.0, 0.0]];
1118 let af = vec![vec![0.2, 0.8], vec![0.6, 0.4], vec![0.9, 0.1]];
1119 let igdp = igd_plus(&tf, &af);
1120 let igd_val = igd(&tf, &af);
1121 assert!(
1122 igdp <= igd_val + 1e-10,
1123 "IGD+={igdp} should be <= IGD={igd_val}"
1124 );
1125 }
1126
1127 // ── r2_indicator ─────────────────────────────────────────────────────────
1128
1129 #[test]
1130 fn test_r2_tchebycheff_basic() {
1131 let front = vec![vec![0.0, 1.0], vec![0.5, 0.5], vec![1.0, 0.0]];
1132 let weights = vec![vec![1.0, 0.0], vec![0.5, 0.5], vec![0.0, 1.0]];
1133 let z_star = vec![0.0, 0.0];
1134 let r2 = r2_indicator(&front, &weights, &z_star, R2Utility::Tchebycheff);
1135 assert!(r2.is_finite(), "R2 should be finite");
1136 assert!(r2 >= 0.0, "R2 should be non-negative for this case");
1137 }
1138
1139 #[test]
1140 fn test_r2_weighted_sum_basic() {
1141 let front = vec![vec![0.5, 0.5]];
1142 let weights = vec![vec![0.5, 0.5]];
1143 let z_star = vec![0.0, 0.0];
1144 let r2 = r2_indicator(&front, &weights, &z_star, R2Utility::WeightedSum);
1145 // 0.5*0.5 + 0.5*0.5 = 0.5
1146 assert!((r2 - 0.5).abs() < 1e-10, "Expected 0.5, got {r2}");
1147 }
1148
1149 #[test]
1150 fn test_r2_pbi_basic() {
1151 let front = vec![vec![0.5, 0.5], vec![0.0, 1.0], vec![1.0, 0.0]];
1152 let weights = vec![vec![0.5, 0.5]];
1153 let z_star = vec![0.0, 0.0];
1154 let r2 = r2_indicator(&front, &weights, &z_star, R2Utility::Pbi);
1155 assert!(r2.is_finite());
1156 }
1157
1158 #[test]
1159 fn test_r2_empty_inputs() {
1160 assert_eq!(
1161 r2_indicator(&[], &[vec![0.5, 0.5]], &[0.0, 0.0], R2Utility::Tchebycheff),
1162 f64::INFINITY
1163 );
1164 assert_eq!(
1165 r2_indicator(&[vec![0.5, 0.5]], &[], &[0.0, 0.0], R2Utility::Tchebycheff),
1166 f64::INFINITY
1167 );
1168 }
1169
1170 #[test]
1171 fn test_r2_better_front_lower_r2() {
1172 // A front closer to the ideal should have lower R2
1173 let weights = vec![vec![1.0, 0.0], vec![0.5, 0.5], vec![0.0, 1.0]];
1174 let z_star = vec![0.0, 0.0];
1175
1176 let good_front = vec![vec![0.1, 0.9], vec![0.5, 0.5], vec![0.9, 0.1]];
1177 let bad_front = vec![vec![0.5, 2.0], vec![1.5, 1.5], vec![2.0, 0.5]];
1178
1179 let r2_good = r2_indicator(&good_front, &weights, &z_star, R2Utility::Tchebycheff);
1180 let r2_bad = r2_indicator(&bad_front, &weights, &z_star, R2Utility::Tchebycheff);
1181
1182 assert!(
1183 r2_good < r2_bad,
1184 "Good front R2={r2_good} should be < bad front R2={r2_bad}"
1185 );
1186 }
1187
1188 // ── hypervolume_contribution ─────────────────────────────────────────────
1189
1190 #[test]
1191 fn test_hv_contribution_2d_basic() {
1192 let front = vec![vec![0.0, 1.0], vec![0.5, 0.5], vec![1.0, 0.0]];
1193 let ref_pt = vec![2.0, 2.0];
1194 let hvc = hypervolume_contribution(&front, &ref_pt, 10_000, 42);
1195
1196 assert_eq!(hvc.len(), 3);
1197 for &v in &hvc {
1198 assert!(v >= 0.0, "HVC must be non-negative");
1199 }
1200 // Total HVC should sum close to total HV
1201 let total_hvc: f64 = hvc.iter().sum();
1202 let total_hv = hypervolume_2d(&front, &ref_pt);
1203 // Note: sum of exclusive contributions ≤ total HV (no overlaps in 2D for a Pareto front)
1204 assert!(
1205 total_hvc <= total_hv + 1e-9,
1206 "Sum of HVC should not exceed total HV"
1207 );
1208 }
1209
1210 #[test]
1211 fn test_hv_contribution_empty_front() {
1212 let hvc = hypervolume_contribution(&[], &[2.0, 2.0], 1000, 42);
1213 assert!(hvc.is_empty());
1214 }
1215
1216 #[test]
1217 fn test_hv_contribution_extreme_points_higher() {
1218 // In a 2D Pareto front, extreme points typically contribute more
1219 let front = vec![vec![0.0, 2.0], vec![1.0, 1.0], vec![2.0, 0.0]];
1220 let ref_pt = vec![3.0, 3.0];
1221 let hvc = hypervolume_contribution(&front, &ref_pt, 10_000, 42);
1222
1223 // The middle point (1,1) typically has lower contribution than extremes
1224 assert!(hvc[0] > 0.0, "Extreme point 0 should have positive HVC");
1225 assert!(hvc[2] > 0.0, "Extreme point 2 should have positive HVC");
1226 }
1227}