Skip to main content

sklears_svm/hyperparameter_optimization/
bayesian_optimization.rs

1//! Bayesian Optimization for hyperparameter tuning
2
3use std::time::Instant;
4
5use scirs2_core::ndarray::{Array1, Array2};
6use scirs2_core::random::Random;
7
8use crate::kernels::KernelType;
9use crate::svc::SVC;
10use sklears_core::error::{Result, SklearsError};
11use sklears_core::traits::{Fit, Predict};
12
13use super::{
14    OptimizationConfig, OptimizationResult, ParameterSet, ParameterSpec, ScoringMetric, SearchSpace,
15};
16
17/// Bayesian Optimization hyperparameter optimizer
18pub struct BayesianOptimizationCV {
19    config: OptimizationConfig,
20    search_space: SearchSpace,
21    rng: Random<scirs2_core::random::rngs::StdRng>,
22}
23
24impl BayesianOptimizationCV {
25    /// Create a new Bayesian optimization optimizer
26    pub fn new(config: OptimizationConfig, search_space: SearchSpace) -> Self {
27        let rng = if let Some(seed) = config.random_state {
28            Random::seed(seed)
29        } else {
30            Random::seed(42) // Default seed for reproducibility
31        };
32
33        Self {
34            config,
35            search_space,
36            rng,
37        }
38    }
39
40    /// Run Bayesian optimization
41    pub fn fit(&mut self, x: &Array2<f64>, y: &Array1<f64>) -> Result<OptimizationResult> {
42        let start_time = Instant::now();
43
44        if self.config.verbose {
45            println!(
46                "Bayesian optimization with {} iterations",
47                self.config.n_iterations
48            );
49        }
50
51        // Initialize with random samples
52        let n_initial = (self.config.n_iterations / 5).clamp(5, 20);
53        let mut evaluated_params: Vec<(ParameterSet, f64)> = Vec::new();
54        let mut best_score = -f64::INFINITY;
55        let mut best_params = ParameterSet::new();
56        let mut score_history = Vec::new();
57        let mut iterations_without_improvement = 0;
58
59        // Phase 1: Random exploration
60        if self.config.verbose {
61            println!("Phase 1: Random exploration ({} samples)", n_initial);
62        }
63
64        for i in 0..n_initial {
65            let params = self.sample_random_params()?;
66            let score = self.evaluate_params(&params, x, y)?;
67
68            evaluated_params.push((params.clone(), score));
69            score_history.push(score);
70
71            if score > best_score {
72                best_score = score;
73                best_params = params.clone();
74                iterations_without_improvement = 0;
75            } else {
76                iterations_without_improvement += 1;
77            }
78
79            if self.config.verbose {
80                println!("Sample {}/{}: Score {:.6}", i + 1, n_initial, score);
81            }
82        }
83
84        // Phase 2: Bayesian optimization with Expected Improvement
85        if self.config.verbose {
86            println!("Phase 2: Bayesian optimization");
87        }
88
89        for iteration in n_initial..self.config.n_iterations {
90            // Build surrogate model (Gaussian Process approximation)
91            // Select next point using Expected Improvement acquisition function
92            let next_params = self.select_next_point(&evaluated_params)?;
93
94            // Evaluate the selected point
95            let score = self.evaluate_params(&next_params, x, y)?;
96
97            evaluated_params.push((next_params.clone(), score));
98            score_history.push(score);
99
100            if score > best_score {
101                best_score = score;
102                best_params = next_params.clone();
103                iterations_without_improvement = 0;
104
105                if self.config.verbose {
106                    println!(
107                        "Iteration {}/{}: NEW BEST Score {:.6}",
108                        iteration + 1,
109                        self.config.n_iterations,
110                        score
111                    );
112                }
113            } else {
114                iterations_without_improvement += 1;
115
116                if self.config.verbose && (iteration + 1) % 10 == 0 {
117                    println!(
118                        "Iteration {}/{}: Score {:.6} (best: {:.6})",
119                        iteration + 1,
120                        self.config.n_iterations,
121                        score,
122                        best_score
123                    );
124                }
125            }
126
127            // Early stopping
128            if let Some(patience) = self.config.early_stopping_patience {
129                if iterations_without_improvement >= patience {
130                    if self.config.verbose {
131                        println!("Early stopping at iteration {}", iteration + 1);
132                    }
133                    break;
134                }
135            }
136        }
137
138        if self.config.verbose {
139            println!("Best score: {:.6}", best_score);
140            println!("Best params: {:?}", best_params);
141        }
142
143        Ok(OptimizationResult {
144            best_params,
145            best_score,
146            cv_results: evaluated_params,
147            n_iterations: score_history.len(),
148            optimization_time: start_time.elapsed().as_secs_f64(),
149            score_history,
150        })
151    }
152
153    /// Sample random parameters from search space
154    fn sample_random_params(&mut self) -> Result<ParameterSet> {
155        // Clone search space specs to avoid borrow checker issues
156        let c_spec = self.search_space.c.clone();
157        let kernel_spec = self.search_space.kernel.clone();
158        let tol_spec = self.search_space.tol.clone();
159        let max_iter_spec = self.search_space.max_iter.clone();
160
161        let c = self.sample_value(&c_spec)?;
162
163        let kernel = if let Some(ref spec) = kernel_spec {
164            self.sample_kernel(spec)?
165        } else {
166            KernelType::Rbf { gamma: 1.0 }
167        };
168
169        let tol = if let Some(ref spec) = tol_spec {
170            self.sample_value(spec)?
171        } else {
172            1e-3
173        };
174
175        let max_iter = if let Some(ref spec) = max_iter_spec {
176            self.sample_value(spec)? as usize
177        } else {
178            1000
179        };
180
181        Ok(ParameterSet {
182            c,
183            kernel,
184            tol,
185            max_iter,
186        })
187    }
188
189    /// Select next point to evaluate using Expected Improvement
190    fn select_next_point(&mut self, evaluated: &[(ParameterSet, f64)]) -> Result<ParameterSet> {
191        // Generate candidate points
192        let n_candidates = 100;
193        let mut candidates = Vec::with_capacity(n_candidates);
194
195        for _ in 0..n_candidates {
196            candidates.push(self.sample_random_params()?);
197        }
198
199        // Calculate Expected Improvement for each candidate
200        let best_observed = evaluated
201            .iter()
202            .map(|(_, score)| *score)
203            .max_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
204            .unwrap_or(0.0);
205
206        let mut best_ei = -f64::INFINITY;
207        let mut best_candidate = candidates[0].clone();
208
209        for candidate in &candidates {
210            let ei = self.expected_improvement(candidate, evaluated, best_observed)?;
211            if ei > best_ei {
212                best_ei = ei;
213                best_candidate = candidate.clone();
214            }
215        }
216
217        Ok(best_candidate)
218    }
219
220    /// Calculate Expected Improvement acquisition function
221    fn expected_improvement(
222        &self,
223        candidate: &ParameterSet,
224        evaluated: &[(ParameterSet, f64)],
225        best_observed: f64,
226    ) -> Result<f64> {
227        // Simplified Gaussian Process prediction using RBF kernel
228        // Mean prediction: weighted average of observed values
229        // Std prediction: based on distance to nearest neighbors
230
231        let (mean, std) = self.gp_predict(candidate, evaluated)?;
232
233        if std < 1e-10 {
234            return Ok(0.0);
235        }
236
237        // Expected Improvement formula
238        let z = (mean - best_observed - 0.01) / std; // 0.01 is exploration parameter (xi)
239        let ei = (mean - best_observed - 0.01) * self.normal_cdf(z) + std * self.normal_pdf(z);
240
241        Ok(ei.max(0.0))
242    }
243
244    /// Simplified Gaussian Process prediction
245    fn gp_predict(
246        &self,
247        candidate: &ParameterSet,
248        evaluated: &[(ParameterSet, f64)],
249    ) -> Result<(f64, f64)> {
250        if evaluated.is_empty() {
251            return Ok((0.0, 1.0));
252        }
253
254        // Calculate distances and weights using RBF kernel
255        let length_scale = 1.0;
256        let mut total_weight = 0.0;
257        let mut weighted_mean = 0.0;
258
259        for (params, score) in evaluated {
260            let dist = self.parameter_distance(candidate, params)?;
261            let weight = (-0.5 * (dist / length_scale).powi(2)).exp();
262            total_weight += weight;
263            weighted_mean += weight * score;
264        }
265
266        let mean = if total_weight > 1e-10 {
267            weighted_mean / total_weight
268        } else {
269            evaluated.iter().map(|(_, score)| score).sum::<f64>() / evaluated.len() as f64
270        };
271
272        // Estimate uncertainty based on distance to nearest neighbor
273        let min_dist = evaluated
274            .iter()
275            .map(|(params, _)| self.parameter_distance(candidate, params).unwrap_or(1.0))
276            .min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
277            .unwrap_or(1.0);
278
279        let std = (0.1 + 0.9 * (1.0 - (-min_dist.powi(2)).exp())).min(1.0);
280
281        Ok((mean, std))
282    }
283
284    /// Calculate distance between two parameter sets
285    fn parameter_distance(&self, a: &ParameterSet, b: &ParameterSet) -> Result<f64> {
286        // Normalized Euclidean distance in parameter space
287        let c_dist = ((a.c.ln() - b.c.ln()) / 3.0).powi(2); // Normalized log distance
288        let tol_dist = ((a.tol.ln() - b.tol.ln()) / 3.0).powi(2);
289        let max_iter_dist = ((a.max_iter as f64 - b.max_iter as f64) / 2500.0).powi(2);
290
291        // Kernel distance (0 if same, 1 if different)
292        let kernel_dist = if std::mem::discriminant(&a.kernel) == std::mem::discriminant(&b.kernel)
293        {
294            0.0
295        } else {
296            1.0
297        };
298
299        Ok((c_dist + tol_dist + max_iter_dist + kernel_dist).sqrt())
300    }
301
302    /// Standard normal CDF (cumulative distribution function)
303    fn normal_cdf(&self, x: f64) -> f64 {
304        0.5 * (1.0 + self.erf(x / std::f64::consts::SQRT_2))
305    }
306
307    /// Standard normal PDF (probability density function)
308    fn normal_pdf(&self, x: f64) -> f64 {
309        (1.0 / (2.0 * std::f64::consts::PI).sqrt()) * (-0.5 * x.powi(2)).exp()
310    }
311
312    /// Error function approximation
313    fn erf(&self, x: f64) -> f64 {
314        // Abramowitz and Stegun approximation
315        let a1 = 0.254829592;
316        let a2 = -0.284496736;
317        let a3 = 1.421413741;
318        let a4 = -1.453152027;
319        let a5 = 1.061405429;
320        let p = 0.3275911;
321
322        let sign = if x < 0.0 { -1.0 } else { 1.0 };
323        let x = x.abs();
324
325        let t = 1.0 / (1.0 + p * x);
326        let y = 1.0 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * (-x * x).exp();
327
328        sign * y
329    }
330
331    /// Sample a single value from parameter specification
332    fn sample_value(&mut self, spec: &ParameterSpec) -> Result<f64> {
333        match spec {
334            ParameterSpec::Fixed(value) => Ok(*value),
335            ParameterSpec::Uniform { min, max } => {
336                use scirs2_core::random::essentials::Uniform;
337                let dist = Uniform::new(*min, *max).map_err(|e| {
338                    SklearsError::InvalidInput(format!(
339                        "Failed to create uniform distribution: {}",
340                        e
341                    ))
342                })?;
343                Ok(self.rng.sample(dist))
344            }
345            ParameterSpec::LogUniform { min, max } => {
346                use scirs2_core::random::essentials::Uniform;
347                let log_min = min.ln();
348                let log_max = max.ln();
349                let dist = Uniform::new(log_min, log_max).map_err(|e| {
350                    SklearsError::InvalidInput(format!(
351                        "Failed to create uniform distribution: {}",
352                        e
353                    ))
354                })?;
355                let log_val = self.rng.sample(dist);
356                Ok(log_val.exp())
357            }
358            ParameterSpec::Choice(choices) => {
359                if choices.is_empty() {
360                    return Err(SklearsError::InvalidInput("Empty choice list".to_string()));
361                }
362                use scirs2_core::random::essentials::Uniform;
363                let dist = Uniform::new(0, choices.len()).map_err(|e| {
364                    SklearsError::InvalidInput(format!(
365                        "Failed to create uniform distribution: {}",
366                        e
367                    ))
368                })?;
369                let idx = self.rng.sample(dist);
370                Ok(choices[idx])
371            }
372            ParameterSpec::KernelChoice(_) => Err(SklearsError::InvalidInput(
373                "Use sample_kernel for kernel specs".to_string(),
374            )),
375        }
376    }
377
378    /// Sample a kernel from kernel specification
379    fn sample_kernel(&mut self, spec: &ParameterSpec) -> Result<KernelType> {
380        match spec {
381            ParameterSpec::KernelChoice(kernels) => {
382                if kernels.is_empty() {
383                    return Err(SklearsError::InvalidInput(
384                        "Empty kernel choice list".to_string(),
385                    ));
386                }
387                use scirs2_core::random::essentials::Uniform;
388                let dist = Uniform::new(0, kernels.len()).map_err(|e| {
389                    SklearsError::InvalidInput(format!(
390                        "Failed to create uniform distribution: {}",
391                        e
392                    ))
393                })?;
394                let idx = self.rng.sample(dist);
395                Ok(kernels[idx].clone())
396            }
397            _ => Err(SklearsError::InvalidInput(
398                "Invalid kernel specification".to_string(),
399            )),
400        }
401    }
402
403    /// Evaluate parameter set using cross-validation
404    fn evaluate_params(
405        &self,
406        params: &ParameterSet,
407        x: &Array2<f64>,
408        y: &Array1<f64>,
409    ) -> Result<f64> {
410        let scores = self.cross_validate(params, x, y)?;
411        Ok(scores.iter().sum::<f64>() / scores.len() as f64)
412    }
413
414    /// Perform cross-validation
415    fn cross_validate(
416        &self,
417        params: &ParameterSet,
418        x: &Array2<f64>,
419        y: &Array1<f64>,
420    ) -> Result<Vec<f64>> {
421        let n_samples = x.nrows();
422        let fold_size = n_samples / self.config.cv_folds;
423        let mut scores = Vec::new();
424
425        for fold in 0..self.config.cv_folds {
426            let start_idx = fold * fold_size;
427            let end_idx = if fold == self.config.cv_folds - 1 {
428                n_samples
429            } else {
430                (fold + 1) * fold_size
431            };
432
433            // Create train/test splits
434            let mut x_train_data = Vec::new();
435            let mut y_train_vals = Vec::new();
436            let mut x_test_data = Vec::new();
437            let mut y_test_vals = Vec::new();
438
439            for i in 0..n_samples {
440                if i >= start_idx && i < end_idx {
441                    // Test set
442                    for j in 0..x.ncols() {
443                        x_test_data.push(x[[i, j]]);
444                    }
445                    y_test_vals.push(y[i]);
446                } else {
447                    // Training set
448                    for j in 0..x.ncols() {
449                        x_train_data.push(x[[i, j]]);
450                    }
451                    y_train_vals.push(y[i]);
452                }
453            }
454
455            let n_train = y_train_vals.len();
456            let n_test = y_test_vals.len();
457            let n_features = x.ncols();
458
459            let x_train = Array2::from_shape_vec((n_train, n_features), x_train_data)?;
460            let y_train = Array1::from_vec(y_train_vals);
461            let x_test = Array2::from_shape_vec((n_test, n_features), x_test_data)?;
462            let y_test = Array1::from_vec(y_test_vals);
463
464            // Train and evaluate model
465            let svm = SVC::new()
466                .c(params.c)
467                .kernel(params.kernel.clone())
468                .tol(params.tol)
469                .max_iter(params.max_iter);
470
471            let fitted_svm = svm.fit(&x_train, &y_train)?;
472            let y_pred = fitted_svm.predict(&x_test)?;
473
474            let score = self.calculate_score(&y_test, &y_pred)?;
475            scores.push(score);
476        }
477
478        Ok(scores)
479    }
480
481    /// Calculate score based on scoring metric
482    fn calculate_score(&self, y_true: &Array1<f64>, y_pred: &Array1<f64>) -> Result<f64> {
483        match self.config.scoring {
484            ScoringMetric::Accuracy => {
485                let correct = y_true
486                    .iter()
487                    .zip(y_pred.iter())
488                    .map(|(&t, &p)| if (t - p).abs() < 0.5 { 1.0 } else { 0.0 })
489                    .sum::<f64>();
490                Ok(correct / y_true.len() as f64)
491            }
492            ScoringMetric::MeanSquaredError => {
493                let mse = y_true
494                    .iter()
495                    .zip(y_pred.iter())
496                    .map(|(&t, &p)| (t - p).powi(2))
497                    .sum::<f64>()
498                    / y_true.len() as f64;
499                Ok(-mse) // Negative because we want to maximize
500            }
501            ScoringMetric::MeanAbsoluteError => {
502                let mae = y_true
503                    .iter()
504                    .zip(y_pred.iter())
505                    .map(|(&t, &p)| (t - p).abs())
506                    .sum::<f64>()
507                    / y_true.len() as f64;
508                Ok(-mae) // Negative because we want to maximize
509            }
510            _ => {
511                // For now, default to accuracy for other metrics
512                let correct = y_true
513                    .iter()
514                    .zip(y_pred.iter())
515                    .map(|(&t, &p)| if (t - p).abs() < 0.5 { 1.0 } else { 0.0 })
516                    .sum::<f64>();
517                Ok(correct / y_true.len() as f64)
518            }
519        }
520    }
521}