Skip to main content

trustformers_optim/
hyperparameter_tuning.rs

1//! # Automated Hyperparameter Tuning Framework
2//!
3//! This module provides state-of-the-art automated hyperparameter optimization
4//! for all TrustformeRS optimizers using modern optimization techniques including
5//! Bayesian optimization, TPE (Tree-structured Parzen Estimator), and multi-objective
6//! optimization for the 2025 era.
7//!
8//! ## Key Features
9//!
10//! - **Bayesian Optimization**: Uses Gaussian processes for efficient hyperparameter search
11//! - **Multi-Objective Optimization**: Simultaneously optimizes convergence speed and stability
12//! - **Adaptive Sampling**: Intelligent exploration vs exploitation balance
13//! - **Transfer Learning**: Leverages previous optimization results across tasks
14//! - **Ensemble Methods**: Combines multiple tuning strategies for robustness
15//! - **Real-time Adaptation**: Adjusts hyperparameters during training based on performance
16//!
17//! ## Supported Optimizers
18//!
19//! Works with all TrustformeRS optimizers including aMacP, NovoGrad, Adam, AdamW,
20//! LAMB, Lion, Sophia, and 40+ other variants.
21
22// reason: research-stage module — reserved API/scaffolding fields and methods
23// retained intentionally for in-progress features; not yet on active call paths.
24#![allow(dead_code)]
25
26use crate::{amacp::AMacPConfig, novograd::NovoGradConfig};
27// Explicit import for .choose() method
28use scirs2_core::random::*; // Replaces rand - SciRS2 Integration Policy
29use serde::{Deserialize, Serialize};
30use std::collections::HashMap;
31use std::time::{Duration, Instant};
32use trustformers_core::errors::{Result, TrustformersError};
33
34/// Hyperparameter search space definition
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct HyperparameterSpace {
37    /// Learning rate bounds (min, max)
38    pub learning_rate: (f32, f32),
39    /// Beta1 momentum bounds
40    pub beta1: (f32, f32),
41    /// Beta2 momentum bounds
42    pub beta2: (f32, f32),
43    /// Weight decay bounds
44    pub weight_decay: (f32, f32),
45    /// Epsilon bounds
46    pub epsilon: (f32, f32),
47    /// Batch size options (discrete)
48    pub batch_sizes: Vec<usize>,
49    /// Whether to use logarithmic scaling for learning rate
50    pub log_scale_lr: bool,
51    /// Custom parameter ranges for specific optimizers
52    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    /// Create search space optimized for transformer models
72    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    /// Create search space for vision models
91    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    /// Create search space for scientific computing
105    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/// Individual hyperparameter configuration sample
122#[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    /// Performance score (higher is better)
132    pub performance_score: Option<f32>,
133    /// Training time in seconds
134    pub training_time: Option<f32>,
135    /// Memory usage in bytes
136    pub memory_usage: Option<usize>,
137}
138
139/// Training task definition for hyperparameter optimization
140#[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/// Performance metrics for hyperparameter evaluation
162#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct PerformanceMetrics {
164    pub final_loss: f32,
165    pub convergence_epoch: usize,
166    /// Wall-clock time the tuner measured around the objective call.
167    pub training_time: Duration,
168    /// Peak memory reported by the objective, if it measured any. `None` means the
169    /// objective did not report a figure — the tuner never estimates one.
170    pub memory_peak: Option<usize>,
171    pub stability_score: f32,
172    pub throughput: f32, // samples/second
173    pub gradient_norm_variance: f32,
174    pub composite_score: f32,
175}
176
177/// What a caller-supplied objective reports after really training a configuration.
178///
179/// The tuner cannot know any of these numbers: only the caller runs the model. Every
180/// field is therefore a *measurement* handed back by the objective, and the tuner adds
181/// only the wall-clock time it timed itself.
182#[derive(Debug, Clone)]
183pub struct TrialOutcome {
184    /// Validation loss at the end of the trial. Lower is better.
185    pub final_loss: f32,
186    /// Epoch at which the run converged (or the number of epochs actually run).
187    pub convergence_epoch: usize,
188    /// Training stability in `[0, 1]`; `1.0` means no divergence was observed.
189    pub stability_score: f32,
190    /// Observed throughput in samples per second.
191    pub throughput: f32,
192    /// Variance of the gradient norm observed during the trial.
193    pub gradient_norm_variance: f32,
194    /// Peak memory in bytes, if the caller measured it.
195    pub peak_memory_bytes: Option<usize>,
196}
197
198impl TrialOutcome {
199    /// Minimal outcome for objectives that only produce a loss.
200    ///
201    /// Unmeasured quantities stay neutral (`stability_score = 1.0`, zero throughput
202    /// and gradient variance, no memory figure) rather than being invented.
203    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/// Bayesian optimization state using Tree-structured Parzen Estimator (TPE)
216#[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, // Fraction of samples to consider as "good"
226}
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    /// Suggest next hyperparameter configuration using TPE
243    pub fn suggest(&mut self) -> HyperparameterSample {
244        if self.samples.len() < self.n_startup_trials {
245            // Random sampling for initial trials
246            self.random_sample()
247        } else {
248            // TPE-based sampling
249            self.tpe_sample()
250        }
251    }
252
253    /// Update optimizer with performance result
254    pub fn update(&mut self, mut sample: HyperparameterSample, performance: f32) {
255        sample.performance_score = Some(performance);
256
257        // Update performance threshold as median of all samples
258        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        // Classify sample as good or poor
268        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        // Keep only top gamma fraction as good samples
277        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        // Import trait for .choose() method
291        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        // Simplified TPE implementation
325        // In practice, this would use kernel density estimation
326        // Import trait for .choose() method
327        let mut rng = thread_rng();
328
329        if self.good_samples.is_empty() {
330            return self.random_sample();
331        }
332
333        // Sample from good samples with some noise
334        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    /// Get best hyperparameters found so far
371    pub fn get_best(&self) -> Option<&HyperparameterSample> {
372        self.samples.iter().filter(|s| s.performance_score.is_some()).max_by(|a, b| {
373            // Safe: filter ensures performance_score is Some
374            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/// Multi-objective hyperparameter optimizer
383#[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    /// Update with multi-objective performance metrics
408    pub fn update_multi_objective(
409        &mut self,
410        sample: HyperparameterSample,
411        metrics: &PerformanceMetrics,
412    ) {
413        // Combine multiple objectives into single score
414        let mut weighted_score = 0.0;
415        weighted_score += self.weights[0] * (1.0 / (1.0 + metrics.final_loss)); // Minimize loss
416        weighted_score += self.weights[1] * (1.0 / (1.0 + metrics.convergence_epoch as f32)); // Faster convergence
417        if self.weights.len() > 2 {
418            weighted_score += self.weights[2] * metrics.stability_score; // Maximize stability
419        }
420        if self.weights.len() > 3 {
421            weighted_score += self.weights[3] * (1.0 / (1.0 + metrics.training_time.as_secs_f32()));
422            // Minimize time
423        }
424
425        self.bayesian_opt.update(sample, weighted_score);
426        self.update_pareto_front();
427    }
428
429    fn update_pareto_front(&mut self) {
430        // Simple Pareto front update (could be optimized)
431        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/// Complete hyperparameter tuning framework
455#[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    /// Create new hyperparameter tuner
481    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    /// Enable multi-objective optimization
503    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    /// Get next hyperparameter configuration to try
512    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    /// Evaluates one hyperparameter configuration by *running the caller's objective*.
522    ///
523    /// The tuner has no model and no data, so it cannot produce a score on its own:
524    /// `objective` must actually train and validate the configuration and return the
525    /// measurements it observed. The tuner contributes the wall-clock timing and the
526    /// composite score, then feeds the result to the Bayesian search.
527    ///
528    /// # Errors
529    ///
530    /// Propagates any error the objective returns.
531    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        // Update optimizer with results
555        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        // Update best configuration
562        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    /// Aggregates a measured [`TrialOutcome`] into a single scalar to search on.
580    ///
581    /// This is a weighting of measurements, not a model of them: every input comes
582    /// from the caller's objective.
583    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    /// Runs the full search, evaluating every suggested configuration with `objective`.
593    ///
594    /// # Errors
595    ///
596    /// Propagates objective errors, and reports an error when no trial completed.
597    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    /// Human-readable summary of the search, for callers that want to print one.
630    ///
631    /// Library code must not write to stdout, so this returns the text instead of
632    /// printing it.
633    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    /// Get optimization history for analysis
665    pub fn get_history(&self) -> &[(HyperparameterSample, PerformanceMetrics)] {
666        &self.optimization_history
667    }
668
669    /// Get Pareto front for multi-objective optimization
670    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
675/// Convenience functions for common optimization tasks
676impl HyperparameterTuner {
677    /// Optimize aMacP hyperparameters for transformer training
678    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, // 125M parameters
689            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    /// Optimize NovoGrad hyperparameters for large language models
711    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, // 1B parameters
722            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    /// A deterministic analytic objective, used only to exercise the search machinery.
836    ///
837    /// It is *not* a stand-in for training: it lives behind `#[cfg(test)]` and callers
838    /// must always supply their own objective.
839    fn quadratic_objective(config: &HyperparameterSample) -> Result<TrialOutcome> {
840        // Minimised at lr = 1e-3, so the search has something real to find.
841        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    /// Regression: metrics used to come from a closed-form formula plus `thread_rng`
886    /// noise, so they were a function of the formula's shape rather than of anything
887    /// the caller ran. They must now come from the caller's objective, verbatim.
888    #[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        // 0.4/(1.125) + 0.3/8 + 0.2*0.5 + 0.1*0.25
918        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    /// Repeating the same configuration must give the same score: the old
927    /// implementation added `rng.random_range(-0.1..=0.1)` to every evaluation.
928    #[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    /// The reported training time must be a real measurement of the objective call.
941    #[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    /// An objective that fails must fail the trial, not be replaced by a guess.
958    #[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    /// The search must actually be driven by the objective's landscape.
968    #[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        // The analytic optimum is 1e-3; the search must land within an order of
973        // magnitude of it rather than anywhere in [1e-5, 1e-1].
974        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        // The convenience wrappers must thread the caller's objective through.
987        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}