1use std::collections::HashMap;
40use std::fmt;
41
42#[inline]
47fn xorshift64(state: &mut u64) -> u64 {
48 *state ^= *state << 13;
49 *state ^= *state >> 7;
50 *state ^= *state << 17;
51 *state
52}
53
54#[inline]
56fn rng_f64(state: &mut u64) -> f64 {
57 let bits = xorshift64(state) >> 11;
59 bits as f64 / (1u64 << 53) as f64
60}
61
62#[inline]
64fn rng_i64_range(state: &mut u64, lo: i64, hi: i64) -> i64 {
65 if lo >= hi {
66 return lo;
67 }
68 let span = (hi - lo + 1) as u64;
69 lo + (xorshift64(state) % span) as i64
70}
71
72#[inline]
74fn rng_usize_range(state: &mut u64, lo: usize, hi: usize) -> usize {
75 if lo >= hi {
76 return lo;
77 }
78 lo + (xorshift64(state) as usize % (hi - lo))
79}
80
81#[derive(Debug, Clone, PartialEq)]
87pub enum HpTunerError {
88 NoSpecs,
90 NoHistory,
92 InvalidSpec(String),
94}
95
96impl fmt::Display for HpTunerError {
97 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
98 match self {
99 HpTunerError::NoSpecs => write!(f, "no hyperparameter specs defined"),
100 HpTunerError::NoHistory => write!(f, "no trial history available"),
101 HpTunerError::InvalidSpec(msg) => write!(f, "invalid spec: {}", msg),
102 }
103 }
104}
105
106impl std::error::Error for HpTunerError {}
107
108#[derive(Debug, Clone, PartialEq)]
114pub enum HpType {
115 Continuous { lo: f64, hi: f64 },
117 Discrete { lo: i64, hi: i64 },
119 Categorical { choices: Vec<String> },
121}
122
123#[derive(Debug, Clone)]
129pub struct HpSpec {
130 pub name: String,
132 pub hp_type: HpType,
134 pub log_scale: bool,
137}
138
139impl HpSpec {
140 pub fn validate(&self) -> Result<(), HpTunerError> {
142 match &self.hp_type {
143 HpType::Continuous { lo, hi } => {
144 if lo >= hi {
145 return Err(HpTunerError::InvalidSpec(format!(
146 "Continuous spec '{}': lo ({}) must be < hi ({})",
147 self.name, lo, hi
148 )));
149 }
150 if self.log_scale && *lo <= 0.0 {
151 return Err(HpTunerError::InvalidSpec(format!(
152 "Continuous spec '{}': log_scale requires lo > 0, got {}",
153 self.name, lo
154 )));
155 }
156 }
157 HpType::Discrete { lo, hi } => {
158 if lo > hi {
159 return Err(HpTunerError::InvalidSpec(format!(
160 "Discrete spec '{}': lo ({}) must be <= hi ({})",
161 self.name, lo, hi
162 )));
163 }
164 }
165 HpType::Categorical { choices } => {
166 if choices.is_empty() {
167 return Err(HpTunerError::InvalidSpec(format!(
168 "Categorical spec '{}': choices must not be empty",
169 self.name
170 )));
171 }
172 }
173 }
174 Ok(())
175 }
176}
177
178#[derive(Debug, Clone, PartialEq)]
184pub enum HpValue {
185 Float(f64),
187 Int(i64),
189 Choice(String),
191}
192
193impl fmt::Display for HpValue {
194 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
195 match self {
196 HpValue::Float(v) => write!(f, "{:.6e}", v),
197 HpValue::Int(v) => write!(f, "{}", v),
198 HpValue::Choice(s) => write!(f, "{}", s),
199 }
200 }
201}
202
203#[derive(Debug, Clone, Default)]
209pub struct HpConfig(pub HashMap<String, HpValue>);
210
211impl HpConfig {
212 pub fn new() -> Self {
214 HpConfig(HashMap::new())
215 }
216
217 pub fn get(&self, name: &str) -> Option<&HpValue> {
219 self.0.get(name)
220 }
221
222 pub fn insert(&mut self, name: String, value: HpValue) {
224 self.0.insert(name, value);
225 }
226
227 pub fn len(&self) -> usize {
229 self.0.len()
230 }
231
232 pub fn is_empty(&self) -> bool {
234 self.0.is_empty()
235 }
236
237 pub fn continuous_vec(&self, specs: &[HpSpec]) -> Vec<f64> {
240 let mut result = Vec::with_capacity(specs.len());
241 for spec in specs {
242 match self.0.get(&spec.name) {
243 Some(HpValue::Float(v)) => result.push(*v),
244 Some(HpValue::Int(v)) => result.push(*v as f64),
245 _ => {}
246 }
247 }
248 result
249 }
250}
251
252#[derive(Debug, Clone)]
258pub struct TuningResult {
259 pub trial_id: u64,
261 pub config: HpConfig,
263 pub score: f64,
265 pub timestamp: u64,
267}
268
269#[derive(Debug, Clone)]
275pub enum TuningStrategy {
276 RandomSearch { n_trials: u32 },
278 GridSearch,
280 BayesianOptimization {
282 n_trials: u32,
283 n_initial: u32,
285 exploration_weight: f64,
287 },
288}
289
290#[derive(Debug, Clone)]
296pub struct TunerConfig {
297 pub specs: Vec<HpSpec>,
299 pub maximize: bool,
301 pub seed: u64,
304}
305
306#[derive(Debug, Clone, PartialEq)]
312pub struct TunerStats {
313 pub total_trials: usize,
314 pub best_score: f64,
315 pub worst_score: f64,
316 pub avg_score: f64,
317 pub improvement_rate: f64,
319}
320
321#[derive(Debug)]
328pub struct HyperparameterTuner {
329 pub config: TunerConfig,
330 pub history: Vec<TuningResult>,
331 pub next_trial_id: u64,
332}
333
334impl HyperparameterTuner {
335 pub fn new(config: TunerConfig) -> Self {
337 HyperparameterTuner {
338 config,
339 history: Vec::new(),
340 next_trial_id: 0,
341 }
342 }
343
344 pub fn add_spec(&mut self, spec: HpSpec) -> &mut Self {
346 self.config.specs.push(spec);
347 self
348 }
349
350 pub fn sample_value(spec: &HpSpec, rng: &mut u64) -> HpValue {
356 match &spec.hp_type {
357 HpType::Continuous { lo, hi } => {
358 if spec.log_scale {
359 let log_lo = lo.ln();
360 let log_hi = hi.ln();
361 let log_val = log_lo + rng_f64(rng) * (log_hi - log_lo);
362 HpValue::Float(log_val.exp())
363 } else {
364 HpValue::Float(lo + rng_f64(rng) * (hi - lo))
365 }
366 }
367 HpType::Discrete { lo, hi } => HpValue::Int(rng_i64_range(rng, *lo, *hi)),
368 HpType::Categorical { choices } => {
369 let idx = rng_usize_range(rng, 0, choices.len());
370 HpValue::Choice(choices[idx].clone())
371 }
372 }
373 }
374
375 pub fn sample_config(&self, rng: &mut u64) -> HpConfig {
377 let mut cfg = HpConfig::new();
378 for spec in &self.config.specs {
379 let val = Self::sample_value(spec, rng);
380 cfg.insert(spec.name.clone(), val);
381 }
382 cfg
383 }
384
385 pub fn record_result(&mut self, config: HpConfig, score: f64, now: u64) -> u64 {
391 let id = self.next_trial_id;
392 self.history.push(TuningResult {
393 trial_id: id,
394 config,
395 score,
396 timestamp: now,
397 });
398 self.next_trial_id += 1;
399 id
400 }
401
402 pub fn best_config(&self) -> Option<&TuningResult> {
408 if self.history.is_empty() {
409 return None;
410 }
411 if self.config.maximize {
412 self.history.iter().max_by(|a, b| {
413 a.score
414 .partial_cmp(&b.score)
415 .unwrap_or(std::cmp::Ordering::Equal)
416 })
417 } else {
418 self.history.iter().min_by(|a, b| {
419 a.score
420 .partial_cmp(&b.score)
421 .unwrap_or(std::cmp::Ordering::Equal)
422 })
423 }
424 }
425
426 fn ucb_for_candidate(&self, candidate: &HpConfig, exploration_weight: f64) -> (f64, f64, f64) {
433 if self.history.is_empty() {
434 return (0.0, 1.0, exploration_weight);
435 }
436
437 let specs = &self.config.specs;
439 let cand_vec = candidate.continuous_vec(specs);
440
441 let mut weighted_scores: Vec<(f64, f64)> = self
443 .history
444 .iter()
445 .map(|r| {
446 let hist_vec = r.config.continuous_vec(specs);
447 let dist = euclidean_dist(&cand_vec, &hist_vec);
448 (dist, r.score)
449 })
450 .collect();
451
452 weighted_scores.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
454
455 let k = weighted_scores.len().min(5);
457 let neighbors = &weighted_scores[..k];
458
459 let scores: Vec<f64> = neighbors.iter().map(|(_, s)| *s).collect();
460 let mean = scores.iter().sum::<f64>() / scores.len() as f64;
461
462 let variance = scores.iter().map(|s| (s - mean).powi(2)).sum::<f64>() / scores.len() as f64;
463 let std_dev = variance.sqrt();
464
465 let epsilon = 1e-8;
466 let ucb = mean + exploration_weight * std_dev + epsilon;
467 (mean, std_dev, ucb)
468 }
469
470 pub fn suggest_next(&self, rng: &mut u64) -> HpConfig {
476 if self.history.is_empty() || self.config.specs.is_empty() {
479 return self.sample_config(rng);
480 }
481
482 let exploration_weight = 1.0_f64;
484
485 let n_candidates = 10_usize;
486 let mut best_cfg = self.sample_config(rng);
487 let (_, _, mut best_ucb) = self.ucb_for_candidate(&best_cfg, exploration_weight);
488
489 for _ in 1..n_candidates {
490 let candidate = self.sample_config(rng);
491 let (_, _, ucb) = self.ucb_for_candidate(&candidate, exploration_weight);
492 if ucb > best_ucb {
493 best_ucb = ucb;
494 best_cfg = candidate;
495 }
496 }
497 best_cfg
498 }
499
500 pub fn run_random_search(
507 &mut self,
508 n_trials: u32,
509 mut scorer: impl FnMut(&HpConfig) -> f64,
510 rng: &mut u64,
511 ) -> Vec<TuningResult> {
512 for _ in 0..n_trials {
513 let cfg = self.sample_config(rng);
514 let score = scorer(&cfg);
515 self.record_result(cfg, score, 0);
516 }
517 let mut results = self.history.clone();
518 sort_results(&mut results, self.config.maximize);
519 results
520 }
521
522 pub fn grid_configs(&self) -> Vec<HpConfig> {
532 if self.config.specs.is_empty() {
533 return Vec::new();
534 }
535
536 let value_lists: Vec<Vec<HpValue>> =
538 self.config.specs.iter().map(spec_grid_values).collect();
539
540 let mut configs: Vec<HpConfig> = vec![HpConfig::new()];
542 for (spec, values) in self.config.specs.iter().zip(value_lists.iter()) {
543 let mut next_configs: Vec<HpConfig> = Vec::new();
544 for existing in &configs {
545 for val in values {
546 let mut new_cfg = existing.clone();
547 new_cfg.insert(spec.name.clone(), val.clone());
548 next_configs.push(new_cfg);
549 }
550 }
551 configs = next_configs;
552 }
553 configs
554 }
555
556 pub fn run_grid_search(
559 &mut self,
560 mut scorer: impl FnMut(&HpConfig) -> f64,
561 ) -> Vec<TuningResult> {
562 let configs = self.grid_configs();
563 for cfg in configs {
564 let score = scorer(&cfg);
565 self.record_result(cfg, score, 0);
566 }
567 let mut results = self.history.clone();
568 sort_results(&mut results, self.config.maximize);
569 results
570 }
571
572 pub fn run_bayesian(
579 &mut self,
580 n_trials: u32,
581 n_initial: u32,
582 exploration_weight: f64,
583 mut scorer: impl FnMut(&HpConfig) -> f64,
584 rng: &mut u64,
585 ) -> Vec<TuningResult> {
586 let initial = n_initial.min(n_trials);
587
588 for _ in 0..initial {
590 let cfg = self.sample_config(rng);
591 let score = scorer(&cfg);
592 self.record_result(cfg, score, 0);
593 }
594
595 for _ in initial..n_trials {
597 let cfg = self.suggest_next_bayesian(rng, exploration_weight);
598 let score = scorer(&cfg);
599 self.record_result(cfg, score, 0);
600 }
601
602 let mut results = self.history.clone();
603 sort_results(&mut results, self.config.maximize);
604 results
605 }
606
607 fn suggest_next_bayesian(&self, rng: &mut u64, exploration_weight: f64) -> HpConfig {
609 if self.history.is_empty() || self.config.specs.is_empty() {
610 return self.sample_config(rng);
611 }
612
613 let n_candidates = 10_usize;
614 let mut best_cfg = self.sample_config(rng);
615 let (_, _, mut best_ucb) = self.ucb_for_candidate(&best_cfg, exploration_weight);
616
617 for _ in 1..n_candidates {
618 let candidate = self.sample_config(rng);
619 let (_, _, ucb) = self.ucb_for_candidate(&candidate, exploration_weight);
620 if self.config.maximize {
621 if ucb > best_ucb {
622 best_ucb = ucb;
623 best_cfg = candidate;
624 }
625 } else {
626 let (mean, std_dev, _) = self.ucb_for_candidate(&candidate, exploration_weight);
628 let lcb = mean - exploration_weight * std_dev;
629 let (best_mean, best_std, _) =
630 self.ucb_for_candidate(&best_cfg, exploration_weight);
631 let best_lcb = best_mean - exploration_weight * best_std;
632 if lcb < best_lcb {
633 best_ucb = ucb;
634 best_cfg = candidate;
635 }
636 }
637 }
638 best_cfg
639 }
640
641 pub fn importance_scores(&self) -> HashMap<String, f64> {
651 let mut result = HashMap::new();
652 if self.history.len() < 2 {
653 for spec in &self.config.specs {
654 result.insert(spec.name.clone(), 0.0);
655 }
656 return result;
657 }
658
659 for spec in &self.config.specs {
660 let importance = compute_importance(spec, &self.history);
661 result.insert(spec.name.clone(), importance);
662 }
663 result
664 }
665
666 pub fn stats(&self) -> TunerStats {
672 if self.history.is_empty() {
673 return TunerStats {
674 total_trials: 0,
675 best_score: 0.0,
676 worst_score: 0.0,
677 avg_score: 0.0,
678 improvement_rate: 0.0,
679 };
680 }
681
682 let scores: Vec<f64> = self.history.iter().map(|r| r.score).collect();
683 let best_score = if self.config.maximize {
684 scores.iter().cloned().fold(f64::NEG_INFINITY, f64::max)
685 } else {
686 scores.iter().cloned().fold(f64::INFINITY, f64::min)
687 };
688 let worst_score = if self.config.maximize {
689 scores.iter().cloned().fold(f64::INFINITY, f64::min)
690 } else {
691 scores.iter().cloned().fold(f64::NEG_INFINITY, f64::max)
692 };
693 let avg_score = scores.iter().sum::<f64>() / scores.len() as f64;
694
695 let improvement_rate = compute_improvement_rate(&scores, self.config.maximize);
696
697 TunerStats {
698 total_trials: self.history.len(),
699 best_score,
700 worst_score,
701 avg_score,
702 improvement_rate,
703 }
704 }
705}
706
707fn euclidean_dist(a: &[f64], b: &[f64]) -> f64 {
713 a.iter()
714 .zip(b.iter())
715 .map(|(x, y)| (x - y).powi(2))
716 .sum::<f64>()
717 .sqrt()
718}
719
720fn sort_results(results: &mut [TuningResult], maximize: bool) {
722 if maximize {
723 results.sort_by(|a, b| {
724 b.score
725 .partial_cmp(&a.score)
726 .unwrap_or(std::cmp::Ordering::Equal)
727 });
728 } else {
729 results.sort_by(|a, b| {
730 a.score
731 .partial_cmp(&b.score)
732 .unwrap_or(std::cmp::Ordering::Equal)
733 });
734 }
735}
736
737fn spec_grid_values(spec: &HpSpec) -> Vec<HpValue> {
739 const N_CONTINUOUS: usize = 5;
740 match &spec.hp_type {
741 HpType::Continuous { lo, hi } => (0..N_CONTINUOUS)
742 .map(|i| {
743 let t = i as f64 / (N_CONTINUOUS - 1) as f64;
744 if spec.log_scale && *lo > 0.0 {
745 let log_lo = lo.ln();
746 let log_hi = hi.ln();
747 HpValue::Float((log_lo + t * (log_hi - log_lo)).exp())
748 } else {
749 HpValue::Float(lo + t * (hi - lo))
750 }
751 })
752 .collect(),
753 HpType::Discrete { lo, hi } => (*lo..=*hi).map(HpValue::Int).collect(),
754 HpType::Categorical { choices } => {
755 choices.iter().map(|c| HpValue::Choice(c.clone())).collect()
756 }
757 }
758}
759
760fn compute_importance(spec: &HpSpec, history: &[TuningResult]) -> f64 {
762 const N_BUCKETS: usize = 5;
763
764 let mut bucket_scores: Vec<Vec<f64>> = vec![Vec::new(); N_BUCKETS];
766
767 for result in history {
768 let bucket_idx = match result.config.get(&spec.name) {
769 Some(HpValue::Float(v)) => {
770 if let HpType::Continuous { lo, hi } = &spec.hp_type {
772 let range = hi - lo;
773 if range <= 0.0 {
774 0
775 } else {
776 let normalized = (v - lo) / range;
777 let idx = (normalized * N_BUCKETS as f64) as usize;
778 idx.min(N_BUCKETS - 1)
779 }
780 } else {
781 0
782 }
783 }
784 Some(HpValue::Int(v)) => {
785 if let HpType::Discrete { lo, hi } = &spec.hp_type {
786 let range = (hi - lo) as f64;
787 if range <= 0.0 {
788 0
789 } else {
790 let normalized = (v - lo) as f64 / range;
791 let idx = (normalized * N_BUCKETS as f64) as usize;
792 idx.min(N_BUCKETS - 1)
793 }
794 } else {
795 0
796 }
797 }
798 Some(HpValue::Choice(c)) => {
799 if let HpType::Categorical { choices } = &spec.hp_type {
800 choices.iter().position(|ch| ch == c).unwrap_or(0) % N_BUCKETS
801 } else {
802 0
803 }
804 }
805 None => continue,
806 };
807 bucket_scores[bucket_idx].push(result.score);
808 }
809
810 let means: Vec<f64> = bucket_scores
812 .iter()
813 .filter(|b| !b.is_empty())
814 .map(|b| b.iter().sum::<f64>() / b.len() as f64)
815 .collect();
816
817 if means.len() < 2 {
818 return 0.0;
819 }
820
821 let mean_of_means = means.iter().sum::<f64>() / means.len() as f64;
823 means
824 .iter()
825 .map(|m| (m - mean_of_means).powi(2))
826 .sum::<f64>()
827 / means.len() as f64
828}
829
830fn compute_improvement_rate(scores: &[f64], maximize: bool) -> f64 {
832 if scores.is_empty() {
833 return 0.0;
834 }
835 let mut improvements = 0usize;
836 let mut running_best = scores[0];
837 for &s in scores.iter().skip(1) {
839 let improved = if maximize {
840 s > running_best
841 } else {
842 s < running_best
843 };
844 if improved {
845 improvements += 1;
846 running_best = s;
847 }
848 }
849 improvements as f64 / scores.len() as f64
850}
851
852#[cfg(test)]
857mod tests {
858 use crate::hyperparameter_tuner::{
859 compute_improvement_rate, euclidean_dist, rng_f64, rng_i64_range, rng_usize_range,
860 sort_results, spec_grid_values, xorshift64, HpConfig, HpSpec, HpTunerError, HpType,
861 HpValue, HyperparameterTuner, TunerConfig, TuningResult,
862 };
863
864 #[test]
869 fn test_xorshift64_non_zero() {
870 let mut state = 1u64;
871 let v = xorshift64(&mut state);
872 assert_ne!(v, 0);
873 }
874
875 #[test]
876 fn test_xorshift64_different_values() {
877 let mut state = 12345u64;
878 let a = xorshift64(&mut state);
879 let b = xorshift64(&mut state);
880 assert_ne!(a, b);
881 }
882
883 #[test]
884 fn test_rng_f64_in_range() {
885 let mut state = 99u64;
886 for _ in 0..1000 {
887 let v = rng_f64(&mut state);
888 assert!((0.0..1.0).contains(&v), "out of [0,1): {}", v);
889 }
890 }
891
892 #[test]
893 fn test_rng_i64_range_bounds() {
894 let mut state = 7u64;
895 for _ in 0..500 {
896 let v = rng_i64_range(&mut state, 3, 7);
897 assert!((3..=7).contains(&v), "out of [3,7]: {}", v);
898 }
899 }
900
901 #[test]
902 fn test_rng_i64_range_equal_bounds() {
903 let mut state = 1u64;
904 assert_eq!(rng_i64_range(&mut state, 5, 5), 5);
905 }
906
907 #[test]
908 fn test_rng_usize_range_bounds() {
909 let mut state = 42u64;
910 for _ in 0..500 {
911 let v = rng_usize_range(&mut state, 0, 4);
912 assert!(v < 4, "out of [0,4): {}", v);
913 }
914 }
915
916 #[test]
921 fn test_spec_validate_continuous_ok() {
922 let spec = HpSpec {
923 name: "lr".into(),
924 hp_type: HpType::Continuous { lo: 1e-4, hi: 1e-1 },
925 log_scale: false,
926 };
927 assert!(spec.validate().is_ok());
928 }
929
930 #[test]
931 fn test_spec_validate_continuous_inverted_bounds() {
932 let spec = HpSpec {
933 name: "lr".into(),
934 hp_type: HpType::Continuous { lo: 1.0, hi: 0.0 },
935 log_scale: false,
936 };
937 assert!(matches!(spec.validate(), Err(HpTunerError::InvalidSpec(_))));
938 }
939
940 #[test]
941 fn test_spec_validate_log_scale_nonpositive_lo() {
942 let spec = HpSpec {
943 name: "lr".into(),
944 hp_type: HpType::Continuous { lo: 0.0, hi: 1.0 },
945 log_scale: true,
946 };
947 assert!(matches!(spec.validate(), Err(HpTunerError::InvalidSpec(_))));
948 }
949
950 #[test]
951 fn test_spec_validate_discrete_ok() {
952 let spec = HpSpec {
953 name: "layers".into(),
954 hp_type: HpType::Discrete { lo: 1, hi: 5 },
955 log_scale: false,
956 };
957 assert!(spec.validate().is_ok());
958 }
959
960 #[test]
961 fn test_spec_validate_discrete_inverted() {
962 let spec = HpSpec {
963 name: "layers".into(),
964 hp_type: HpType::Discrete { lo: 5, hi: 1 },
965 log_scale: false,
966 };
967 assert!(matches!(spec.validate(), Err(HpTunerError::InvalidSpec(_))));
968 }
969
970 #[test]
971 fn test_spec_validate_categorical_ok() {
972 let spec = HpSpec {
973 name: "optim".into(),
974 hp_type: HpType::Categorical {
975 choices: vec!["adam".into(), "sgd".into()],
976 },
977 log_scale: false,
978 };
979 assert!(spec.validate().is_ok());
980 }
981
982 #[test]
983 fn test_spec_validate_categorical_empty() {
984 let spec = HpSpec {
985 name: "optim".into(),
986 hp_type: HpType::Categorical { choices: vec![] },
987 log_scale: false,
988 };
989 assert!(matches!(spec.validate(), Err(HpTunerError::InvalidSpec(_))));
990 }
991
992 #[test]
997 fn test_hp_config_insert_and_get() {
998 let mut cfg = HpConfig::new();
999 cfg.insert("lr".into(), HpValue::Float(0.01));
1000 assert_eq!(cfg.get("lr"), Some(&HpValue::Float(0.01)));
1001 assert_eq!(cfg.get("missing"), None);
1002 }
1003
1004 #[test]
1005 fn test_hp_config_len_is_empty() {
1006 let cfg = HpConfig::new();
1007 assert!(cfg.is_empty());
1008 assert_eq!(cfg.len(), 0);
1009 let mut cfg2 = HpConfig::new();
1010 cfg2.insert("x".into(), HpValue::Int(1));
1011 assert!(!cfg2.is_empty());
1012 assert_eq!(cfg2.len(), 1);
1013 }
1014
1015 #[test]
1020 fn test_sample_continuous_in_range() {
1021 let spec = HpSpec {
1022 name: "lr".into(),
1023 hp_type: HpType::Continuous { lo: 0.0, hi: 1.0 },
1024 log_scale: false,
1025 };
1026 let mut rng = 1234u64;
1027 for _ in 0..100 {
1028 if let HpValue::Float(v) = HyperparameterTuner::sample_value(&spec, &mut rng) {
1029 assert!((0.0..=1.0).contains(&v), "out of range: {}", v);
1030 } else {
1031 panic!("expected Float");
1032 }
1033 }
1034 }
1035
1036 #[test]
1037 fn test_sample_continuous_log_scale() {
1038 let spec = HpSpec {
1039 name: "lr".into(),
1040 hp_type: HpType::Continuous { lo: 1e-4, hi: 1e-1 },
1041 log_scale: true,
1042 };
1043 let mut rng = 77u64;
1044 for _ in 0..200 {
1045 if let HpValue::Float(v) = HyperparameterTuner::sample_value(&spec, &mut rng) {
1046 assert!(
1047 (1e-4..=1e-1 + 1e-10).contains(&v),
1048 "log sample out of range: {}",
1049 v
1050 );
1051 } else {
1052 panic!("expected Float");
1053 }
1054 }
1055 }
1056
1057 #[test]
1058 fn test_sample_discrete_in_range() {
1059 let spec = HpSpec {
1060 name: "layers".into(),
1061 hp_type: HpType::Discrete { lo: 2, hi: 8 },
1062 log_scale: false,
1063 };
1064 let mut rng = 55u64;
1065 for _ in 0..200 {
1066 if let HpValue::Int(v) = HyperparameterTuner::sample_value(&spec, &mut rng) {
1067 assert!((2..=8).contains(&v), "discrete out of range: {}", v);
1068 } else {
1069 panic!("expected Int");
1070 }
1071 }
1072 }
1073
1074 #[test]
1075 fn test_sample_categorical() {
1076 let choices = vec!["adam".to_string(), "sgd".to_string(), "rmsprop".to_string()];
1077 let spec = HpSpec {
1078 name: "opt".into(),
1079 hp_type: HpType::Categorical {
1080 choices: choices.clone(),
1081 },
1082 log_scale: false,
1083 };
1084 let mut rng = 11u64;
1085 for _ in 0..300 {
1086 if let HpValue::Choice(s) = HyperparameterTuner::sample_value(&spec, &mut rng) {
1087 assert!(choices.contains(&s), "unexpected choice: {}", s);
1088 } else {
1089 panic!("expected Choice");
1090 }
1091 }
1092 }
1093
1094 #[test]
1099 fn test_sample_config_keys_match_specs() {
1100 let config = TunerConfig {
1101 specs: vec![
1102 HpSpec {
1103 name: "lr".into(),
1104 hp_type: HpType::Continuous { lo: 1e-4, hi: 1e-1 },
1105 log_scale: false,
1106 },
1107 HpSpec {
1108 name: "layers".into(),
1109 hp_type: HpType::Discrete { lo: 1, hi: 5 },
1110 log_scale: false,
1111 },
1112 HpSpec {
1113 name: "opt".into(),
1114 hp_type: HpType::Categorical {
1115 choices: vec!["adam".into()],
1116 },
1117 log_scale: false,
1118 },
1119 ],
1120 maximize: true,
1121 seed: 42,
1122 };
1123 let tuner = HyperparameterTuner::new(config);
1124 let mut rng = 42u64;
1125 let cfg = tuner.sample_config(&mut rng);
1126 assert!(cfg.get("lr").is_some());
1127 assert!(cfg.get("layers").is_some());
1128 assert!(cfg.get("opt").is_some());
1129 }
1130
1131 #[test]
1136 fn test_record_and_best_maximize() {
1137 let config = TunerConfig {
1138 specs: vec![],
1139 maximize: true,
1140 seed: 0,
1141 };
1142 let mut tuner = HyperparameterTuner::new(config);
1143 tuner.record_result(HpConfig::new(), 0.5, 0);
1144 tuner.record_result(HpConfig::new(), 0.9, 1);
1145 tuner.record_result(HpConfig::new(), 0.2, 2);
1146 let best = tuner.best_config().expect("best must exist");
1147 assert!((best.score - 0.9).abs() < 1e-10);
1148 }
1149
1150 #[test]
1151 fn test_record_and_best_minimize() {
1152 let config = TunerConfig {
1153 specs: vec![],
1154 maximize: false,
1155 seed: 0,
1156 };
1157 let mut tuner = HyperparameterTuner::new(config);
1158 tuner.record_result(HpConfig::new(), 0.5, 0);
1159 tuner.record_result(HpConfig::new(), 0.1, 1);
1160 tuner.record_result(HpConfig::new(), 0.8, 2);
1161 let best = tuner.best_config().expect("best must exist");
1162 assert!((best.score - 0.1).abs() < 1e-10);
1163 }
1164
1165 #[test]
1166 fn test_best_config_empty_returns_none() {
1167 let config = TunerConfig {
1168 specs: vec![],
1169 maximize: true,
1170 seed: 0,
1171 };
1172 let tuner = HyperparameterTuner::new(config);
1173 assert!(tuner.best_config().is_none());
1174 }
1175
1176 #[test]
1177 fn test_trial_id_sequential() {
1178 let config = TunerConfig {
1179 specs: vec![],
1180 maximize: true,
1181 seed: 0,
1182 };
1183 let mut tuner = HyperparameterTuner::new(config);
1184 let id0 = tuner.record_result(HpConfig::new(), 1.0, 0);
1185 let id1 = tuner.record_result(HpConfig::new(), 2.0, 0);
1186 assert_eq!(id0, 0);
1187 assert_eq!(id1, 1);
1188 }
1189
1190 #[test]
1195 fn test_grid_configs_continuous_gives_5_values() {
1196 let spec = HpSpec {
1197 name: "lr".into(),
1198 hp_type: HpType::Continuous { lo: 0.0, hi: 1.0 },
1199 log_scale: false,
1200 };
1201 let vals = spec_grid_values(&spec);
1202 assert_eq!(vals.len(), 5);
1203 if let HpValue::Float(lo) = &vals[0] {
1205 assert!((*lo - 0.0).abs() < 1e-10);
1206 }
1207 if let HpValue::Float(hi) = &vals[4] {
1208 assert!((*hi - 1.0).abs() < 1e-10);
1209 }
1210 }
1211
1212 #[test]
1213 fn test_grid_configs_discrete() {
1214 let spec = HpSpec {
1215 name: "n".into(),
1216 hp_type: HpType::Discrete { lo: 1, hi: 4 },
1217 log_scale: false,
1218 };
1219 let vals = spec_grid_values(&spec);
1220 assert_eq!(vals.len(), 4);
1221 assert_eq!(vals[0], HpValue::Int(1));
1222 assert_eq!(vals[3], HpValue::Int(4));
1223 }
1224
1225 #[test]
1226 fn test_grid_configs_categorical() {
1227 let spec = HpSpec {
1228 name: "opt".into(),
1229 hp_type: HpType::Categorical {
1230 choices: vec!["a".into(), "b".into(), "c".into()],
1231 },
1232 log_scale: false,
1233 };
1234 let vals = spec_grid_values(&spec);
1235 assert_eq!(vals.len(), 3);
1236 assert_eq!(vals[1], HpValue::Choice("b".into()));
1237 }
1238
1239 #[test]
1240 fn test_grid_configs_cartesian_product() {
1241 let config = TunerConfig {
1242 specs: vec![
1243 HpSpec {
1244 name: "a".into(),
1245 hp_type: HpType::Discrete { lo: 0, hi: 1 },
1246 log_scale: false,
1247 },
1248 HpSpec {
1249 name: "b".into(),
1250 hp_type: HpType::Categorical {
1251 choices: vec!["x".into(), "y".into()],
1252 },
1253 log_scale: false,
1254 },
1255 ],
1256 maximize: true,
1257 seed: 0,
1258 };
1259 let tuner = HyperparameterTuner::new(config);
1260 let cfgs = tuner.grid_configs();
1261 assert_eq!(cfgs.len(), 4);
1263 }
1264
1265 #[test]
1266 fn test_grid_configs_empty_specs() {
1267 let config = TunerConfig {
1268 specs: vec![],
1269 maximize: true,
1270 seed: 0,
1271 };
1272 let tuner = HyperparameterTuner::new(config);
1273 assert!(tuner.grid_configs().is_empty());
1274 }
1275
1276 #[test]
1281 fn test_run_random_search_count() {
1282 let config = TunerConfig {
1283 specs: vec![HpSpec {
1284 name: "x".into(),
1285 hp_type: HpType::Continuous { lo: 0.0, hi: 1.0 },
1286 log_scale: false,
1287 }],
1288 maximize: true,
1289 seed: 1,
1290 };
1291 let mut tuner = HyperparameterTuner::new(config);
1292 let mut rng = 1u64;
1293 let results = tuner.run_random_search(10, |_| 0.5, &mut rng);
1294 assert_eq!(results.len(), 10);
1295 }
1296
1297 #[test]
1298 fn test_run_random_search_sorted_maximize() {
1299 let config = TunerConfig {
1300 specs: vec![HpSpec {
1301 name: "x".into(),
1302 hp_type: HpType::Continuous { lo: 0.0, hi: 1.0 },
1303 log_scale: false,
1304 }],
1305 maximize: true,
1306 seed: 42,
1307 };
1308 let mut tuner = HyperparameterTuner::new(config);
1309 let mut rng = 42u64;
1310 let mut counter = 0.0f64;
1311 let results = tuner.run_random_search(
1312 5,
1313 |_| {
1314 counter += 1.0;
1315 counter
1316 },
1317 &mut rng,
1318 );
1319 for w in results.windows(2) {
1321 assert!(
1322 w[0].score >= w[1].score,
1323 "not sorted: {} < {}",
1324 w[0].score,
1325 w[1].score
1326 );
1327 }
1328 }
1329
1330 #[test]
1331 fn test_run_random_search_sorted_minimize() {
1332 let config = TunerConfig {
1333 specs: vec![HpSpec {
1334 name: "x".into(),
1335 hp_type: HpType::Continuous { lo: 0.0, hi: 1.0 },
1336 log_scale: false,
1337 }],
1338 maximize: false,
1339 seed: 7,
1340 };
1341 let mut tuner = HyperparameterTuner::new(config);
1342 let mut rng = 7u64;
1343 let mut counter = 5.0f64;
1344 let results = tuner.run_random_search(
1345 5,
1346 |_| {
1347 counter -= 1.0;
1348 counter
1349 },
1350 &mut rng,
1351 );
1352 for w in results.windows(2) {
1353 assert!(w[0].score <= w[1].score, "not sorted ascending");
1354 }
1355 }
1356
1357 #[test]
1362 fn test_run_grid_search_all_evaluated() {
1363 let config = TunerConfig {
1364 specs: vec![HpSpec {
1365 name: "a".into(),
1366 hp_type: HpType::Discrete { lo: 1, hi: 3 },
1367 log_scale: false,
1368 }],
1369 maximize: true,
1370 seed: 0,
1371 };
1372 let mut tuner = HyperparameterTuner::new(config);
1373 let results = tuner.run_grid_search(|cfg| {
1374 if let Some(HpValue::Int(v)) = cfg.get("a") {
1375 *v as f64
1376 } else {
1377 0.0
1378 }
1379 });
1380 assert_eq!(results.len(), 3);
1382 assert_eq!(results[0].score, 3.0);
1384 }
1385
1386 #[test]
1391 fn test_importance_scores_no_history() {
1392 let config = TunerConfig {
1393 specs: vec![HpSpec {
1394 name: "x".into(),
1395 hp_type: HpType::Continuous { lo: 0.0, hi: 1.0 },
1396 log_scale: false,
1397 }],
1398 maximize: true,
1399 seed: 0,
1400 };
1401 let tuner = HyperparameterTuner::new(config);
1402 let scores = tuner.importance_scores();
1403 assert_eq!(scores.get("x"), Some(&0.0));
1404 }
1405
1406 #[test]
1407 fn test_importance_scores_returns_all_specs() {
1408 let config = TunerConfig {
1409 specs: vec![
1410 HpSpec {
1411 name: "lr".into(),
1412 hp_type: HpType::Continuous { lo: 0.0, hi: 1.0 },
1413 log_scale: false,
1414 },
1415 HpSpec {
1416 name: "layers".into(),
1417 hp_type: HpType::Discrete { lo: 1, hi: 5 },
1418 log_scale: false,
1419 },
1420 ],
1421 maximize: true,
1422 seed: 0,
1423 };
1424 let mut tuner = HyperparameterTuner::new(config);
1425 for i in 0..10 {
1427 let mut cfg = HpConfig::new();
1428 cfg.insert("lr".into(), HpValue::Float(i as f64 * 0.1));
1429 cfg.insert("layers".into(), HpValue::Int(i % 5 + 1));
1430 tuner.record_result(cfg, i as f64, 0);
1431 }
1432 let scores = tuner.importance_scores();
1433 assert!(scores.contains_key("lr"));
1434 assert!(scores.contains_key("layers"));
1435 }
1436
1437 #[test]
1442 fn test_stats_empty() {
1443 let config = TunerConfig {
1444 specs: vec![],
1445 maximize: true,
1446 seed: 0,
1447 };
1448 let tuner = HyperparameterTuner::new(config);
1449 let s = tuner.stats();
1450 assert_eq!(s.total_trials, 0);
1451 assert_eq!(s.improvement_rate, 0.0);
1452 }
1453
1454 #[test]
1455 fn test_stats_correct_values() {
1456 let config = TunerConfig {
1457 specs: vec![],
1458 maximize: true,
1459 seed: 0,
1460 };
1461 let mut tuner = HyperparameterTuner::new(config);
1462 tuner.record_result(HpConfig::new(), 1.0, 0);
1463 tuner.record_result(HpConfig::new(), 3.0, 0);
1464 tuner.record_result(HpConfig::new(), 2.0, 0);
1465 let s = tuner.stats();
1466 assert_eq!(s.total_trials, 3);
1467 assert!((s.best_score - 3.0).abs() < 1e-10);
1468 assert!((s.worst_score - 1.0).abs() < 1e-10);
1469 assert!((s.avg_score - 2.0).abs() < 1e-10);
1470 assert!((s.improvement_rate - 1.0 / 3.0).abs() < 1e-10);
1472 }
1473
1474 #[test]
1475 fn test_stats_minimize() {
1476 let config = TunerConfig {
1477 specs: vec![],
1478 maximize: false,
1479 seed: 0,
1480 };
1481 let mut tuner = HyperparameterTuner::new(config);
1482 tuner.record_result(HpConfig::new(), 10.0, 0);
1483 tuner.record_result(HpConfig::new(), 5.0, 0);
1484 tuner.record_result(HpConfig::new(), 7.0, 0);
1485 let s = tuner.stats();
1486 assert!((s.best_score - 5.0).abs() < 1e-10);
1487 assert!((s.worst_score - 10.0).abs() < 1e-10);
1488 }
1489
1490 #[test]
1495 fn test_suggest_next_returns_config_with_all_specs() {
1496 let config = TunerConfig {
1497 specs: vec![HpSpec {
1498 name: "lr".into(),
1499 hp_type: HpType::Continuous { lo: 1e-4, hi: 1.0 },
1500 log_scale: false,
1501 }],
1502 maximize: true,
1503 seed: 1,
1504 };
1505 let mut tuner = HyperparameterTuner::new(config);
1506 let mut rng = 1u64;
1507 for i in 0..5 {
1509 let mut cfg = HpConfig::new();
1510 cfg.insert("lr".into(), HpValue::Float(0.1 * i as f64));
1511 tuner.record_result(cfg, i as f64, 0);
1512 }
1513 let next = tuner.suggest_next(&mut rng);
1514 assert!(next.get("lr").is_some());
1515 }
1516
1517 #[test]
1518 fn test_suggest_next_no_history_still_works() {
1519 let config = TunerConfig {
1520 specs: vec![HpSpec {
1521 name: "lr".into(),
1522 hp_type: HpType::Continuous { lo: 0.01, hi: 0.1 },
1523 log_scale: false,
1524 }],
1525 maximize: true,
1526 seed: 5,
1527 };
1528 let tuner = HyperparameterTuner::new(config);
1529 let mut rng = 5u64;
1530 let next = tuner.suggest_next(&mut rng);
1531 assert!(next.get("lr").is_some());
1532 }
1533
1534 #[test]
1539 fn test_add_spec_builder() {
1540 let config = TunerConfig {
1541 specs: vec![],
1542 maximize: true,
1543 seed: 0,
1544 };
1545 let mut tuner = HyperparameterTuner::new(config);
1546 tuner.add_spec(HpSpec {
1547 name: "lr".into(),
1548 hp_type: HpType::Continuous { lo: 1e-4, hi: 1.0 },
1549 log_scale: true,
1550 });
1551 assert_eq!(tuner.config.specs.len(), 1);
1552 }
1553
1554 #[test]
1559 fn test_bayesian_optimization_count() {
1560 let config = TunerConfig {
1561 specs: vec![HpSpec {
1562 name: "x".into(),
1563 hp_type: HpType::Continuous { lo: 0.0, hi: 1.0 },
1564 log_scale: false,
1565 }],
1566 maximize: false,
1567 seed: 3,
1568 };
1569 let mut tuner = HyperparameterTuner::new(config);
1570 let mut rng = 3u64;
1571 let results = tuner.run_bayesian(
1572 10,
1573 3,
1574 1.0,
1575 |cfg| {
1576 if let Some(HpValue::Float(x)) = cfg.get("x") {
1577 (*x - 0.3).powi(2)
1578 } else {
1579 1.0
1580 }
1581 },
1582 &mut rng,
1583 );
1584 assert_eq!(results.len(), 10);
1585 }
1586
1587 #[test]
1588 fn test_bayesian_optimization_sorted() {
1589 let config = TunerConfig {
1590 specs: vec![HpSpec {
1591 name: "x".into(),
1592 hp_type: HpType::Continuous { lo: 0.0, hi: 1.0 },
1593 log_scale: false,
1594 }],
1595 maximize: true,
1596 seed: 17,
1597 };
1598 let mut tuner = HyperparameterTuner::new(config);
1599 let mut rng = 17u64;
1600 let results = tuner.run_bayesian(
1601 8,
1602 3,
1603 1.5,
1604 |cfg| {
1605 if let Some(HpValue::Float(x)) = cfg.get("x") {
1606 *x
1607 } else {
1608 0.0
1609 }
1610 },
1611 &mut rng,
1612 );
1613 for w in results.windows(2) {
1614 assert!(w[0].score >= w[1].score, "Bayesian results not sorted");
1615 }
1616 }
1617
1618 #[test]
1623 fn test_euclidean_dist() {
1624 let a = vec![0.0, 0.0];
1625 let b = vec![3.0, 4.0];
1626 assert!((euclidean_dist(&a, &b) - 5.0).abs() < 1e-10);
1627 }
1628
1629 #[test]
1630 fn test_euclidean_dist_same_point() {
1631 let a = vec![1.0, 2.0, 3.0];
1632 assert!((euclidean_dist(&a, &a) - 0.0).abs() < 1e-10);
1633 }
1634
1635 #[test]
1640 fn test_sort_results_maximize() {
1641 let mut results = vec![
1642 TuningResult {
1643 trial_id: 0,
1644 config: HpConfig::new(),
1645 score: 0.2,
1646 timestamp: 0,
1647 },
1648 TuningResult {
1649 trial_id: 1,
1650 config: HpConfig::new(),
1651 score: 0.8,
1652 timestamp: 0,
1653 },
1654 TuningResult {
1655 trial_id: 2,
1656 config: HpConfig::new(),
1657 score: 0.5,
1658 timestamp: 0,
1659 },
1660 ];
1661 sort_results(&mut results, true);
1662 assert!((results[0].score - 0.8).abs() < 1e-10);
1663 assert!((results[2].score - 0.2).abs() < 1e-10);
1664 }
1665
1666 #[test]
1667 fn test_sort_results_minimize() {
1668 let mut results = vec![
1669 TuningResult {
1670 trial_id: 0,
1671 config: HpConfig::new(),
1672 score: 0.8,
1673 timestamp: 0,
1674 },
1675 TuningResult {
1676 trial_id: 1,
1677 config: HpConfig::new(),
1678 score: 0.2,
1679 timestamp: 0,
1680 },
1681 ];
1682 sort_results(&mut results, false);
1683 assert!((results[0].score - 0.2).abs() < 1e-10);
1684 }
1685
1686 #[test]
1691 fn test_improvement_rate_monotone_increase_maximize() {
1692 let scores = vec![1.0, 2.0, 3.0, 4.0];
1693 let rate = compute_improvement_rate(&scores, true);
1695 assert!((rate - 0.75).abs() < 1e-10, "expected 0.75, got {}", rate);
1696 }
1697
1698 #[test]
1699 fn test_improvement_rate_no_improvement() {
1700 let scores = vec![5.0, 4.0, 3.0]; let rate = compute_improvement_rate(&scores, true);
1702 assert_eq!(rate, 0.0);
1703 }
1704
1705 #[test]
1706 fn test_improvement_rate_minimize() {
1707 let scores = vec![10.0, 8.0, 6.0]; let rate = compute_improvement_rate(&scores, false);
1709 assert!((rate - 2.0 / 3.0).abs() < 1e-10);
1710 }
1711
1712 #[test]
1717 fn test_error_display_no_specs() {
1718 let e = HpTunerError::NoSpecs;
1719 assert!(!format!("{}", e).is_empty());
1720 }
1721
1722 #[test]
1723 fn test_error_display_invalid_spec() {
1724 let e = HpTunerError::InvalidSpec("bad range".into());
1725 assert!(format!("{}", e).contains("bad range"));
1726 }
1727
1728 #[test]
1733 fn test_hp_value_display() {
1734 assert!(!format!("{}", HpValue::Float(0.001)).is_empty());
1735 assert!(!format!("{}", HpValue::Int(42)).is_empty());
1736 assert!(!format!("{}", HpValue::Choice("relu".into())).is_empty());
1737 }
1738
1739 #[test]
1744 fn test_grid_continuous_log_scale_endpoints() {
1745 let spec = HpSpec {
1746 name: "lr".into(),
1747 hp_type: HpType::Continuous { lo: 1e-4, hi: 1e-1 },
1748 log_scale: true,
1749 };
1750 let vals = spec_grid_values(&spec);
1751 assert_eq!(vals.len(), 5);
1752 if let HpValue::Float(lo_val) = &vals[0] {
1753 assert!(
1754 (lo_val - 1e-4).abs() < 1e-10,
1755 "log-scale lo wrong: {}",
1756 lo_val
1757 );
1758 }
1759 if let HpValue::Float(hi_val) = &vals[4] {
1760 assert!(
1761 (hi_val - 1e-1).abs() < 1e-10,
1762 "log-scale hi wrong: {}",
1763 hi_val
1764 );
1765 }
1766 }
1767
1768 #[test]
1773 fn test_sample_config_all_spec_types() {
1774 let config = TunerConfig {
1775 specs: vec![
1776 HpSpec {
1777 name: "lr".into(),
1778 hp_type: HpType::Continuous { lo: 1e-4, hi: 0.1 },
1779 log_scale: true,
1780 },
1781 HpSpec {
1782 name: "n".into(),
1783 hp_type: HpType::Discrete { lo: 2, hi: 10 },
1784 log_scale: false,
1785 },
1786 HpSpec {
1787 name: "act".into(),
1788 hp_type: HpType::Categorical {
1789 choices: vec!["relu".into(), "tanh".into()],
1790 },
1791 log_scale: false,
1792 },
1793 ],
1794 maximize: true,
1795 seed: 99,
1796 };
1797 let tuner = HyperparameterTuner::new(config);
1798 let mut rng = 99u64;
1799 let cfg = tuner.sample_config(&mut rng);
1800 assert!(matches!(cfg.get("lr"), Some(HpValue::Float(_))));
1801 assert!(matches!(cfg.get("n"), Some(HpValue::Int(_))));
1802 assert!(matches!(cfg.get("act"), Some(HpValue::Choice(_))));
1803 }
1804}