Skip to main content

sklears_multioutput/optimization/
nsga2_algorithms.rs

1//! Enhanced Evolutionary Multi-Objective Optimization Algorithms
2//!
3//! This module provides NSGA-II (Non-dominated Sorting Genetic Algorithm II) and related
4//! evolutionary algorithms for multi-objective optimization. NSGA-II is one of the most
5//! popular and effective multi-objective evolutionary algorithms.
6//!
7//! ## Key Features
8//!
9//! - **Non-dominated Sorting**: Fast and efficient ranking of solutions based on Pareto dominance
10//! - **Crowding Distance**: Maintains diversity in the population and Pareto front
11//! - **Multiple Algorithm Variants**: Standard NSGA-II, SBX crossover, and differential evolution
12//! - **Advanced Operators**: Simulated binary crossover (SBX) and polynomial mutation
13//! - **Elitism**: Preserves good solutions across generations
14//! - **Hypervolume Tracking**: Monitors convergence quality over generations
15#![allow(non_snake_case)] // Standard ML notation: X for feature matrices, K for kernels
16
17// Use SciRS2-Core for arrays and random number generation (SciRS2 Policy)
18use scirs2_core::ndarray::{array, s, Array1, Array2, ArrayView2};
19use scirs2_core::random::RandNormal;
20use scirs2_core::random::{Rng, RngExt};
21use sklears_core::{
22    error::{Result as SklResult, SklearsError},
23    traits::{Estimator, Fit, Predict, Untrained},
24    types::Float,
25};
26
27use super::multi_objective_optimization::ParetoSolution;
28
29/// NSGA-II (Non-dominated Sorting Genetic Algorithm II) algorithm types
30#[derive(Debug, Clone, PartialEq)]
31pub enum NSGA2Algorithm {
32    /// Standard NSGA-II
33    Standard,
34    /// NSGA-II with simulated binary crossover
35    SBX,
36    /// NSGA-II with differential evolution
37    DE,
38}
39
40/// NSGA-II Configuration
41#[derive(Debug, Clone)]
42pub struct NSGA2Config {
43    /// Population size
44    pub population_size: usize,
45    /// Number of generations
46    pub generations: usize,
47    /// Crossover probability
48    pub crossover_prob: Float,
49    /// Mutation probability
50    pub mutation_prob: Float,
51    /// Distribution index for SBX crossover
52    pub eta_c: Float,
53    /// Distribution index for polynomial mutation
54    pub eta_m: Float,
55    /// Algorithm variant
56    pub algorithm: NSGA2Algorithm,
57    /// Random state for reproducibility
58    pub random_state: Option<u64>,
59}
60
61impl Default for NSGA2Config {
62    fn default() -> Self {
63        Self {
64            population_size: 100,
65            generations: 250,
66            crossover_prob: 0.9,
67            mutation_prob: 0.1,
68            eta_c: 20.0,
69            eta_m: 20.0,
70            algorithm: NSGA2Algorithm::Standard,
71            random_state: None,
72        }
73    }
74}
75
76/// NSGA-II Multi-Objective Optimizer
77#[derive(Debug, Clone)]
78pub struct NSGA2Optimizer<S = Untrained> {
79    state: S,
80    config: NSGA2Config,
81}
82
83/// Trained state for NSGA-II Optimizer
84#[derive(Debug, Clone)]
85pub struct NSGA2OptimizerTrained {
86    /// Pareto-optimal solutions
87    pub pareto_solutions: Vec<ParetoSolution>,
88    /// Best compromise solution
89    pub best_solution: ParetoSolution,
90    /// Convergence history (hypervolume indicator)
91    pub convergence_history: Vec<Float>,
92    /// Final population
93    pub final_population: Vec<ParetoSolution>,
94    /// Configuration used for optimization
95    pub config: NSGA2Config,
96    /// Number of objectives
97    pub n_objectives: usize,
98}
99
100impl Default for NSGA2Optimizer<Untrained> {
101    fn default() -> Self {
102        Self::new()
103    }
104}
105
106impl NSGA2Optimizer<Untrained> {
107    /// Create a new NSGA-II Optimizer
108    pub fn new() -> Self {
109        Self {
110            state: Untrained,
111            config: NSGA2Config::default(),
112        }
113    }
114
115    /// Set the configuration
116    pub fn config(mut self, config: NSGA2Config) -> Self {
117        self.config = config;
118        self
119    }
120
121    /// Set the population size
122    pub fn population_size(mut self, population_size: usize) -> Self {
123        self.config.population_size = population_size;
124        self
125    }
126
127    /// Set the number of generations
128    pub fn generations(mut self, generations: usize) -> Self {
129        self.config.generations = generations;
130        self
131    }
132
133    /// Set the crossover probability
134    pub fn crossover_prob(mut self, crossover_prob: Float) -> Self {
135        self.config.crossover_prob = crossover_prob;
136        self
137    }
138
139    /// Set the mutation probability
140    pub fn mutation_prob(mut self, mutation_prob: Float) -> Self {
141        self.config.mutation_prob = mutation_prob;
142        self
143    }
144
145    /// Set the algorithm variant
146    pub fn algorithm(mut self, algorithm: NSGA2Algorithm) -> Self {
147        self.config.algorithm = algorithm;
148        self
149    }
150}
151
152impl Estimator for NSGA2Optimizer<Untrained> {
153    type Config = NSGA2Config;
154    type Error = SklearsError;
155    type Float = Float;
156
157    fn config(&self) -> &Self::Config {
158        &self.config
159    }
160}
161
162impl Estimator for NSGA2Optimizer<NSGA2OptimizerTrained> {
163    type Config = NSGA2Config;
164    type Error = SklearsError;
165    type Float = Float;
166
167    fn config(&self) -> &Self::Config {
168        &self.state.config
169    }
170}
171
172impl Fit<ArrayView2<'_, Float>, ArrayView2<'_, Float>> for NSGA2Optimizer<Untrained> {
173    type Fitted = NSGA2Optimizer<NSGA2OptimizerTrained>;
174
175    fn fit(self, X: &ArrayView2<'_, Float>, y: &ArrayView2<'_, Float>) -> SklResult<Self::Fitted> {
176        if X.nrows() != y.nrows() {
177            return Err(SklearsError::InvalidInput(
178                "X and y must have the same number of samples".to_string(),
179            ));
180        }
181
182        let _n_samples = X.nrows();
183        let n_features = X.ncols();
184        let n_outputs = y.ncols();
185        let n_objectives = 2; // Default: accuracy and complexity
186
187        let mut rng = match self.config.random_state {
188            Some(seed) => scirs2_core::random::seeded_rng(seed),
189            None => scirs2_core::random::seeded_rng(42), // Default seed
190        };
191
192        // Initialize population
193        let mut population = self.initialize_population(n_features, n_outputs, &mut rng)?;
194
195        // Evaluate initial population
196        self.evaluate_population(&mut population, X, y)?;
197
198        let mut convergence_history = Vec::new();
199
200        // Main evolutionary loop
201        for generation in 0..self.config.generations {
202            // Non-dominated sorting
203            self.nsga2_non_dominated_sort(&mut population)?;
204
205            // Calculate crowding distance
206            self.nsga2_crowding_distance(&mut population)?;
207
208            // Calculate hypervolume for convergence tracking
209            let hypervolume = self.calculate_hypervolume(&population)?;
210            convergence_history.push(hypervolume);
211
212            // Generate offspring population
213            let mut offspring = self.nsga2_generate_offspring(&population, &mut rng)?;
214
215            // Evaluate offspring
216            self.evaluate_population(&mut offspring, X, y)?;
217
218            // Combine parent and offspring populations
219            population.extend(offspring);
220
221            // Environmental selection
222            population = self.nsga2_environmental_selection(population)?;
223
224            if generation % 50 == 0 {
225                println!(
226                    "Generation {}: Hypervolume = {:.6}",
227                    generation, hypervolume
228                );
229            }
230        }
231
232        // Final evaluation
233        self.nsga2_non_dominated_sort(&mut population)?;
234        let pareto_solutions = self.extract_pareto_front(&population)?;
235        let best_solution = self.find_best_compromise(&pareto_solutions)?;
236
237        Ok(NSGA2Optimizer {
238            state: NSGA2OptimizerTrained {
239                pareto_solutions: pareto_solutions.clone(),
240                best_solution,
241                convergence_history,
242                final_population: population,
243                config: self.config.clone(),
244                n_objectives,
245            },
246            config: self.config,
247        })
248    }
249}
250
251impl NSGA2Optimizer<Untrained> {
252    /// Initialize population for NSGA-II
253    fn initialize_population<R: Rng>(
254        &self,
255        n_features: usize,
256        n_outputs: usize,
257        rng: &mut R,
258    ) -> SklResult<Vec<ParetoSolution>> {
259        let mut population = Vec::with_capacity(self.config.population_size);
260        let param_size = n_features * n_outputs + n_outputs; // weights + bias
261
262        for _ in 0..self.config.population_size {
263            let parameters = Array1::from_shape_fn(param_size, |_| rng.random_range(-1.0..1.0));
264            let solution = ParetoSolution {
265                parameters,
266                objectives: Array1::zeros(2), // Will be filled during evaluation
267                rank: 0,
268                crowding_distance: 0.0,
269            };
270            population.push(solution);
271        }
272
273        Ok(population)
274    }
275
276    /// NSGA-II Non-dominated sorting
277    fn nsga2_non_dominated_sort(&self, population: &mut [ParetoSolution]) -> SklResult<()> {
278        let n = population.len();
279        let mut domination_counts = vec![0; n];
280        let mut dominated_solutions = vec![Vec::new(); n];
281        let mut fronts: Vec<Vec<usize>> = Vec::new();
282
283        // Calculate domination relationships
284        for i in 0..n {
285            for j in 0..n {
286                if i != j {
287                    if self.nsga2_dominates(&population[i], &population[j]) {
288                        dominated_solutions[i].push(j);
289                    } else if self.nsga2_dominates(&population[j], &population[i]) {
290                        domination_counts[i] += 1;
291                    }
292                }
293            }
294        }
295
296        // First front
297        let mut current_front: Vec<usize> = (0..n).filter(|&i| domination_counts[i] == 0).collect();
298        let mut rank = 0;
299
300        while !current_front.is_empty() {
301            // Assign rank to current front
302            for &i in &current_front {
303                population[i].rank = rank;
304            }
305
306            fronts.push(current_front.clone());
307
308            // Generate next front
309            let mut next_front = Vec::new();
310            for &i in &current_front {
311                for &j in &dominated_solutions[i] {
312                    domination_counts[j] -= 1;
313                    if domination_counts[j] == 0 {
314                        next_front.push(j);
315                    }
316                }
317            }
318
319            current_front = next_front;
320            rank += 1;
321        }
322
323        Ok(())
324    }
325
326    /// Check if solution a dominates solution b for NSGA-II
327    pub fn nsga2_dominates(&self, a: &ParetoSolution, b: &ParetoSolution) -> bool {
328        let mut at_least_one_better = false;
329
330        for i in 0..a.objectives.len() {
331            if a.objectives[i] > b.objectives[i] {
332                return false; // b is better in at least one objective
333            }
334            if a.objectives[i] < b.objectives[i] {
335                at_least_one_better = true;
336            }
337        }
338
339        at_least_one_better
340    }
341
342    /// Calculate crowding distance for NSGA-II
343    fn nsga2_crowding_distance(&self, population: &mut [ParetoSolution]) -> SklResult<()> {
344        let n = population.len();
345        if n == 0 {
346            return Ok(());
347        }
348
349        // Initialize crowding distances
350        for solution in population.iter_mut() {
351            solution.crowding_distance = 0.0;
352        }
353
354        let n_objectives = population[0].objectives.len();
355
356        for obj_idx in 0..n_objectives {
357            // Sort by objective value
358            let mut indices: Vec<usize> = (0..n).collect();
359            indices.sort_by(|&a, &b| {
360                population[a].objectives[obj_idx]
361                    .partial_cmp(&population[b].objectives[obj_idx])
362                    .unwrap_or(std::cmp::Ordering::Equal)
363            });
364
365            // Set boundary points to infinite distance
366            population[indices[0]].crowding_distance = Float::INFINITY;
367            population[indices[n - 1]].crowding_distance = Float::INFINITY;
368
369            // Calculate distances for intermediate points
370            let obj_range = population[indices[n - 1]].objectives[obj_idx]
371                - population[indices[0]].objectives[obj_idx];
372
373            if obj_range > 0.0 {
374                for i in 1..(n - 1) {
375                    let distance = (population[indices[i + 1]].objectives[obj_idx]
376                        - population[indices[i - 1]].objectives[obj_idx])
377                        / obj_range;
378                    population[indices[i]].crowding_distance += distance;
379                }
380            }
381        }
382
383        Ok(())
384    }
385
386    /// Generate offspring population using NSGA-II
387    fn nsga2_generate_offspring<R: Rng>(
388        &self,
389        population: &[ParetoSolution],
390        rng: &mut R,
391    ) -> SklResult<Vec<ParetoSolution>> {
392        let mut offspring = Vec::new();
393
394        for _ in 0..self.config.population_size {
395            // Binary tournament selection
396            let parent1 = self.nsga2_tournament_selection(population, rng)?;
397            let parent2 = self.nsga2_tournament_selection(population, rng)?;
398
399            // Crossover
400            let mut child = match self.config.algorithm {
401                NSGA2Algorithm::SBX => self.simulated_binary_crossover(&parent1, &parent2, rng)?,
402                _ => self.uniform_crossover(&parent1, &parent2, rng)?,
403            };
404
405            // Mutation
406            match self.config.algorithm {
407                NSGA2Algorithm::SBX => self.polynomial_mutation(&mut child, rng)?,
408                _ => self.gaussian_mutation(&mut child, rng)?,
409            }
410
411            offspring.push(child);
412        }
413
414        Ok(offspring)
415    }
416
417    /// Binary tournament selection for NSGA-II
418    fn nsga2_tournament_selection<R: Rng>(
419        &self,
420        population: &[ParetoSolution],
421        rng: &mut R,
422    ) -> SklResult<ParetoSolution> {
423        let idx1 = rng.random_range(0..population.len());
424        let idx2 = rng.random_range(0..population.len());
425
426        let solution1 = &population[idx1];
427        let solution2 = &population[idx2];
428
429        // Compare by rank first, then by crowding distance
430        if solution1.rank < solution2.rank {
431            Ok(solution1.clone())
432        } else if solution1.rank > solution2.rank {
433            Ok(solution2.clone())
434        } else {
435            // Same rank, compare by crowding distance (higher is better)
436            if solution1.crowding_distance > solution2.crowding_distance {
437                Ok(solution1.clone())
438            } else {
439                Ok(solution2.clone())
440            }
441        }
442    }
443
444    /// Simulated Binary Crossover (SBX)
445    fn simulated_binary_crossover<R: Rng>(
446        &self,
447        parent1: &ParetoSolution,
448        parent2: &ParetoSolution,
449        rng: &mut R,
450    ) -> SklResult<ParetoSolution> {
451        let mut child_params = parent1.parameters.clone();
452
453        if rng.random::<Float>() <= self.config.crossover_prob {
454            for i in 0..child_params.len() {
455                let p1 = parent1.parameters[i];
456                let p2 = parent2.parameters[i];
457
458                if rng.random::<Float>() <= 0.5 {
459                    let u = rng.random::<Float>();
460                    let beta = if u <= 0.5 {
461                        (2.0 * u).powf(1.0 / (self.config.eta_c + 1.0))
462                    } else {
463                        (1.0 / (2.0 * (1.0 - u))).powf(1.0 / (self.config.eta_c + 1.0))
464                    };
465
466                    let child_val = 0.5 * ((1.0 + beta) * p1 + (1.0 - beta) * p2);
467                    child_params[i] = child_val.clamp(-2.0, 2.0);
468                }
469            }
470        }
471
472        Ok(ParetoSolution {
473            parameters: child_params,
474            objectives: Array1::zeros(parent1.objectives.len()),
475            rank: 0,
476            crowding_distance: 0.0,
477        })
478    }
479
480    /// Polynomial mutation
481    fn polynomial_mutation<R: Rng>(
482        &self,
483        solution: &mut ParetoSolution,
484        rng: &mut R,
485    ) -> SklResult<()> {
486        for i in 0..solution.parameters.len() {
487            if rng.random::<Float>() <= self.config.mutation_prob {
488                let u = rng.random::<Float>();
489                let delta = if u < 0.5 {
490                    (2.0 * u).powf(1.0 / (self.config.eta_m + 1.0)) - 1.0
491                } else {
492                    1.0 - (2.0 * (1.0 - u)).powf(1.0 / (self.config.eta_m + 1.0))
493                };
494
495                solution.parameters[i] += delta * 0.1;
496                solution.parameters[i] = solution.parameters[i].clamp(-2.0, 2.0);
497            }
498        }
499        Ok(())
500    }
501
502    /// Environmental selection for NSGA-II
503    fn nsga2_environmental_selection(
504        &self,
505        mut population: Vec<ParetoSolution>,
506    ) -> SklResult<Vec<ParetoSolution>> {
507        // Sort by rank and crowding distance
508        self.nsga2_non_dominated_sort(&mut population)?;
509        self.nsga2_crowding_distance(&mut population)?;
510
511        // Sort population by rank, then by crowding distance
512        population.sort_by(|a, b| {
513            match a.rank.cmp(&b.rank) {
514                std::cmp::Ordering::Equal => {
515                    // Higher crowding distance is better
516                    b.crowding_distance
517                        .partial_cmp(&a.crowding_distance)
518                        .unwrap_or(std::cmp::Ordering::Equal)
519                }
520                other => other,
521            }
522        });
523
524        // Take the best individuals up to population size
525        population.truncate(self.config.population_size);
526        Ok(population)
527    }
528
529    /// Extract Pareto front (rank 0 solutions)
530    fn extract_pareto_front(
531        &self,
532        population: &[ParetoSolution],
533    ) -> SklResult<Vec<ParetoSolution>> {
534        Ok(population
535            .iter()
536            .filter(|sol| sol.rank == 0)
537            .cloned()
538            .collect())
539    }
540
541    /// Evaluate population fitness
542    fn evaluate_population(
543        &self,
544        population: &mut [ParetoSolution],
545        X: &ArrayView2<Float>,
546        y: &ArrayView2<Float>,
547    ) -> SklResult<()> {
548        let n_features = X.ncols();
549        let n_outputs = y.ncols();
550
551        for solution in population.iter_mut() {
552            // Extract weights and bias from parameters
553            let weights = solution
554                .parameters
555                .slice(s![..n_features * n_outputs])
556                .to_owned()
557                .into_shape_with_order((
558                    (n_features, n_outputs),
559                    scirs2_core::ndarray::Order::RowMajor,
560                ))
561                .map_err(|e| SklearsError::InvalidInput(format!("Shape error: {}", e)))?;
562
563            let bias = solution
564                .parameters
565                .slice(s![n_features * n_outputs..])
566                .to_owned();
567
568            // Make predictions
569            let mut predictions = X.dot(&weights);
570            for mut row in predictions.rows_mut() {
571                row += &bias;
572            }
573
574            // Calculate objectives
575            let mse = self.calculate_mse(&predictions.view(), y)?;
576            let complexity = self.calculate_complexity(&weights)?;
577
578            solution.objectives = array![mse, complexity];
579        }
580
581        Ok(())
582    }
583
584    /// Calculate Mean Squared Error
585    fn calculate_mse(
586        &self,
587        predictions: &ArrayView2<Float>,
588        y: &ArrayView2<Float>,
589    ) -> SklResult<Float> {
590        let diff = predictions - y;
591        let squared_diff = &diff * &diff;
592        Ok(squared_diff.sum() / (predictions.nrows() * predictions.ncols()) as Float)
593    }
594
595    /// Calculate model complexity (based on parameter magnitudes)
596    fn calculate_complexity(&self, weights: &Array2<Float>) -> SklResult<Float> {
597        Ok(weights.mapv(|x| x.abs()).sum())
598    }
599
600    /// Uniform crossover operation
601    fn uniform_crossover<R: Rng>(
602        &self,
603        parent1: &ParetoSolution,
604        parent2: &ParetoSolution,
605        rng: &mut R,
606    ) -> SklResult<ParetoSolution> {
607        let mut child_params = parent1.parameters.clone();
608
609        if rng.random::<Float>() <= self.config.crossover_prob {
610            for i in 0..child_params.len() {
611                if rng.random::<Float>() <= 0.5 {
612                    child_params[i] = parent2.parameters[i];
613                }
614            }
615        }
616
617        Ok(ParetoSolution {
618            parameters: child_params,
619            objectives: Array1::zeros(parent1.objectives.len()),
620            rank: 0,
621            crowding_distance: 0.0,
622        })
623    }
624
625    /// Gaussian mutation operation
626    fn gaussian_mutation<R: Rng>(
627        &self,
628        solution: &mut ParetoSolution,
629        rng: &mut R,
630    ) -> SklResult<()> {
631        for i in 0..solution.parameters.len() {
632            if rng.random::<Float>() <= self.config.mutation_prob {
633                let normal = RandNormal::new(0.0, 0.1).map_err(|e| {
634                    SklearsError::InvalidInput(format!(
635                        "Failed to create normal distribution: {}",
636                        e
637                    ))
638                })?;
639                let mutation = rng.sample(normal);
640                solution.parameters[i] += mutation;
641                solution.parameters[i] = solution.parameters[i].clamp(-2.0, 2.0);
642            }
643        }
644        Ok(())
645    }
646
647    /// Calculate hypervolume indicator
648    fn calculate_hypervolume(&self, population: &[ParetoSolution]) -> SklResult<Float> {
649        // Extract non-dominated solutions (Pareto front)
650        let pareto_front: Vec<&ParetoSolution> =
651            population.iter().filter(|sol| sol.rank == 0).collect();
652
653        if pareto_front.is_empty() {
654            return Ok(0.0);
655        }
656
657        // Simple hypervolume calculation using reference point (1.0, 1.0)
658        let reference_point = array![1.0, 1.0];
659        let mut hypervolume = 0.0;
660
661        for solution in &pareto_front {
662            let mut volume = 1.0;
663            for i in 0..solution.objectives.len() {
664                let contribution = (reference_point[i] - solution.objectives[i]).max(0.0);
665                volume *= contribution;
666            }
667            hypervolume += volume;
668        }
669
670        Ok(hypervolume / pareto_front.len() as Float)
671    }
672
673    /// Find best compromise solution from Pareto solutions
674    fn find_best_compromise(
675        &self,
676        pareto_solutions: &[ParetoSolution],
677    ) -> SklResult<ParetoSolution> {
678        if pareto_solutions.is_empty() {
679            return Err(SklearsError::InvalidInput(
680                "No Pareto solutions available".to_string(),
681            ));
682        }
683
684        let mut best_solution = pareto_solutions[0].clone();
685        let mut best_distance = Float::INFINITY;
686
687        // Find solution closest to ideal point (0, 0)
688        for solution in pareto_solutions {
689            let distance = solution.objectives.mapv(|x| x * x).sum().sqrt();
690            if distance < best_distance {
691                best_distance = distance;
692                best_solution = solution.clone();
693            }
694        }
695
696        Ok(best_solution)
697    }
698}
699
700impl Predict<ArrayView2<'_, Float>, Array2<Float>> for NSGA2Optimizer<NSGA2OptimizerTrained> {
701    #[allow(non_snake_case)] // standard ML notation
702    fn predict(&self, X: &ArrayView2<'_, Float>) -> SklResult<Array2<Float>> {
703        let _n_samples = X.nrows();
704        let n_features = X.ncols();
705        let n_outputs = self.state.best_solution.parameters.len() / (n_features + 1);
706
707        // Extract weights and bias from best solution
708        let weights = self
709            .state
710            .best_solution
711            .parameters
712            .slice(s![..n_features * n_outputs])
713            .to_owned()
714            .into_shape_with_order((
715                (n_features, n_outputs),
716                scirs2_core::ndarray::Order::RowMajor,
717            ))
718            .map_err(|e| SklearsError::InvalidInput(format!("Shape error: {}", e)))?;
719
720        let bias = self
721            .state
722            .best_solution
723            .parameters
724            .slice(s![n_features * n_outputs..])
725            .to_owned();
726
727        // Make predictions: y = X * W + b
728        let mut predictions = X.dot(&weights);
729        for mut row in predictions.rows_mut() {
730            row += &bias;
731        }
732
733        Ok(predictions)
734    }
735}
736
737impl NSGA2Optimizer<NSGA2OptimizerTrained> {
738    /// Get the Pareto-optimal solutions
739    pub fn pareto_solutions(&self) -> &[ParetoSolution] {
740        &self.state.pareto_solutions
741    }
742
743    /// Get the best compromise solution
744    pub fn best_solution(&self) -> &ParetoSolution {
745        &self.state.best_solution
746    }
747
748    /// Get the convergence history
749    pub fn convergence_history(&self) -> &[Float] {
750        &self.state.convergence_history
751    }
752
753    /// Get the final population
754    pub fn final_population(&self) -> &[ParetoSolution] {
755        &self.state.final_population
756    }
757}