use crate::error::{OptimError, Result};
use crate::nas_engine::{MultiObjectiveConfig, OptimizerArchitecture, SearchResult};
use scirs2_core::numeric::Float;
use std::fmt::Debug;
use super::core::{
objective_directions, Individual, MultiObjectiveOptimizer, MultiObjectiveStatistics,
ParetoFront,
};
use super::decomposition::{
associate_with_references, das_dennis_weights, divisions_for_at_least, normalize_by_hyperplane,
};
use super::hypervolume::normalize_front_for_minimization;
use super::nsga2::NSGA2;
use super::operators;
pub const DEFAULT_REFERENCE_TARGET: usize = 20;
const NSGA3_COMPONENT_MUTATION_RATE: f64 = 0.2;
const NSGA3_PARAMETER_MUTATION_RATE: f64 = 0.3;
pub struct NSGA3<T: Float + Debug + Send + Sync + 'static> {
pub(super) base: NSGA2<T>,
pub(super) reference_directions: Vec<Vec<T>>,
pub(super) association_count: Vec<usize>,
pub(super) niche_count: Vec<usize>,
pub(super) reference_target: usize,
pub(super) divisions: usize,
}
impl<
T: Float
+ Debug
+ Default
+ Clone
+ Send
+ Sync
+ std::fmt::Debug
+ PartialOrd
+ std::iter::Sum,
> NSGA3<T>
{
pub fn new(reference_target: usize, crossover_prob: f64, mutation_prob: f64) -> Self {
Self::with_seed(
reference_target,
crossover_prob,
mutation_prob,
scirs2_core::random::random::<u64>(),
)
}
pub fn with_seed(
reference_target: usize,
crossover_prob: f64,
mutation_prob: f64,
seed: u64,
) -> Self {
let reference_target = reference_target.max(1);
Self {
base: NSGA2::with_seed(reference_target, crossover_prob, mutation_prob, seed),
reference_directions: Vec::new(),
association_count: Vec::new(),
niche_count: Vec::new(),
reference_target,
divisions: 0,
}
}
pub fn reference_directions(&self) -> &[Vec<T>] {
&self.reference_directions
}
pub fn association_count(&self) -> &[usize] {
&self.association_count
}
pub fn niche_count(&self) -> &[usize] {
&self.niche_count
}
pub fn population_size(&self) -> usize {
self.base.population_size
}
pub fn set_hypervolume_reference(&mut self, reference: Vec<T>) -> Result<()> {
self.base.set_hypervolume_reference(reference)
}
fn build_reference_directions(&mut self, config: &MultiObjectiveConfig<T>) -> Result<()> {
let num_objectives = config.objectives.len();
if num_objectives == 0 {
return Err(OptimError::InvalidConfig(
"NSGA-III needs at least one configured objective to place reference \
directions"
.to_string(),
));
}
let (divisions, lattice_size) =
divisions_for_at_least(num_objectives, self.reference_target).ok_or_else(|| {
OptimError::InvalidConfig(format!(
"no Das-Dennis lattice with at least {} directions exists for {} \
objectives",
self.reference_target, num_objectives
))
})?;
self.divisions = divisions;
self.reference_directions = das_dennis_weights::<T>(num_objectives, divisions);
self.association_count = vec![0; self.reference_directions.len()];
self.niche_count = vec![0; self.reference_directions.len()];
self.base.population_size = lattice_size;
Ok(())
}
fn minimization_objectives(&self, individuals: &[Individual<T>]) -> Vec<Vec<T>> {
let directions = objective_directions(&self.base.config.objectives);
let raw: Vec<Vec<T>> = individuals
.iter()
.map(|individual| individual.objectives.clone())
.collect();
normalize_front_for_minimization(&raw, &directions)
}
fn niching_selection(
&mut self,
already_selected: &[usize],
splitting_front: &[usize],
slots: usize,
) -> Vec<usize> {
if slots == 0 || splitting_front.is_empty() || self.reference_directions.is_empty() {
return Vec::new();
}
let considered: Vec<usize> = already_selected
.iter()
.chain(splitting_front.iter())
.copied()
.collect();
let individuals: Vec<Individual<T>> = considered
.iter()
.filter_map(|index| self.base.population.get(*index).cloned())
.collect();
if individuals.len() != considered.len() {
return splitting_front.iter().copied().take(slots).collect();
}
let points = self.minimization_objectives(&individuals);
let num_objectives = self.base.config.objectives.len();
let Some((normalized, _intercepts)) = normalize_by_hyperplane(&points, num_objectives)
else {
return splitting_front.iter().copied().take(slots).collect();
};
let associations = associate_with_references(&normalized, &self.reference_directions);
let mut niche_count = vec![0usize; self.reference_directions.len()];
let mut association_count = vec![0usize; self.reference_directions.len()];
for (position, (direction, _)) in associations.iter().enumerate() {
if *direction < association_count.len() {
association_count[*direction] += 1;
if position < already_selected.len() {
niche_count[*direction] += 1;
}
}
}
let offset = already_selected.len();
let mut available: Vec<Vec<(usize, T)>> = vec![Vec::new(); self.reference_directions.len()];
for (position, (direction, distance)) in associations.iter().enumerate().skip(offset) {
let candidate = considered[position];
if *direction < available.len() {
available[*direction].push((candidate, *distance));
}
}
let mut excluded = vec![false; self.reference_directions.len()];
let mut chosen = Vec::with_capacity(slots);
while chosen.len() < slots {
let mut best_directions: Vec<usize> = Vec::new();
let mut best_count = usize::MAX;
for direction in 0..self.reference_directions.len() {
if excluded[direction] || available[direction].is_empty() {
continue;
}
let count = niche_count[direction];
if count < best_count {
best_count = count;
best_directions.clear();
best_directions.push(direction);
} else if count == best_count {
best_directions.push(direction);
}
}
if best_directions.is_empty() {
break;
}
let picked = best_directions[self.base.rng.gen_range(0..best_directions.len())];
let position = if niche_count[picked] == 0 {
let mut best_position = 0usize;
let mut best_distance = T::infinity();
for (index, (_, distance)) in available[picked].iter().enumerate() {
if *distance < best_distance {
best_distance = *distance;
best_position = index;
}
}
best_position
} else {
self.base.rng.gen_range(0..available[picked].len())
};
let (candidate, _) = available[picked].remove(position);
chosen.push(candidate);
niche_count[picked] += 1;
if available[picked].is_empty() {
excluded[picked] = true;
}
}
self.niche_count = niche_count;
self.association_count = association_count;
chosen
}
fn environmental_selection(&mut self) -> Result<()> {
let target = self.base.population_size.max(1);
let fronts = self.base.non_dominated_sort();
let mut selected: Vec<usize> = Vec::with_capacity(target);
let mut splitting: Vec<usize> = Vec::new();
for front in &fronts {
let evaluated: Vec<usize> = front
.iter()
.copied()
.filter(|index| {
self.base
.population
.get(*index)
.is_some_and(|individual| self.base.is_evaluated(individual))
})
.collect();
if evaluated.is_empty() {
continue;
}
if selected.len() + evaluated.len() <= target {
selected.extend(evaluated);
} else {
splitting = evaluated;
break;
}
}
if !splitting.is_empty() && selected.len() < target {
let slots = target - selected.len();
let chosen = self.niching_selection(&selected, &splitting, slots);
selected.extend(chosen);
} else if splitting.is_empty() {
self.refresh_association_counts(&selected);
}
let unevaluated: Vec<usize> = (0..self.base.population.len())
.filter(|index| {
self.base
.population
.get(*index)
.is_some_and(|individual| !self.base.is_evaluated(individual))
})
.collect();
let keep_unevaluated = target.saturating_sub(selected.len());
let mut kept: Vec<Individual<T>> = selected
.iter()
.filter_map(|index| self.base.population.get(*index).cloned())
.collect();
kept.extend(
unevaluated
.iter()
.take(keep_unevaluated)
.filter_map(|index| self.base.population.get(*index).cloned()),
);
self.base.population = kept;
Ok(())
}
fn refresh_association_counts(&mut self, selected: &[usize]) {
let mut niche_count = vec![0usize; self.reference_directions.len()];
let mut association_count = vec![0usize; self.reference_directions.len()];
let individuals: Vec<Individual<T>> = selected
.iter()
.filter_map(|index| self.base.population.get(*index).cloned())
.collect();
if !individuals.is_empty() && !self.reference_directions.is_empty() {
let points = self.minimization_objectives(&individuals);
let num_objectives = self.base.config.objectives.len();
if let Some((normalized, _)) = normalize_by_hyperplane(&points, num_objectives) {
for (direction, _) in
associate_with_references(&normalized, &self.reference_directions)
{
if direction < niche_count.len() {
niche_count[direction] += 1;
association_count[direction] += 1;
}
}
}
}
self.niche_count = niche_count;
self.association_count = association_count;
}
fn absorb_results(&mut self, results: &[SearchResult<T>]) {
for result in results {
let objectives = self.base.objective_vector_for_result(result);
let architecture_id = result.architecture.architecture_id.as_str();
let existing = if architecture_id.is_empty() {
None
} else {
self.base.population.iter().position(|individual| {
individual.architecture.architecture_id == architecture_id
})
};
match existing {
Some(index) => {
self.base.population[index].objectives = objectives;
self.base.population[index].architecture = result.architecture.clone();
}
None => {
let id = if architecture_id.is_empty() {
format!("nsga3_{}", self.base.population.len())
} else {
architecture_id.to_string()
};
self.base.population.push(Individual {
id,
architecture: result.architecture.clone(),
objectives,
constraints: Vec::new(),
rank: 0,
crowding_distance: T::zero(),
fitness: T::zero(),
});
}
}
}
}
pub(crate) fn mean_objective_distance(&self, results: &[SearchResult<T>]) -> f64 {
self.base.mean_objective_distance(results)
}
pub(crate) fn select_by_rank_and_niching(
&mut self,
results: &[SearchResult<T>],
k: usize,
) -> Vec<usize> {
if results.is_empty() || k == 0 {
return Vec::new();
}
let saved_population = std::mem::take(&mut self.base.population);
let saved_size = self.base.population_size;
self.base.population = results
.iter()
.enumerate()
.map(|(index, result)| Individual {
architecture: result.architecture.clone(),
objectives: self.base.objective_vector_for_result(result),
constraints: Vec::new(),
rank: 0,
crowding_distance: T::zero(),
fitness: T::zero(),
id: format!("sel_{}", index),
})
.collect();
self.base.population_size = k.min(results.len());
let fronts = self.base.non_dominated_sort();
let mut selected: Vec<usize> = Vec::with_capacity(self.base.population_size);
let mut splitting: Vec<usize> = Vec::new();
for front in &fronts {
if selected.len() + front.len() <= self.base.population_size {
selected.extend(front.iter().copied());
} else {
splitting = front.clone();
break;
}
}
if !splitting.is_empty() && selected.len() < self.base.population_size {
let slots = self.base.population_size - selected.len();
let chosen = self.niching_selection(&selected, &splitting, slots);
selected.extend(chosen);
}
self.base.population = saved_population;
self.base.population_size = saved_size;
selected
}
}
impl<
T: Float
+ Debug
+ Default
+ Clone
+ Send
+ Sync
+ std::fmt::Debug
+ PartialOrd
+ std::iter::Sum,
> MultiObjectiveOptimizer<T> for NSGA3<T>
{
fn initialize(&mut self, config: &MultiObjectiveConfig<T>) -> Result<()> {
self.build_reference_directions(config)?;
self.base.initialize(config)
}
fn update_pareto_front(&mut self, results: &[SearchResult<T>]) -> Result<ParetoFront<T>> {
if self.reference_directions.is_empty() {
return Err(OptimError::InvalidConfig(
"NSGA-III has no reference directions; call initialize with a \
configuration that declares at least one objective first"
.to_string(),
));
}
self.absorb_results(results);
self.base.generation += 1;
self.base.statistics.total_evaluations += results.len();
self.environmental_selection()?;
let fronts = self.base.non_dominated_sort();
for front in &fronts {
self.base.calculate_crowding_distance(front);
}
self.base.update_pareto_front_from_population();
let niched = self.niche_count.iter().filter(|count| **count > 0).count();
self.base.statistics.algorithm_metrics.insert(
"reference_directions".to_string(),
T::from(self.reference_directions.len()).unwrap_or_else(T::zero),
);
self.base.statistics.algorithm_metrics.insert(
"occupied_niches".to_string(),
T::from(niched).unwrap_or_else(T::zero),
);
Ok(self.base.get_pareto_front().clone())
}
fn get_pareto_front(&self) -> &ParetoFront<T> {
self.base.get_pareto_front()
}
fn select_candidates(
&mut self,
population: &[OptimizerArchitecture<T>],
_objectives: &[T],
) -> Result<Vec<OptimizerArchitecture<T>>> {
if self.base.population.is_empty() && population.is_empty() {
return Err(OptimError::InvalidConfig(
"NSGA-III has no population to reproduce from; call initialize first".to_string(),
));
}
let pool: Vec<OptimizerArchitecture<T>> = if self.base.population.is_empty() {
population.to_vec()
} else {
self.base
.population
.iter()
.map(|individual| individual.architecture.clone())
.chain(population.iter().cloned())
.collect()
};
let mut offspring = Vec::with_capacity(self.base.population_size);
for _ in 0..self.base.population_size {
let first = pool[self.base.rng.gen_range(0..pool.len())].clone();
let second = pool[self.base.rng.gen_range(0..pool.len())].clone();
let mut child = if self.base.rng.gen_range(0.0..1.0) < self.base.crossover_prob {
operators::crossover_architectures(&mut self.base.rng, &first, &second)
} else {
first
};
if self.base.rng.gen_range(0.0..1.0) < self.base.mutation_prob {
operators::mutate_architecture(
&mut self.base.rng,
&mut child,
NSGA3_COMPONENT_MUTATION_RATE,
NSGA3_PARAMETER_MUTATION_RATE,
);
}
offspring.push(child);
}
Ok(offspring)
}
fn name(&self) -> &str {
"NSGA-III"
}
fn get_statistics(&self) -> MultiObjectiveStatistics<T> {
self.base.get_statistics()
}
}