1use 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
19pub struct RandomSearchCV {
21 config: OptimizationConfig,
22 search_space: SearchSpace,
23 rng: Random<scirs2_core::random::rngs::StdRng>,
24}
25
26impl RandomSearchCV {
27 pub fn new(config: OptimizationConfig, search_space: SearchSpace) -> Self {
29 let rng = if let Some(seed) = config.random_state {
30 Random::seed(seed)
31 } else {
32 Random::seed(42) };
34
35 Self {
36 config,
37 search_space,
38 rng,
39 }
40 }
41
42 pub fn fit(&mut self, x: &Array2<f64>, y: &Array1<f64>) -> Result<OptimizationResult> {
44 let start_time = Instant::now();
45
46 if self.config.verbose {
47 println!("Random search with {} iterations", self.config.n_iterations);
48 }
49
50 let param_samples = self.sample_parameters(self.config.n_iterations)?;
52
53 let cv_results: Vec<(ParameterSet, f64)> = {
55 #[cfg(feature = "parallel")]
56 if self.config.n_jobs.is_some() {
57 param_samples
59 .into_par_iter()
60 .map(|params| {
61 let score = self.evaluate_params(¶ms, x, y).unwrap_or_else(|e| {
62 eprintln!("SVM evaluation error: {}", e);
63 -f64::INFINITY
64 });
65 (params, score)
66 })
67 .collect()
68 } else {
69 param_samples
71 .into_iter()
72 .enumerate()
73 .map(|(i, params)| {
74 let score = self.evaluate_params(¶ms, x, y).unwrap_or_else(|e| {
75 eprintln!("SVM evaluation error: {}", e);
76 -f64::INFINITY
77 });
78 if self.config.verbose && (i + 1) % 10 == 0 {
79 println!(
80 "Iteration {}/{}: Score {:.6}",
81 i + 1,
82 self.config.n_iterations,
83 score
84 );
85 }
86 (params, score)
87 })
88 .collect()
89 }
90
91 #[cfg(not(feature = "parallel"))]
92 {
93 param_samples
95 .into_iter()
96 .enumerate()
97 .map(|(i, params)| {
98 let score = self.evaluate_params(¶ms, x, y).unwrap_or_else(|e| {
99 eprintln!("SVM evaluation error: {}", e);
100 -f64::INFINITY
101 });
102 if self.config.verbose && (i + 1) % 10 == 0 {
103 println!(
104 "Iteration {}/{}: Score {:.6}",
105 i + 1,
106 self.config.n_iterations,
107 score
108 );
109 }
110 (params, score)
111 })
112 .collect()
113 }
114 };
115
116 let (best_params, best_score) = cv_results
118 .iter()
119 .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
120 .map(|(p, s)| (p.clone(), *s))
121 .ok_or_else(|| {
122 SklearsError::Other("No valid parameter combinations found".to_string())
123 })?;
124
125 let score_history: Vec<f64> = cv_results.iter().map(|(_, score)| *score).collect();
126 let n_iterations = cv_results.len();
127
128 if self.config.verbose {
129 println!("Best score: {:.6}", best_score);
130 println!("Best params: {:?}", best_params);
131 }
132
133 Ok(OptimizationResult {
134 best_params,
135 best_score,
136 cv_results,
137 n_iterations,
138 optimization_time: start_time.elapsed().as_secs_f64(),
139 score_history,
140 })
141 }
142
143 fn sample_parameters(&mut self, n_samples: usize) -> Result<Vec<ParameterSet>> {
145 let mut params = Vec::with_capacity(n_samples);
146
147 let c_spec = self.search_space.c.clone();
149 let kernel_spec = self.search_space.kernel.clone();
150 let tol_spec = self.search_space.tol.clone();
151 let max_iter_spec = self.search_space.max_iter.clone();
152
153 for _ in 0..n_samples {
154 let c = self.sample_value(&c_spec)?;
155
156 let kernel = if let Some(ref spec) = kernel_spec {
157 self.sample_kernel(spec)?
158 } else {
159 KernelType::Rbf { gamma: 1.0 }
160 };
161
162 let tol = if let Some(ref spec) = tol_spec {
163 self.sample_value(spec)?
164 } else {
165 1e-3
166 };
167
168 let max_iter = if let Some(ref spec) = max_iter_spec {
169 self.sample_value(spec)? as usize
170 } else {
171 1000
172 };
173
174 params.push(ParameterSet {
175 c,
176 kernel,
177 tol,
178 max_iter,
179 });
180 }
181
182 Ok(params)
183 }
184
185 fn sample_value(&mut self, spec: &ParameterSpec) -> Result<f64> {
187 match spec {
188 ParameterSpec::Fixed(value) => Ok(*value),
189 ParameterSpec::Uniform { min, max } => {
190 use scirs2_core::random::essentials::Uniform;
191 let dist = Uniform::new(*min, *max).map_err(|e| {
192 SklearsError::InvalidInput(format!(
193 "Failed to create uniform distribution: {}",
194 e
195 ))
196 })?;
197 Ok(self.rng.sample(dist))
198 }
199 ParameterSpec::LogUniform { min, max } => {
200 use scirs2_core::random::essentials::Uniform;
201 let log_min = min.ln();
202 let log_max = max.ln();
203 let dist = Uniform::new(log_min, log_max).map_err(|e| {
204 SklearsError::InvalidInput(format!(
205 "Failed to create log-uniform distribution: {}",
206 e
207 ))
208 })?;
209 let log_val = self.rng.sample(dist);
210 Ok(log_val.exp())
211 }
212 ParameterSpec::Choice(choices) => {
213 if choices.is_empty() {
214 return Err(SklearsError::InvalidInput("Empty choice list".to_string()));
215 }
216 use scirs2_core::random::essentials::Uniform;
217 let dist = Uniform::new(0, choices.len()).map_err(|e| {
218 SklearsError::InvalidInput(format!(
219 "Failed to create uniform distribution: {}",
220 e
221 ))
222 })?;
223 let idx = self.rng.sample(dist);
224 Ok(choices[idx])
225 }
226 ParameterSpec::KernelChoice(_) => Err(SklearsError::InvalidInput(
227 "Use sample_kernel for kernel specs".to_string(),
228 )),
229 }
230 }
231
232 fn sample_kernel(&mut self, spec: &ParameterSpec) -> Result<KernelType> {
234 match spec {
235 ParameterSpec::KernelChoice(kernels) => {
236 if kernels.is_empty() {
237 return Err(SklearsError::InvalidInput(
238 "Empty kernel choice list".to_string(),
239 ));
240 }
241 use scirs2_core::random::essentials::Uniform;
242 let dist = Uniform::new(0, kernels.len()).map_err(|e| {
243 SklearsError::InvalidInput(format!(
244 "Failed to create uniform distribution: {}",
245 e
246 ))
247 })?;
248 let idx = self.rng.sample(dist);
249 Ok(kernels[idx].clone())
250 }
251 _ => Err(SklearsError::InvalidInput(
252 "Invalid kernel specification".to_string(),
253 )),
254 }
255 }
256
257 fn evaluate_params(
259 &self,
260 params: &ParameterSet,
261 x: &Array2<f64>,
262 y: &Array1<f64>,
263 ) -> Result<f64> {
264 let scores = self.cross_validate(params, x, y)?;
265 Ok(scores.iter().sum::<f64>() / scores.len() as f64)
266 }
267
268 fn cross_validate(
270 &self,
271 params: &ParameterSet,
272 x: &Array2<f64>,
273 y: &Array1<f64>,
274 ) -> Result<Vec<f64>> {
275 let mut scores = Vec::new();
276
277 let fold_indices = self.stratified_k_fold_split(y, self.config.cv_folds)?;
279
280 for fold in 0..self.config.cv_folds {
281 let test_indices = &fold_indices[fold];
282 let train_indices: Vec<usize> = fold_indices
283 .iter()
284 .enumerate()
285 .filter(|(i, _)| *i != fold)
286 .flat_map(|(_, indices)| indices.iter().copied())
287 .collect();
288
289 let mut x_train_data = Vec::new();
291 let mut y_train_vals = Vec::new();
292 let mut x_test_data = Vec::new();
293 let mut y_test_vals = Vec::new();
294
295 for &i in &train_indices {
296 for j in 0..x.ncols() {
297 x_train_data.push(x[[i, j]]);
298 }
299 y_train_vals.push(y[i]);
300 }
301
302 for &i in test_indices {
303 for j in 0..x.ncols() {
304 x_test_data.push(x[[i, j]]);
305 }
306 y_test_vals.push(y[i]);
307 }
308
309 let n_train = y_train_vals.len();
310 let n_test = y_test_vals.len();
311 let n_features = x.ncols();
312
313 let unique_classes: std::collections::HashSet<_> =
315 y_train_vals.iter().map(|&v| v as i32).collect();
316 if unique_classes.len() < 2 {
317 return Err(SklearsError::InvalidInput(format!(
318 "Training fold {} has only {} unique class(es). Need at least 2 classes for SVM.",
319 fold, unique_classes.len()
320 )));
321 }
322
323 let x_train = Array2::from_shape_vec((n_train, n_features), x_train_data)?;
324 let y_train = Array1::from_vec(y_train_vals);
325 let x_test = Array2::from_shape_vec((n_test, n_features), x_test_data)?;
326 let y_test = Array1::from_vec(y_test_vals);
327
328 let svm = SVC::new()
330 .c(params.c)
331 .kernel(params.kernel.clone())
332 .tol(params.tol)
333 .max_iter(params.max_iter);
334
335 let fitted_svm = svm.fit(&x_train, &y_train)?;
336 let y_pred = fitted_svm.predict(&x_test)?;
337
338 let score = self.calculate_score(&y_test, &y_pred)?;
339 scores.push(score);
340 }
341
342 Ok(scores)
343 }
344
345 fn stratified_k_fold_split(&self, y: &Array1<f64>, n_folds: usize) -> Result<Vec<Vec<usize>>> {
347 use std::collections::HashMap;
348
349 let mut class_indices: HashMap<i32, Vec<usize>> = HashMap::new();
351 for (idx, &label) in y.iter().enumerate() {
352 class_indices.entry(label as i32).or_default().push(idx);
353 }
354
355 if class_indices.len() < 2 {
357 return Err(SklearsError::InvalidInput(format!(
358 "Dataset has only {} unique class(es). Need at least 2 classes for classification.",
359 class_indices.len()
360 )));
361 }
362
363 let mut rng = if let Some(seed) = self.config.random_state {
365 scirs2_core::random::Random::seed(seed)
366 } else {
367 scirs2_core::random::Random::seed(42)
368 };
369
370 for indices in class_indices.values_mut() {
371 for i in (1..indices.len()).rev() {
373 use scirs2_core::random::essentials::Uniform;
374 let dist = Uniform::new(0, i + 1).map_err(|e| {
375 SklearsError::InvalidInput(format!(
376 "Failed to create uniform distribution: {}",
377 e
378 ))
379 })?;
380 let j = rng.sample(dist);
381 indices.swap(i, j);
382 }
383 }
384
385 let mut folds: Vec<Vec<usize>> = vec![Vec::new(); n_folds];
387
388 for indices in class_indices.values() {
390 for (fold_idx, &sample_idx) in indices.iter().enumerate() {
391 folds[fold_idx % n_folds].push(sample_idx);
392 }
393 }
394
395 for (fold_idx, fold) in folds.iter().enumerate() {
397 if fold.is_empty() {
398 return Err(SklearsError::InvalidInput(format!(
399 "Fold {} is empty. Consider using fewer folds for this dataset size.",
400 fold_idx
401 )));
402 }
403 }
404
405 Ok(folds)
406 }
407
408 fn calculate_score(&self, y_true: &Array1<f64>, y_pred: &Array1<f64>) -> Result<f64> {
410 match self.config.scoring {
411 ScoringMetric::Accuracy => {
412 let correct = y_true
413 .iter()
414 .zip(y_pred.iter())
415 .map(|(&t, &p)| if (t - p).abs() < 0.5 { 1.0 } else { 0.0 })
416 .sum::<f64>();
417 Ok(correct / y_true.len() as f64)
418 }
419 ScoringMetric::MeanSquaredError => {
420 let mse = y_true
421 .iter()
422 .zip(y_pred.iter())
423 .map(|(&t, &p)| (t - p).powi(2))
424 .sum::<f64>()
425 / y_true.len() as f64;
426 Ok(-mse) }
428 ScoringMetric::MeanAbsoluteError => {
429 let mae = y_true
430 .iter()
431 .zip(y_pred.iter())
432 .map(|(&t, &p)| (t - p).abs())
433 .sum::<f64>()
434 / y_true.len() as f64;
435 Ok(-mae) }
437 _ => {
438 let correct = y_true
440 .iter()
441 .zip(y_pred.iter())
442 .map(|(&t, &p)| if (t - p).abs() < 0.5 { 1.0 } else { 0.0 })
443 .sum::<f64>();
444 Ok(correct / y_true.len() as f64)
445 }
446 }
447 }
448}
449
450#[cfg(test)]
451mod tests {
452 use super::*;
453 use scirs2_core::ndarray::{Array1, Array2};
454
455 fn generate_simple_dataset() -> (Array2<f64>, Array1<f64>) {
456 let x = Array2::from_shape_vec(
459 (6, 2),
460 vec![
461 1.0, 1.0, 1.1, 1.1, 1.2, 1.2,
463 5.0, 5.0, 5.1, 5.1, 5.2, 5.2,
465 ],
466 )
467 .expect("Failed to create test dataset: shape error");
468
469 let y = Array1::from_vec(vec![-1.0, -1.0, -1.0, 1.0, 1.0, 1.0]);
470
471 (x, y)
472 }
473
474 #[test]
475 fn test_stratified_k_fold_split() {
476 let config = OptimizationConfig {
477 n_iterations: 5,
478 cv_folds: 3,
479 scoring: ScoringMetric::Accuracy,
480 random_state: Some(42),
481 n_jobs: None,
482 verbose: false,
483 early_stopping_patience: None,
484 };
485 let search_space = SearchSpace::default();
486 let optimizer = RandomSearchCV::new(config, search_space);
487
488 let y = Array1::from_vec(vec![
490 -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0,
491 ]);
492
493 let folds = optimizer
494 .stratified_k_fold_split(&y, 3)
495 .expect("Failed to create folds");
496
497 assert_eq!(folds.len(), 3);
499
500 for fold in &folds {
502 assert!(!fold.is_empty(), "Fold should not be empty");
503 }
504
505 let mut all_indices: Vec<usize> = folds.iter().flat_map(|f| f.iter().copied()).collect();
507 all_indices.sort_unstable();
508 assert_eq!(all_indices, (0..12).collect::<Vec<_>>());
509
510 for (fold_idx, fold) in folds.iter().enumerate() {
512 let fold_labels: Vec<i32> = fold.iter().map(|&i| y[i] as i32).collect();
513 let unique_classes: std::collections::HashSet<_> =
514 fold_labels.iter().copied().collect();
515 assert!(
516 unique_classes.len() >= 2,
517 "Fold {} should have at least 2 classes, got {:?}",
518 fold_idx,
519 unique_classes
520 );
521 }
522 }
523
524 #[test]
525 #[ignore = "SVM solver performance issue - takes >10 seconds even with minimal dataset. Run with --ignored flag when needed."]
526 fn test_random_search_basic() {
527 let (x, y) = generate_simple_dataset();
528
529 let config = OptimizationConfig {
530 n_iterations: 1, cv_folds: 2, scoring: ScoringMetric::Accuracy,
533 random_state: Some(42),
534 n_jobs: None,
535 verbose: false,
536 early_stopping_patience: None,
537 };
538
539 let search_space = SearchSpace {
540 c: ParameterSpec::Fixed(1.0), gamma: None,
542 degree: None,
543 coef0: None,
544 kernel: Some(ParameterSpec::KernelChoice(vec![KernelType::Linear])), tol: Some(ParameterSpec::Fixed(0.1)), max_iter: Some(ParameterSpec::Fixed(10.0)), };
548
549 let mut optimizer = RandomSearchCV::new(config, search_space);
550 let result = optimizer
551 .fit(&x, &y)
552 .expect("RandomSearchCV fit should succeed");
553
554 assert!(
557 result.best_score >= 0.3,
558 "Best score should be at least 0.3, got {}",
559 result.best_score
560 );
561 assert_eq!(result.n_iterations, 1);
562 assert_eq!(result.cv_results.len(), 1);
563 assert_eq!(result.score_history.len(), 1);
564 assert!(result.best_params.c > 0.0);
565 }
566
567 #[test]
568 #[ignore = "SVM solver performance issue - takes >20 seconds even with minimal dataset. Run with --ignored flag when needed."]
569 fn test_random_search_with_early_stopping() {
570 let (x, y) = generate_simple_dataset();
571
572 let config = OptimizationConfig {
573 n_iterations: 2, cv_folds: 2, scoring: ScoringMetric::Accuracy,
576 random_state: Some(42),
577 n_jobs: None,
578 verbose: false,
579 early_stopping_patience: Some(1), };
581
582 let search_space = SearchSpace {
583 c: ParameterSpec::Fixed(1.0), gamma: None,
585 degree: None,
586 coef0: None,
587 kernel: Some(ParameterSpec::KernelChoice(vec![KernelType::Linear])), tol: Some(ParameterSpec::Fixed(0.1)), max_iter: Some(ParameterSpec::Fixed(10.0)), };
591 let mut optimizer = RandomSearchCV::new(config, search_space);
592 let result = optimizer
593 .fit(&x, &y)
594 .expect("RandomSearchCV fit should succeed");
595
596 assert!(
598 result.n_iterations <= 2,
599 "Should complete within 2 iterations, got {}",
600 result.n_iterations
601 );
602 assert!(
604 result.best_score >= 0.3,
605 "Best score should be at least 0.3, got {}",
606 result.best_score
607 );
608 }
609
610 #[test]
611 #[ignore = "SVM solver performance issue - takes >30 seconds. Run with --ignored flag when needed."]
612 fn test_cross_validation_no_single_class() {
613 let (x, y) = generate_simple_dataset();
615
616 let config = OptimizationConfig {
617 n_iterations: 1,
618 cv_folds: 2,
619 scoring: ScoringMetric::Accuracy,
620 random_state: Some(42),
621 n_jobs: None,
622 verbose: false,
623 early_stopping_patience: None,
624 };
625 let search_space = SearchSpace::default();
626 let optimizer = RandomSearchCV::new(config, search_space);
627
628 let params = ParameterSet {
630 c: 1.0,
631 kernel: KernelType::Linear,
632 tol: 0.1, max_iter: 10, };
635
636 let result = optimizer.cross_validate(¶ms, &x, &y);
638 assert!(
639 result.is_ok(),
640 "Cross-validation should succeed with stratified K-fold: {:?}",
641 result.err()
642 );
643
644 let scores = result.expect("Should get scores");
645 assert_eq!(scores.len(), 2, "Should have 2 CV scores");
646
647 for score in scores {
649 assert!(score.is_finite(), "Score should be finite, got {}", score);
650 }
651 }
652
653 #[test]
654 fn test_random_search_parameter_sampling() {
655 let config = OptimizationConfig::default();
656 let search_space = SearchSpace {
657 c: ParameterSpec::Choice(vec![0.1, 1.0, 10.0]),
658 gamma: Some(ParameterSpec::LogUniform {
659 min: 0.01,
660 max: 1.0,
661 }),
662 degree: Some(ParameterSpec::Choice(vec![2.0, 3.0, 4.0])),
663 coef0: Some(ParameterSpec::Uniform { min: 0.0, max: 1.0 }),
664 kernel: None,
665 tol: None,
666 max_iter: None,
667 };
668
669 let mut optimizer = RandomSearchCV::new(config, search_space);
670
671 let params_vec = optimizer
673 .sample_parameters(20)
674 .expect("Failed to sample parameters");
675 for params in params_vec {
676 assert!([0.1, 1.0, 10.0].contains(¶ms.c));
678 assert!(params.tol > 0.0);
679 assert!(params.max_iter > 0);
680 }
681 }
682
683 #[test]
684 #[ignore = "SVM solver performance issue - takes >30 seconds (3 metrics tested). Run with --ignored flag when needed."]
685 fn test_random_search_scoring_metrics() {
686 let (x, y) = generate_simple_dataset();
687
688 let metrics = vec![
689 ScoringMetric::Accuracy,
690 ScoringMetric::MeanSquaredError,
691 ScoringMetric::MeanAbsoluteError,
692 ];
693
694 for metric in metrics {
695 let config = OptimizationConfig {
696 n_iterations: 1, cv_folds: 2, scoring: metric.clone(),
699 random_state: Some(42),
700 n_jobs: None,
701 verbose: false,
702 early_stopping_patience: None,
703 };
704
705 let search_space = SearchSpace {
706 c: ParameterSpec::Fixed(1.0),
707 gamma: None,
708 degree: None,
709 coef0: None,
710 kernel: Some(ParameterSpec::KernelChoice(vec![KernelType::Linear])),
712 tol: Some(ParameterSpec::Fixed(0.1)), max_iter: Some(ParameterSpec::Fixed(10.0)), };
715
716 let mut optimizer = RandomSearchCV::new(config, search_space);
717 let result = optimizer.fit(&x, &y);
718 assert!(
719 result.is_ok(),
720 "Optimization should succeed for {:?}, got error: {:?}",
721 metric,
722 result.err()
723 );
724 }
725 }
726}