Skip to main content

optirs_nas/multi_objective/
nsga3.rs

1//! NSGA-III — reference-direction based many-objective optimizer (Deb & Jain,
2//! IEEE TEVC 2014), implemented for real.
3//!
4//! NSGA-III keeps NSGA-II's non-dominated sorting but replaces crowding distance,
5//! which loses its discriminating power beyond two or three objectives, with
6//! **niche-preserving selection against a set of reference directions**:
7//!
8//! 1. The population and the newly evaluated solutions are sorted into
9//!    non-dominated fronts (reused verbatim from [`NSGA2`]).
10//! 2. Whole fronts are accepted while they fit. The front that overflows the
11//!    population is the *splitting* front.
12//! 3. Everything under consideration is normalized adaptively — translated by the
13//!    ideal point and scaled by the intercepts of the hyperplane through the
14//!    extreme points ([`super::decomposition::normalize_by_hyperplane`]).
15//! 4. Every solution is associated with its nearest reference direction by
16//!    perpendicular distance.
17//! 5. Members of the splitting front are taken one at a time, always from the
18//!    reference direction that is currently least represented among the already
19//!    selected solutions (the closest member when that direction is still empty).
20//!
21//! Step 5 is what makes the selection pressure uniform across the whole front
22//! instead of concentrating it where solutions happen to be dense.
23//!
24//! This module used to contain the struct definition and nothing else — no
25//! constructor, no `impl`, no algorithm — while the engine advertised
26//! `MultiObjectiveAlgorithm::NSGA3` and served it with a placeholder that reported
27//! an empty Pareto front.
28
29use crate::error::{OptimError, Result};
30use crate::nas_engine::{MultiObjectiveConfig, OptimizerArchitecture, SearchResult};
31use scirs2_core::numeric::Float;
32use std::fmt::Debug;
33
34use super::core::{
35    objective_directions, Individual, MultiObjectiveOptimizer, MultiObjectiveStatistics,
36    ParetoFront,
37};
38use super::decomposition::{
39    associate_with_references, das_dennis_weights, divisions_for_at_least, normalize_by_hyperplane,
40};
41use super::hypervolume::normalize_front_for_minimization;
42use super::nsga2::NSGA2;
43use super::operators;
44
45/// Default number of reference directions requested when the caller does not say.
46/// The population is sized to the lattice that supplies them.
47pub const DEFAULT_REFERENCE_TARGET: usize = 20;
48
49/// Per-component-position probability of a structural mutation.
50const NSGA3_COMPONENT_MUTATION_RATE: f64 = 0.2;
51
52/// Per-parameter probability of a numeric mutation.
53const NSGA3_PARAMETER_MUTATION_RATE: f64 = 0.3;
54
55/// NSGA-III implementation
56pub struct NSGA3<T: Float + Debug + Send + Sync + 'static> {
57    /// Base NSGA-II functionality: population storage, dominance sorting, Pareto
58    /// front publication and the variation operators.
59    pub(super) base: NSGA2<T>,
60    /// Reference directions (a Das-Dennis simplex lattice)
61    pub(super) reference_directions: Vec<Vec<T>>,
62    /// How many solutions under consideration are associated with each reference
63    /// direction, measured during the most recent selection.
64    pub(super) association_count: Vec<usize>,
65    /// How many *selected* solutions are associated with each reference direction
66    /// (`rho` in the paper), measured during the most recent selection.
67    pub(super) niche_count: Vec<usize>,
68    /// Requested number of reference directions
69    pub(super) reference_target: usize,
70    /// Divisions of the Das-Dennis lattice actually used
71    pub(super) divisions: usize,
72}
73
74impl<
75        T: Float
76            + Debug
77            + Default
78            + Clone
79            + Send
80            + Sync
81            + std::fmt::Debug
82            + PartialOrd
83            + std::iter::Sum,
84    > NSGA3<T>
85{
86    /// Create NSGA-III with a reference-direction target, seeded from OS entropy.
87    pub fn new(reference_target: usize, crossover_prob: f64, mutation_prob: f64) -> Self {
88        Self::with_seed(
89            reference_target,
90            crossover_prob,
91            mutation_prob,
92            scirs2_core::random::random::<u64>(),
93        )
94    }
95
96    /// Create a fully reproducible NSGA-III.
97    pub fn with_seed(
98        reference_target: usize,
99        crossover_prob: f64,
100        mutation_prob: f64,
101        seed: u64,
102    ) -> Self {
103        let reference_target = reference_target.max(1);
104        Self {
105            base: NSGA2::with_seed(reference_target, crossover_prob, mutation_prob, seed),
106            reference_directions: Vec::new(),
107            association_count: Vec::new(),
108            niche_count: Vec::new(),
109            reference_target,
110            divisions: 0,
111        }
112    }
113
114    /// The reference directions in use.
115    pub fn reference_directions(&self) -> &[Vec<T>] {
116        &self.reference_directions
117    }
118
119    /// Association counts from the most recent selection: how many solutions under
120    /// consideration mapped onto each reference direction.
121    pub fn association_count(&self) -> &[usize] {
122        &self.association_count
123    }
124
125    /// Niche counts (`rho`) from the most recent selection: how many *selected*
126    /// solutions mapped onto each reference direction.
127    pub fn niche_count(&self) -> &[usize] {
128        &self.niche_count
129    }
130
131    /// Population size, which NSGA-III ties to the number of reference directions.
132    pub fn population_size(&self) -> usize {
133        self.base.population_size
134    }
135
136    /// Pin the hypervolume reference point (raw objective space).
137    pub fn set_hypervolume_reference(&mut self, reference: Vec<T>) -> Result<()> {
138        self.base.set_hypervolume_reference(reference)
139    }
140
141    /// Build the reference-direction lattice and size the population to it.
142    fn build_reference_directions(&mut self, config: &MultiObjectiveConfig<T>) -> Result<()> {
143        let num_objectives = config.objectives.len();
144        if num_objectives == 0 {
145            return Err(OptimError::InvalidConfig(
146                "NSGA-III needs at least one configured objective to place reference \
147                 directions"
148                    .to_string(),
149            ));
150        }
151        let (divisions, lattice_size) =
152            divisions_for_at_least(num_objectives, self.reference_target).ok_or_else(|| {
153                OptimError::InvalidConfig(format!(
154                    "no Das-Dennis lattice with at least {} directions exists for {} \
155                     objectives",
156                    self.reference_target, num_objectives
157                ))
158            })?;
159        self.divisions = divisions;
160        self.reference_directions = das_dennis_weights::<T>(num_objectives, divisions);
161        self.association_count = vec![0; self.reference_directions.len()];
162        self.niche_count = vec![0; self.reference_directions.len()];
163        // NSGA-III's population size is the number of reference directions: one
164        // niche per direction is what the selection aims for.
165        self.base.population_size = lattice_size;
166        Ok(())
167    }
168
169    /// Objective vectors of `individuals` in minimization space.
170    fn minimization_objectives(&self, individuals: &[Individual<T>]) -> Vec<Vec<T>> {
171        let directions = objective_directions(&self.base.config.objectives);
172        let raw: Vec<Vec<T>> = individuals
173            .iter()
174            .map(|individual| individual.objectives.clone())
175            .collect();
176        normalize_front_for_minimization(&raw, &directions)
177    }
178
179    /// Niche-preserving selection (Deb & Jain, Algorithm 1).
180    ///
181    /// `already_selected` holds the indices accepted from the fronts that fitted
182    /// whole; `splitting_front` holds the candidates competing for the remaining
183    /// `slots`. Returns the indices taken from the splitting front.
184    ///
185    /// All indices are into `self.base.population`.
186    fn niching_selection(
187        &mut self,
188        already_selected: &[usize],
189        splitting_front: &[usize],
190        slots: usize,
191    ) -> Vec<usize> {
192        if slots == 0 || splitting_front.is_empty() || self.reference_directions.is_empty() {
193            return Vec::new();
194        }
195        // Normalize over everything under consideration, which is what makes the
196        // association scale-free.
197        let considered: Vec<usize> = already_selected
198            .iter()
199            .chain(splitting_front.iter())
200            .copied()
201            .collect();
202        let individuals: Vec<Individual<T>> = considered
203            .iter()
204            .filter_map(|index| self.base.population.get(*index).cloned())
205            .collect();
206        if individuals.len() != considered.len() {
207            // A stale index would silently shift every association; refuse rather
208            // than select against a misaligned table.
209            return splitting_front.iter().copied().take(slots).collect();
210        }
211        let points = self.minimization_objectives(&individuals);
212        let num_objectives = self.base.config.objectives.len();
213        let Some((normalized, _intercepts)) = normalize_by_hyperplane(&points, num_objectives)
214        else {
215            return splitting_front.iter().copied().take(slots).collect();
216        };
217        let associations = associate_with_references(&normalized, &self.reference_directions);
218
219        // rho_j: selected solutions per direction; association_count: all of them.
220        let mut niche_count = vec![0usize; self.reference_directions.len()];
221        let mut association_count = vec![0usize; self.reference_directions.len()];
222        for (position, (direction, _)) in associations.iter().enumerate() {
223            if *direction < association_count.len() {
224                association_count[*direction] += 1;
225                if position < already_selected.len() {
226                    niche_count[*direction] += 1;
227                }
228            }
229        }
230
231        // Candidates grouped by the direction they are associated with, carrying
232        // their perpendicular distance.
233        let offset = already_selected.len();
234        let mut available: Vec<Vec<(usize, T)>> = vec![Vec::new(); self.reference_directions.len()];
235        for (position, (direction, distance)) in associations.iter().enumerate().skip(offset) {
236            let candidate = considered[position];
237            if *direction < available.len() {
238                available[*direction].push((candidate, *distance));
239            }
240        }
241
242        let mut excluded = vec![false; self.reference_directions.len()];
243        let mut chosen = Vec::with_capacity(slots);
244        while chosen.len() < slots {
245            // Least represented direction that still has candidates.
246            let mut best_directions: Vec<usize> = Vec::new();
247            let mut best_count = usize::MAX;
248            for direction in 0..self.reference_directions.len() {
249                if excluded[direction] || available[direction].is_empty() {
250                    continue;
251                }
252                let count = niche_count[direction];
253                if count < best_count {
254                    best_count = count;
255                    best_directions.clear();
256                    best_directions.push(direction);
257                } else if count == best_count {
258                    best_directions.push(direction);
259                }
260            }
261            if best_directions.is_empty() {
262                break;
263            }
264            // Ties between equally represented directions are broken at random,
265            // exactly as the published algorithm specifies.
266            let picked = best_directions[self.base.rng.gen_range(0..best_directions.len())];
267
268            let position = if niche_count[picked] == 0 {
269                // An empty niche takes the candidate closest to its direction.
270                let mut best_position = 0usize;
271                let mut best_distance = T::infinity();
272                for (index, (_, distance)) in available[picked].iter().enumerate() {
273                    if *distance < best_distance {
274                        best_distance = *distance;
275                        best_position = index;
276                    }
277                }
278                best_position
279            } else {
280                // An occupied niche takes an arbitrary one: the point of the rule is
281                // to spread across directions, not to pack one of them tightly.
282                self.base.rng.gen_range(0..available[picked].len())
283            };
284            let (candidate, _) = available[picked].remove(position);
285            chosen.push(candidate);
286            niche_count[picked] += 1;
287            if available[picked].is_empty() {
288                excluded[picked] = true;
289            }
290        }
291
292        self.niche_count = niche_count;
293        self.association_count = association_count;
294        chosen
295    }
296
297    /// NSGA-III environmental selection: keep the best `population_size`
298    /// individuals by front rank, resolving the splitting front by niching.
299    fn environmental_selection(&mut self) -> Result<()> {
300        let target = self.base.population_size.max(1);
301        let fronts = self.base.non_dominated_sort();
302        let mut selected: Vec<usize> = Vec::with_capacity(target);
303        let mut splitting: Vec<usize> = Vec::new();
304        for front in &fronts {
305            let evaluated: Vec<usize> = front
306                .iter()
307                .copied()
308                .filter(|index| {
309                    self.base
310                        .population
311                        .get(*index)
312                        .is_some_and(|individual| self.base.is_evaluated(individual))
313                })
314                .collect();
315            if evaluated.is_empty() {
316                continue;
317            }
318            if selected.len() + evaluated.len() <= target {
319                selected.extend(evaluated);
320            } else {
321                splitting = evaluated;
322                break;
323            }
324        }
325        if !splitting.is_empty() && selected.len() < target {
326            let slots = target - selected.len();
327            let chosen = self.niching_selection(&selected, &splitting, slots);
328            selected.extend(chosen);
329        } else if splitting.is_empty() {
330            // Nothing had to be split, so no niching happened this round; report
331            // the associations of what was selected rather than leaving the counts
332            // from an earlier generation in place.
333            self.refresh_association_counts(&selected);
334        }
335
336        // Unevaluated individuals are kept so the optimizer still has architectures
337        // to reproduce from, but they never displace an evaluated solution.
338        let unevaluated: Vec<usize> = (0..self.base.population.len())
339            .filter(|index| {
340                self.base
341                    .population
342                    .get(*index)
343                    .is_some_and(|individual| !self.base.is_evaluated(individual))
344            })
345            .collect();
346        let keep_unevaluated = target.saturating_sub(selected.len());
347
348        let mut kept: Vec<Individual<T>> = selected
349            .iter()
350            .filter_map(|index| self.base.population.get(*index).cloned())
351            .collect();
352        kept.extend(
353            unevaluated
354                .iter()
355                .take(keep_unevaluated)
356                .filter_map(|index| self.base.population.get(*index).cloned()),
357        );
358        self.base.population = kept;
359        Ok(())
360    }
361
362    /// Recompute the association/niche tables for a selection that needed no
363    /// niching, so the reported counts always describe the current population.
364    fn refresh_association_counts(&mut self, selected: &[usize]) {
365        let mut niche_count = vec![0usize; self.reference_directions.len()];
366        let mut association_count = vec![0usize; self.reference_directions.len()];
367        let individuals: Vec<Individual<T>> = selected
368            .iter()
369            .filter_map(|index| self.base.population.get(*index).cloned())
370            .collect();
371        if !individuals.is_empty() && !self.reference_directions.is_empty() {
372            let points = self.minimization_objectives(&individuals);
373            let num_objectives = self.base.config.objectives.len();
374            if let Some((normalized, _)) = normalize_by_hyperplane(&points, num_objectives) {
375                for (direction, _) in
376                    associate_with_references(&normalized, &self.reference_directions)
377                {
378                    if direction < niche_count.len() {
379                        niche_count[direction] += 1;
380                        association_count[direction] += 1;
381                    }
382                }
383            }
384        }
385        self.niche_count = niche_count;
386        self.association_count = association_count;
387    }
388
389    /// Absorb `results` into the population, matching by architecture id.
390    fn absorb_results(&mut self, results: &[SearchResult<T>]) {
391        for result in results {
392            let objectives = self.base.objective_vector_for_result(result);
393            let architecture_id = result.architecture.architecture_id.as_str();
394            let existing = if architecture_id.is_empty() {
395                None
396            } else {
397                self.base.population.iter().position(|individual| {
398                    individual.architecture.architecture_id == architecture_id
399                })
400            };
401            match existing {
402                Some(index) => {
403                    self.base.population[index].objectives = objectives;
404                    self.base.population[index].architecture = result.architecture.clone();
405                }
406                None => {
407                    let id = if architecture_id.is_empty() {
408                        format!("nsga3_{}", self.base.population.len())
409                    } else {
410                        architecture_id.to_string()
411                    };
412                    self.base.population.push(Individual {
413                        id,
414                        architecture: result.architecture.clone(),
415                        objectives,
416                        constraints: Vec::new(),
417                        rank: 0,
418                        crowding_distance: T::zero(),
419                        fitness: T::zero(),
420                    });
421                }
422            }
423        }
424    }
425
426    /// Mean pairwise Euclidean distance between the objective vectors of
427    /// `results` — the diversity measurement for the engine adapter.
428    pub(crate) fn mean_objective_distance(&self, results: &[SearchResult<T>]) -> f64 {
429        self.base.mean_objective_distance(results)
430    }
431
432    /// Rank `results` and return the indices of the best `k`, by front rank and
433    /// then by reference-direction niching — NSGA-III's own selection rather than
434    /// NSGA-II's crowding distance.
435    pub(crate) fn select_by_rank_and_niching(
436        &mut self,
437        results: &[SearchResult<T>],
438        k: usize,
439    ) -> Vec<usize> {
440        if results.is_empty() || k == 0 {
441            return Vec::new();
442        }
443        // Selection works on a scratch population built from `results`, so the
444        // returned indices are indices into `results`.
445        let saved_population = std::mem::take(&mut self.base.population);
446        let saved_size = self.base.population_size;
447        self.base.population = results
448            .iter()
449            .enumerate()
450            .map(|(index, result)| Individual {
451                architecture: result.architecture.clone(),
452                objectives: self.base.objective_vector_for_result(result),
453                constraints: Vec::new(),
454                rank: 0,
455                crowding_distance: T::zero(),
456                fitness: T::zero(),
457                id: format!("sel_{}", index),
458            })
459            .collect();
460        self.base.population_size = k.min(results.len());
461
462        let fronts = self.base.non_dominated_sort();
463        let mut selected: Vec<usize> = Vec::with_capacity(self.base.population_size);
464        let mut splitting: Vec<usize> = Vec::new();
465        for front in &fronts {
466            if selected.len() + front.len() <= self.base.population_size {
467                selected.extend(front.iter().copied());
468            } else {
469                splitting = front.clone();
470                break;
471            }
472        }
473        if !splitting.is_empty() && selected.len() < self.base.population_size {
474            let slots = self.base.population_size - selected.len();
475            let chosen = self.niching_selection(&selected, &splitting, slots);
476            selected.extend(chosen);
477        }
478
479        self.base.population = saved_population;
480        self.base.population_size = saved_size;
481        selected
482    }
483}
484
485impl<
486        T: Float
487            + Debug
488            + Default
489            + Clone
490            + Send
491            + Sync
492            + std::fmt::Debug
493            + PartialOrd
494            + std::iter::Sum,
495    > MultiObjectiveOptimizer<T> for NSGA3<T>
496{
497    fn initialize(&mut self, config: &MultiObjectiveConfig<T>) -> Result<()> {
498        self.build_reference_directions(config)?;
499        // `build_reference_directions` has already sized the population; NSGA-II's
500        // initialize then samples that many architectures.
501        self.base.initialize(config)
502    }
503
504    /// Absorb `results`, run NSGA-III's environmental selection (dominance ranks
505    /// resolved by reference-direction niching) and publish the non-dominated set.
506    fn update_pareto_front(&mut self, results: &[SearchResult<T>]) -> Result<ParetoFront<T>> {
507        if self.reference_directions.is_empty() {
508            return Err(OptimError::InvalidConfig(
509                "NSGA-III has no reference directions; call initialize with a \
510                 configuration that declares at least one objective first"
511                    .to_string(),
512            ));
513        }
514        self.absorb_results(results);
515        self.base.generation += 1;
516        self.base.statistics.total_evaluations += results.len();
517        self.environmental_selection()?;
518        // Re-rank what survived so the published front carries correct ranks.
519        let fronts = self.base.non_dominated_sort();
520        for front in &fronts {
521            self.base.calculate_crowding_distance(front);
522        }
523        self.base.update_pareto_front_from_population();
524        let niched = self.niche_count.iter().filter(|count| **count > 0).count();
525        self.base.statistics.algorithm_metrics.insert(
526            "reference_directions".to_string(),
527            T::from(self.reference_directions.len()).unwrap_or_else(T::zero),
528        );
529        self.base.statistics.algorithm_metrics.insert(
530            "occupied_niches".to_string(),
531            T::from(niched).unwrap_or_else(T::zero),
532        );
533        Ok(self.base.get_pareto_front().clone())
534    }
535
536    fn get_pareto_front(&self) -> &ParetoFront<T> {
537        self.base.get_pareto_front()
538    }
539
540    /// Produce one offspring per population slot.
541    ///
542    /// NSGA-III's published mating selection is **random**, not NSGA-II's crowded
543    /// tournament: diversity is enforced by the niching in environmental
544    /// selection, so biasing mating by crowding would double-count it.
545    fn select_candidates(
546        &mut self,
547        population: &[OptimizerArchitecture<T>],
548        _objectives: &[T],
549    ) -> Result<Vec<OptimizerArchitecture<T>>> {
550        if self.base.population.is_empty() && population.is_empty() {
551            return Err(OptimError::InvalidConfig(
552                "NSGA-III has no population to reproduce from; call initialize first".to_string(),
553            ));
554        }
555        let pool: Vec<OptimizerArchitecture<T>> = if self.base.population.is_empty() {
556            population.to_vec()
557        } else {
558            self.base
559                .population
560                .iter()
561                .map(|individual| individual.architecture.clone())
562                .chain(population.iter().cloned())
563                .collect()
564        };
565        let mut offspring = Vec::with_capacity(self.base.population_size);
566        for _ in 0..self.base.population_size {
567            let first = pool[self.base.rng.gen_range(0..pool.len())].clone();
568            let second = pool[self.base.rng.gen_range(0..pool.len())].clone();
569            let mut child = if self.base.rng.gen_range(0.0..1.0) < self.base.crossover_prob {
570                operators::crossover_architectures(&mut self.base.rng, &first, &second)
571            } else {
572                first
573            };
574            if self.base.rng.gen_range(0.0..1.0) < self.base.mutation_prob {
575                operators::mutate_architecture(
576                    &mut self.base.rng,
577                    &mut child,
578                    NSGA3_COMPONENT_MUTATION_RATE,
579                    NSGA3_PARAMETER_MUTATION_RATE,
580                );
581            }
582            offspring.push(child);
583        }
584        Ok(offspring)
585    }
586
587    fn name(&self) -> &str {
588        "NSGA-III"
589    }
590
591    fn get_statistics(&self) -> MultiObjectiveStatistics<T> {
592        self.base.get_statistics()
593    }
594}