Skip to main content

sklears_svm/hyperparameter_optimization/
evolutionary_optimization.rs

1//! Evolutionary Algorithms for hyperparameter optimization
2
3use std::time::Instant;
4
5#[cfg(feature = "parallel")]
6use rayon::prelude::*;
7use scirs2_core::ndarray::{Array1, Array2};
8use scirs2_core::random::Random;
9
10use crate::kernels::KernelType;
11use crate::svc::SVC;
12use sklears_core::error::{Result, SklearsError};
13use sklears_core::traits::{Fit, Predict};
14
15use super::{
16    OptimizationConfig, OptimizationResult, ParameterSet, ParameterSpec, ScoringMetric, SearchSpace,
17};
18
19/// Selection methods for evolutionary algorithm
20#[derive(Debug, Clone)]
21pub enum SelectionMethod {
22    Tournament { size: usize },
23    RouletteWheel,
24    RankBased,
25}
26
27/// Individual in the genetic algorithm population
28#[derive(Debug, Clone)]
29pub struct Individual {
30    pub params: ParameterSet,
31    pub fitness: f64,
32}
33
34impl Individual {
35    pub fn new(params: ParameterSet) -> Self {
36        Self {
37            params,
38            fitness: -f64::INFINITY,
39        }
40    }
41}
42
43/// Evolutionary Optimization hyperparameter optimizer
44pub struct EvolutionaryOptimizationCV {
45    config: OptimizationConfig,
46    search_space: SearchSpace,
47    rng: Random<scirs2_core::random::rngs::StdRng>,
48    population_size: usize,
49    selection_method: SelectionMethod,
50    mutation_rate: f64,
51    crossover_rate: f64,
52    elite_ratio: f64,
53}
54
55impl EvolutionaryOptimizationCV {
56    /// Create a new evolutionary optimization optimizer
57    pub fn new(config: OptimizationConfig, search_space: SearchSpace) -> Self {
58        let rng = if let Some(seed) = config.random_state {
59            Random::seed(seed)
60        } else {
61            Random::seed(42) // Default seed for reproducibility
62        };
63
64        Self {
65            config,
66            search_space,
67            rng,
68            population_size: 50,
69            selection_method: SelectionMethod::Tournament { size: 3 },
70            mutation_rate: 0.1,
71            crossover_rate: 0.8,
72            elite_ratio: 0.1,
73        }
74    }
75
76    /// Builder method to set population size
77    pub fn population_size(mut self, size: usize) -> Self {
78        self.population_size = size;
79        self
80    }
81
82    /// Builder method to set selection method
83    pub fn selection_method(mut self, method: SelectionMethod) -> Self {
84        self.selection_method = method;
85        self
86    }
87
88    /// Builder method to set mutation rate
89    pub fn mutation_rate(mut self, rate: f64) -> Self {
90        self.mutation_rate = rate;
91        self
92    }
93
94    /// Builder method to set crossover rate
95    pub fn crossover_rate(mut self, rate: f64) -> Self {
96        self.crossover_rate = rate;
97        self
98    }
99
100    /// Builder method to set elite ratio
101    pub fn elite_ratio(mut self, ratio: f64) -> Self {
102        self.elite_ratio = ratio;
103        self
104    }
105
106    /// Run evolutionary optimization
107    pub fn fit(&mut self, x: &Array2<f64>, y: &Array1<f64>) -> Result<OptimizationResult> {
108        let start_time = Instant::now();
109
110        if self.config.verbose {
111            println!(
112                "Evolutionary optimization with {} generations, population size {}",
113                self.config.n_iterations, self.population_size
114            );
115        }
116
117        // Initialize population
118        let mut population = self.initialize_population()?;
119
120        // Evaluate initial population
121        self.evaluate_population(&mut population, x, y)?;
122
123        let mut best_individual = population[0].clone();
124        let mut cv_results = Vec::new();
125        let mut score_history = Vec::new();
126        let mut generations_without_improvement = 0;
127
128        // Evolution loop
129        for generation in 0..self.config.n_iterations {
130            // Sort population by fitness (descending)
131            population.sort_by(|a, b| {
132                b.fitness
133                    .partial_cmp(&a.fitness)
134                    .unwrap_or(std::cmp::Ordering::Equal)
135            });
136
137            // Track best individual
138            if population[0].fitness > best_individual.fitness {
139                best_individual = population[0].clone();
140                generations_without_improvement = 0;
141            } else {
142                generations_without_improvement += 1;
143            }
144
145            // Store results
146            for ind in &population {
147                cv_results.push((ind.params.clone(), ind.fitness));
148            }
149            score_history.push(best_individual.fitness);
150
151            if self.config.verbose && (generation + 1) % 10 == 0 {
152                println!(
153                    "Generation {}/{}: Best score {:.6}",
154                    generation + 1,
155                    self.config.n_iterations,
156                    best_individual.fitness
157                );
158            }
159
160            // Early stopping check
161            if let Some(patience) = self.config.early_stopping_patience {
162                if generations_without_improvement >= patience {
163                    if self.config.verbose {
164                        println!("Early stopping at generation {}", generation + 1);
165                    }
166                    break;
167                }
168            }
169
170            // Create next generation
171            let mut next_generation = Vec::new();
172
173            // Elitism: preserve top individuals
174            let n_elite = (self.population_size as f64 * self.elite_ratio) as usize;
175            for individual in population.iter().take(n_elite.min(population.len())) {
176                next_generation.push(individual.clone());
177            }
178
179            // Generate offspring
180            while next_generation.len() < self.population_size {
181                // Selection
182                let parent1 = self.select_individual(&population)?;
183                let parent2 = self.select_individual(&population)?;
184
185                // Crossover
186                use scirs2_core::random::essentials::Uniform;
187                let dist = Uniform::new(0.0, 1.0).map_err(|e| {
188                    SklearsError::InvalidInput(format!(
189                        "Failed to create uniform distribution: {}",
190                        e
191                    ))
192                })?;
193                let offspring = if self.rng.sample(dist) < self.crossover_rate {
194                    self.crossover(&parent1.params, &parent2.params)?
195                } else {
196                    parent1.params.clone()
197                };
198
199                // Mutation
200                let dist = Uniform::new(0.0, 1.0).map_err(|e| {
201                    SklearsError::InvalidInput(format!(
202                        "Failed to create uniform distribution: {}",
203                        e
204                    ))
205                })?;
206                let mutated = if self.rng.sample(dist) < self.mutation_rate {
207                    self.mutate(&offspring)?
208                } else {
209                    offspring
210                };
211
212                next_generation.push(Individual::new(mutated));
213            }
214
215            // Evaluate new generation
216            self.evaluate_population(&mut next_generation, x, y)?;
217            population = next_generation;
218        }
219
220        // Final evaluation to get best
221        population.sort_by(|a, b| {
222            b.fitness
223                .partial_cmp(&a.fitness)
224                .unwrap_or(std::cmp::Ordering::Equal)
225        });
226        let best_individual = population[0].clone();
227
228        if self.config.verbose {
229            println!("Best score: {:.6}", best_individual.fitness);
230            println!("Best params: {:?}", best_individual.params);
231        }
232
233        Ok(OptimizationResult {
234            best_params: best_individual.params,
235            best_score: best_individual.fitness,
236            cv_results,
237            n_iterations: score_history.len(),
238            optimization_time: start_time.elapsed().as_secs_f64(),
239            score_history,
240        })
241    }
242
243    /// Initialize population with random individuals
244    fn initialize_population(&mut self) -> Result<Vec<Individual>> {
245        let mut population = Vec::with_capacity(self.population_size);
246
247        // Clone search space specs to avoid borrow checker issues
248        let c_spec = self.search_space.c.clone();
249        let kernel_spec = self.search_space.kernel.clone();
250        let tol_spec = self.search_space.tol.clone();
251        let max_iter_spec = self.search_space.max_iter.clone();
252
253        for _ in 0..self.population_size {
254            let c = self.sample_value(&c_spec)?;
255
256            let kernel = if let Some(ref spec) = kernel_spec {
257                self.sample_kernel(spec)?
258            } else {
259                KernelType::Rbf { gamma: 1.0 }
260            };
261
262            let tol = if let Some(ref spec) = tol_spec {
263                self.sample_value(spec)?
264            } else {
265                1e-3
266            };
267
268            let max_iter = if let Some(ref spec) = max_iter_spec {
269                self.sample_value(spec)? as usize
270            } else {
271                1000
272            };
273
274            population.push(Individual::new(ParameterSet {
275                c,
276                kernel,
277                tol,
278                max_iter,
279            }));
280        }
281
282        Ok(population)
283    }
284
285    /// Evaluate fitness for all individuals in population
286    fn evaluate_population(
287        &self,
288        population: &mut [Individual],
289        x: &Array2<f64>,
290        y: &Array1<f64>,
291    ) -> Result<()> {
292        #[cfg(feature = "parallel")]
293        if self.config.n_jobs.is_some() {
294            // Parallel evaluation
295            let fitnesses: Vec<f64> = population
296                .par_iter()
297                .map(|ind| {
298                    self.evaluate_params(&ind.params, x, y)
299                        .unwrap_or(-f64::INFINITY)
300                })
301                .collect();
302
303            for (ind, fitness) in population.iter_mut().zip(fitnesses.iter()) {
304                ind.fitness = *fitness;
305            }
306        } else {
307            // Sequential evaluation
308            for ind in population.iter_mut() {
309                ind.fitness = self.evaluate_params(&ind.params, x, y)?;
310            }
311        }
312
313        #[cfg(not(feature = "parallel"))]
314        {
315            // Sequential evaluation (parallel feature disabled)
316            for ind in population.iter_mut() {
317                ind.fitness = self.evaluate_params(&ind.params, x, y)?;
318            }
319        }
320
321        Ok(())
322    }
323
324    /// Select an individual from population using selection method
325    fn select_individual(&mut self, population: &[Individual]) -> Result<Individual> {
326        match &self.selection_method {
327            SelectionMethod::Tournament { size } => self.tournament_selection(population, *size),
328            SelectionMethod::RouletteWheel => self.roulette_wheel_selection(population),
329            SelectionMethod::RankBased => self.rank_based_selection(population),
330        }
331    }
332
333    /// Tournament selection
334    fn tournament_selection(
335        &mut self,
336        population: &[Individual],
337        tournament_size: usize,
338    ) -> Result<Individual> {
339        use scirs2_core::random::essentials::Uniform;
340        let dist = Uniform::new(0, population.len()).map_err(|e| {
341            SklearsError::InvalidInput(format!("Failed to create uniform distribution: {}", e))
342        })?;
343
344        let mut best_idx = self.rng.sample(dist);
345        let mut best_fitness = population[best_idx].fitness;
346
347        for _ in 1..tournament_size {
348            let idx = self.rng.sample(dist);
349            if population[idx].fitness > best_fitness {
350                best_idx = idx;
351                best_fitness = population[idx].fitness;
352            }
353        }
354
355        Ok(population[best_idx].clone())
356    }
357
358    /// Roulette wheel selection (fitness proportionate)
359    fn roulette_wheel_selection(&mut self, population: &[Individual]) -> Result<Individual> {
360        // Shift fitnesses to be non-negative
361        let min_fitness = population
362            .iter()
363            .map(|ind| ind.fitness)
364            .min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
365            .unwrap_or(0.0);
366        let offset = if min_fitness < 0.0 {
367            -min_fitness + 1.0
368        } else {
369            0.0
370        };
371
372        let total_fitness: f64 = population.iter().map(|ind| ind.fitness + offset).sum();
373
374        if total_fitness <= 0.0 {
375            // All fitnesses are equal or negative, select randomly
376            use scirs2_core::random::essentials::Uniform;
377            let dist = Uniform::new(0, population.len()).map_err(|e| {
378                SklearsError::InvalidInput(format!("Failed to create uniform distribution: {}", e))
379            })?;
380            let idx = self.rng.sample(dist);
381            return Ok(population[idx].clone());
382        }
383
384        use scirs2_core::random::essentials::Uniform;
385        let dist = Uniform::new(0.0, total_fitness).map_err(|e| {
386            SklearsError::InvalidInput(format!("Failed to create uniform distribution: {}", e))
387        })?;
388        let mut spin = self.rng.sample(dist);
389
390        for ind in population {
391            spin -= ind.fitness + offset;
392            if spin <= 0.0 {
393                return Ok(ind.clone());
394            }
395        }
396
397        // Fallback (shouldn't reach here)
398        Ok(population[population.len() - 1].clone())
399    }
400
401    /// Rank-based selection
402    fn rank_based_selection(&mut self, population: &[Individual]) -> Result<Individual> {
403        // Assign ranks (higher fitness = higher rank)
404        let total_rank = population.len() * (population.len() + 1) / 2;
405
406        use scirs2_core::random::essentials::Uniform;
407        let dist = Uniform::new(0.0, total_rank as f64).map_err(|e| {
408            SklearsError::InvalidInput(format!("Failed to create uniform distribution: {}", e))
409        })?;
410        let mut spin = self.rng.sample(dist);
411
412        for (i, ind) in population.iter().enumerate() {
413            let rank = population.len() - i; // Higher index = better fitness (assumes sorted)
414            spin -= rank as f64;
415            if spin <= 0.0 {
416                return Ok(ind.clone());
417            }
418        }
419
420        // Fallback
421        Ok(population[population.len() - 1].clone())
422    }
423
424    /// Crossover two parameter sets
425    fn crossover(
426        &mut self,
427        parent1: &ParameterSet,
428        parent2: &ParameterSet,
429    ) -> Result<ParameterSet> {
430        use scirs2_core::random::essentials::Uniform;
431        let dist = Uniform::new(0.0, 1.0).map_err(|e| {
432            SklearsError::InvalidInput(format!("Failed to create uniform distribution: {}", e))
433        })?;
434
435        // Uniform crossover: each gene comes from either parent with 50% probability
436        let c = if self.rng.sample(dist) < 0.5 {
437            parent1.c
438        } else {
439            parent2.c
440        };
441
442        let kernel = if self.rng.sample(dist) < 0.5 {
443            parent1.kernel.clone()
444        } else {
445            parent2.kernel.clone()
446        };
447
448        let tol = if self.rng.sample(dist) < 0.5 {
449            parent1.tol
450        } else {
451            parent2.tol
452        };
453
454        let max_iter = if self.rng.sample(dist) < 0.5 {
455            parent1.max_iter
456        } else {
457            parent2.max_iter
458        };
459
460        Ok(ParameterSet {
461            c,
462            kernel,
463            tol,
464            max_iter,
465        })
466    }
467
468    /// Mutate a parameter set
469    fn mutate(&mut self, params: &ParameterSet) -> Result<ParameterSet> {
470        use scirs2_core::random::essentials::Uniform;
471        let dist = Uniform::new(0.0, 1.0).map_err(|e| {
472            SklearsError::InvalidInput(format!("Failed to create uniform distribution: {}", e))
473        })?;
474
475        // Clone search space specs to avoid borrow checker issues
476        let c_spec = self.search_space.c.clone();
477        let kernel_spec = self.search_space.kernel.clone();
478        let tol_spec = self.search_space.tol.clone();
479        let max_iter_spec = self.search_space.max_iter.clone();
480
481        // Mutate each gene with 20% probability
482        let c = if self.rng.sample(dist) < 0.2 {
483            self.sample_value(&c_spec)?
484        } else {
485            params.c
486        };
487
488        let kernel = if self.rng.sample(dist) < 0.2 {
489            if let Some(ref spec) = kernel_spec {
490                self.sample_kernel(spec)?
491            } else {
492                params.kernel.clone()
493            }
494        } else {
495            params.kernel.clone()
496        };
497
498        let tol = if self.rng.sample(dist) < 0.2 {
499            if let Some(ref spec) = tol_spec {
500                self.sample_value(spec)?
501            } else {
502                params.tol
503            }
504        } else {
505            params.tol
506        };
507
508        let max_iter = if self.rng.sample(dist) < 0.2 {
509            if let Some(ref spec) = max_iter_spec {
510                self.sample_value(spec)? as usize
511            } else {
512                params.max_iter
513            }
514        } else {
515            params.max_iter
516        };
517
518        Ok(ParameterSet {
519            c,
520            kernel,
521            tol,
522            max_iter,
523        })
524    }
525
526    /// Sample a single value from parameter specification
527    fn sample_value(&mut self, spec: &ParameterSpec) -> Result<f64> {
528        match spec {
529            ParameterSpec::Fixed(value) => Ok(*value),
530            ParameterSpec::Uniform { min, max } => {
531                use scirs2_core::random::essentials::Uniform;
532                let dist = Uniform::new(*min, *max).map_err(|e| {
533                    SklearsError::InvalidInput(format!(
534                        "Failed to create uniform distribution: {}",
535                        e
536                    ))
537                })?;
538                Ok(self.rng.sample(dist))
539            }
540            ParameterSpec::LogUniform { min, max } => {
541                use scirs2_core::random::essentials::Uniform;
542                let log_min = min.ln();
543                let log_max = max.ln();
544                let dist = Uniform::new(log_min, log_max).map_err(|e| {
545                    SklearsError::InvalidInput(format!(
546                        "Failed to create uniform distribution: {}",
547                        e
548                    ))
549                })?;
550                let log_val = self.rng.sample(dist);
551                Ok(log_val.exp())
552            }
553            ParameterSpec::Choice(choices) => {
554                if choices.is_empty() {
555                    return Err(SklearsError::InvalidInput("Empty choice list".to_string()));
556                }
557                use scirs2_core::random::essentials::Uniform;
558                let dist = Uniform::new(0, choices.len()).map_err(|e| {
559                    SklearsError::InvalidInput(format!(
560                        "Failed to create uniform distribution: {}",
561                        e
562                    ))
563                })?;
564                let idx = self.rng.sample(dist);
565                Ok(choices[idx])
566            }
567            ParameterSpec::KernelChoice(_) => Err(SklearsError::InvalidInput(
568                "Use sample_kernel for kernel specs".to_string(),
569            )),
570        }
571    }
572
573    /// Sample a kernel from kernel specification
574    fn sample_kernel(&mut self, spec: &ParameterSpec) -> Result<KernelType> {
575        match spec {
576            ParameterSpec::KernelChoice(kernels) => {
577                if kernels.is_empty() {
578                    return Err(SklearsError::InvalidInput(
579                        "Empty kernel choice list".to_string(),
580                    ));
581                }
582                use scirs2_core::random::essentials::Uniform;
583                let dist = Uniform::new(0, kernels.len()).map_err(|e| {
584                    SklearsError::InvalidInput(format!(
585                        "Failed to create uniform distribution: {}",
586                        e
587                    ))
588                })?;
589                let idx = self.rng.sample(dist);
590                Ok(kernels[idx].clone())
591            }
592            _ => Err(SklearsError::InvalidInput(
593                "Invalid kernel specification".to_string(),
594            )),
595        }
596    }
597
598    /// Evaluate parameter set using cross-validation
599    fn evaluate_params(
600        &self,
601        params: &ParameterSet,
602        x: &Array2<f64>,
603        y: &Array1<f64>,
604    ) -> Result<f64> {
605        let scores = self.cross_validate(params, x, y)?;
606        Ok(scores.iter().sum::<f64>() / scores.len() as f64)
607    }
608
609    /// Perform cross-validation
610    fn cross_validate(
611        &self,
612        params: &ParameterSet,
613        x: &Array2<f64>,
614        y: &Array1<f64>,
615    ) -> Result<Vec<f64>> {
616        let n_samples = x.nrows();
617        let fold_size = n_samples / self.config.cv_folds;
618        let mut scores = Vec::new();
619
620        for fold in 0..self.config.cv_folds {
621            let start_idx = fold * fold_size;
622            let end_idx = if fold == self.config.cv_folds - 1 {
623                n_samples
624            } else {
625                (fold + 1) * fold_size
626            };
627
628            // Create train/test splits
629            let mut x_train_data = Vec::new();
630            let mut y_train_vals = Vec::new();
631            let mut x_test_data = Vec::new();
632            let mut y_test_vals = Vec::new();
633
634            for i in 0..n_samples {
635                if i >= start_idx && i < end_idx {
636                    // Test set
637                    for j in 0..x.ncols() {
638                        x_test_data.push(x[[i, j]]);
639                    }
640                    y_test_vals.push(y[i]);
641                } else {
642                    // Training set
643                    for j in 0..x.ncols() {
644                        x_train_data.push(x[[i, j]]);
645                    }
646                    y_train_vals.push(y[i]);
647                }
648            }
649
650            let n_train = y_train_vals.len();
651            let n_test = y_test_vals.len();
652            let n_features = x.ncols();
653
654            let x_train = Array2::from_shape_vec((n_train, n_features), x_train_data)?;
655            let y_train = Array1::from_vec(y_train_vals);
656            let x_test = Array2::from_shape_vec((n_test, n_features), x_test_data)?;
657            let y_test = Array1::from_vec(y_test_vals);
658
659            // Train and evaluate model
660            let svm = SVC::new()
661                .c(params.c)
662                .kernel(params.kernel.clone())
663                .tol(params.tol)
664                .max_iter(params.max_iter);
665
666            let fitted_svm = svm.fit(&x_train, &y_train)?;
667            let y_pred = fitted_svm.predict(&x_test)?;
668
669            let score = self.calculate_score(&y_test, &y_pred)?;
670            scores.push(score);
671        }
672
673        Ok(scores)
674    }
675
676    /// Calculate score based on scoring metric
677    fn calculate_score(&self, y_true: &Array1<f64>, y_pred: &Array1<f64>) -> Result<f64> {
678        match self.config.scoring {
679            ScoringMetric::Accuracy => {
680                let correct = y_true
681                    .iter()
682                    .zip(y_pred.iter())
683                    .map(|(&t, &p)| if (t - p).abs() < 0.5 { 1.0 } else { 0.0 })
684                    .sum::<f64>();
685                Ok(correct / y_true.len() as f64)
686            }
687            ScoringMetric::MeanSquaredError => {
688                let mse = y_true
689                    .iter()
690                    .zip(y_pred.iter())
691                    .map(|(&t, &p)| (t - p).powi(2))
692                    .sum::<f64>()
693                    / y_true.len() as f64;
694                Ok(-mse) // Negative because we want to maximize
695            }
696            ScoringMetric::MeanAbsoluteError => {
697                let mae = y_true
698                    .iter()
699                    .zip(y_pred.iter())
700                    .map(|(&t, &p)| (t - p).abs())
701                    .sum::<f64>()
702                    / y_true.len() as f64;
703                Ok(-mae) // Negative because we want to maximize
704            }
705            _ => {
706                // For now, default to accuracy for other metrics
707                let correct = y_true
708                    .iter()
709                    .zip(y_pred.iter())
710                    .map(|(&t, &p)| if (t - p).abs() < 0.5 { 1.0 } else { 0.0 })
711                    .sum::<f64>();
712                Ok(correct / y_true.len() as f64)
713            }
714        }
715    }
716}