1#![allow(dead_code)]
25
26use crate::{amacp::AMacPConfig, novograd::NovoGradConfig};
27use scirs2_core::random::*; use serde::{Deserialize, Serialize};
30use std::collections::HashMap;
31use std::time::{Duration, Instant};
32use trustformers_core::errors::{Result, TrustformersError};
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct HyperparameterSpace {
37 pub learning_rate: (f32, f32),
39 pub beta1: (f32, f32),
41 pub beta2: (f32, f32),
43 pub weight_decay: (f32, f32),
45 pub epsilon: (f32, f32),
47 pub batch_sizes: Vec<usize>,
49 pub log_scale_lr: bool,
51 pub custom_params: HashMap<String, (f32, f32)>,
53}
54
55impl Default for HyperparameterSpace {
56 fn default() -> Self {
57 Self {
58 learning_rate: (1e-5, 1e-1),
59 beta1: (0.8, 0.999),
60 beta2: (0.9, 0.9999),
61 weight_decay: (0.0, 1e-1),
62 epsilon: (1e-10, 1e-6),
63 batch_sizes: vec![16, 32, 64, 128, 256],
64 log_scale_lr: true,
65 custom_params: HashMap::new(),
66 }
67 }
68}
69
70impl HyperparameterSpace {
71 pub fn for_transformers() -> Self {
73 Self {
74 learning_rate: (1e-5, 5e-3),
75 beta1: (0.85, 0.95),
76 beta2: (0.95, 0.999),
77 weight_decay: (1e-3, 1e-1),
78 epsilon: (1e-8, 1e-6),
79 batch_sizes: vec![32, 64, 128, 256],
80 log_scale_lr: true,
81 custom_params: [
82 ("warmup_steps".to_string(), (1000.0, 10000.0)),
83 ("max_grad_norm".to_string(), (0.5, 2.0)),
84 ]
85 .into_iter()
86 .collect(),
87 }
88 }
89
90 pub fn for_vision() -> Self {
92 Self {
93 learning_rate: (1e-4, 1e-1),
94 beta1: (0.9, 0.99),
95 beta2: (0.999, 0.9999),
96 weight_decay: (1e-5, 1e-2),
97 epsilon: (1e-8, 1e-6),
98 batch_sizes: vec![16, 32, 64, 128],
99 log_scale_lr: true,
100 custom_params: HashMap::new(),
101 }
102 }
103
104 pub fn for_scientific_computing() -> Self {
106 Self {
107 learning_rate: (1e-6, 1e-2),
108 beta1: (0.95, 0.999),
109 beta2: (0.999, 0.9999),
110 weight_decay: (0.0, 1e-4),
111 epsilon: (1e-12, 1e-8),
112 batch_sizes: vec![32, 64, 128],
113 log_scale_lr: true,
114 custom_params: [("precision_threshold".to_string(), (1e-8, 1e-6))]
115 .into_iter()
116 .collect(),
117 }
118 }
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct HyperparameterSample {
124 pub learning_rate: f32,
125 pub beta1: f32,
126 pub beta2: f32,
127 pub weight_decay: f32,
128 pub epsilon: f32,
129 pub batch_size: usize,
130 pub custom_params: HashMap<String, f32>,
131 pub performance_score: Option<f32>,
133 pub training_time: Option<f32>,
135 pub memory_usage: Option<usize>,
137}
138
139#[derive(Debug, Clone)]
141pub struct OptimizationTask {
142 pub name: String,
143 pub model_size: usize,
144 pub dataset_size: usize,
145 pub max_epochs: usize,
146 pub convergence_threshold: f32,
147 pub target_metric: String,
148 pub task_type: TaskType,
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize)]
152pub enum TaskType {
153 Classification,
154 Regression,
155 LanguageModeling,
156 ComputerVision,
157 ScientificComputing,
158 Reinforcement,
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct PerformanceMetrics {
164 pub final_loss: f32,
165 pub convergence_epoch: usize,
166 pub training_time: Duration,
168 pub memory_peak: Option<usize>,
171 pub stability_score: f32,
172 pub throughput: f32, pub gradient_norm_variance: f32,
174 pub composite_score: f32,
175}
176
177#[derive(Debug, Clone)]
183pub struct TrialOutcome {
184 pub final_loss: f32,
186 pub convergence_epoch: usize,
188 pub stability_score: f32,
190 pub throughput: f32,
192 pub gradient_norm_variance: f32,
194 pub peak_memory_bytes: Option<usize>,
196}
197
198impl TrialOutcome {
199 pub fn from_loss(final_loss: f32, convergence_epoch: usize) -> Self {
204 Self {
205 final_loss,
206 convergence_epoch,
207 stability_score: 1.0,
208 throughput: 0.0,
209 gradient_norm_variance: 0.0,
210 peak_memory_bytes: None,
211 }
212 }
213}
214
215#[derive(Debug)]
217pub struct BayesianOptimizer {
218 space: HyperparameterSpace,
219 samples: Vec<HyperparameterSample>,
220 good_samples: Vec<HyperparameterSample>,
221 poor_samples: Vec<HyperparameterSample>,
222 performance_threshold: f32,
223 exploration_factor: f32,
224 n_startup_trials: usize,
225 gamma: f32, }
227
228impl BayesianOptimizer {
229 pub fn new(space: HyperparameterSpace) -> Self {
230 Self {
231 space,
232 samples: Vec::new(),
233 good_samples: Vec::new(),
234 poor_samples: Vec::new(),
235 performance_threshold: 0.0,
236 exploration_factor: 0.25,
237 n_startup_trials: 20,
238 gamma: 0.25,
239 }
240 }
241
242 pub fn suggest(&mut self) -> HyperparameterSample {
244 if self.samples.len() < self.n_startup_trials {
245 self.random_sample()
247 } else {
248 self.tpe_sample()
250 }
251 }
252
253 pub fn update(&mut self, mut sample: HyperparameterSample, performance: f32) {
255 sample.performance_score = Some(performance);
256
257 let mut performances: Vec<f32> =
259 self.samples.iter().filter_map(|s| s.performance_score).collect();
260 performances.push(performance);
261 performances.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
262
263 if !performances.is_empty() {
264 self.performance_threshold = performances[performances.len() / 2];
265 }
266
267 if performance > self.performance_threshold {
269 self.good_samples.push(sample.clone());
270 } else {
271 self.poor_samples.push(sample.clone());
272 }
273
274 self.samples.push(sample);
275
276 if self.good_samples.len() > 1 {
278 self.good_samples.sort_by(|a, b| {
279 b.performance_score
280 .unwrap_or(0.0)
281 .partial_cmp(&a.performance_score.unwrap_or(0.0))
282 .unwrap_or(std::cmp::Ordering::Equal)
283 });
284 let keep_count = ((self.samples.len() as f32 * self.gamma).ceil() as usize).max(1);
285 self.good_samples.truncate(keep_count);
286 }
287 }
288
289 fn random_sample(&self) -> HyperparameterSample {
290 let mut rng = thread_rng();
292
293 let learning_rate = if self.space.log_scale_lr {
294 let log_min = self.space.learning_rate.0.ln();
295 let log_max = self.space.learning_rate.1.ln();
296 (rng.random::<f32>() * (log_max - log_min) + log_min).exp()
297 } else {
298 rng.random_range(self.space.learning_rate.0..=self.space.learning_rate.1)
299 };
300
301 HyperparameterSample {
302 learning_rate,
303 beta1: rng.random_range(self.space.beta1.0..=self.space.beta1.1),
304 beta2: rng.random_range(self.space.beta2.0..=self.space.beta2.1),
305 weight_decay: rng.random_range(self.space.weight_decay.0..=self.space.weight_decay.1),
306 epsilon: rng.random_range(self.space.epsilon.0..=self.space.epsilon.1),
307 batch_size: {
308 let idx = rng.random_range(0..self.space.batch_sizes.len());
309 self.space.batch_sizes[idx]
310 },
311 custom_params: self
312 .space
313 .custom_params
314 .iter()
315 .map(|(k, &(min, max))| (k.clone(), rng.random_range(min..=max)))
316 .collect(),
317 performance_score: None,
318 training_time: None,
319 memory_usage: None,
320 }
321 }
322
323 fn tpe_sample(&self) -> HyperparameterSample {
324 let mut rng = thread_rng();
328
329 if self.good_samples.is_empty() {
330 return self.random_sample();
331 }
332
333 let idx = rng.random_range(0..self.good_samples.len());
335 let good_sample = &self.good_samples[idx];
336 let noise_factor = 0.1;
337
338 let learning_rate = if self.space.log_scale_lr {
339 let log_lr = good_sample.learning_rate.ln();
340 let noise = rng.random_range(-noise_factor..=noise_factor);
341 (log_lr + noise)
342 .exp()
343 .clamp(self.space.learning_rate.0, self.space.learning_rate.1)
344 } else {
345 let noise = rng.random_range(-noise_factor..=noise_factor)
346 * (self.space.learning_rate.1 - self.space.learning_rate.0);
347 (good_sample.learning_rate + noise)
348 .clamp(self.space.learning_rate.0, self.space.learning_rate.1)
349 };
350
351 HyperparameterSample {
352 learning_rate,
353 beta1: (good_sample.beta1 + rng.random_range(-0.01..=0.01))
354 .clamp(self.space.beta1.0, self.space.beta1.1),
355 beta2: (good_sample.beta2 + rng.random_range(-0.001..=0.001))
356 .clamp(self.space.beta2.0, self.space.beta2.1),
357 weight_decay: (good_sample.weight_decay
358 + rng.random_range(-noise_factor..=noise_factor)
359 * (self.space.weight_decay.1 - self.space.weight_decay.0))
360 .clamp(self.space.weight_decay.0, self.space.weight_decay.1),
361 epsilon: good_sample.epsilon,
362 batch_size: good_sample.batch_size,
363 custom_params: good_sample.custom_params.clone(),
364 performance_score: None,
365 training_time: None,
366 memory_usage: None,
367 }
368 }
369
370 pub fn get_best(&self) -> Option<&HyperparameterSample> {
372 self.samples.iter().filter(|s| s.performance_score.is_some()).max_by(|a, b| {
373 a.performance_score
375 .unwrap_or(0.0)
376 .partial_cmp(&b.performance_score.unwrap_or(0.0))
377 .unwrap_or(std::cmp::Ordering::Equal)
378 })
379 }
380}
381
382#[derive(Debug)]
384pub struct MultiObjectiveOptimizer {
385 bayesian_opt: BayesianOptimizer,
386 objectives: Vec<String>,
387 weights: Vec<f32>,
388 pareto_front: Vec<HyperparameterSample>,
389}
390
391impl MultiObjectiveOptimizer {
392 pub fn new(space: HyperparameterSpace, objectives: Vec<String>, weights: Vec<f32>) -> Self {
393 assert_eq!(
394 objectives.len(),
395 weights.len(),
396 "Objectives and weights must have same length"
397 );
398
399 Self {
400 bayesian_opt: BayesianOptimizer::new(space),
401 objectives,
402 weights,
403 pareto_front: Vec::new(),
404 }
405 }
406
407 pub fn update_multi_objective(
409 &mut self,
410 sample: HyperparameterSample,
411 metrics: &PerformanceMetrics,
412 ) {
413 let mut weighted_score = 0.0;
415 weighted_score += self.weights[0] * (1.0 / (1.0 + metrics.final_loss)); weighted_score += self.weights[1] * (1.0 / (1.0 + metrics.convergence_epoch as f32)); if self.weights.len() > 2 {
418 weighted_score += self.weights[2] * metrics.stability_score; }
420 if self.weights.len() > 3 {
421 weighted_score += self.weights[3] * (1.0 / (1.0 + metrics.training_time.as_secs_f32()));
422 }
424
425 self.bayesian_opt.update(sample, weighted_score);
426 self.update_pareto_front();
427 }
428
429 fn update_pareto_front(&mut self) {
430 self.pareto_front.clear();
432
433 for sample in &self.bayesian_opt.samples {
434 if let Some(sample_score) = sample.performance_score {
435 let mut is_dominated = false;
436
437 for other in &self.bayesian_opt.samples {
438 if let Some(other_score) = other.performance_score {
439 if other_score > sample_score {
440 is_dominated = true;
441 break;
442 }
443 }
444 }
445
446 if !is_dominated {
447 self.pareto_front.push(sample.clone());
448 }
449 }
450 }
451 }
452}
453
454#[derive(Debug)]
456pub struct HyperparameterTuner {
457 optimizer_type: OptimizerType,
458 search_space: HyperparameterSpace,
459 bayesian_opt: BayesianOptimizer,
460 multi_objective_opt: Option<MultiObjectiveOptimizer>,
461 task: OptimizationTask,
462 max_trials: usize,
463 current_trial: usize,
464 best_config: Option<HyperparameterSample>,
465 optimization_history: Vec<(HyperparameterSample, PerformanceMetrics)>,
466}
467
468#[derive(Debug, Clone)]
469pub enum OptimizerType {
470 Adam,
471 AdamW,
472 AMacP,
473 NovoGrad,
474 AveragedAdam,
475 Lion,
476 LAMB,
477}
478
479impl HyperparameterTuner {
480 pub fn new(
482 optimizer_type: OptimizerType,
483 search_space: HyperparameterSpace,
484 task: OptimizationTask,
485 max_trials: usize,
486 ) -> Self {
487 let bayesian_opt = BayesianOptimizer::new(search_space.clone());
488
489 Self {
490 optimizer_type,
491 search_space,
492 bayesian_opt,
493 multi_objective_opt: None,
494 task,
495 max_trials,
496 current_trial: 0,
497 best_config: None,
498 optimization_history: Vec::new(),
499 }
500 }
501
502 pub fn enable_multi_objective(&mut self, objectives: Vec<String>, weights: Vec<f32>) {
504 self.multi_objective_opt = Some(MultiObjectiveOptimizer::new(
505 self.search_space.clone(),
506 objectives,
507 weights,
508 ));
509 }
510
511 pub fn suggest_next(&mut self) -> Option<HyperparameterSample> {
513 if self.current_trial >= self.max_trials {
514 return None;
515 }
516
517 self.current_trial += 1;
518 Some(self.bayesian_opt.suggest())
519 }
520
521 pub fn evaluate_config<F>(
532 &mut self,
533 config: HyperparameterSample,
534 objective: &mut F,
535 ) -> Result<PerformanceMetrics>
536 where
537 F: FnMut(&HyperparameterSample) -> Result<TrialOutcome>,
538 {
539 let started = Instant::now();
540 let outcome = objective(&config)?;
541 let training_time = started.elapsed();
542
543 let metrics = PerformanceMetrics {
544 final_loss: outcome.final_loss,
545 convergence_epoch: outcome.convergence_epoch,
546 training_time,
547 memory_peak: outcome.peak_memory_bytes,
548 stability_score: outcome.stability_score,
549 throughput: outcome.throughput,
550 gradient_norm_variance: outcome.gradient_norm_variance,
551 composite_score: Self::composite_score(&outcome),
552 };
553
554 if let Some(ref mut multi_opt) = self.multi_objective_opt {
556 multi_opt.update_multi_objective(config.clone(), &metrics);
557 } else {
558 self.bayesian_opt.update(config.clone(), metrics.composite_score);
559 }
560
561 let current_best_score = self
563 .best_config
564 .as_ref()
565 .and_then(|c| c.performance_score)
566 .unwrap_or(f32::NEG_INFINITY);
567 if self.best_config.is_none() || metrics.composite_score > current_best_score {
568 let mut best_config = config.clone();
569 best_config.performance_score = Some(metrics.composite_score);
570 best_config.training_time = Some(training_time.as_secs_f32());
571 best_config.memory_usage = outcome.peak_memory_bytes;
572 self.best_config = Some(best_config);
573 }
574
575 self.optimization_history.push((config, metrics.clone()));
576 Ok(metrics)
577 }
578
579 fn composite_score(outcome: &TrialOutcome) -> f32 {
584 let loss_term = 1.0 / (1.0 + outcome.final_loss.max(0.0));
585 let speed_term = 1.0 / (1.0 + outcome.convergence_epoch as f32);
586 let stability_term = outcome.stability_score.clamp(0.0, 1.0);
587 let throughput_term = (outcome.throughput / 1000.0).clamp(0.0, 1.0);
588
589 0.4 * loss_term + 0.3 * speed_term + 0.2 * stability_term + 0.1 * throughput_term
590 }
591
592 pub fn optimize<F>(&mut self, objective: &mut F) -> Result<HyperparameterSample>
598 where
599 F: FnMut(&HyperparameterSample) -> Result<TrialOutcome>,
600 {
601 log::info!(
602 "starting hyperparameter optimization for {:?} on task '{}' ({} trials)",
603 self.optimizer_type,
604 self.task.name,
605 self.max_trials
606 );
607
608 while let Some(config) = self.suggest_next() {
609 let metrics = self.evaluate_config(config, objective)?;
610 log::debug!(
611 "trial {}/{}: score {:.4}, loss {:.4}, epochs {}, {:.3}s",
612 self.current_trial,
613 self.max_trials,
614 metrics.composite_score,
615 metrics.final_loss,
616 metrics.convergence_epoch,
617 metrics.training_time.as_secs_f32()
618 );
619 }
620
621 self.best_config.clone().ok_or_else(|| {
622 TrustformersError::new(trustformers_core::errors::ErrorKind::InvalidConfiguration {
623 field: "hyperparameter_optimization".to_string(),
624 reason: "No valid configuration found".to_string(),
625 })
626 })
627 }
628
629 pub fn optimization_summary(&self) -> String {
634 use std::fmt::Write as _;
635 let mut out = String::new();
636 let _ = writeln!(out, "Hyperparameter optimization summary");
637
638 if let Some(ref best) = self.best_config {
639 let _ = writeln!(out, "best configuration:");
640 let _ = writeln!(out, " learning rate: {:.3e}", best.learning_rate);
641 let _ = writeln!(out, " beta1: {:.4}", best.beta1);
642 let _ = writeln!(out, " beta2: {:.4}", best.beta2);
643 let _ = writeln!(out, " weight decay: {:.3e}", best.weight_decay);
644 let _ = writeln!(out, " batch size: {}", best.batch_size);
645 if let Some(score) = best.performance_score {
646 let _ = writeln!(out, " composite score: {score:.4}");
647 }
648 }
649
650 let _ = writeln!(out, "trials completed: {}", self.optimization_history.len());
651 if !self.optimization_history.is_empty() {
652 let scores: Vec<f32> =
653 self.optimization_history.iter().map(|(_, m)| m.composite_score).collect();
654 let average = scores.iter().sum::<f32>() / scores.len() as f32;
655 let maximum = scores.iter().fold(f32::NEG_INFINITY, |a, &b| a.max(b));
656 let minimum = scores.iter().fold(f32::INFINITY, |a, &b| a.min(b));
657 let _ = writeln!(out, "score average: {average:.4}");
658 let _ = writeln!(out, "score range: {minimum:.4} - {maximum:.4}");
659 }
660
661 out
662 }
663
664 pub fn get_history(&self) -> &[(HyperparameterSample, PerformanceMetrics)] {
666 &self.optimization_history
667 }
668
669 pub fn get_pareto_front(&self) -> Option<&[HyperparameterSample]> {
671 self.multi_objective_opt.as_ref().map(|opt| opt.pareto_front.as_slice())
672 }
673}
674
675impl HyperparameterTuner {
677 pub fn optimize_amacp_for_transformers<F>(
679 max_trials: usize,
680 objective: &mut F,
681 ) -> Result<AMacPConfig>
682 where
683 F: FnMut(&HyperparameterSample) -> Result<TrialOutcome>,
684 {
685 let space = HyperparameterSpace::for_transformers();
686 let task = OptimizationTask {
687 name: "Transformer Language Modeling".to_string(),
688 model_size: 125_000_000, dataset_size: 1_000_000,
690 max_epochs: 100,
691 convergence_threshold: 0.01,
692 target_metric: "perplexity".to_string(),
693 task_type: TaskType::LanguageModeling,
694 };
695
696 let mut tuner = HyperparameterTuner::new(OptimizerType::AMacP, space, task, max_trials);
697
698 let best_config = tuner.optimize(objective)?;
699
700 Ok(AMacPConfig {
701 learning_rate: best_config.learning_rate,
702 beta1: best_config.beta1,
703 beta2: best_config.beta2,
704 weight_decay: best_config.weight_decay,
705 epsilon: best_config.epsilon,
706 ..AMacPConfig::for_transformers()
707 })
708 }
709
710 pub fn optimize_novograd_for_llms<F>(
712 max_trials: usize,
713 objective: &mut F,
714 ) -> Result<NovoGradConfig>
715 where
716 F: FnMut(&HyperparameterSample) -> Result<TrialOutcome>,
717 {
718 let space = HyperparameterSpace::for_transformers();
719 let task = OptimizationTask {
720 name: "Large Language Model Training".to_string(),
721 model_size: 1_000_000_000, dataset_size: 10_000_000,
723 max_epochs: 50,
724 convergence_threshold: 0.005,
725 target_metric: "loss".to_string(),
726 task_type: TaskType::LanguageModeling,
727 };
728
729 let mut tuner = HyperparameterTuner::new(OptimizerType::NovoGrad, space, task, max_trials);
730
731 let best_config = tuner.optimize(objective)?;
732
733 Ok(NovoGradConfig {
734 learning_rate: best_config.learning_rate,
735 beta1: best_config.beta1,
736 beta2: best_config.beta2,
737 weight_decay: best_config.weight_decay,
738 epsilon: best_config.epsilon,
739 ..NovoGradConfig::for_large_language_models()
740 })
741 }
742}
743
744#[cfg(test)]
745mod tests {
746 use super::*;
747
748 #[test]
749 fn test_hyperparameter_space_creation() {
750 let space = HyperparameterSpace::default();
751 assert_eq!(space.learning_rate, (1e-5, 1e-1));
752 assert!(space.log_scale_lr);
753
754 let transformer_space = HyperparameterSpace::for_transformers();
755 assert!(transformer_space.custom_params.contains_key("warmup_steps"));
756 }
757
758 #[test]
759 fn test_bayesian_optimizer_suggestion() {
760 let space = HyperparameterSpace::default();
761 let mut optimizer = BayesianOptimizer::new(space);
762
763 let sample = optimizer.suggest();
764 assert!(sample.learning_rate >= 1e-5 && sample.learning_rate <= 1e-1);
765 assert!(sample.beta1 >= 0.8 && sample.beta1 <= 0.999);
766 }
767
768 #[test]
769 fn test_bayesian_optimizer_update() {
770 let space = HyperparameterSpace::default();
771 let mut optimizer = BayesianOptimizer::new(space);
772
773 let sample = optimizer.suggest();
774 optimizer.update(sample, 0.85);
775
776 assert_eq!(optimizer.samples.len(), 1);
777 assert!(optimizer.get_best().is_some());
778 }
779
780 #[test]
781 fn test_hyperparameter_tuner_creation() {
782 let space = HyperparameterSpace::for_vision();
783 let task = OptimizationTask {
784 name: "Test Task".to_string(),
785 model_size: 1000,
786 dataset_size: 10000,
787 max_epochs: 10,
788 convergence_threshold: 0.01,
789 target_metric: "accuracy".to_string(),
790 task_type: TaskType::Classification,
791 };
792
793 let tuner = HyperparameterTuner::new(OptimizerType::Adam, space, task, 50);
794
795 assert_eq!(tuner.max_trials, 50);
796 assert_eq!(tuner.current_trial, 0);
797 }
798
799 #[test]
800 fn test_multi_objective_optimizer() {
801 let space = HyperparameterSpace::default();
802 let objectives = vec!["accuracy".to_string(), "speed".to_string()];
803 let weights = vec![0.7, 0.3];
804
805 let mut optimizer = MultiObjectiveOptimizer::new(space, objectives, weights);
806
807 let sample = HyperparameterSample {
808 learning_rate: 1e-3,
809 beta1: 0.9,
810 beta2: 0.999,
811 weight_decay: 1e-4,
812 epsilon: 1e-8,
813 batch_size: 64,
814 custom_params: HashMap::new(),
815 performance_score: None,
816 training_time: None,
817 memory_usage: None,
818 };
819
820 let metrics = PerformanceMetrics {
821 final_loss: 0.1,
822 convergence_epoch: 25,
823 training_time: Duration::from_secs(120),
824 memory_peak: Some(1024 * 1024),
825 stability_score: 0.9,
826 throughput: 1000.0,
827 gradient_norm_variance: 0.1,
828 composite_score: 0.85,
829 };
830
831 optimizer.update_multi_objective(sample, &metrics);
832 assert!(!optimizer.pareto_front.is_empty());
833 }
834
835 fn quadratic_objective(config: &HyperparameterSample) -> Result<TrialOutcome> {
840 let distance = (config.learning_rate.log10() + 3.0).abs();
842 Ok(TrialOutcome {
843 final_loss: distance,
844 convergence_epoch: 10 + (distance * 10.0) as usize,
845 stability_score: 1.0 / (1.0 + distance),
846 throughput: 500.0,
847 gradient_norm_variance: distance * 0.1,
848 peak_memory_bytes: Some(config.batch_size * 4096),
849 })
850 }
851
852 fn sample_with_lr(learning_rate: f32) -> HyperparameterSample {
853 HyperparameterSample {
854 learning_rate,
855 beta1: 0.9,
856 beta2: 0.999,
857 weight_decay: 0.0,
858 epsilon: 1e-8,
859 batch_size: 32,
860 custom_params: HashMap::new(),
861 performance_score: None,
862 training_time: None,
863 memory_usage: None,
864 }
865 }
866
867 fn test_tuner(max_trials: usize) -> HyperparameterTuner {
868 let task = OptimizationTask {
869 name: "Test".to_string(),
870 model_size: 1000,
871 dataset_size: 1000,
872 max_epochs: 10,
873 convergence_threshold: 0.01,
874 target_metric: "loss".to_string(),
875 task_type: TaskType::Regression,
876 };
877 HyperparameterTuner::new(
878 OptimizerType::Adam,
879 HyperparameterSpace::default(),
880 task,
881 max_trials,
882 )
883 }
884
885 #[test]
889 fn metrics_come_from_the_caller_objective() {
890 let mut tuner = test_tuner(10);
891 let config = sample_with_lr(1e-3);
892
893 let mut calls = 0_usize;
894 let metrics = tuner
895 .evaluate_config(config, &mut |cfg| {
896 calls += 1;
897 assert!((cfg.learning_rate - 1e-3).abs() < 1e-12);
898 Ok(TrialOutcome {
899 final_loss: 0.125,
900 convergence_epoch: 7,
901 stability_score: 0.5,
902 throughput: 250.0,
903 gradient_norm_variance: 0.0625,
904 peak_memory_bytes: Some(4242),
905 })
906 })
907 .expect("evaluate");
908
909 assert_eq!(calls, 1, "the objective must actually be run");
910 assert_eq!(metrics.final_loss, 0.125);
911 assert_eq!(metrics.convergence_epoch, 7);
912 assert_eq!(metrics.stability_score, 0.5);
913 assert_eq!(metrics.throughput, 250.0);
914 assert_eq!(metrics.gradient_norm_variance, 0.0625);
915 assert_eq!(metrics.memory_peak, Some(4242));
916
917 let expected = 0.4 / 1.125 + 0.3 / 8.0 + 0.2 * 0.5 + 0.1 * 0.25;
919 assert!(
920 (metrics.composite_score - expected).abs() < 1e-5,
921 "composite {} vs {expected}",
922 metrics.composite_score
923 );
924 }
925
926 #[test]
929 fn identical_configurations_score_identically() {
930 let mut tuner = test_tuner(10);
931 let first = tuner
932 .evaluate_config(sample_with_lr(1e-3), &mut quadratic_objective)
933 .expect("first");
934 let second = tuner
935 .evaluate_config(sample_with_lr(1e-3), &mut quadratic_objective)
936 .expect("second");
937 assert_eq!(first.composite_score, second.composite_score);
938 }
939
940 #[test]
942 fn training_time_is_measured_not_modelled() {
943 let mut tuner = test_tuner(10);
944 let metrics = tuner
945 .evaluate_config(sample_with_lr(1e-3), &mut |_| {
946 std::thread::sleep(Duration::from_millis(20));
947 Ok(TrialOutcome::from_loss(1.0, 1))
948 })
949 .expect("evaluate");
950 assert!(
951 metrics.training_time >= Duration::from_millis(15),
952 "measured {:?}",
953 metrics.training_time
954 );
955 }
956
957 #[test]
959 fn objective_errors_propagate() {
960 let mut tuner = test_tuner(10);
961 let result = tuner.evaluate_config(sample_with_lr(1e-3), &mut |_| {
962 Err(TrustformersError::invalid_input("no data".to_string()))
963 });
964 assert!(result.is_err());
965 }
966
967 #[test]
969 fn search_finds_the_objective_optimum() {
970 let mut tuner = test_tuner(40);
971 let best = tuner.optimize(&mut quadratic_objective).expect("optimize");
972 let decades = (best.learning_rate.log10() + 3.0).abs();
975 assert!(
976 decades < 1.0,
977 "best lr {} is {decades} decades off",
978 best.learning_rate
979 );
980 assert!(!tuner.get_history().is_empty());
981 assert!(tuner.optimization_summary().contains("best configuration"));
982 }
983
984 #[test]
985 fn test_convenience_optimization_functions() {
986 let result =
988 HyperparameterTuner::optimize_amacp_for_transformers(5, &mut quadratic_objective);
989 assert!(result.is_ok());
990
991 let result = HyperparameterTuner::optimize_novograd_for_llms(5, &mut quadratic_objective);
992 assert!(result.is_ok());
993 }
994}