//! Multi-objective optimization with Pareto frontier analysis //! //! This module provides comprehensive multi-objective optimization including: //! - Pareto frontier computation and maintenance //! - Multi-objective optimization algorithms (NSGA-II, MOEA/D) //! - Hypervolume and other quality indicators //! - Trade-off analysis and visualization //! - Interactive optimization with user preferences use crate::{AutoMLError, AutoMLResult}; use rand::prelude::*; use serde::{Deserialize, Serialize}; use std::cmp::Ordering; use std::collections::HashMap; /// Multi-objective solution with multiple objective values #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MultiObjectiveSolution { pub id: String, pub parameters: HashMap, pub objectives: Vec, pub objective_names: Vec, pub constraints: Vec, pub metadata: HashMap, pub dominance_rank: Option, pub crowding_distance: Option, } impl MultiObjectiveSolution { /// Create new multi-objective solution pub fn new( id: String, parameters: HashMap, objectives: Vec, objective_names: Vec, ) -> Self { Self { id, parameters, objectives, objective_names, constraints: Vec::new(), metadata: HashMap::new(), dominance_rank: None, crowding_distance: None, } } /// Check if this solution dominates another solution pub fn dominates(&self, other: &Self) -> bool { if self.objectives.len() != other.objectives.len() { return false; } let mut at_least_one_better = false; for i in 0..self.objectives.len() { if self.objectives[i] < other.objectives[i] { return false; // This solution is worse in at least one objective } else if self.objectives[i] > other.objectives[i] { at_least_one_better = true; } } at_least_one_better } /// Check if this solution is feasible (satisfies all constraints) pub fn is_feasible(&self) -> bool { self.constraints.iter().all(|&c| c <= 0.0) // Constraints are <= 0 } /// Get objective value by name pub fn get_objective(&self, name: &str) -> Option { self.objective_names .iter() .position(|n| n == name) .map(|i| self.objectives[i]) } /// Calculate distance to reference point pub fn distance_to_reference(&self, reference: &[f64]) -> f64 { if reference.len() != self.objectives.len() { return f64::INFINITY; } self.objectives .iter() .zip(reference) .map(|(obj, &ref_val)| (obj - ref_val).powi(2)) .sum::() .sqrt() } } /// Pareto frontier with efficient operations #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ParetoFrontier { solutions: Vec, objective_names: Vec, n_objectives: usize, reference_point: Option>, ideal_point: Option>, nadir_point: Option>, } impl ParetoFrontier { /// Create new empty Pareto frontier pub fn new(objective_names: Vec) -> AutoMLResult { let n_objectives = objective_names.len(); if n_objectives == 0 { return Err(AutoMLError::ConfigurationError( "Must have at least one objective".to_string(), )); } Ok(Self { solutions: Vec::new(), objective_names, n_objectives, reference_point: None, ideal_point: None, nadir_point: None, }) } /// Add solution to frontier, updating Pareto set pub fn add_solution(&mut self, mut solution: MultiObjectiveSolution) -> AutoMLResult { if solution.objectives.len() != self.n_objectives { return Err(AutoMLError::ValidationError( "Solution has wrong number of objectives".to_string(), )); } solution.objective_names = self.objective_names.clone(); // Check if solution is dominated by existing solutions for existing in &self.solutions { if existing.dominates(&solution) { return Ok(false); // Solution is dominated, not added } } // Remove solutions dominated by the new solution self.solutions .retain(|existing| !solution.dominates(existing)); // Add the new solution self.solutions.push(solution); // Update ideal and nadir points self.update_reference_points(); Ok(true) } /// Get all solutions on the frontier pub fn get_solutions(&self) -> &[MultiObjectiveSolution] { &self.solutions } /// Get number of solutions on frontier pub fn size(&self) -> usize { self.solutions.len() } /// Check if frontier is empty pub fn is_empty(&self) -> bool { self.solutions.is_empty() } /// Get best solution for specific objective pub fn get_best_for_objective(&self, objective_idx: usize) -> Option<&MultiObjectiveSolution> { if objective_idx >= self.n_objectives { return None; } self.solutions.iter().max_by(|a, b| { a.objectives[objective_idx] .partial_cmp(&b.objectives[objective_idx]) .unwrap_or(Ordering::Equal) }) } /// Get solution closest to reference point pub fn get_closest_to_reference(&self, reference: &[f64]) -> Option<&MultiObjectiveSolution> { if reference.len() != self.n_objectives { return None; } self.solutions.iter().min_by(|a, b| { a.distance_to_reference(reference) .partial_cmp(&b.distance_to_reference(reference)) .unwrap_or(Ordering::Equal) }) } /// Calculate hypervolume indicator pub fn calculate_hypervolume(&self, reference_point: Option<&[f64]>) -> AutoMLResult { if self.solutions.is_empty() { return Ok(0.0); } let ref_point = reference_point .map(<[f64]>::to_vec) .or_else(|| self.reference_point.clone()) .unwrap_or_else(|| vec![0.0; self.n_objectives]); if ref_point.len() != self.n_objectives { return Err(AutoMLError::ValidationError( "Reference point has wrong dimensions".to_string(), )); } // Simplified hypervolume calculation for 2D case if self.n_objectives == 2 { self.calculate_hypervolume_2d(&ref_point) } else { // For higher dimensions, use Monte Carlo approximation self.calculate_hypervolume_monte_carlo(&ref_point, 100000) } } /// Calculate 2D hypervolume exactly fn calculate_hypervolume_2d(&self, reference: &[f64]) -> AutoMLResult { if self.solutions.is_empty() { return Ok(0.0); } // Sort solutions by first objective (descending) let mut sorted_solutions: Vec<_> = self.solutions.iter().collect(); sorted_solutions.sort_by(|a, b| b.objectives[0].total_cmp(&a.objectives[0])); let mut hypervolume = 0.0; let mut prev_y = reference[1]; for solution in sorted_solutions { if solution.objectives[1] > prev_y { let width = solution.objectives[0] - reference[0]; let height = solution.objectives[1] - prev_y; if width > 0.0 && height > 0.0 { hypervolume += width * height; prev_y = solution.objectives[1]; } } } Ok(hypervolume) } /// Calculate hypervolume using Monte Carlo sampling fn calculate_hypervolume_monte_carlo( &self, reference: &[f64], n_samples: usize, ) -> AutoMLResult { if self.solutions.is_empty() { return Ok(0.0); } // Find bounds for sampling let mut max_objectives = reference.to_vec(); for solution in &self.solutions { for (i, &obj_val) in solution.objectives.iter().enumerate() { max_objectives[i] = max_objectives[i].max(obj_val); } } let mut rng = rand::thread_rng(); let mut dominated_count = 0; for _ in 0..n_samples { // Generate random point in objective space let mut random_point = Vec::with_capacity(self.n_objectives); for i in 0..self.n_objectives { random_point.push(rng.gen_range(reference[i]..=max_objectives[i])); } // Check if point is dominated by any solution for solution in &self.solutions { let mut dominates = true; for j in 0..self.n_objectives { if solution.objectives[j] < random_point[j] { dominates = false; break; } } if dominates { dominated_count += 1; break; } } } // Calculate volume let mut total_volume = 1.0; for i in 0..self.n_objectives { total_volume *= max_objectives[i] - reference[i]; } Ok(total_volume * (dominated_count as f64) / (n_samples as f64)) } /// Calculate spacing metric (diversity measure) pub fn calculate_spacing(&self) -> f64 { if self.solutions.len() < 2 { return 0.0; } let mut distances = Vec::new(); // Calculate minimum distance to other solutions for each solution for (i, sol1) in self.solutions.iter().enumerate() { let mut min_dist = f64::INFINITY; for (j, sol2) in self.solutions.iter().enumerate() { if i != j { let dist = self.euclidean_distance(&sol1.objectives, &sol2.objectives); min_dist = min_dist.min(dist); } } distances.push(min_dist); } // Calculate mean distance let mean_dist: f64 = distances.iter().sum::() / distances.len() as f64; // Calculate spacing (standard deviation of distances) let variance: f64 = distances .iter() .map(|&d| (d - mean_dist).powi(2)) .sum::() / distances.len() as f64; variance.sqrt() } /// Calculate spread metric (extent of frontier) pub fn calculate_spread(&self) -> f64 { if self.solutions.is_empty() { return 0.0; } let mut total_spread = 0.0; for obj_idx in 0..self.n_objectives { let obj_values: Vec = self .solutions .iter() .map(|s| s.objectives[obj_idx]) .collect(); let min_val = obj_values.iter().fold(f64::INFINITY, |a, &b| a.min(b)); let max_val = obj_values.iter().fold(f64::NEG_INFINITY, |a, &b| a.max(b)); total_spread += (max_val - min_val).powi(2); } total_spread.sqrt() } /// Merge with another Pareto frontier pub fn merge(&mut self, other: &Self) -> AutoMLResult<()> { if other.n_objectives != self.n_objectives { return Err(AutoMLError::ValidationError( "Frontiers have different number of objectives".to_string(), )); } for solution in &other.solutions { self.add_solution(solution.clone())?; } Ok(()) } /// Filter solutions based on constraints pub fn filter_feasible(&mut self) { self.solutions.retain(MultiObjectiveSolution::is_feasible); self.update_reference_points(); } /// Get knee point (best compromise solution) pub fn get_knee_point(&self) -> Option<&MultiObjectiveSolution> { if self.solutions.len() < 3 { return self.solutions.first(); } let ideal = self.ideal_point.as_ref()?; let nadir = self.nadir_point.as_ref()?; // Normalize objectives let mut normalized_solutions = Vec::new(); for solution in &self.solutions { let mut normalized_obj = Vec::new(); for i in 0..self.n_objectives { let norm_val = if nadir[i] != ideal[i] { (solution.objectives[i] - ideal[i]) / (nadir[i] - ideal[i]) } else { 0.0 }; normalized_obj.push(norm_val); } normalized_solutions.push(normalized_obj); } // Find solution with maximum distance from line connecting extreme solutions let mut max_distance = 0.0; let mut knee_idx = 0; for (i, norm_obj) in normalized_solutions.iter().enumerate() { // Calculate distance to the diagonal line in normalized space let sum: f64 = norm_obj.iter().sum(); let mean = sum / self.n_objectives as f64; let variance: f64 = norm_obj.iter().map(|&x| (x - mean).powi(2)).sum(); let distance = variance.sqrt(); if distance > max_distance { max_distance = distance; knee_idx = i; } } self.solutions.get(knee_idx) } /// Update ideal and nadir points fn update_reference_points(&mut self) { if self.solutions.is_empty() { self.ideal_point = None; self.nadir_point = None; return; } let mut ideal = vec![f64::INFINITY; self.n_objectives]; let mut nadir = vec![f64::NEG_INFINITY; self.n_objectives]; for solution in &self.solutions { for i in 0..self.n_objectives { ideal[i] = ideal[i].min(solution.objectives[i]); nadir[i] = nadir[i].max(solution.objectives[i]); } } self.ideal_point = Some(ideal); self.nadir_point = Some(nadir); } /// Calculate Euclidean distance between two points fn euclidean_distance(&self, point1: &[f64], point2: &[f64]) -> f64 { point1 .iter() .zip(point2) .map(|(a, b)| (a - b).powi(2)) .sum::() .sqrt() } /// Get ideal point (minimum values for each objective) pub fn get_ideal_point(&self) -> Option<&Vec> { self.ideal_point.as_ref() } /// Get nadir point (maximum values for each objective) pub fn get_nadir_point(&self) -> Option<&Vec> { self.nadir_point.as_ref() } /// Set reference point for hypervolume calculation pub fn set_reference_point(&mut self, reference: Vec) -> AutoMLResult<()> { if reference.len() != self.n_objectives { return Err(AutoMLError::ValidationError( "Reference point has wrong dimensions".to_string(), )); } self.reference_point = Some(reference); Ok(()) } /// Export frontier data for visualization pub fn export_data(&self) -> HashMap> { let mut data = HashMap::new(); for (i, obj_name) in self.objective_names.iter().enumerate() { let values: Vec = self.solutions.iter().map(|s| s.objectives[i]).collect(); data.insert(obj_name.clone(), values); } data } } /// Multi-objective optimization algorithm implementations #[derive(Debug, Clone, Serialize, Deserialize)] pub enum MOOptimizationAlgorithm { /// NSGA-II (Non-dominated Sorting Genetic Algorithm II) NSGA2 { population_size: usize, n_generations: usize, crossover_rate: f64, mutation_rate: f64, }, /// MOEA/D (Multi-Objective Evolutionary Algorithm based on Decomposition) MOEAD { population_size: usize, n_generations: usize, n_neighbors: usize, weight_vectors: Vec>, }, /// SPEA2 (Strength Pareto Evolutionary Algorithm 2) SPEA2 { population_size: usize, archive_size: usize, n_generations: usize, }, } /// Multi-objective optimization result #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MOOptimizationResult { pub pareto_frontier: ParetoFrontier, pub all_evaluated_solutions: Vec, pub hypervolume_history: Vec, pub spacing_history: Vec, pub n_evaluations: usize, pub optimization_time_seconds: f64, } /// Main multi-objective optimizer pub struct MultiObjectiveOptimizer { algorithm: MOOptimizationAlgorithm, objective_names: Vec, constraints: Vec, random_state: Option, } impl MultiObjectiveOptimizer { /// Create new multi-objective optimizer pub fn new( algorithm: MOOptimizationAlgorithm, objective_names: Vec, ) -> AutoMLResult { if objective_names.is_empty() { return Err(AutoMLError::ConfigurationError( "Must specify at least one objective".to_string(), )); } Ok(Self { algorithm, objective_names, constraints: Vec::new(), random_state: Some(42), }) } /// Add constraint to optimization pub fn add_constraint(&mut self, constraint_name: String) { self.constraints.push(constraint_name); } /// Run multi-objective optimization pub async fn optimize(&self, mut evaluation_fn: F) -> AutoMLResult where F: FnMut(&HashMap) -> AutoMLResult<(Vec, Vec)>, { let _start_time = std::time::Instant::now(); let mut rng = StdRng::seed_from_u64(self.random_state.unwrap_or(42)); match &self.algorithm { MOOptimizationAlgorithm::NSGA2 { population_size, n_generations, crossover_rate, mutation_rate, } => { self.run_nsga2( &mut evaluation_fn, *population_size, *n_generations, *crossover_rate, *mutation_rate, &mut rng, ) .await } _ => { // Default implementation (simplified NSGA-II) self.run_nsga2(&mut evaluation_fn, 50, 100, 0.9, 0.1, &mut rng) .await } } } /// Run NSGA-II algorithm async fn run_nsga2( &self, evaluation_fn: &mut F, population_size: usize, n_generations: usize, _crossover_rate: f64, _mutation_rate: f64, rng: &mut StdRng, ) -> AutoMLResult where F: FnMut(&HashMap) -> AutoMLResult<(Vec, Vec)>, { let start_time = std::time::Instant::now(); let mut all_solutions = Vec::new(); let mut hypervolume_history = Vec::new(); let mut spacing_history = Vec::new(); let mut n_evaluations = 0; // Initialize population let mut population = Vec::new(); for i in 0..population_size { // Generate random parameters (simplified) let mut parameters = HashMap::new(); parameters.insert("param1".to_string(), rng.gen_range(0.0..1.0).to_string()); parameters.insert("param2".to_string(), rng.gen_range(0.0..1.0).to_string()); // Evaluate solution let (objectives, constraints) = evaluation_fn(¶meters)?; n_evaluations += 1; let solution = MultiObjectiveSolution { id: format!("gen0_ind{i}"), parameters, objectives, objective_names: self.objective_names.clone(), constraints, metadata: HashMap::new(), dominance_rank: None, crowding_distance: None, }; population.push(solution.clone()); all_solutions.push(solution); } // Evolution loop for generation in 0..n_generations { // Non-dominated sorting let fronts = self.non_dominated_sort(&population); // Calculate crowding distance for each front let mut ranked_population = Vec::new(); for (rank, front) in fronts.iter().enumerate() { let mut front_with_distance = front.clone(); self.calculate_crowding_distance(&mut front_with_distance); for mut solution in front_with_distance { solution.dominance_rank = Some(rank); ranked_population.push(solution); } } // Create new population through selection, crossover, and mutation let mut new_population = Vec::new(); while new_population.len() < population_size { // Tournament selection let parent1 = self.tournament_selection(&ranked_population, rng); let _parent2 = self.tournament_selection(&ranked_population, rng); // Simple crossover and mutation (simplified) let mut child = parent1.clone(); child.id = format!("gen{}_ind{}", generation + 1, new_population.len()); // Mutate parameters slightly for value in child.parameters.values_mut() { if rng.r#gen::() < 0.1 { // 10% mutation rate let current_val: f64 = value.parse().unwrap_or(0.5); let mutated_val = (current_val + rng.gen_range(-0.1..0.1)).clamp(0.0, 1.0); *value = mutated_val.to_string(); } } // Evaluate child let (objectives, constraints) = evaluation_fn(&child.parameters)?; n_evaluations += 1; child.objectives = objectives; child.constraints = constraints; child.dominance_rank = None; child.crowding_distance = None; new_population.push(child.clone()); all_solutions.push(child); } population = new_population; // Calculate metrics for this generation let mut current_frontier = ParetoFrontier::new(self.objective_names.clone())?; for solution in &population { current_frontier.add_solution(solution.clone())?; } let hypervolume = current_frontier.calculate_hypervolume(None).unwrap_or(0.0); let spacing = current_frontier.calculate_spacing(); hypervolume_history.push(hypervolume); spacing_history.push(spacing); } // Extract final Pareto frontier let mut final_frontier = ParetoFrontier::new(self.objective_names.clone())?; for solution in &all_solutions { final_frontier.add_solution(solution.clone())?; } let optimization_time = start_time.elapsed().as_secs_f64(); Ok(MOOptimizationResult { pareto_frontier: final_frontier, all_evaluated_solutions: all_solutions, hypervolume_history, spacing_history, n_evaluations, optimization_time_seconds: optimization_time, }) } /// Non-dominated sorting for NSGA-II fn non_dominated_sort( &self, population: &[MultiObjectiveSolution], ) -> Vec> { let mut fronts = Vec::new(); let mut domination_count = vec![0; population.len()]; let mut dominated_solutions = vec![Vec::new(); population.len()]; // Calculate domination relationships for i in 0..population.len() { for j in 0..population.len() { if i != j { if population[i].dominates(&population[j]) { dominated_solutions[i].push(j); } else if population[j].dominates(&population[i]) { domination_count[i] += 1; } } } } // Find first front (non-dominated solutions) let mut current_front = Vec::new(); for i in 0..population.len() { if domination_count[i] == 0 { current_front.push(population[i].clone()); } } let mut front_index = 0; while !current_front.is_empty() { fronts.push(current_front.clone()); let mut next_front = Vec::new(); for sol_idx in 0..population.len() { if domination_count[sol_idx] == front_index + 1 { // Check if this solution should be in the next front let mut dominated_by_current_front = false; for front_sol in ¤t_front { if front_sol.dominates(&population[sol_idx]) { dominated_by_current_front = true; break; } } if !dominated_by_current_front { next_front.push(population[sol_idx].clone()); } } } current_front = next_front; front_index += 1; if front_index > population.len() { break; // Safety check } } fronts } /// Calculate crowding distance for solutions in a front fn calculate_crowding_distance(&self, front: &mut [MultiObjectiveSolution]) { if front.len() <= 2 { for solution in front.iter_mut() { solution.crowding_distance = Some(f64::INFINITY); } return; } // Initialize distances to 0 for solution in front.iter_mut() { solution.crowding_distance = Some(0.0); } // Calculate distance for each objective for obj_idx in 0..self.objective_names.len() { // Sort by objective value front.sort_by(|a, b| { a.objectives[obj_idx] .partial_cmp(&b.objectives[obj_idx]) .unwrap_or(Ordering::Equal) }); // Set boundary solutions to infinite distance front[0].crowding_distance = Some(f64::INFINITY); front[front.len() - 1].crowding_distance = Some(f64::INFINITY); let obj_range = front[front.len() - 1].objectives[obj_idx] - front[0].objectives[obj_idx]; if obj_range > 0.0 { for i in 1..front.len() - 1 { let distance_increment = (front[i + 1].objectives[obj_idx] - front[i - 1].objectives[obj_idx]) / obj_range; let current_distance = front[i].crowding_distance.unwrap_or(0.0); front[i].crowding_distance = Some(current_distance + distance_increment); } } } } /// Tournament selection for NSGA-II fn tournament_selection( &self, population: &[MultiObjectiveSolution], rng: &mut StdRng, ) -> MultiObjectiveSolution { let tournament_size = 2; let mut best = &population[rng.gen_range(0..population.len())]; for _ in 1..tournament_size { let candidate = &population[rng.gen_range(0..population.len())]; // Compare based on dominance rank and crowding distance if self.compare_solutions(candidate, best) { best = candidate; } } best.clone() } /// Compare two solutions for selection (rank first, then crowding distance) fn compare_solutions(&self, a: &MultiObjectiveSolution, b: &MultiObjectiveSolution) -> bool { let rank_a = a.dominance_rank.unwrap_or(usize::MAX); let rank_b = b.dominance_rank.unwrap_or(usize::MAX); if rank_a < rank_b { return true; } else if rank_a > rank_b { return false; } // Same rank, compare crowding distance (higher is better) let dist_a = a.crowding_distance.unwrap_or(0.0); let dist_b = b.crowding_distance.unwrap_or(0.0); dist_a > dist_b } }