Skip to main content

scirs2_optimize/multi_objective/indicators/
mod.rs

1//! Performance indicators for multi-objective optimization
2//!
3//! Metrics to evaluate the quality of Pareto fronts.
4
5use crate::multi_objective::solutions::Solution;
6
7/// Calculate the hypervolume indicator.
8///
9/// Uses an exact algorithm for up to 7 objectives:
10/// - 2D: O(N log N) sweep-line staircase
11/// - 3–7D: WFG (Hypervolume by Slicing Objectives) exact algorithm
12///
13/// For 8 or more objectives, where WFG's O(N^(d-2)) cost is comparable to
14/// Monte Carlo, falls back to Monte Carlo sampling with 10,000 samples.
15pub fn hypervolume(pareto_front: &[Solution], reference_point: &[f64]) -> f64 {
16    if pareto_front.is_empty() {
17        return 0.0;
18    }
19
20    let n_objectives = pareto_front[0].objectives.len();
21
22    if n_objectives == 2 {
23        hypervolume_2d(pareto_front, reference_point)
24    } else if n_objectives < 8 {
25        // Exact WFG algorithm for 3–7 objectives.
26        // Delegate to pareto::hypervolume_from_objectives which wraps the
27        // corrected wfg_hv_recursive and handles strict-dominance filtering.
28        let objectives: Vec<Vec<f64>> =
29            pareto_front.iter().map(|s| s.objectives.to_vec()).collect();
30        crate::multi_objective::pareto::hypervolume_from_objectives(&objectives, reference_point)
31    } else {
32        // Monte Carlo fallback for 8+ objectives where WFG cost is prohibitive.
33        hypervolume_monte_carlo(pareto_front, reference_point, 10000)
34    }
35}
36
37/// Calculate 2D hypervolume
38fn hypervolume_2d(pareto_front: &[Solution], reference_point: &[f64]) -> f64 {
39    let mut sorted_front = pareto_front.to_vec();
40    sorted_front.sort_by(|a, b| {
41        a.objectives[0]
42            .partial_cmp(&b.objectives[0])
43            .expect("Operation failed")
44    });
45
46    let mut volume = 0.0;
47    let mut prev_x = 0.0;
48
49    for solution in &sorted_front {
50        let x = solution.objectives[0];
51        let y = solution.objectives[1];
52
53        if x < reference_point[0] && y < reference_point[1] {
54            let width = x - prev_x;
55            let height = reference_point[1] - y;
56            volume += width * height;
57            prev_x = x;
58        }
59    }
60
61    // Add last rectangle
62    if prev_x < reference_point[0] {
63        let last_y = sorted_front.last().map(|s| s.objectives[1]).unwrap_or(0.0);
64        if last_y < reference_point[1] {
65            volume += (reference_point[0] - prev_x) * (reference_point[1] - last_y);
66        }
67    }
68
69    volume
70}
71
72/// Monte Carlo approximation for hypervolume
73fn hypervolume_monte_carlo(
74    pareto_front: &[Solution],
75    reference_point: &[f64],
76    n_samples: usize,
77) -> f64 {
78    use scirs2_core::random::{Rng, RngExt};
79    let mut rng = scirs2_core::random::rng();
80    let n_objectives = reference_point.len();
81
82    let mut count = 0;
83
84    for _ in 0..n_samples {
85        let point: Vec<f64> = (0..n_objectives)
86            .map(|i| rng.random::<f64>() * reference_point[i])
87            .collect();
88
89        if is_dominated_by_front(&point, pareto_front) {
90            count += 1;
91        }
92    }
93
94    let total_volume: f64 = reference_point.iter().product();
95    total_volume * (count as f64 / n_samples as f64)
96}
97
98fn is_dominated_by_front(point: &[f64], pareto_front: &[Solution]) -> bool {
99    for solution in pareto_front {
100        if dominates(
101            solution.objectives.as_slice().expect("Operation failed"),
102            point,
103        ) {
104            return true;
105        }
106    }
107    false
108}
109
110fn dominates(a: &[f64], b: &[f64]) -> bool {
111    let mut at_least_one_better = false;
112
113    for i in 0..a.len() {
114        if a[i] > b[i] {
115            return false;
116        }
117        if a[i] < b[i] {
118            at_least_one_better = true;
119        }
120    }
121
122    at_least_one_better
123}
124
125/// Calculate the Inverted Generational Distance (IGD)
126pub fn igd(pareto_front: &[Solution], true_pareto_front: &[Vec<f64>]) -> f64 {
127    if true_pareto_front.is_empty() {
128        return f64::INFINITY;
129    }
130
131    let sum: f64 = true_pareto_front
132        .iter()
133        .map(|true_point| {
134            pareto_front
135                .iter()
136                .map(|solution| {
137                    euclidean_distance(
138                        solution.objectives.as_slice().expect("Operation failed"),
139                        true_point,
140                    )
141                })
142                .fold(f64::INFINITY, |a, b| a.min(b))
143        })
144        .sum();
145
146    sum / true_pareto_front.len() as f64
147}
148
149/// Calculate the Generational Distance (GD)
150pub fn generational_distance(pareto_front: &[Solution], true_pareto_front: &[Vec<f64>]) -> f64 {
151    if pareto_front.is_empty() {
152        return f64::INFINITY;
153    }
154
155    let sum: f64 = pareto_front
156        .iter()
157        .map(|solution| {
158            true_pareto_front
159                .iter()
160                .map(|true_point| {
161                    euclidean_distance(
162                        solution.objectives.as_slice().expect("Operation failed"),
163                        true_point,
164                    )
165                })
166                .fold(f64::INFINITY, |a, b| a.min(b))
167        })
168        .sum();
169
170    sum / pareto_front.len() as f64
171}
172
173/// Calculate spacing indicator
174pub fn spacing(pareto_front: &[Solution]) -> f64 {
175    if pareto_front.len() < 2 {
176        return 0.0;
177    }
178
179    let distances: Vec<f64> = pareto_front
180        .iter()
181        .map(|sol| {
182            pareto_front
183                .iter()
184                .filter(|other| !std::ptr::eq(*other, sol))
185                .map(|other| {
186                    euclidean_distance(
187                        sol.objectives.as_slice().expect("Operation failed"),
188                        other.objectives.as_slice().expect("Operation failed"),
189                    )
190                })
191                .fold(f64::INFINITY, |a, b| a.min(b))
192        })
193        .collect();
194
195    let mean_distance = distances.iter().sum::<f64>() / distances.len() as f64;
196
197    let variance = distances
198        .iter()
199        .map(|d| (d - mean_distance).powi(2))
200        .sum::<f64>()
201        / distances.len() as f64;
202
203    variance.sqrt()
204}
205
206fn euclidean_distance(a: &[f64], b: &[f64]) -> f64 {
207    a.iter()
208        .zip(b.iter())
209        .map(|(x, y)| (x - y).powi(2))
210        .sum::<f64>()
211        .sqrt()
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217    use scirs2_core::ndarray::array;
218
219    fn make_solution(objs: &[f64]) -> Solution {
220        Solution::new(
221            array![0.0],
222            scirs2_core::ndarray::Array1::from_vec(objs.to_vec()),
223        )
224    }
225
226    // ======= 3D hypervolume tests using the exact WFG algorithm =======
227
228    /// 1 point at the origin (0,0,0) with reference (2,2,2).
229    /// The point dominates the entire box → HV = 2×2×2 = 8.
230    /// This is the critical discriminating test: the old broken WFG returned 0.
231    #[test]
232    fn test_hypervolume_3d_single_point_at_origin() {
233        let front = vec![make_solution(&[0.0, 0.0, 0.0])];
234        let hv = hypervolume(&front, &[2.0, 2.0, 2.0]);
235        assert!(
236            (hv - 8.0).abs() < 1e-10,
237            "Expected 8.0 for single 3D point at origin, got {}",
238            hv
239        );
240    }
241
242    /// 1 point at (0.5, 0.5, 0.5) with reference (1,1,1).
243    /// HV = 0.5 × 0.5 × 0.5 = 0.125 (exact).
244    #[test]
245    fn test_hypervolume_3d_single_point_half() {
246        let front = vec![make_solution(&[0.5, 0.5, 0.5])];
247        let hv = hypervolume(&front, &[1.0, 1.0, 1.0]);
248        assert!(
249            (hv - 0.125).abs() < 1e-10,
250            "Expected 0.125 for 3D half-ref point, got {}",
251            hv
252        );
253    }
254
255    /// 2 non-dominated points at (1,3,2) and (3,1,4) with reference (5,5,5).
256    /// By inclusion-exclusion:
257    ///   p1 box: 4×2×3 = 24
258    ///   p2 box: 2×4×1 = 8
259    ///   intersection: 2×2×1 = 4
260    ///   HV = 24 + 8 − 4 = 28 (exact).
261    #[test]
262    fn test_hypervolume_3d_two_points() {
263        let front = vec![
264            make_solution(&[1.0, 3.0, 2.0]),
265            make_solution(&[3.0, 1.0, 4.0]),
266        ];
267        let hv = hypervolume(&front, &[5.0, 5.0, 5.0]);
268        assert!(
269            (hv - 28.0).abs() < 1e-10,
270            "Expected 28.0 for two non-dominated 3D points, got {}",
271            hv
272        );
273    }
274
275    /// Compare WFG result against Monte Carlo on a 3D case.
276    /// Expected HV = 28.0. MC with 100,000 samples has σ ≈ 0.5%,
277    /// so we allow ±5% relative tolerance to keep the test non-flaky.
278    #[test]
279    fn test_hypervolume_3d_wfg_vs_monte_carlo() {
280        let front = vec![
281            make_solution(&[1.0, 3.0, 2.0]),
282            make_solution(&[3.0, 1.0, 4.0]),
283        ];
284        let exact_hv = hypervolume(&front, &[5.0, 5.0, 5.0]);
285        let mc_hv = hypervolume_monte_carlo(&front, &[5.0, 5.0, 5.0], 100_000);
286        let tol = 0.05 * exact_hv; // 5% relative tolerance
287        assert!(
288            (exact_hv - mc_hv).abs() < tol,
289            "WFG ({}) and Monte Carlo ({}) differ by more than 5%",
290            exact_hv,
291            mc_hv
292        );
293    }
294
295    /// 3D hypervolume with an empty front returns 0.
296    #[test]
297    fn test_hypervolume_3d_empty() {
298        let front: Vec<Solution> = vec![];
299        let hv = hypervolume(&front, &[1.0, 1.0, 1.0]);
300        assert_eq!(hv, 0.0);
301    }
302
303    /// Point at the reference boundary should contribute 0 (strict dominance).
304    #[test]
305    fn test_hypervolume_3d_point_at_boundary() {
306        let front = vec![make_solution(&[1.0, 1.0, 1.0])];
307        let hv = hypervolume(&front, &[1.0, 1.0, 1.0]);
308        assert_eq!(hv, 0.0, "Point at boundary should contribute 0");
309    }
310}