1pub mod evolution;
26pub mod grid;
27pub mod support;
28pub mod surrogate;
29pub mod tpe;
30
31use crate::error::{OptimError, Result};
32use scirs2_core::numeric::Float;
33use scirs2_core::random::Random;
34use serde::{Deserialize, Serialize};
35use std::collections::HashMap;
36use std::fmt;
37
38use support::StrategyRng;
39
40#[derive(Debug)]
42pub struct HyperparameterOptimizer<T: Float> {
43 search_space: HyperparameterSpace<T>,
45
46 strategy: OptimizationStrategy,
48
49 evaluation_history: Vec<HyperparameterEvaluation<T>>,
51
52 best_config: Option<HyperparameterConfiguration<T>>,
54
55 state: OptimizerState<T>,
57
58 rng: StrategyRng,
61
62 grid_resolution: usize,
64
65 tpe_config: tpe::TpeConfig,
67
68 surrogate_config: surrogate::SurrogateConfig,
70
71 evolution_config: evolution::EvolutionConfig,
73
74 acquisition: AcquisitionFunction,
76
77 last_strategy_used: OptimizationStrategy,
81}
82
83#[derive(Debug, Clone)]
85pub struct HyperparameterSpace<T: Float> {
86 parameters: HashMap<String, ParameterRange<T>>,
88
89 dependencies: Vec<ParameterDependency>,
91
92 constraints: Vec<HyperparameterConstraint<T>>,
94
95 categorical_parameters: HashMap<String, Vec<String>>,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct ParameterRange<T: Float> {
102 pub name: String,
104
105 pub min_value: T,
107
108 pub max_value: T,
110
111 pub distribution: DistributionType,
113
114 pub log_scale: bool,
116
117 pub discrete_values: Option<Vec<T>>,
119}
120
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
123pub enum DistributionType {
124 Uniform,
126 Normal,
128 LogNormal,
130 Beta,
132 Exponential,
134}
135
136#[derive(Debug, Clone)]
138pub struct ParameterDependency {
139 pub dependent: String,
141
142 pub parent: String,
144
145 pub dependency_type: DependencyType,
147
148 pub condition: DependencyCondition,
150}
151
152#[derive(Debug, Clone, Copy, PartialEq, Eq)]
154pub enum DependencyType {
155 Conditional,
157 Derived,
159 RangeDependent,
161}
162
163#[derive(Debug, Clone)]
165pub enum DependencyCondition {
166 Equals(String),
168 GreaterThan(f64),
170 LessThan(f64),
172 InRange(f64, f64),
174 InSet(Vec<String>),
176}
177
178#[derive(Debug, Clone)]
180pub struct HyperparameterConstraint<T: Float> {
181 pub name: String,
183
184 pub parameters: Vec<String>,
186
187 pub constraint_type: ConstraintType<T>,
189
190 pub penalty: T,
192}
193
194#[derive(Debug, Clone)]
196pub enum ConstraintType<T: Float> {
197 Linear { coefficients: Vec<T>, bound: T },
199 NonLinear { function_name: String },
201 MutualExclusion,
203 Ordering,
205}
206
207#[derive(Debug, Clone, Serialize, Deserialize)]
209pub struct HyperparameterConfiguration<T: Float> {
210 pub id: String,
212
213 pub parameters: HashMap<String, T>,
215
216 pub categorical_parameters: HashMap<String, String>,
218
219 pub score: Option<T>,
221
222 pub metadata: HashMap<String, String>,
224}
225
226#[derive(Debug, Clone)]
228pub struct HyperparameterEvaluation<T: Float> {
229 pub configuration: HyperparameterConfiguration<T>,
231
232 pub metrics: EvaluationMetrics<T>,
234
235 pub duration_seconds: f64,
237
238 pub status: EvaluationStatus,
240
241 pub error_message: Option<String>,
243}
244
245#[derive(Debug, Clone)]
247pub struct EvaluationMetrics<T: Float> {
248 pub primary_objective: T,
250
251 pub secondary_objectives: HashMap<String, T>,
253
254 pub validation_score: Option<T>,
256
257 pub training_time: f64,
259
260 pub memory_usage: f64,
262
263 pub converged: bool,
265
266 pub convergence_iterations: Option<u32>,
268}
269
270#[derive(Debug, Clone, Copy, PartialEq, Eq)]
272pub enum EvaluationStatus {
273 Success,
275 Failed,
277 Terminated,
279 Running,
281 Timeout,
283}
284
285#[derive(Debug, Clone, Copy, PartialEq, Eq)]
287pub enum OptimizationStrategy {
288 Random,
290 Grid,
292 Bayesian,
294 Evolutionary,
296 ParticleSwarm,
298 TPE,
300 SuccessiveHalving,
302 Hyperband,
304 BOHB,
306}
307
308#[derive(Debug)]
310pub struct OptimizerState<T: Float> {
311 pub iteration: u32,
313
314 pub num_evaluations: u32,
316
317 pub best_score: Option<T>,
319
320 pub population: Vec<HyperparameterConfiguration<T>>,
322
323 pub surrogate_model: Option<SurrogateModel<T>>,
325
326 pub early_stopping: EarlyStoppingState<T>,
328}
329
330#[derive(Debug)]
332pub struct SurrogateModel<T: Float> {
333 pub model_type: SurrogateModelType,
335
336 pub training_data: Vec<(Vec<T>, T)>,
338
339 pub hyperparameters: HashMap<String, f64>,
341
342 pub acquisition_function: AcquisitionFunction,
344}
345
346#[derive(Debug, Clone, Copy, PartialEq, Eq)]
348pub enum SurrogateModelType {
349 KernelRegression,
353 GaussianProcess,
357 RandomForest,
359 NeuralNetwork,
361 Polynomial,
364}
365
366#[derive(Debug, Clone, Copy, PartialEq, Eq)]
368pub enum AcquisitionFunction {
369 ExpectedImprovement,
371 UpperConfidenceBound,
373 ProbabilityOfImprovement,
375 EntropySearch,
377}
378
379#[derive(Debug)]
381pub struct EarlyStoppingState<T: Float> {
382 pub enabled: bool,
384
385 pub patience: u32,
387
388 pub patience_counter: u32,
390
391 pub min_improvement: T,
393
394 pub best_score_for_stopping: Option<T>,
396}
397
398impl<T: Float + Default + Clone + Send + Sync> HyperparameterOptimizer<T> {
399 pub fn new(search_space: HyperparameterSpace<T>, strategy: OptimizationStrategy) -> Self {
401 Self::with_seed(search_space, strategy, scirs2_core::random::random::<u64>())
402 }
403
404 pub fn with_seed(
406 search_space: HyperparameterSpace<T>,
407 strategy: OptimizationStrategy,
408 seed: u64,
409 ) -> Self {
410 Self {
411 search_space,
412 strategy,
413 evaluation_history: Vec::new(),
414 best_config: None,
415 rng: Random::seed(seed),
416 grid_resolution: grid::DEFAULT_GRID_RESOLUTION,
417 tpe_config: tpe::TpeConfig::default(),
418 surrogate_config: surrogate::SurrogateConfig::default(),
419 evolution_config: evolution::EvolutionConfig::default(),
420 acquisition: AcquisitionFunction::ExpectedImprovement,
421 last_strategy_used: strategy,
422 state: OptimizerState {
423 iteration: 0,
424 num_evaluations: 0,
425 best_score: None,
426 population: Vec::new(),
427 surrogate_model: None,
428 early_stopping: EarlyStoppingState {
429 enabled: false,
430 patience: 50,
431 patience_counter: 0,
432 min_improvement: T::from(0.001).unwrap_or_else(T::zero),
433 best_score_for_stopping: None,
434 },
435 },
436 }
437 }
438
439 pub fn suggest_configuration(&mut self) -> Result<HyperparameterConfiguration<T>> {
452 if self.search_space.is_empty() {
453 return Err(OptimError::InvalidConfig(
454 "hyperparameter search requires a non-empty search space".to_string(),
455 ));
456 }
457
458 let (parameters, categorical_parameters, used) = match self.strategy {
459 OptimizationStrategy::Random => {
460 let (parameters, categorical) = self.sample_from_prior();
461 (parameters, categorical, OptimizationStrategy::Random)
462 }
463 OptimizationStrategy::Grid => {
464 let index = self.state.num_evaluations as usize;
465 let (parameters, categorical) = grid::suggest(
466 &self.search_space,
467 index,
468 self.grid_resolution,
469 &mut self.rng,
470 )?;
471 (parameters, categorical, OptimizationStrategy::Grid)
472 }
473 OptimizationStrategy::TPE => {
474 let scored = Self::scored_observations(&self.evaluation_history);
475 match tpe::suggest(&self.search_space, &scored, &self.tpe_config, &mut self.rng) {
476 Ok((parameters, categorical)) => {
477 (parameters, categorical, OptimizationStrategy::TPE)
478 }
479 Err(_) if scored.len() < self.tpe_config.startup_trials.max(2) => {
480 drop(scored);
483 let (parameters, categorical) = self.sample_from_prior();
484 (parameters, categorical, OptimizationStrategy::Random)
485 }
486 Err(error) => return Err(error),
487 }
488 }
489 OptimizationStrategy::Bayesian => {
490 let scored = Self::scored_observations(&self.evaluation_history);
491 match surrogate::suggest(
492 &self.search_space,
493 &scored,
494 self.acquisition,
495 &self.surrogate_config,
496 &mut self.rng,
497 ) {
498 Ok((parameters, categorical)) => {
499 (parameters, categorical, OptimizationStrategy::Bayesian)
500 }
501 Err(_) if scored.len() < self.surrogate_config.startup_trials.max(2) => {
502 drop(scored);
503 let (parameters, categorical) = self.sample_from_prior();
504 (parameters, categorical, OptimizationStrategy::Random)
505 }
506 Err(error) => return Err(error),
507 }
508 }
509 OptimizationStrategy::Evolutionary => {
510 let population: Vec<(&HyperparameterConfiguration<T>, f64)> = self
511 .state
512 .population
513 .iter()
514 .filter_map(|config| config.score.and_then(|s| s.to_f64()).map(|s| (config, s)))
515 .collect();
516 if population.is_empty() {
517 drop(population);
518 let (parameters, categorical) = self.sample_from_prior();
519 (parameters, categorical, OptimizationStrategy::Random)
520 } else {
521 let (parameters, categorical) = evolution::breed(
522 &self.search_space,
523 &population,
524 &self.evolution_config,
525 &mut self.rng,
526 )?;
527 (parameters, categorical, OptimizationStrategy::Evolutionary)
528 }
529 }
530 unimplemented => {
531 return Err(OptimError::NotImplemented(format!(
532 "hyperparameter strategy {:?} is not implemented in optirs-nas; \
533 available strategies are Random, Grid, TPE, Bayesian and Evolutionary",
534 unimplemented
535 )))
536 }
537 };
538
539 self.last_strategy_used = used;
540 Ok(HyperparameterConfiguration {
541 id: format!("config_{}", self.state.num_evaluations),
542 parameters,
543 categorical_parameters,
544 score: None,
545 metadata: HashMap::from([("strategy".to_string(), format!("{:?}", used))]),
546 })
547 }
548
549 pub fn last_strategy_used(&self) -> OptimizationStrategy {
553 self.last_strategy_used
554 }
555
556 pub fn strategy(&self) -> OptimizationStrategy {
558 self.strategy
559 }
560
561 pub fn grid_resolution(&self) -> usize {
563 self.grid_resolution
564 }
565
566 pub fn set_grid_resolution(&mut self, resolution: usize) {
568 self.grid_resolution = resolution.max(1);
569 }
570
571 pub fn grid_size(&self) -> Option<usize> {
573 grid::grid_size(&self.search_space, self.grid_resolution)
574 }
575
576 pub fn set_acquisition_function(&mut self, acquisition: AcquisitionFunction) {
578 self.acquisition = acquisition;
579 }
580
581 pub fn set_tpe_config(&mut self, config: tpe::TpeConfig) {
583 self.tpe_config = config;
584 }
585
586 pub fn set_surrogate_config(&mut self, config: surrogate::SurrogateConfig) {
588 self.surrogate_config = config;
589 }
590
591 pub fn set_evolution_config(&mut self, config: evolution::EvolutionConfig) {
593 self.evolution_config = config;
594 }
595
596 pub fn get_search_space(&self) -> &HyperparameterSpace<T> {
598 &self.search_space
599 }
600
601 pub fn population(&self) -> &[HyperparameterConfiguration<T>] {
604 &self.state.population
605 }
606
607 pub fn surrogate_model(&self) -> Option<&SurrogateModel<T>> {
610 self.state.surrogate_model.as_ref()
611 }
612
613 fn scored_observations(
615 history: &[HyperparameterEvaluation<T>],
616 ) -> Vec<(&HyperparameterConfiguration<T>, f64)> {
617 history
618 .iter()
619 .filter(|evaluation| matches!(evaluation.status, EvaluationStatus::Success))
620 .filter_map(|evaluation| {
621 evaluation
622 .configuration
623 .score
624 .and_then(|score| score.to_f64())
625 .map(|score| (&evaluation.configuration, score))
626 })
627 .collect()
628 }
629
630 pub fn record_evaluation(&mut self, evaluation: HyperparameterEvaluation<T>) {
632 self.state.num_evaluations += 1;
633
634 if let Some(score) = evaluation.configuration.score {
636 let is_better = match self.best_config.as_ref().and_then(|best| best.score) {
637 Some(best) => score > best,
638 None => true,
639 };
640 if is_better {
641 self.best_config = Some(evaluation.configuration.clone());
642 self.state.best_score = Some(score);
643 }
644 }
645
646 if self.state.early_stopping.enabled {
648 self.update_early_stopping(&evaluation);
649 }
650
651 self.evaluation_history.push(evaluation);
653
654 self.update_strategy_state();
656 }
657
658 pub fn should_stop_early(&self) -> bool {
660 if !self.state.early_stopping.enabled {
661 return false;
662 }
663
664 self.state.early_stopping.patience_counter >= self.state.early_stopping.patience
665 }
666
667 pub fn get_best_configuration(&self) -> Option<&HyperparameterConfiguration<T>> {
669 self.best_config.as_ref()
670 }
671
672 pub fn get_statistics(&self) -> OptimizationStatistics<T> {
674 let scores: Vec<T> = self
675 .evaluation_history
676 .iter()
677 .filter_map(|eval| eval.configuration.score)
678 .collect();
679
680 let mean_score = if scores.is_empty() {
681 T::zero()
682 } else {
683 scores.iter().fold(T::zero(), |acc, &x| acc + x)
684 / T::from(scores.len()).unwrap_or_else(|| T::one())
685 };
686
687 OptimizationStatistics {
688 num_evaluations: self.state.num_evaluations,
689 best_score: self.state.best_score,
690 mean_score: Some(mean_score),
691 num_successful_evaluations: self
692 .evaluation_history
693 .iter()
694 .filter(|eval| matches!(eval.status, EvaluationStatus::Success))
695 .count() as u32,
696 convergence_iteration: self.get_convergence_iteration(),
697 }
698 }
699
700 fn sample_from_prior(&mut self) -> (HashMap<String, T>, HashMap<String, String>) {
711 let mut parameters = HashMap::new();
712 let mut categorical_parameters = HashMap::new();
713
714 for name in support::sorted_parameter_names(&self.search_space) {
715 let Some(range) = self.search_space.parameters.get(&name).cloned() else {
716 continue;
717 };
718 let raw =
719 support::snap_to_discrete(&range, support::sample_parameter(&range, &mut self.rng));
720 parameters.insert(
721 name,
722 scirs2_core::numeric::NumCast::from(raw).unwrap_or(range.min_value),
723 );
724 }
725
726 for name in support::sorted_categorical_names(&self.search_space) {
727 let Some(categories) = self.search_space.categorical_parameters.get(&name).cloned()
728 else {
729 continue;
730 };
731 if categories.is_empty() {
732 continue;
733 }
734 let idx = self.rng.gen_range(0..categories.len());
735 categorical_parameters.insert(name, categories[idx].clone());
736 }
737
738 (parameters, categorical_parameters)
739 }
740
741 fn update_early_stopping(&mut self, evaluation: &HyperparameterEvaluation<T>) {
742 if let Some(score) = evaluation.configuration.score {
743 if let Some(best_score) = self.state.early_stopping.best_score_for_stopping {
744 if score > best_score + self.state.early_stopping.min_improvement {
745 self.state.early_stopping.best_score_for_stopping = Some(score);
746 self.state.early_stopping.patience_counter = 0;
747 } else {
748 self.state.early_stopping.patience_counter += 1;
749 }
750 } else {
751 self.state.early_stopping.best_score_for_stopping = Some(score);
752 }
753 }
754 }
755
756 fn update_strategy_state(&mut self) {
763 self.state.iteration += 1;
764
765 match self.strategy {
766 OptimizationStrategy::Evolutionary => {
767 let scored: Vec<HyperparameterConfiguration<T>> = self
768 .evaluation_history
769 .iter()
770 .filter(|evaluation| {
771 matches!(evaluation.status, EvaluationStatus::Success)
772 && evaluation.configuration.score.is_some()
773 })
774 .map(|evaluation| evaluation.configuration.clone())
775 .collect();
776 self.state.population = scored;
777 evolution::survivor_selection(&mut self.state.population, &self.evolution_config);
778 }
779 OptimizationStrategy::Bayesian => {
780 let continuous = support::sorted_parameter_names(&self.search_space);
781 let training_data: Vec<(Vec<T>, T)> = self
782 .evaluation_history
783 .iter()
784 .filter_map(|evaluation| {
785 let score = evaluation.configuration.score?;
786 let vector = continuous
787 .iter()
788 .map(|name| {
789 evaluation
790 .configuration
791 .parameters
792 .get(name)
793 .copied()
794 .unwrap_or_else(T::zero)
795 })
796 .collect();
797 Some((vector, score))
798 })
799 .collect();
800 let mut hyperparameters = HashMap::new();
801 hyperparameters.insert("bandwidth".to_string(), self.surrogate_config.bandwidth);
802 hyperparameters.insert("ucb_beta".to_string(), self.surrogate_config.ucb_beta);
803 self.state.surrogate_model = Some(SurrogateModel {
804 model_type: surrogate::IMPLEMENTED_SURROGATE,
805 training_data,
806 hyperparameters,
807 acquisition_function: self.acquisition,
808 });
809 }
810 _ => {}
811 }
812 }
813
814 fn get_convergence_iteration(&self) -> Option<u32> {
815 let window_size = 10;
817 if self.evaluation_history.len() < window_size * 2 {
818 return None;
819 }
820
821 let recent_scores: Vec<T> = self
822 .evaluation_history
823 .iter()
824 .rev()
825 .take(window_size)
826 .filter_map(|eval| eval.configuration.score)
827 .collect();
828
829 let older_scores: Vec<T> = self
830 .evaluation_history
831 .iter()
832 .rev()
833 .skip(window_size)
834 .take(window_size)
835 .filter_map(|eval| eval.configuration.score)
836 .collect();
837
838 if recent_scores.len() == window_size && older_scores.len() == window_size {
839 let recent_mean = recent_scores.iter().fold(T::zero(), |acc, &x| acc + x)
840 / T::from(window_size).unwrap_or_else(|| T::one());
841 let older_mean = older_scores.iter().fold(T::zero(), |acc, &x| acc + x)
842 / T::from(window_size).unwrap_or_else(|| T::one());
843
844 let improvement = recent_mean - older_mean;
845 let threshold = T::from(0.001).unwrap_or_else(T::zero);
846
847 if improvement < threshold {
848 return Some(self.state.iteration - window_size as u32);
849 }
850 }
851
852 None
853 }
854}
855
856#[derive(Debug, Clone)]
858pub struct OptimizationStatistics<T: Float> {
859 pub num_evaluations: u32,
861
862 pub best_score: Option<T>,
864
865 pub mean_score: Option<T>,
867
868 pub num_successful_evaluations: u32,
870
871 pub convergence_iteration: Option<u32>,
873}
874
875impl<T: Float> HyperparameterSpace<T> {
876 pub fn new() -> Self {
878 Self {
879 parameters: HashMap::new(),
880 dependencies: Vec::new(),
881 constraints: Vec::new(),
882 categorical_parameters: HashMap::new(),
883 }
884 }
885
886 pub fn parameter_ranges(&self) -> &HashMap<String, ParameterRange<T>> {
888 &self.parameters
889 }
890
891 pub fn categorical_options(&self) -> &HashMap<String, Vec<String>> {
893 &self.categorical_parameters
894 }
895
896 pub fn dependencies(&self) -> &[ParameterDependency] {
898 &self.dependencies
899 }
900
901 pub fn constraints(&self) -> &[HyperparameterConstraint<T>] {
903 &self.constraints
904 }
905
906 pub fn is_empty(&self) -> bool {
908 self.parameters.is_empty() && self.categorical_parameters.is_empty()
909 }
910
911 pub fn add_parameter(&mut self, name: String, range: ParameterRange<T>) {
913 self.parameters.insert(name, range);
914 }
915
916 pub fn add_categorical_parameter(&mut self, name: String, categories: Vec<String>) {
918 self.categorical_parameters.insert(name, categories);
919 }
920
921 pub fn add_dependency(&mut self, dependency: ParameterDependency) {
923 self.dependencies.push(dependency);
924 }
925
926 pub fn add_constraint(&mut self, constraint: HyperparameterConstraint<T>) {
928 self.constraints.push(constraint);
929 }
930
931 pub fn validate_configuration(&self, config: &HyperparameterConfiguration<T>) -> bool {
933 for (name, value) in &config.parameters {
935 if let Some(range) = self.parameters.get(name) {
936 if *value < range.min_value || *value > range.max_value {
937 return false;
938 }
939 }
940 }
941
942 for (name, value) in &config.categorical_parameters {
944 if let Some(categories) = self.categorical_parameters.get(name) {
945 if !categories.contains(value) {
946 return false;
947 }
948 }
949 }
950
951 for constraint in &self.constraints {
953 if !self.check_constraint(constraint, config) {
954 return false;
955 }
956 }
957
958 true
959 }
960
961 fn check_constraint(
962 &self,
963 constraint: &HyperparameterConstraint<T>,
964 config: &HyperparameterConfiguration<T>,
965 ) -> bool {
966 match &constraint.constraint_type {
967 ConstraintType::Linear {
968 coefficients,
969 bound,
970 } => {
971 let mut sum = T::zero();
972 for (i, param_name) in constraint.parameters.iter().enumerate() {
973 if let Some(value) = config.parameters.get(param_name) {
974 sum = sum + coefficients[i] * *value;
975 }
976 }
977 sum <= *bound
978 }
979 ConstraintType::MutualExclusion => {
980 let active_count = constraint
981 .parameters
982 .iter()
983 .filter(|param| config.parameters.contains_key(*param))
984 .count();
985 active_count <= 1
986 }
987 ConstraintType::Ordering => {
988 let mut values = Vec::new();
989 for param_name in &constraint.parameters {
990 if let Some(value) = config.parameters.get(param_name) {
991 values.push(*value);
992 }
993 }
994
995 for i in 1..values.len() {
996 if values[i - 1] > values[i] {
997 return false;
998 }
999 }
1000 true
1001 }
1002 ConstraintType::NonLinear { .. } => {
1003 true
1005 }
1006 }
1007 }
1008}
1009
1010impl<T: Float> Default for HyperparameterSpace<T> {
1011 fn default() -> Self {
1012 Self::new()
1013 }
1014}
1015
1016impl<T: Float + fmt::Debug> fmt::Display for HyperparameterConfiguration<T> {
1017 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1018 writeln!(f, "Configuration: {}", self.id)?;
1019 for (name, value) in &self.parameters {
1020 writeln!(f, " {}: {:?}", name, value)?;
1021 }
1022 for (name, value) in &self.categorical_parameters {
1023 writeln!(f, " {}: {}", name, value)?;
1024 }
1025 if let Some(score) = self.score {
1026 writeln!(f, " Score: {:?}", score)?;
1027 }
1028 Ok(())
1029 }
1030}
1031
1032#[cfg(test)]
1033mod tests;