Skip to main content

sklears_multioutput/optimization/
multi_objective_optimization.rs

1//! Multi-Objective Optimization for Multi-Output Learning
2//!
3//! This module provides advanced multi-objective optimization techniques for multi-output
4//! learning problems, where multiple conflicting objectives need to be optimized simultaneously.
5//! It implements genetic algorithm-based approaches to find Pareto-optimal solutions.
6//!
7//! ## Key Features
8//!
9//! - **Genetic Algorithm**: Population-based evolutionary optimization
10//! - **Pareto Optimization**: Find trade-off solutions between conflicting objectives
11//! - **Non-dominated Sorting**: Efficient ranking of solutions using NSGA-II principles
12//! - **Crowding Distance**: Maintain diversity in the Pareto front
13//! - **Multiple Objectives**: Support for accuracy, complexity, MSE, MAE, and custom objectives
14//! - **Tournament Selection**: Efficient parent selection for reproduction
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::{s, Array1, Array2, ArrayView2};
19use scirs2_core::random::thread_rng;
20use scirs2_core::random::RandNormal;
21use sklears_core::{
22    error::{Result as SklResult, SklearsError},
23    traits::{Estimator, Fit, Predict, Untrained},
24    types::Float,
25};
26
27/// Multi-Objective Optimization for multi-output learning
28#[derive(Debug, Clone)]
29pub struct MultiObjectiveOptimizer<S = Untrained> {
30    state: S,
31    config: MultiObjectiveConfig,
32}
33
34/// Configuration for Multi-Objective Optimization
35#[derive(Debug, Clone)]
36pub struct MultiObjectiveConfig {
37    /// Population size for genetic algorithm
38    pub population_size: usize,
39    /// Number of generations
40    pub generations: usize,
41    /// Mutation rate
42    pub mutation_rate: Float,
43    /// Crossover rate
44    pub crossover_rate: Float,
45    /// Selection pressure
46    pub selection_pressure: Float,
47    /// Objective functions
48    pub objectives: Vec<String>,
49    /// Random state for reproducibility
50    pub random_state: Option<u64>,
51}
52
53impl Default for MultiObjectiveConfig {
54    fn default() -> Self {
55        Self {
56            population_size: 100,
57            generations: 100,
58            mutation_rate: 0.1,
59            crossover_rate: 0.8,
60            selection_pressure: 2.0,
61            objectives: vec!["accuracy".to_string(), "complexity".to_string()],
62            random_state: None,
63        }
64    }
65}
66
67/// Pareto-optimal solution
68#[derive(Debug, Clone)]
69pub struct ParetoSolution {
70    /// Solution parameters
71    pub parameters: Array1<Float>,
72    /// Objective values
73    pub objectives: Array1<Float>,
74    /// Dominance rank
75    pub rank: usize,
76    /// Crowding distance
77    pub crowding_distance: Float,
78}
79
80/// Trained state for Multi-Objective Optimizer
81#[derive(Debug, Clone)]
82pub struct MultiObjectiveOptimizerTrained {
83    /// Pareto-optimal solutions
84    pub pareto_solutions: Vec<ParetoSolution>,
85    /// Best compromise solution
86    pub best_solution: ParetoSolution,
87    /// Convergence history
88    pub convergence_history: Vec<Float>,
89    /// Configuration used for optimization
90    pub config: MultiObjectiveConfig,
91    /// Number of outputs
92    pub n_outputs: usize,
93}
94
95impl MultiObjectiveOptimizer<Untrained> {
96    /// Create a new Multi-Objective Optimizer
97    pub fn new() -> Self {
98        Self {
99            state: Untrained,
100            config: MultiObjectiveConfig::default(),
101        }
102    }
103
104    /// Set the configuration
105    pub fn config(mut self, config: MultiObjectiveConfig) -> Self {
106        self.config = config;
107        self
108    }
109
110    /// Set the population size
111    pub fn population_size(mut self, population_size: usize) -> Self {
112        self.config.population_size = population_size;
113        self
114    }
115
116    /// Set the number of generations
117    pub fn generations(mut self, generations: usize) -> Self {
118        self.config.generations = generations;
119        self
120    }
121
122    /// Set the mutation rate
123    pub fn mutation_rate(mut self, mutation_rate: Float) -> Self {
124        self.config.mutation_rate = mutation_rate;
125        self
126    }
127
128    /// Set the crossover rate
129    pub fn crossover_rate(mut self, crossover_rate: Float) -> Self {
130        self.config.crossover_rate = crossover_rate;
131        self
132    }
133
134    /// Set the selection pressure
135    pub fn selection_pressure(mut self, selection_pressure: Float) -> Self {
136        self.config.selection_pressure = selection_pressure;
137        self
138    }
139
140    /// Set the objective functions
141    pub fn objectives(mut self, objectives: Vec<String>) -> Self {
142        self.config.objectives = objectives;
143        self
144    }
145
146    /// Set the random state
147    pub fn random_state(mut self, random_state: Option<u64>) -> Self {
148        self.config.random_state = random_state;
149        self
150    }
151}
152
153impl Default for MultiObjectiveOptimizer<Untrained> {
154    fn default() -> Self {
155        Self::new()
156    }
157}
158
159impl Estimator for MultiObjectiveOptimizer<Untrained> {
160    type Config = MultiObjectiveConfig;
161    type Error = SklearsError;
162    type Float = Float;
163
164    fn config(&self) -> &Self::Config {
165        &self.config
166    }
167}
168
169impl Fit<ArrayView2<'_, Float>, ArrayView2<'_, Float>> for MultiObjectiveOptimizer<Untrained> {
170    type Fitted = MultiObjectiveOptimizer<MultiObjectiveOptimizerTrained>;
171
172    fn fit(self, X: &ArrayView2<'_, Float>, y: &ArrayView2<'_, Float>) -> SklResult<Self::Fitted> {
173        let (n_samples, n_features) = X.dim();
174        let (y_samples, n_outputs) = y.dim();
175
176        if n_samples != y_samples {
177            return Err(SklearsError::InvalidInput(
178                "X and y must have the same number of samples".to_string(),
179            ));
180        }
181
182        let mut rng = thread_rng();
183
184        // Initialize population
185        let mut population = self.initialize_population(n_features, n_outputs, &mut rng)?;
186        let mut convergence_history = Vec::new();
187
188        for _generation in 0..self.config.generations {
189            // Evaluate objectives for all solutions
190            self.evaluate_population(&mut population, X, y)?;
191
192            // Non-dominated sorting
193            self.non_dominated_sort(&mut population)?;
194
195            // Calculate crowding distance
196            self.calculate_crowding_distance(&mut population)?;
197
198            // Selection, crossover, and mutation
199            population = self.evolve_population(population, &mut rng)?;
200
201            // Track convergence
202            let hypervolume = self.calculate_hypervolume(&population)?;
203            convergence_history.push(hypervolume);
204        }
205
206        // Final evaluation and sorting
207        self.evaluate_population(&mut population, X, y)?;
208        self.non_dominated_sort(&mut population)?;
209
210        // Extract Pareto-optimal solutions
211        let pareto_solutions: Vec<ParetoSolution> =
212            population.into_iter().filter(|sol| sol.rank == 0).collect();
213
214        // Find best compromise solution (closest to ideal point)
215        let best_solution = self.find_best_compromise(&pareto_solutions)?;
216
217        Ok(MultiObjectiveOptimizer {
218            state: MultiObjectiveOptimizerTrained {
219                pareto_solutions,
220                best_solution,
221                convergence_history,
222                config: self.config.clone(),
223                n_outputs,
224            },
225            config: self.config,
226        })
227    }
228}
229
230impl MultiObjectiveOptimizer<Untrained> {
231    /// Initialize population with random solutions
232    fn initialize_population(
233        &self,
234        n_features: usize,
235        n_outputs: usize,
236        rng: &mut scirs2_core::random::CoreRandom,
237    ) -> SklResult<Vec<ParetoSolution>> {
238        let mut population = Vec::new();
239
240        for _ in 0..self.config.population_size {
241            // Random parameters (weights and bias)
242            let param_size = n_features * n_outputs + n_outputs;
243            let normal_dist = RandNormal::new(0.0, 1.0).expect("operation should succeed");
244            let mut parameters = Array1::<Float>::zeros(param_size);
245            for i in 0..param_size {
246                parameters[i] = rng.sample(normal_dist);
247            }
248
249            let solution = ParetoSolution {
250                parameters,
251                objectives: Array1::<Float>::zeros(self.config.objectives.len()),
252                rank: 0,
253                crowding_distance: 0.0,
254            };
255
256            population.push(solution);
257        }
258
259        Ok(population)
260    }
261
262    /// Evaluate objectives for all solutions in the population
263    fn evaluate_population(
264        &self,
265        population: &mut [ParetoSolution],
266        X: &ArrayView2<'_, Float>,
267        y: &ArrayView2<'_, Float>,
268    ) -> SklResult<()> {
269        let (_n_samples, n_features) = X.dim();
270        let n_outputs = y.ncols();
271
272        for solution in population.iter_mut() {
273            // Extract weights and bias from parameters
274            let weights_size = n_features * n_outputs;
275            let weights = solution
276                .parameters
277                .slice(s![..weights_size])
278                .to_owned()
279                .into_shape_with_order((
280                    (n_features, n_outputs),
281                    scirs2_core::ndarray::Order::RowMajor,
282                ))
283                .expect("operation should succeed");
284            let bias = solution.parameters.slice(s![weights_size..]).to_owned();
285
286            // Make predictions
287            let predictions = X.dot(&weights) + &bias;
288
289            // Calculate objectives
290            let mut objectives = Array1::<Float>::zeros(self.config.objectives.len());
291
292            for (i, objective) in self.config.objectives.iter().enumerate() {
293                let objective_value = match objective.as_str() {
294                    "accuracy" => self.calculate_accuracy(&predictions, y)?,
295                    "complexity" => self.calculate_complexity(&weights, &bias)?,
296                    "mse" => self.calculate_mse(&predictions, y)?,
297                    "mae" => self.calculate_mae(&predictions, y)?,
298                    _ => {
299                        return Err(SklearsError::InvalidInput(format!(
300                            "Unknown objective: {}",
301                            objective
302                        )))
303                    }
304                };
305                objectives[i] = objective_value;
306            }
307
308            solution.objectives = objectives;
309        }
310
311        Ok(())
312    }
313
314    /// Calculate accuracy objective
315    fn calculate_accuracy(
316        &self,
317        predictions: &Array2<Float>,
318        y: &ArrayView2<'_, Float>,
319    ) -> SklResult<Float> {
320        let mse = predictions
321            .iter()
322            .zip(y.iter())
323            .map(|(pred, true_val)| (pred - true_val).powi(2))
324            .sum::<Float>()
325            / (predictions.len() as Float);
326        Ok(-mse) // Negative because we want to minimize MSE (maximize accuracy)
327    }
328
329    /// Calculate complexity objective
330    fn calculate_complexity(
331        &self,
332        weights: &Array2<Float>,
333        bias: &Array1<Float>,
334    ) -> SklResult<Float> {
335        let weight_complexity = weights.mapv(|x| x.abs()).sum();
336        let bias_complexity = bias.mapv(|x| x.abs()).sum();
337        Ok(weight_complexity + bias_complexity)
338    }
339
340    /// Calculate MSE objective
341    fn calculate_mse(
342        &self,
343        predictions: &Array2<Float>,
344        y: &ArrayView2<'_, Float>,
345    ) -> SklResult<Float> {
346        let mse = predictions
347            .iter()
348            .zip(y.iter())
349            .map(|(pred, true_val)| (pred - true_val).powi(2))
350            .sum::<Float>()
351            / (predictions.len() as Float);
352        Ok(mse)
353    }
354
355    /// Calculate MAE objective
356    fn calculate_mae(
357        &self,
358        predictions: &Array2<Float>,
359        y: &ArrayView2<'_, Float>,
360    ) -> SklResult<Float> {
361        let mae = predictions
362            .iter()
363            .zip(y.iter())
364            .map(|(pred, true_val)| (pred - true_val).abs())
365            .sum::<Float>()
366            / (predictions.len() as Float);
367        Ok(mae)
368    }
369
370    /// Non-dominated sorting
371    fn non_dominated_sort(&self, population: &mut [ParetoSolution]) -> SklResult<()> {
372        let n = population.len();
373        let mut domination_count = vec![0; n];
374        let mut dominated_solutions = vec![Vec::new(); n];
375
376        // Calculate domination relationships
377        for i in 0..n {
378            for j in 0..n {
379                if i != j {
380                    if self.dominates(&population[i], &population[j]) {
381                        dominated_solutions[i].push(j);
382                    } else if self.dominates(&population[j], &population[i]) {
383                        domination_count[i] += 1;
384                    }
385                }
386            }
387        }
388
389        // Assign ranks
390        let mut current_rank = 0;
391        let mut current_front: Vec<usize> = (0..n).filter(|&i| domination_count[i] == 0).collect();
392
393        while !current_front.is_empty() {
394            let mut next_front = Vec::new();
395
396            for &i in &current_front {
397                population[i].rank = current_rank;
398
399                for &j in &dominated_solutions[i] {
400                    domination_count[j] -= 1;
401                    if domination_count[j] == 0 {
402                        next_front.push(j);
403                    }
404                }
405            }
406
407            current_front = next_front;
408            current_rank += 1;
409        }
410
411        Ok(())
412    }
413
414    /// Check if solution a dominates solution b
415    fn dominates(&self, a: &ParetoSolution, b: &ParetoSolution) -> bool {
416        let mut at_least_one_better = false;
417
418        for i in 0..a.objectives.len() {
419            if a.objectives[i] < b.objectives[i] {
420                return false; // a is worse in at least one objective
421            } else if a.objectives[i] > b.objectives[i] {
422                at_least_one_better = true;
423            }
424        }
425
426        at_least_one_better
427    }
428
429    /// Calculate crowding distance
430    fn calculate_crowding_distance(&self, population: &mut [ParetoSolution]) -> SklResult<()> {
431        let n = population.len();
432        let n_objectives = self.config.objectives.len();
433
434        // Initialize crowding distances
435        for solution in population.iter_mut() {
436            solution.crowding_distance = 0.0;
437        }
438
439        // Calculate crowding distance for each objective
440        for obj_idx in 0..n_objectives {
441            // Sort by objective value
442            let mut indices: Vec<usize> = (0..n).collect();
443            indices.sort_by(|&i, &j| {
444                population[i].objectives[obj_idx]
445                    .partial_cmp(&population[j].objectives[obj_idx])
446                    .expect("operation should succeed")
447            });
448
449            // Set boundary points to infinite distance
450            population[indices[0]].crowding_distance = Float::INFINITY;
451            population[indices[n - 1]].crowding_distance = Float::INFINITY;
452
453            // Calculate crowding distance for middle points
454            let obj_range = population[indices[n - 1]].objectives[obj_idx]
455                - population[indices[0]].objectives[obj_idx];
456
457            if obj_range > 0.0 {
458                for i in 1..n - 1 {
459                    let distance = (population[indices[i + 1]].objectives[obj_idx]
460                        - population[indices[i - 1]].objectives[obj_idx])
461                        / obj_range;
462                    population[indices[i]].crowding_distance += distance;
463                }
464            }
465        }
466
467        Ok(())
468    }
469
470    /// Evolve population through selection, crossover, and mutation
471    fn evolve_population(
472        &self,
473        population: Vec<ParetoSolution>,
474        rng: &mut scirs2_core::random::CoreRandom,
475    ) -> SklResult<Vec<ParetoSolution>> {
476        let mut new_population = Vec::new();
477
478        while new_population.len() < self.config.population_size {
479            // Tournament selection
480            let parent1 = self.tournament_selection(&population, rng)?;
481            let parent2 = self.tournament_selection(&population, rng)?;
482
483            // Crossover
484            let (mut child1, mut child2) = self.crossover(&parent1, &parent2, rng)?;
485
486            // Mutation
487            self.mutate(&mut child1, rng)?;
488            self.mutate(&mut child2, rng)?;
489
490            new_population.push(child1);
491            if new_population.len() < self.config.population_size {
492                new_population.push(child2);
493            }
494        }
495
496        Ok(new_population)
497    }
498
499    /// Tournament selection
500    fn tournament_selection(
501        &self,
502        population: &[ParetoSolution],
503        rng: &mut scirs2_core::random::CoreRandom,
504    ) -> SklResult<ParetoSolution> {
505        let tournament_size = 3;
506        let mut best_solution = None;
507
508        for _ in 0..tournament_size {
509            let idx = rng.gen_range(0..population.len());
510            let candidate = &population[idx];
511
512            if let Some(ref current_best) = best_solution {
513                if self.is_better_solution(candidate, current_best) {
514                    best_solution = Some(candidate.clone());
515                }
516            } else {
517                best_solution = Some(candidate.clone());
518            }
519        }
520
521        best_solution
522            .ok_or_else(|| SklearsError::InvalidInput("Tournament selection failed".to_string()))
523    }
524
525    /// Check if solution a is better than solution b
526    fn is_better_solution(&self, a: &ParetoSolution, b: &ParetoSolution) -> bool {
527        if a.rank < b.rank {
528            true
529        } else if a.rank == b.rank {
530            a.crowding_distance > b.crowding_distance
531        } else {
532            false
533        }
534    }
535
536    /// Crossover operation
537    fn crossover(
538        &self,
539        parent1: &ParetoSolution,
540        parent2: &ParetoSolution,
541        rng: &mut scirs2_core::random::CoreRandom,
542    ) -> SklResult<(ParetoSolution, ParetoSolution)> {
543        let mut child1 = parent1.clone();
544        let mut child2 = parent2.clone();
545
546        if rng.random::<Float>() < self.config.crossover_rate {
547            // Uniform crossover
548            for i in 0..parent1.parameters.len() {
549                if rng.random::<Float>() < 0.5 {
550                    child1.parameters[i] = parent2.parameters[i];
551                    child2.parameters[i] = parent1.parameters[i];
552                }
553            }
554        }
555
556        Ok((child1, child2))
557    }
558
559    /// Mutation operation
560    fn mutate(
561        &self,
562        solution: &mut ParetoSolution,
563        rng: &mut scirs2_core::random::CoreRandom,
564    ) -> SklResult<()> {
565        for param in solution.parameters.iter_mut() {
566            if rng.random::<Float>() < self.config.mutation_rate {
567                let mutation = rng.gen_range(-0.1..0.1);
568                *param += mutation;
569            }
570        }
571        Ok(())
572    }
573
574    /// Calculate hypervolume (convergence metric)
575    fn calculate_hypervolume(&self, population: &[ParetoSolution]) -> SklResult<Float> {
576        // Simplified hypervolume calculation
577        let pareto_front: Vec<&ParetoSolution> =
578            population.iter().filter(|sol| sol.rank == 0).collect();
579
580        if pareto_front.is_empty() {
581            return Ok(0.0);
582        }
583
584        // Use the sum of objective values as a proxy for hypervolume
585        let hypervolume = pareto_front
586            .iter()
587            .map(|sol| sol.objectives.sum())
588            .sum::<Float>()
589            / pareto_front.len() as Float;
590
591        Ok(hypervolume)
592    }
593
594    /// Find best compromise solution
595    fn find_best_compromise(
596        &self,
597        pareto_solutions: &[ParetoSolution],
598    ) -> SklResult<ParetoSolution> {
599        if pareto_solutions.is_empty() {
600            return Err(SklearsError::InvalidInput(
601                "No Pareto solutions available".to_string(),
602            ));
603        }
604
605        // Find the solution closest to the ideal point (origin)
606        let mut best_solution = pareto_solutions[0].clone();
607        let mut best_distance = Float::INFINITY;
608
609        for solution in pareto_solutions {
610            let distance = solution.objectives.mapv(|x| x * x).sum().sqrt();
611            if distance < best_distance {
612                best_distance = distance;
613                best_solution = solution.clone();
614            }
615        }
616
617        Ok(best_solution)
618    }
619}
620
621impl Predict<ArrayView2<'_, Float>, Array2<Float>>
622    for MultiObjectiveOptimizer<MultiObjectiveOptimizerTrained>
623{
624    fn predict(&self, X: &ArrayView2<'_, Float>) -> SklResult<Array2<Float>> {
625        let (_n_samples, n_features) = X.dim();
626        let best_solution = &self.state.best_solution;
627
628        // Extract weights and bias from best solution parameters
629        let n_outputs = self.state.n_outputs;
630        let weights_size = n_features * n_outputs;
631        let weights = best_solution
632            .parameters
633            .slice(s![..weights_size])
634            .to_owned()
635            .into_shape_with_order((
636                (n_features, n_outputs),
637                scirs2_core::ndarray::Order::RowMajor,
638            ))
639            .expect("operation should succeed");
640        let bias = best_solution
641            .parameters
642            .slice(s![weights_size..weights_size + n_outputs])
643            .to_owned();
644
645        let predictions = X.dot(&weights) + &bias;
646        Ok(predictions)
647    }
648}
649
650impl Estimator for MultiObjectiveOptimizer<MultiObjectiveOptimizerTrained> {
651    type Config = MultiObjectiveConfig;
652    type Error = SklearsError;
653    type Float = Float;
654
655    fn config(&self) -> &Self::Config {
656        &self.state.config
657    }
658}
659
660impl MultiObjectiveOptimizer<MultiObjectiveOptimizerTrained> {
661    /// Get the Pareto-optimal solutions
662    pub fn pareto_solutions(&self) -> &[ParetoSolution] {
663        &self.state.pareto_solutions
664    }
665
666    /// Get the best compromise solution
667    pub fn best_solution(&self) -> &ParetoSolution {
668        &self.state.best_solution
669    }
670
671    /// Get the convergence history
672    pub fn convergence_history(&self) -> &[Float] {
673        &self.state.convergence_history
674    }
675}