optirs-nas 0.3.2

OptiRS Neural Architecture Search and hyperparameter optimization
Documentation
//! MultiObjectiveOptimizer implementations for the engine.
//!
//! Every adapter here delegates to a real algorithm in [`crate::multi_objective`]:
//! `NSGA2Optimizer` to `NSGA2`, `WeightedSumOptimizer` to `WeightedSum`,
//! `NSGA3Optimizer` to `NSGA3`, and `MOEADAdapter` to `MOEADOptimizer`. (The
//! MOEA/D wrapper is not called `MOEADOptimizer` only because the algorithm it
//! wraps already carries that name.)
//!
//! The `NSGA3Optimizer` / `MOEADOptimizer` / `PAESOptimizer` / `SPEA2Optimizer`
//! placeholders that used to live here were generated by a macro whose
//! `update_pareto_front` returned an empty Pareto front and whose
//! `calculate_diversity` returned a hardcoded `0.5`. They were reachable —
//! `create_multi_objective_optimizer` instantiated them for the corresponding
//! `MultiObjectiveAlgorithm` values — so a search configured for NSGA-III or
//! SPEA2 silently produced no front at all while reporting a fabricated
//! diversity. NSGA-III and MOEA/D are now backed by real implementations;
//! `PAES` / `SPEA2` / `EpsilonConstraint` / `GoalProgramming` / `Custom` still
//! yield an honest `OptimError::NotImplemented` at engine construction.

use crate::error::Result;
use crate::multi_objective;
use crate::nas_engine::config::*;
use crate::nas_engine::results::*;
use scirs2_core::numeric::Float;
use std::fmt::Debug;

use super::support::MultiObjectiveOptimizer;

/// NSGA-II multi-objective optimizer wrapper that delegates Pareto-front
/// maintenance, candidate selection and diversity measurement to the complete
/// [`crate::multi_objective::NSGA2`] implementation.
pub(super) struct NSGA2Optimizer<
    T: Float + Debug + Default + Clone + Send + Sync + 'static + PartialOrd + std::iter::Sum,
> {
    /// Live NSGA-II engine holding population, Pareto front and statistics.
    pub(super) inner: crate::multi_objective::NSGA2<T>,
    /// Configured objectives (direction + metric mapping) required by NSGA-II
    /// to compute dominance.
    pub(super) config: MultiObjectiveConfig<T>,
}
impl<T: Float + Debug + Default + Clone + Send + Sync + 'static + PartialOrd + std::iter::Sum>
    NSGA2Optimizer<T>
{
    /// Construct an NSGA-II optimizer from a multi-objective configuration. The
    /// inner [`crate::multi_objective::NSGA2`] is created with standard
    /// crossover/mutation probabilities; its population is (re)sized per update
    /// so that every individual carries real objective values.
    pub fn new(config: &MultiObjectiveConfig<T>) -> Result<Self> {
        use crate::multi_objective::MultiObjectiveOptimizer as InnerMultiObjective;
        let mut inner = crate::multi_objective::NSGA2::<T>::new(1, 0.9, 0.1);
        inner.initialize(config)?;
        Ok(Self {
            inner,
            config: config.clone(),
        })
    }
}
impl<T: Float + Debug + Default + Clone + Send + Sync + 'static + PartialOrd + std::iter::Sum>
    MultiObjectiveOptimizer<T> for NSGA2Optimizer<T>
{
    fn update_pareto_front(
        &mut self,
        results: &[SearchResult<T>],
    ) -> Result<multi_objective::ParetoFront<T>> {
        use crate::multi_objective::MultiObjectiveOptimizer as InnerMultiObjective;
        if results.is_empty() {
            return Ok(self.inner.get_pareto_front().clone());
        }
        // The *same* NSGA-II instance is reused across generations on purpose: it
        // latches the hypervolume reference point on first use and remembers the
        // previous hypervolume to derive the `convergence` metric. Rebuilding it
        // per call (as this adapter used to) re-derived the reference from every
        // new front, which made hypervolumes from different generations
        // incomparable and pinned `convergence` at 1 forever.
        Ok(self.inner.pareto_front_from_results(results))
    }
    fn select_candidates(
        &self,
        candidates: &[SearchResult<T>],
        population_size: usize,
    ) -> Result<Vec<SearchResult<T>>> {
        use crate::multi_objective::MultiObjectiveOptimizer as InnerMultiObjective;
        if candidates.is_empty() || population_size == 0 {
            return Ok(Vec::new());
        }
        let mut ranker = crate::multi_objective::NSGA2::<T>::new(candidates.len(), 0.9, 0.1);
        ranker.initialize(&self.config)?;
        let selected_indices = ranker.select_by_rank_and_crowding(candidates, population_size);
        Ok(selected_indices
            .into_iter()
            .map(|idx| candidates[idx].clone())
            .collect())
    }
    fn calculate_diversity(&self, population: &[SearchResult<T>]) -> f64 {
        self.inner.mean_objective_distance(population)
    }
}
/// Weighted-sum multi-objective optimizer wrapper delegating to the real
/// [`crate::multi_objective::WeightedSum`] scalarizer.
///
/// This adapter previously did not exist: `MultiObjectiveAlgorithm::WeightedSum`
/// was silently served by NSGA-II, so the configured per-objective `weight`
/// values were ignored entirely.
pub(super) struct WeightedSumOptimizer<
    T: Float + Debug + Default + Clone + Send + Sync + 'static + PartialOrd + std::iter::Sum,
> {
    pub(super) inner: crate::multi_objective::WeightedSum<T>,
}
impl<T: Float + Debug + Default + Clone + Send + Sync + 'static + PartialOrd + std::iter::Sum>
    WeightedSumOptimizer<T>
{
    pub fn new(config: &MultiObjectiveConfig<T>) -> Result<Self> {
        Ok(Self {
            inner: crate::multi_objective::WeightedSum::new(&config.objectives)?,
        })
    }
}
impl<T: Float + Debug + Default + Clone + Send + Sync + 'static + PartialOrd + std::iter::Sum>
    MultiObjectiveOptimizer<T> for WeightedSumOptimizer<T>
{
    fn update_pareto_front(
        &mut self,
        results: &[SearchResult<T>],
    ) -> Result<multi_objective::ParetoFront<T>> {
        use crate::multi_objective::MultiObjectiveOptimizer as InnerMultiObjective;
        self.inner.update_pareto_front(results)
    }

    fn select_candidates(
        &self,
        candidates: &[SearchResult<T>],
        population_size: usize,
    ) -> Result<Vec<SearchResult<T>>> {
        if candidates.is_empty() || population_size == 0 {
            return Ok(Vec::new());
        }
        // Rank by the configured weighted-sum scalarization (direction aware),
        // then keep the best `population_size`.
        let mut scored: Vec<(usize, T)> = candidates
            .iter()
            .enumerate()
            .map(|(idx, result)| (idx, self.inner.scalarized_cost_for_result(result)))
            .collect();
        scored.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
        Ok(scored
            .into_iter()
            .take(population_size)
            .map(|(idx, _)| candidates[idx].clone())
            .collect())
    }

    fn calculate_diversity(&self, population: &[SearchResult<T>]) -> f64 {
        self.inner.mean_objective_distance(population)
    }
}

/// NSGA-III adapter delegating front maintenance, selection and diversity to the
/// real [`crate::multi_objective::NSGA3`].
///
/// `MultiObjectiveAlgorithm::NSGA3` used to be served by a macro-generated
/// placeholder that reported an empty front and a hardcoded diversity of `0.5`.
pub(super) struct NSGA3Optimizer<
    T: Float + Debug + Default + Clone + Send + Sync + 'static + PartialOrd + std::iter::Sum,
> {
    /// Live NSGA-III engine holding the population, reference directions, niche
    /// counts, Pareto front and statistics.
    pub(super) inner: crate::multi_objective::NSGA3<T>,
    /// Configured objectives, needed to rebuild a ranker in the `&self`-only
    /// `select_candidates`.
    pub(super) config: MultiObjectiveConfig<T>,
    /// Seed used for the ranking-only NSGA-III instances, so ranking is
    /// reproducible instead of drawing fresh OS entropy per call.
    pub(super) ranking_seed: u64,
}

impl<T: Float + Debug + Default + Clone + Send + Sync + 'static + PartialOrd + std::iter::Sum>
    NSGA3Optimizer<T>
{
    pub fn new(config: &MultiObjectiveConfig<T>) -> Result<Self> {
        use crate::multi_objective::MultiObjectiveOptimizer as InnerMultiObjective;
        let ranking_seed = scirs2_core::random::random::<u64>();
        let mut inner = crate::multi_objective::NSGA3::<T>::with_seed(
            crate::multi_objective::nsga3::DEFAULT_REFERENCE_TARGET,
            0.9,
            0.1,
            scirs2_core::random::random::<u64>(),
        );
        inner.initialize(config)?;
        Ok(Self {
            inner,
            config: config.clone(),
            ranking_seed,
        })
    }
}

impl<T: Float + Debug + Default + Clone + Send + Sync + 'static + PartialOrd + std::iter::Sum>
    MultiObjectiveOptimizer<T> for NSGA3Optimizer<T>
{
    fn update_pareto_front(
        &mut self,
        results: &[SearchResult<T>],
    ) -> Result<multi_objective::ParetoFront<T>> {
        use crate::multi_objective::MultiObjectiveOptimizer as InnerMultiObjective;
        if results.is_empty() {
            return Ok(self.inner.get_pareto_front().clone());
        }
        // The same instance is reused across generations on purpose: NSGA-III is
        // elitist, so its population *is* the accumulated state, and it latches the
        // hypervolume reference point on first use.
        self.inner.update_pareto_front(results)
    }

    fn select_candidates(
        &self,
        candidates: &[SearchResult<T>],
        population_size: usize,
    ) -> Result<Vec<SearchResult<T>>> {
        use crate::multi_objective::MultiObjectiveOptimizer as InnerMultiObjective;
        if candidates.is_empty() || population_size == 0 {
            return Ok(Vec::new());
        }
        // Ranking mutates the niche tables and draws from the RNG, and the engine
        // trait hands out only `&self` here, so ranking runs on a separate
        // reproducibly seeded instance — the same pattern `NSGA2Optimizer` uses.
        let mut ranker = crate::multi_objective::NSGA3::<T>::with_seed(
            crate::multi_objective::nsga3::DEFAULT_REFERENCE_TARGET,
            0.9,
            0.1,
            self.ranking_seed,
        );
        ranker.initialize(&self.config)?;
        let selected = ranker.select_by_rank_and_niching(candidates, population_size);
        Ok(selected
            .into_iter()
            .filter_map(|index| candidates.get(index).cloned())
            .collect())
    }

    fn calculate_diversity(&self, population: &[SearchResult<T>]) -> f64 {
        self.inner.mean_objective_distance(population)
    }
}

/// MOEA/D adapter delegating to the real
/// [`crate::multi_objective::MOEADOptimizer`].
///
/// `MultiObjectiveAlgorithm::MOEAD` used to be served by a macro-generated
/// placeholder that reported an empty front and a hardcoded diversity of `0.5`.
pub(super) struct MOEADAdapter<
    T: Float + Debug + Default + Clone + Send + Sync + 'static + PartialOrd + std::iter::Sum,
> {
    /// Live MOEA/D engine holding the weight lattice, subproblem solutions, ideal
    /// point, external archive and statistics.
    pub(super) inner: crate::multi_objective::MOEADOptimizer<T>,
}

impl<T: Float + Debug + Default + Clone + Send + Sync + 'static + PartialOrd + std::iter::Sum>
    MOEADAdapter<T>
{
    pub fn new(config: &MultiObjectiveConfig<T>) -> Result<Self> {
        use crate::multi_objective::MultiObjectiveOptimizer as InnerMultiObjective;
        let mut inner = crate::multi_objective::MOEADOptimizer::<T>::new(config.clone())?;
        inner.initialize(config)?;
        Ok(Self { inner })
    }
}

impl<T: Float + Debug + Default + Clone + Send + Sync + 'static + PartialOrd + std::iter::Sum>
    MultiObjectiveOptimizer<T> for MOEADAdapter<T>
{
    fn update_pareto_front(
        &mut self,
        results: &[SearchResult<T>],
    ) -> Result<multi_objective::ParetoFront<T>> {
        use crate::multi_objective::MultiObjectiveOptimizer as InnerMultiObjective;
        if results.is_empty() {
            return Ok(self.inner.get_pareto_front().clone());
        }
        // Reusing the instance is what makes MOEA/D work at all: the ideal point,
        // the per-subproblem incumbents and the external archive are the algorithm's
        // accumulated state.
        self.inner.update_pareto_front(results)
    }

    fn select_candidates(
        &self,
        candidates: &[SearchResult<T>],
        population_size: usize,
    ) -> Result<Vec<SearchResult<T>>> {
        if candidates.is_empty() || population_size == 0 {
            return Ok(Vec::new());
        }
        Ok(self
            .inner
            .select_by_decomposition(candidates, population_size)
            .into_iter()
            .filter_map(|index| candidates.get(index).cloned())
            .collect())
    }

    fn calculate_diversity(&self, population: &[SearchResult<T>]) -> f64 {
        self.inner.mean_objective_distance(population)
    }
}