Skip to main content

numra_optim/
multiobjective.rs

1//! NSGA-II multi-objective optimizer.
2//!
3//! Author: Moussa Leblouba
4//! Date: 8 February 2026
5//! Modified: 2 May 2026
6
7use crate::error::OptimError;
8use crate::types::{OptimResult, OptimStatus, ParetoPoint, ParetoResult};
9use numra_core::Scalar;
10use rand::rngs::SmallRng;
11use rand::{Rng, SeedableRng};
12
13/// Type alias for objective function references used in NSGA-II.
14type ObjFnSlice<'a, S> = &'a [&'a dyn Fn(&[S]) -> S];
15
16/// Options for NSGA-II multi-objective optimization.
17#[derive(Clone, Debug)]
18pub struct NsgaIIOptions<S: Scalar> {
19    /// Population size (must be even). Default: 100.
20    pub pop_size: usize,
21    /// Maximum number of generations. Default: 200.
22    pub max_generations: usize,
23    /// SBX crossover distribution index. Default: 20.0.
24    pub crossover_eta: S,
25    /// Polynomial mutation distribution index. Default: 20.0.
26    pub mutation_eta: S,
27    /// Crossover probability. Default: 0.9.
28    pub crossover_prob: S,
29    /// Mutation probability per variable. Default: None (= 1/n).
30    pub mutation_prob: Option<S>,
31    /// Random seed for reproducibility. Default: 42.
32    pub seed: u64,
33    /// Print progress information. Default: false.
34    pub verbose: bool,
35}
36
37impl<S: Scalar> Default for NsgaIIOptions<S> {
38    fn default() -> Self {
39        Self {
40            pop_size: 100,
41            max_generations: 200,
42            crossover_eta: S::from_f64(20.0),
43            mutation_eta: S::from_f64(20.0),
44            crossover_prob: S::from_f64(0.9),
45            mutation_prob: None,
46            seed: 42,
47            verbose: false,
48        }
49    }
50}
51
52/// Perform non-dominated sorting on a population given their objective values.
53///
54/// Returns a vector of fronts, where each front is a vector of individual indices.
55/// Front 0 contains the non-dominated set, front 1 the next layer, etc.
56fn non_dominated_sort<S: Scalar>(objectives: &[Vec<S>]) -> Vec<Vec<usize>> {
57    let n = objectives.len();
58    if n == 0 {
59        return vec![];
60    }
61
62    // For each individual p:
63    //   s_p: set of individuals that p dominates
64    //   n_p: number of individuals that dominate p
65    let mut s_p: Vec<Vec<usize>> = vec![vec![]; n];
66    let mut n_p: Vec<usize> = vec![0; n];
67
68    for i in 0..n {
69        for j in (i + 1)..n {
70            let dom_ij = dominates(&objectives[i], &objectives[j]);
71            let dom_ji = dominates(&objectives[j], &objectives[i]);
72            if dom_ij {
73                s_p[i].push(j);
74                n_p[j] += 1;
75            } else if dom_ji {
76                s_p[j].push(i);
77                n_p[i] += 1;
78            }
79        }
80    }
81
82    let mut fronts: Vec<Vec<usize>> = vec![];
83
84    // First front: all individuals with n_p == 0
85    let mut current_front: Vec<usize> = (0..n).filter(|&i| n_p[i] == 0).collect();
86
87    while !current_front.is_empty() {
88        let mut next_front = vec![];
89        for &p in &current_front {
90            for &q in &s_p[p] {
91                n_p[q] -= 1;
92                if n_p[q] == 0 {
93                    next_front.push(q);
94                }
95            }
96        }
97        fronts.push(current_front);
98        current_front = next_front;
99    }
100
101    fronts
102}
103
104/// Returns true if `a` dominates `b` (all objectives of a <= b, at least one strictly <).
105fn dominates<S: Scalar>(a: &[S], b: &[S]) -> bool {
106    let mut any_strictly_less = false;
107    for (ai, bi) in a.iter().zip(b.iter()) {
108        if *ai > *bi {
109            return false;
110        }
111        if *ai < *bi {
112            any_strictly_less = true;
113        }
114    }
115    any_strictly_less
116}
117
118/// Compute crowding distances for individuals in a single front.
119///
120/// `front` contains indices into the `objectives` array.
121/// Returns a vector of crowding distances, one per element in `front`.
122fn crowding_distance<S: Scalar>(front: &[usize], objectives: &[Vec<S>]) -> Vec<S> {
123    let n = front.len();
124    if n <= 2 {
125        return vec![S::INFINITY; n];
126    }
127
128    let n_obj = objectives[front[0]].len();
129    let mut distances = vec![S::ZERO; n];
130
131    #[allow(clippy::needless_range_loop)]
132    for m in 0..n_obj {
133        // Sort front indices by objective m
134        let mut sorted_indices: Vec<usize> = (0..n).collect();
135        sorted_indices.sort_by(|&a, &b| {
136            let fa = objectives[front[a]][m];
137            let fb = objectives[front[b]][m];
138            fa.partial_cmp(&fb).unwrap_or(std::cmp::Ordering::Equal)
139        });
140
141        let obj_min = objectives[front[sorted_indices[0]]][m];
142        let obj_max = objectives[front[sorted_indices[n - 1]]][m];
143        let range = obj_max - obj_min;
144
145        // Endpoints get infinity
146        distances[sorted_indices[0]] = S::INFINITY;
147        distances[sorted_indices[n - 1]] = S::INFINITY;
148
149        if range < S::from_f64(1e-30) {
150            // All values essentially the same for this objective; skip contribution
151            continue;
152        }
153
154        for i in 1..(n - 1) {
155            let prev_obj = objectives[front[sorted_indices[i - 1]]][m];
156            let next_obj = objectives[front[sorted_indices[i + 1]]][m];
157            distances[sorted_indices[i]] += (next_obj - prev_obj) / range;
158        }
159    }
160
161    distances
162}
163
164/// Binary tournament selection.
165/// Pick 2 random individuals. Lower front rank wins; if tied, higher crowding distance wins.
166fn tournament_select<S: Scalar>(
167    rng: &mut SmallRng,
168    ranks: &[usize],
169    crowding: &[S],
170    pop_size: usize,
171) -> usize {
172    let a = rng.gen_range(0..pop_size);
173    let b = rng.gen_range(0..pop_size);
174    if ranks[a] < ranks[b] {
175        a
176    } else if ranks[b] < ranks[a] {
177        b
178    } else if crowding[a] > crowding[b] {
179        a
180    } else {
181        b
182    }
183}
184
185/// Simulated Binary Crossover (SBX) for two parents.
186/// Produces two children, clipped to bounds.
187fn sbx_crossover<S: Scalar>(
188    p1: &[S],
189    p2: &[S],
190    bounds: &[(S, S)],
191    eta: S,
192    prob: S,
193    rng: &mut SmallRng,
194) -> (Vec<S>, Vec<S>) {
195    let n = p1.len();
196    let mut c1 = p1.to_vec();
197    let mut c2 = p2.to_vec();
198
199    if S::from_f64(rng.gen::<f64>()) > prob {
200        return (c1, c2);
201    }
202
203    let half = S::HALF;
204    let one = S::ONE;
205
206    for j in 0..n {
207        if (p1[j] - p2[j]).abs() < S::from_f64(1e-14) {
208            continue;
209        }
210
211        let u = S::from_f64(rng.gen::<f64>());
212        let exp = one / (eta + one);
213        let beta = if u <= half {
214            (S::TWO * u).powf(exp)
215        } else {
216            (one / (S::TWO * (one - u))).powf(exp)
217        };
218
219        c1[j] = half * ((one + beta) * p1[j] + (one - beta) * p2[j]);
220        c2[j] = half * ((one - beta) * p1[j] + (one + beta) * p2[j]);
221
222        // Clip to bounds
223        c1[j] = c1[j].clamp(bounds[j].0, bounds[j].1);
224        c2[j] = c2[j].clamp(bounds[j].0, bounds[j].1);
225    }
226
227    (c1, c2)
228}
229
230/// Polynomial mutation.
231/// Mutates each variable with given probability, clipped to bounds.
232fn polynomial_mutation<S: Scalar>(
233    x: &mut [S],
234    bounds: &[(S, S)],
235    eta: S,
236    prob: S,
237    rng: &mut SmallRng,
238) {
239    let half = S::HALF;
240    let one = S::ONE;
241    let two = S::TWO;
242
243    for j in 0..x.len() {
244        if S::from_f64(rng.gen::<f64>()) >= prob {
245            continue;
246        }
247
248        let u = S::from_f64(rng.gen::<f64>());
249        let exp = one / (eta + one);
250        let delta = if u < half {
251            (two * u).powf(exp) - one
252        } else {
253            one - (two * (one - u)).powf(exp)
254        };
255
256        let range = bounds[j].1 - bounds[j].0;
257        x[j] += delta * range;
258        x[j] = x[j].clamp(bounds[j].0, bounds[j].1);
259    }
260}
261
262/// Evaluate all objectives for a single individual.
263fn evaluate<S: Scalar>(objectives: ObjFnSlice<'_, S>, x: &[S]) -> Vec<S> {
264    objectives.iter().map(|f| f(x)).collect()
265}
266
267/// NSGA-II multi-objective optimization.
268///
269/// Minimizes multiple objectives simultaneously, returning a set of Pareto-optimal solutions.
270///
271/// # Arguments
272///
273/// * `objectives` - Slice of objective functions, each taking `&[S]` and returning `S`.
274/// * `bounds` - Variable bounds as `(lower, upper)` pairs, one per decision variable.
275/// * `opts` - Algorithm options.
276///
277/// # Returns
278///
279/// An `OptimResult` with:
280/// - `x`: the Pareto point with the best (minimum) first objective value.
281/// - `f`: that point's first objective value.
282/// - `pareto`: the full Pareto front as `Some(ParetoResult)`.
283pub fn nsga2_optimize<S: Scalar>(
284    objectives: ObjFnSlice<'_, S>,
285    bounds: &[(S, S)],
286    opts: &NsgaIIOptions<S>,
287) -> Result<OptimResult<S>, OptimError> {
288    let start = std::time::Instant::now();
289    let n = bounds.len();
290    let pop_size = opts.pop_size;
291    let mutation_prob = opts
292        .mutation_prob
293        .unwrap_or_else(|| S::ONE / S::from_usize(n));
294    let mut rng = SmallRng::seed_from_u64(opts.seed);
295
296    // 1. Initialize population uniformly in bounds
297    let mut population: Vec<Vec<S>> = (0..pop_size)
298        .map(|_| {
299            (0..n)
300                .map(|j| {
301                    let (lo, hi) = bounds[j];
302                    lo + S::from_f64(rng.gen::<f64>()) * (hi - lo)
303                })
304                .collect()
305        })
306        .collect();
307
308    // 2. Evaluate all objectives
309    let mut obj_values: Vec<Vec<S>> = population.iter().map(|x| evaluate(objectives, x)).collect();
310
311    // 3. Main generational loop
312    for gen in 0..opts.max_generations {
313        // Compute ranks and crowding distances for current population
314        let fronts = non_dominated_sort(&obj_values);
315        let mut ranks = vec![0_usize; pop_size];
316        let mut crowding = vec![S::ZERO; pop_size];
317        for (rank, front) in fronts.iter().enumerate() {
318            let cd = crowding_distance(front, &obj_values);
319            for (i, &idx) in front.iter().enumerate() {
320                ranks[idx] = rank;
321                crowding[idx] = cd[i];
322            }
323        }
324
325        if opts.verbose && gen % 10 == 0 {
326            let n_front0 = fronts.first().map_or(0, |f| f.len());
327            eprintln!(
328                "NSGA-II gen {}: front 0 size = {}, pop = {}",
329                gen, n_front0, pop_size
330            );
331        }
332
333        // 3a. Create offspring population via tournament + SBX + mutation
334        let mut offspring: Vec<Vec<S>> = Vec::with_capacity(pop_size);
335        while offspring.len() < pop_size {
336            let p1_idx = tournament_select(&mut rng, &ranks, &crowding, pop_size);
337            let p2_idx = tournament_select(&mut rng, &ranks, &crowding, pop_size);
338
339            let (mut c1, mut c2) = sbx_crossover(
340                &population[p1_idx],
341                &population[p2_idx],
342                bounds,
343                opts.crossover_eta,
344                opts.crossover_prob,
345                &mut rng,
346            );
347
348            polynomial_mutation(&mut c1, bounds, opts.mutation_eta, mutation_prob, &mut rng);
349            polynomial_mutation(&mut c2, bounds, opts.mutation_eta, mutation_prob, &mut rng);
350
351            offspring.push(c1);
352            if offspring.len() < pop_size {
353                offspring.push(c2);
354            }
355        }
356
357        // 3b. Evaluate offspring objectives
358        let offspring_obj: Vec<Vec<S>> =
359            offspring.iter().map(|x| evaluate(objectives, x)).collect();
360
361        // 3c. Combine parent + offspring (2N individuals)
362        let mut combined_pop: Vec<Vec<S>> = population;
363        combined_pop.extend(offspring);
364        let mut combined_obj: Vec<Vec<S>> = obj_values;
365        combined_obj.extend(offspring_obj);
366
367        // 3d. Non-dominated sort the combined population
368        let combined_fronts = non_dominated_sort(&combined_obj);
369
370        // 3e-f. Fill next generation from fronts
371        let mut new_population: Vec<Vec<S>> = Vec::with_capacity(pop_size);
372        let mut new_obj: Vec<Vec<S>> = Vec::with_capacity(pop_size);
373
374        for front in &combined_fronts {
375            if new_population.len() + front.len() <= pop_size {
376                // Entire front fits
377                for &idx in front {
378                    new_population.push(combined_pop[idx].clone());
379                    new_obj.push(combined_obj[idx].clone());
380                }
381            } else {
382                // Partial front: sort by crowding distance, take the best
383                let remaining = pop_size - new_population.len();
384                let cd = crowding_distance(front, &combined_obj);
385                let mut sorted: Vec<usize> = (0..front.len()).collect();
386                sorted.sort_by(|&a, &b| {
387                    cd[b]
388                        .partial_cmp(&cd[a])
389                        .unwrap_or(std::cmp::Ordering::Equal)
390                });
391                for &i in sorted.iter().take(remaining) {
392                    let idx = front[i];
393                    new_population.push(combined_pop[idx].clone());
394                    new_obj.push(combined_obj[idx].clone());
395                }
396                break;
397            }
398        }
399
400        population = new_population;
401        obj_values = new_obj;
402    }
403
404    // 4. Extract front 0 from final population
405    let final_fronts = non_dominated_sort(&obj_values);
406    let front0 = &final_fronts[0];
407
408    let pareto_points: Vec<ParetoPoint<S>> = front0
409        .iter()
410        .map(|&idx| ParetoPoint {
411            x: population[idx].clone(),
412            objectives: obj_values[idx].clone(),
413        })
414        .collect();
415
416    // Find the point with best (minimum) first objective
417    let best_idx = front0
418        .iter()
419        .min_by(|&&a, &&b| {
420            obj_values[a][0]
421                .partial_cmp(&obj_values[b][0])
422                .unwrap_or(std::cmp::Ordering::Equal)
423        })
424        .copied()
425        .unwrap_or(0);
426
427    let result = OptimResult {
428        x: population[best_idx].clone(),
429        f: obj_values[best_idx][0],
430        grad: vec![],
431        iterations: opts.max_generations,
432        n_feval: 0,
433        n_geval: 0,
434        converged: true,
435        message: format!(
436            "NSGA-II completed {} generations, Pareto front size = {}",
437            opts.max_generations,
438            pareto_points.len()
439        ),
440        status: OptimStatus::MaxIterations,
441        history: vec![],
442        lambda_eq: vec![],
443        lambda_ineq: vec![],
444        active_bounds: vec![],
445        constraint_violation: S::ZERO,
446        wall_time_secs: 0.0,
447        pareto: Some(ParetoResult {
448            points: pareto_points,
449        }),
450        sensitivity: None,
451    }
452    .with_wall_time(start);
453
454    Ok(result)
455}
456
457#[cfg(test)]
458mod tests {
459    use super::*;
460
461    type ObjRef<'a> = Vec<&'a dyn Fn(&[f64]) -> f64>;
462
463    #[test]
464    fn test_nsga2_zdt1() {
465        // ZDT1 in 3D (reduced for speed)
466        let n = 3;
467        let bounds = vec![(0.0, 1.0); n];
468        let f1 = |x: &[f64]| x[0];
469        let f2 = |x: &[f64]| {
470            let g = 1.0 + 9.0 * x[1..].iter().copied().sum::<f64>() / (x.len() - 1) as f64;
471            g * (1.0 - (x[0] / g).sqrt())
472        };
473        let objectives: ObjRef<'_> = vec![&f1, &f2];
474        let opts = NsgaIIOptions {
475            pop_size: 50,
476            max_generations: 100,
477            ..Default::default()
478        };
479        let result = nsga2_optimize(&objectives, &bounds, &opts).unwrap();
480        let pareto = result.pareto.as_ref().unwrap();
481        assert!(
482            pareto.points.len() >= 5,
483            "too few points: {}",
484            pareto.points.len()
485        );
486        for p in &pareto.points {
487            assert_eq!(p.objectives.len(), 2);
488        }
489    }
490
491    #[test]
492    fn test_nsga2_simple_biobj() {
493        // f1 = x^2, f2 = (x-2)^2, Pareto front: x in [0, 2]
494        let bounds = vec![(0.0, 4.0)];
495        let f1 = |x: &[f64]| x[0] * x[0];
496        let f2 = |x: &[f64]| (x[0] - 2.0).powi(2);
497        let objectives: ObjRef<'_> = vec![&f1, &f2];
498        let opts = NsgaIIOptions {
499            pop_size: 50,
500            max_generations: 100,
501            ..Default::default()
502        };
503        let result = nsga2_optimize(&objectives, &bounds, &opts).unwrap();
504        let pareto = result.pareto.as_ref().unwrap();
505        assert!(pareto.points.len() >= 3);
506        for p in &pareto.points {
507            assert!(p.x[0] >= -0.5 && p.x[0] <= 2.5, "x={}", p.x[0]);
508        }
509    }
510
511    #[test]
512    fn test_nsga2_three_objectives() {
513        let bounds = vec![(0.0, 2.0), (0.0, 2.0)];
514        let f1 = |x: &[f64]| x[0] * x[0];
515        let f2 = |x: &[f64]| x[1] * x[1];
516        let f3 = |x: &[f64]| (x[0] - 1.0).powi(2) + (x[1] - 1.0).powi(2);
517        let objectives: ObjRef<'_> = vec![&f1, &f2, &f3];
518        let opts = NsgaIIOptions {
519            pop_size: 50,
520            max_generations: 100,
521            ..Default::default()
522        };
523        let result = nsga2_optimize(&objectives, &bounds, &opts).unwrap();
524        let pareto = result.pareto.as_ref().unwrap();
525        assert!(!pareto.points.is_empty());
526        for p in &pareto.points {
527            assert_eq!(p.objectives.len(), 3);
528        }
529    }
530
531    #[test]
532    fn test_nsga2_deterministic() {
533        let bounds = vec![(0.0, 1.0); 2];
534        let f1 = |x: &[f64]| x[0];
535        let f2 = |x: &[f64]| 1.0 - x[0] + x[1];
536        let objectives: ObjRef<'_> = vec![&f1, &f2];
537        let opts = NsgaIIOptions {
538            pop_size: 20,
539            max_generations: 50,
540            ..Default::default()
541        };
542        let r1 = nsga2_optimize(&objectives, &bounds, &opts).unwrap();
543        let r2 = nsga2_optimize(&objectives, &bounds, &opts).unwrap();
544        assert_eq!(
545            r1.pareto.unwrap().points.len(),
546            r2.pareto.unwrap().points.len()
547        );
548    }
549
550    #[test]
551    fn test_nsga2_returns_nondominated() {
552        let bounds = vec![(0.0, 5.0); 2];
553        let f1 = |x: &[f64]| x[0] * x[0] + x[1];
554        let f2 = |x: &[f64]| x[1] * x[1] + x[0];
555        let objectives: ObjRef<'_> = vec![&f1, &f2];
556        let opts = NsgaIIOptions {
557            pop_size: 30,
558            max_generations: 80,
559            ..Default::default()
560        };
561        let result = nsga2_optimize(&objectives, &bounds, &opts).unwrap();
562        let pareto = result.pareto.as_ref().unwrap();
563        for (i, pi) in pareto.points.iter().enumerate() {
564            for (j, pj) in pareto.points.iter().enumerate() {
565                if i == j {
566                    continue;
567                }
568                let all_leq = pi
569                    .objectives
570                    .iter()
571                    .zip(&pj.objectives)
572                    .all(|(a, b)| a <= b);
573                let any_lt = pi.objectives.iter().zip(&pj.objectives).any(|(a, b)| a < b);
574                assert!(
575                    !(all_leq && any_lt),
576                    "point {} dominates point {}: {:?} vs {:?}",
577                    i,
578                    j,
579                    pi.objectives,
580                    pj.objectives
581                );
582            }
583        }
584    }
585}