1use crate::error::{OptimError, Result};
8use crate::optimizers::*;
9use crate::utils::{scalar_or, total_order, try_f64, try_scalar};
10use scirs2_core::ndarray::{Array, Dimension, ScalarOperand};
11use scirs2_core::numeric::Float;
12use scirs2_core::random::thread_rng;
13use std::collections::{HashMap, VecDeque};
14use std::fmt::Debug;
15use std::time::{Duration, Instant};
16
17#[derive(Debug, Clone)]
19pub struct SelfTuningConfig {
20 pub evaluation_window: usize,
22
23 pub improvement_threshold: f64,
25
26 pub max_switches_per_epoch: usize,
28
29 pub auto_lr_adjustment: bool,
31
32 pub auto_optimizer_selection: bool,
34
35 pub auto_batch_size_tuning: bool,
37
38 pub warmup_steps: usize,
40
41 pub exploration_rate: f64,
43
44 pub exploration_decay: f64,
46
47 pub target_metric: TargetMetric,
49
50 pub min_adaptation_interval: Duration,
57}
58
59impl Default for SelfTuningConfig {
60 fn default() -> Self {
61 Self {
62 evaluation_window: 100,
63 improvement_threshold: 0.01,
64 max_switches_per_epoch: 3,
65 auto_lr_adjustment: true,
66 auto_optimizer_selection: true,
67 auto_batch_size_tuning: false,
68 warmup_steps: 1000,
69 exploration_rate: 0.1,
70 exploration_decay: 0.99,
71 target_metric: TargetMetric::Loss,
72 min_adaptation_interval: Duration::from_secs(1),
73 }
74 }
75}
76
77#[derive(Debug, Clone, Copy, PartialEq)]
79pub enum TargetMetric {
80 Loss,
82 Accuracy,
84 ConvergenceTime,
86 Throughput,
88 Custom,
90}
91
92#[derive(Debug, Clone)]
94pub struct PerformanceStats {
95 pub loss: f64,
97
98 pub accuracy: Option<f64>,
100
101 pub gradient_norm: f64,
103
104 pub throughput: f64,
106
107 pub memory_usage: f64,
109
110 pub step_time: Duration,
112
113 pub learning_rate: f64,
115
116 pub optimizer_type: String,
118
119 pub custom_metrics: HashMap<String, f64>,
121}
122
123pub struct SelfTuningOptimizer<A: Float, D: Dimension> {
125 config: SelfTuningConfig,
127
128 current_optimizer: Box<dyn OptimizerTrait<A, D>>,
130
131 optimizer_candidates: Vec<OptimizerCandidate<A, D>>,
133
134 performance_history: VecDeque<PerformanceStats>,
136
137 search_state: HyperparameterSearchState,
139
140 selection_strategy: OptimizerSelectionStrategy,
142
143 current_candidate_idx: usize,
149
150 step_count: usize,
152
153 switches_this_epoch: usize,
155
156 best_performance: Option<f64>,
158
159 last_adaptation_time: Instant,
161
162 bandit_state: BanditState,
164}
165
166struct OptimizerCandidate<A: Float, D: Dimension> {
168 name: String,
170
171 factory: Box<dyn Fn() -> Box<dyn OptimizerTrait<A, D>>>,
173
174 performance_history: Vec<f64>,
177
178 usage_count: usize,
180
181 average_reward: f64,
186
187 confidence_interval: (f64, f64),
190}
191
192#[derive(Debug)]
194struct HyperparameterSearchState {
195 learning_rate: f64,
197
198 lr_bounds: (f64, f64),
200
201 batch_size: usize,
203
204 batch_size_bounds: (usize, usize),
206
207 search_iterations: usize,
210
211 observed_metrics: Vec<f64>,
214
215 best_hyperparameters: HashMap<String, f64>,
218}
219
220#[derive(Debug, Clone)]
222pub struct HyperparameterSearchSummary {
223 pub search_iterations: usize,
225 pub observations: usize,
227 pub learning_rate: f64,
229 pub lr_bounds: (f64, f64),
231 pub batch_size: usize,
233 pub batch_size_bounds: (usize, usize),
235 pub best_hyperparameters: HashMap<String, f64>,
238}
239
240#[derive(Debug, Clone)]
249pub enum OptimizerSelectionStrategy {
250 MultiArmedBandit {
252 algorithm: BanditAlgorithm,
254 },
255
256 PerformanceBased {
258 min_difference: f64,
260 },
261
262 RoundRobin {
264 current_index: usize,
266 },
267
268 MetaLearning {
270 problem_features: Vec<f64>,
272 optimizer_mappings: HashMap<String, f64>,
274 },
275}
276
277#[derive(Debug, Clone, Copy, PartialEq, Eq)]
283pub enum BanditAlgorithm {
284 EpsilonGreedy,
287 UCB1,
289 ThompsonSampling,
292 LinUCB,
294}
295
296#[derive(Debug)]
298struct BanditState {
299 reward_estimates: Vec<f64>,
301
302 confidence_bounds: Vec<f64>,
304
305 selection_counts: Vec<usize>,
307
308 total_selections: usize,
310
311 exploration_param: f64,
313}
314
315pub trait OptimizerTrait<A: Float + ScalarOperand + Debug, D: Dimension>: Send + Sync {
317 fn name(&self) -> &str;
319
320 fn step(&mut self, params: &mut [Array<A, D>], grads: &[Array<A, D>]) -> Result<()>;
322
323 fn learning_rate(&self) -> A;
325
326 fn set_learning_rate(&mut self, lr: A);
328
329 fn get_state(&self) -> HashMap<String, Vec<u8>>;
331
332 fn set_state(&mut self, state: HashMap<String, Vec<u8>>) -> Result<()>;
334
335 fn clone_optimizer(&self) -> Box<dyn OptimizerTrait<A, D>>;
337}
338
339impl<
340 A: Float + ScalarOperand + Debug + Send + Sync + 'static + scirs2_core::numeric::FromPrimitive,
341 D: Dimension + 'static,
342 > SelfTuningOptimizer<A, D>
343{
344 pub fn new(config: SelfTuningConfig) -> Result<Self> {
346 let mut optimizer_candidates = Vec::new();
347
348 optimizer_candidates.push(OptimizerCandidate {
350 name: "Adam".to_string(),
351 factory: Box::new(|| Box::new(AdamOptimizerWrapper::new(0.001, 0.9, 0.999, 1e-8, 0.0))),
352 performance_history: Vec::new(),
353 usage_count: 0,
354 average_reward: 0.0,
355 confidence_interval: (0.0, 0.0),
356 });
357
358 optimizer_candidates.push(OptimizerCandidate {
359 name: "SGD".to_string(),
360 factory: Box::new(|| Box::new(SGDOptimizerWrapper::new(0.01, 0.9, 0.0))),
361 performance_history: Vec::new(),
362 usage_count: 0,
363 average_reward: 0.0,
364 confidence_interval: (0.0, 0.0),
365 });
366
367 optimizer_candidates.push(OptimizerCandidate {
368 name: "AdamW".to_string(),
369 factory: Box::new(|| {
370 Box::new(AdamWOptimizerWrapper::new(0.001, 0.9, 0.999, 1e-8, 0.01))
371 }),
372 performance_history: Vec::new(),
373 usage_count: 0,
374 average_reward: 0.0,
375 confidence_interval: (0.0, 0.0),
376 });
377
378 let current_optimizer = (optimizer_candidates[0].factory)();
380
381 let search_state = HyperparameterSearchState {
382 learning_rate: 0.001,
383 lr_bounds: (1e-6, 1.0),
384 batch_size: 32,
385 batch_size_bounds: (8, 512),
386 search_iterations: 0,
387 observed_metrics: Vec::new(),
388 best_hyperparameters: HashMap::new(),
389 };
390
391 let selection_strategy = OptimizerSelectionStrategy::MultiArmedBandit {
392 algorithm: BanditAlgorithm::UCB1,
393 };
394
395 let bandit_state = BanditState {
396 reward_estimates: vec![0.0; optimizer_candidates.len()],
397 confidence_bounds: vec![1.0; optimizer_candidates.len()],
398 selection_counts: vec![0; optimizer_candidates.len()],
399 total_selections: 0,
400 exploration_param: 2.0,
401 };
402
403 Ok(Self {
404 config,
405 current_optimizer,
406 optimizer_candidates,
407 performance_history: VecDeque::new(),
408 search_state,
409 selection_strategy,
410 current_candidate_idx: 0,
411 step_count: 0,
412 switches_this_epoch: 0,
413 best_performance: None,
414 last_adaptation_time: Instant::now(),
415 bandit_state,
416 })
417 }
418
419 pub fn add_optimizer_candidate<F>(&mut self, name: String, factory: F)
421 where
422 F: Fn() -> Box<dyn OptimizerTrait<A, D>> + 'static,
423 {
424 self.optimizer_candidates.push(OptimizerCandidate {
425 name,
426 factory: Box::new(factory),
427 performance_history: Vec::new(),
428 usage_count: 0,
429 average_reward: 0.0,
430 confidence_interval: (0.0, 0.0),
431 });
432
433 self.bandit_state.reward_estimates.push(0.0);
435 self.bandit_state.confidence_bounds.push(1.0);
436 self.bandit_state.selection_counts.push(0);
437 }
438
439 pub fn set_selection_strategy(&mut self, strategy: OptimizerSelectionStrategy) {
445 self.selection_strategy = strategy;
446 }
447
448 pub fn selection_strategy(&self) -> &OptimizerSelectionStrategy {
450 &self.selection_strategy
451 }
452
453 pub fn step(
455 &mut self,
456 params: &mut [Array<A, D>],
457 grads: &[Array<A, D>],
458 stats: PerformanceStats,
459 ) -> Result<()> {
460 self.step_count += 1;
461
462 self.performance_history.push_back(stats.clone());
464 if self.performance_history.len() > self.config.evaluation_window {
465 self.performance_history.pop_front();
466 }
467
468 self.current_optimizer.step(params, grads)?;
470
471 self.record_candidate_performance(&stats);
480
481 if self.step_count > self.config.warmup_steps {
483 self.maybe_adapt_optimizer(&stats)?;
484 self.maybe_adapt_learning_rate(&stats)?;
485 self.maybe_adapt_hyperparameters(&stats)?;
486 }
487
488 if let Some(performance) = self.extract_performance_metric(&stats) {
490 let improved = match self.best_performance {
491 None => true,
492 Some(best) => self.is_better_performance(performance, best),
493 };
494 if improved {
495 self.best_performance = Some(performance);
496 }
497 }
498
499 Ok(())
500 }
501
502 fn maybe_adapt_optimizer(&mut self, stats: &PerformanceStats) -> Result<()> {
504 if !self.config.auto_optimizer_selection {
505 return Ok(());
506 }
507
508 if self.switches_this_epoch >= self.config.max_switches_per_epoch {
509 return Ok(());
510 }
511
512 if self.switches_this_epoch > 0
516 && self.last_adaptation_time.elapsed() < self.config.min_adaptation_interval
517 {
518 return Ok(());
519 }
520
521 let should_adapt = self.should_adapt_optimizer(stats);
522
523 if should_adapt {
524 self.adapt_optimizer(stats)?;
525 self.switches_this_epoch += 1;
526 }
527
528 Ok(())
529 }
530
531 fn should_adapt_optimizer(&self, stats: &PerformanceStats) -> bool {
533 if self.performance_history.len() < self.config.evaluation_window / 2 {
534 return false;
535 }
536
537 let mut recent_performance: Vec<f64> = self
542 .performance_history
543 .iter()
544 .rev()
545 .take(self.config.evaluation_window / 4)
546 .filter_map(|s| self.extract_performance_metric(s))
547 .collect();
548 if let Some(current) = self.extract_performance_metric(stats) {
549 recent_performance.insert(0, current);
550 }
551
552 let older_performance: Vec<f64> = self
553 .performance_history
554 .iter()
555 .rev()
556 .skip(self.config.evaluation_window / 4)
557 .take(self.config.evaluation_window / 4)
558 .filter_map(|s| self.extract_performance_metric(s))
559 .collect();
560
561 if recent_performance.is_empty() || older_performance.is_empty() {
562 return false;
563 }
564
565 let recent_avg = recent_performance.iter().sum::<f64>() / recent_performance.len() as f64;
566 let older_avg = older_performance.iter().sum::<f64>() / older_performance.len() as f64;
567
568 match self.config.target_metric {
570 TargetMetric::Loss => {
571 (recent_avg - older_avg).abs() < self.config.improvement_threshold
572 || recent_avg > older_avg
573 }
574 TargetMetric::Accuracy | TargetMetric::Throughput => {
575 (recent_avg - older_avg).abs() < self.config.improvement_threshold
576 || recent_avg < older_avg
577 }
578 _ => false,
579 }
580 }
581
582 fn adapt_optimizer(&mut self, stats: &PerformanceStats) -> Result<()> {
584 let new_optimizer_idx = match &self.selection_strategy {
585 OptimizerSelectionStrategy::MultiArmedBandit { algorithm } => {
586 self.select_optimizer_bandit(*algorithm)
587 }
588 OptimizerSelectionStrategy::PerformanceBased { .. } => {
589 self.select_optimizer_performance_based()
590 }
591 OptimizerSelectionStrategy::RoundRobin { .. } => {
596 (self.current_candidate_idx + 1) % self.optimizer_candidates.len()
597 }
598 OptimizerSelectionStrategy::MetaLearning { .. } => {
599 self.select_optimizer_meta_learning(stats)
600 }
601 };
602
603 if new_optimizer_idx < self.optimizer_candidates.len() {
605 let current_lr = self.current_optimizer.learning_rate();
606 let current_state = self.current_optimizer.get_state();
607
608 self.current_optimizer = (self.optimizer_candidates[new_optimizer_idx].factory)();
609 self.current_optimizer.set_learning_rate(current_lr);
610
611 if self.current_optimizer.set_state(current_state).is_err() {
613 }
615
616 self.optimizer_candidates[new_optimizer_idx].usage_count += 1;
618 self.current_candidate_idx = new_optimizer_idx;
619 self.last_adaptation_time = Instant::now();
620 if let OptimizerSelectionStrategy::RoundRobin { current_index } =
623 &mut self.selection_strategy
624 {
625 *current_index = new_optimizer_idx;
626 }
627 }
628
629 Ok(())
630 }
631
632 fn select_optimizer_bandit(&mut self, algorithm: BanditAlgorithm) -> usize {
634 match algorithm {
635 BanditAlgorithm::UCB1 => self.select_ucb1(),
636 BanditAlgorithm::EpsilonGreedy => self.select_epsilon_greedy(),
637 BanditAlgorithm::ThompsonSampling => self.select_thompson_sampling(),
638 BanditAlgorithm::LinUCB => self.select_linucb(),
639 }
640 }
641
642 fn select_ucb1(&self) -> usize {
644 if self.bandit_state.total_selections == 0 {
645 return 0;
646 }
647
648 let mut best_score = f64::NEG_INFINITY;
649 let mut best_idx = 0;
650
651 for i in 0..self.optimizer_candidates.len() {
652 let ucb_score = if self.bandit_state.selection_counts[i] == 0 {
653 f64::INFINITY
654 } else {
655 let mean_reward = self.bandit_state.reward_estimates[i];
656 let confidence = (self.bandit_state.exploration_param
657 * (self.bandit_state.total_selections as f64).ln()
658 / self.bandit_state.selection_counts[i] as f64)
659 .sqrt();
660 mean_reward + confidence
661 };
662
663 if ucb_score > best_score {
664 best_score = ucb_score;
665 best_idx = i;
666 }
667 }
668
669 best_idx
670 }
671
672 fn select_epsilon_greedy(&self) -> usize {
674 let mut rng = thread_rng();
675
676 if scalar_or(rng.random::<f64>(), A::zero())
677 < scalar_or(self.config.exploration_rate, A::zero())
678 {
679 rng.gen_range(0..self.optimizer_candidates.len())
681 } else {
682 self.bandit_state
684 .reward_estimates
685 .iter()
686 .enumerate()
687 .max_by(|a, b| total_order(a.1, b.1))
688 .map(|(idx, _)| idx)
689 .unwrap_or(0)
690 }
691 }
692
693 fn select_thompson_sampling(&self) -> usize {
695 let mut rng = thread_rng();
697
698 let mut best_sample = f64::NEG_INFINITY;
699 let mut best_idx = 0;
700
701 for (i, _) in self.optimizer_candidates.iter().enumerate() {
702 let mean = self.bandit_state.reward_estimates[i];
703 let std = self.bandit_state.confidence_bounds[i];
704 let sample = rng.gen_range(mean - std..mean + std);
705
706 if sample > best_sample {
707 best_sample = sample;
708 best_idx = i;
709 }
710 }
711
712 best_idx
713 }
714
715 fn select_linucb(&self) -> usize {
717 self.select_ucb1()
719 }
720
721 fn select_optimizer_performance_based(&self) -> usize {
723 self.optimizer_candidates
724 .iter()
725 .enumerate()
726 .max_by(|a, b| total_order(&a.1.average_reward, &b.1.average_reward))
727 .map(|(idx, _)| idx)
728 .unwrap_or(0)
729 }
730
731 fn problem_feature_vector(stats: &PerformanceStats) -> [f64; 5] {
735 [
736 stats.loss,
737 stats.gradient_norm,
738 stats.throughput,
739 stats.memory_usage,
740 stats.learning_rate,
741 ]
742 }
743
744 fn select_optimizer_meta_learning(&self, stats: &PerformanceStats) -> usize {
758 const SIMILARITY_THRESHOLD: f64 = 0.9;
761
762 let OptimizerSelectionStrategy::MetaLearning {
763 problem_features,
764 optimizer_mappings,
765 } = &self.selection_strategy
766 else {
767 return self.select_optimizer_performance_based();
768 };
769 if optimizer_mappings.is_empty() {
770 return self.select_optimizer_performance_based();
771 }
772
773 let current = Self::problem_feature_vector(stats);
774 let shared = problem_features.len().min(current.len());
775 let (mut dot, mut norm_stored, mut norm_current) = (0.0, 0.0, 0.0);
776 for i in 0..shared {
777 dot += problem_features[i] * current[i];
778 norm_stored += problem_features[i] * problem_features[i];
779 norm_current += current[i] * current[i];
780 }
781 let similarity = if norm_stored > 0.0 && norm_current > 0.0 {
782 dot / (norm_stored.sqrt() * norm_current.sqrt())
783 } else {
784 0.0
787 };
788 if similarity < SIMILARITY_THRESHOLD {
789 return self.select_optimizer_performance_based();
790 }
791
792 self.optimizer_candidates
793 .iter()
794 .enumerate()
795 .filter_map(|(idx, candidate)| {
796 optimizer_mappings
797 .get(&candidate.name)
798 .map(|score| (idx, *score))
799 })
800 .max_by(|a, b| total_order(&a.1, &b.1))
801 .map(|(idx, _)| idx)
802 .unwrap_or_else(|| self.select_optimizer_performance_based())
803 }
804
805 fn maybe_adapt_learning_rate(&mut self, stats: &PerformanceStats) -> Result<()> {
807 if !self.config.auto_lr_adjustment {
808 return Ok(());
809 }
810
811 let current_lr = try_f64(self.current_optimizer.learning_rate())?;
813 let gradient_norm = stats.gradient_norm;
814
815 let new_lr = if gradient_norm > 10.0 {
816 current_lr * 0.9
818 } else if gradient_norm < 0.1 {
819 current_lr * 1.1
821 } else {
822 current_lr
823 };
824
825 let clamped_lr = new_lr
826 .max(self.search_state.lr_bounds.0)
827 .min(self.search_state.lr_bounds.1);
828
829 if (clamped_lr - current_lr).abs() > current_lr * 0.01 {
830 self.current_optimizer
831 .set_learning_rate(try_scalar::<A, _>(clamped_lr)?);
832 self.search_state.learning_rate = clamped_lr;
833 }
834
835 Ok(())
836 }
837
838 fn maybe_adapt_hyperparameters(&mut self, stats: &PerformanceStats) -> Result<()> {
849 let Some(metric) = self.extract_performance_metric(stats) else {
850 return Ok(());
851 };
852
853 let improved = match self.best_observed_metric() {
854 Some(best) => self.metric_is_better(metric, best),
855 None => true,
856 };
857
858 self.search_state.observed_metrics.push(metric);
859 let cap = self.config.evaluation_window.max(1) * 4;
860 if self.search_state.observed_metrics.len() > cap {
861 self.search_state.observed_metrics.remove(0);
862 }
863 self.search_state.search_iterations += 1;
864
865 if improved {
866 self.search_state
867 .best_hyperparameters
868 .insert("learning_rate".to_string(), self.search_state.learning_rate);
869 self.search_state.best_hyperparameters.insert(
870 "batch_size".to_string(),
871 self.search_state.batch_size as f64,
872 );
873 self.search_state
874 .best_hyperparameters
875 .insert("target_metric".to_string(), metric);
876 }
877
878 Ok(())
879 }
880
881 fn metric_is_better(&self, candidate: f64, incumbent: f64) -> bool {
884 match self.config.target_metric {
885 TargetMetric::Loss => candidate < incumbent,
886 _ => candidate > incumbent,
887 }
888 }
889
890 fn best_observed_metric(&self) -> Option<f64> {
892 self.search_state
893 .best_hyperparameters
894 .get("target_metric")
895 .copied()
896 }
897
898 pub fn hyperparameter_search_summary(&self) -> HyperparameterSearchSummary {
902 HyperparameterSearchSummary {
903 search_iterations: self.search_state.search_iterations,
904 observations: self.search_state.observed_metrics.len(),
905 learning_rate: self.search_state.learning_rate,
906 lr_bounds: self.search_state.lr_bounds,
907 batch_size: self.search_state.batch_size,
908 batch_size_bounds: self.search_state.batch_size_bounds,
909 best_hyperparameters: self.search_state.best_hyperparameters.clone(),
910 }
911 }
912
913 fn extract_performance_metric(&self, stats: &PerformanceStats) -> Option<f64> {
915 match self.config.target_metric {
916 TargetMetric::Loss => Some(stats.loss),
917 TargetMetric::Accuracy => stats.accuracy,
918 TargetMetric::Throughput => Some(stats.throughput),
919 TargetMetric::ConvergenceTime => Some(stats.step_time.as_secs_f64()),
920 TargetMetric::Custom => stats.custom_metrics.values().next().copied(),
921 }
922 }
923
924 fn lower_is_better(&self) -> bool {
926 matches!(
927 self.config.target_metric,
928 TargetMetric::Loss | TargetMetric::ConvergenceTime
929 )
930 }
931
932 fn reward_from_metric(&self, metric: f64) -> f64 {
935 if self.lower_is_better() {
936 -metric
937 } else {
938 metric
939 }
940 }
941
942 fn record_candidate_performance(&mut self, stats: &PerformanceStats) {
949 let Some(metric) = self.extract_performance_metric(stats) else {
950 return;
951 };
952 if !metric.is_finite() {
953 return;
954 }
955 let reward = self.reward_from_metric(metric);
956
957 let window = self.config.evaluation_window.max(1);
958 let idx = self.current_candidate_idx;
959 let Some(candidate) = self.optimizer_candidates.get_mut(idx) else {
960 return;
961 };
962
963 candidate.performance_history.push(reward);
964 if candidate.performance_history.len() > window {
965 let excess = candidate.performance_history.len() - window;
966 candidate.performance_history.drain(0..excess);
967 }
968
969 let samples = candidate.performance_history.len();
970 let count = samples as f64;
971 let mean = candidate.performance_history.iter().sum::<f64>() / count;
972 let half_width = if samples > 1 {
975 let variance = candidate
976 .performance_history
977 .iter()
978 .map(|&r| (r - mean) * (r - mean))
979 .sum::<f64>()
980 / (count - 1.0);
981 1.96 * (variance / count).sqrt()
982 } else {
983 0.0
984 };
985
986 candidate.average_reward = mean;
987 candidate.confidence_interval = (mean - half_width, mean + half_width);
988
989 if let Some(estimate) = self.bandit_state.reward_estimates.get_mut(idx) {
991 *estimate = mean;
992 }
993 if let Some(bound) = self.bandit_state.confidence_bounds.get_mut(idx) {
994 *bound = if samples > 1 { half_width } else { 1.0 };
997 }
998 }
999
1000 fn is_better_performance(&self, new_perf: f64, oldperf: f64) -> bool {
1002 match self.config.target_metric {
1003 TargetMetric::Loss | TargetMetric::ConvergenceTime => new_perf < oldperf,
1004 TargetMetric::Accuracy | TargetMetric::Throughput => new_perf > oldperf,
1005 TargetMetric::Custom => new_perf > oldperf, }
1007 }
1008
1009 pub fn reset_epoch(&mut self) {
1011 self.switches_this_epoch = 0;
1012 }
1013
1014 pub fn get_optimizer_info(&self) -> OptimizerInfo {
1016 OptimizerInfo {
1017 name: self.current_optimizer.name().to_string(),
1018 learning_rate: try_f64(self.current_optimizer.learning_rate()).unwrap_or(f64::NAN),
1021 step_count: self.step_count,
1022 switches_this_epoch: self.switches_this_epoch,
1023 performance_window_size: self.performance_history.len(),
1024 best_performance: self.best_performance,
1025 }
1026 }
1027
1028 pub fn get_statistics(&self) -> SelfTuningStatistics {
1030 let optimizer_usage: HashMap<String, usize> = self
1031 .optimizer_candidates
1032 .iter()
1033 .map(|c| (c.name.clone(), c.usage_count))
1034 .collect();
1035
1036 SelfTuningStatistics {
1037 total_steps: self.step_count,
1038 total_optimizer_switches: self
1039 .optimizer_candidates
1040 .iter()
1041 .map(|c| c.usage_count)
1042 .sum(),
1043 optimizer_usage,
1044 current_learning_rate: self.search_state.learning_rate,
1045 average_step_time: self
1046 .performance_history
1047 .iter()
1048 .map(|s| s.step_time.as_secs_f64())
1049 .sum::<f64>()
1050 / self.performance_history.len().max(1) as f64,
1051 exploration_rate: self.config.exploration_rate,
1052 }
1053 }
1054}
1055
1056#[derive(Debug, Clone)]
1058pub struct OptimizerInfo {
1059 pub name: String,
1060 pub learning_rate: f64,
1061 pub step_count: usize,
1062 pub switches_this_epoch: usize,
1063 pub performance_window_size: usize,
1064 pub best_performance: Option<f64>,
1065}
1066
1067#[derive(Debug, Clone)]
1069pub struct SelfTuningStatistics {
1070 pub total_steps: usize,
1071 pub total_optimizer_switches: usize,
1072 pub optimizer_usage: HashMap<String, usize>,
1073 pub current_learning_rate: f64,
1074 pub average_step_time: f64,
1075 pub exploration_rate: f64,
1076}
1077
1078struct AdamOptimizerWrapper<A: Float + ScalarOperand + Debug, D: Dimension> {
1080 inner: crate::optimizers::Adam<A>,
1081 _phantom: std::marker::PhantomData<D>,
1082}
1083
1084impl<A: Float + ScalarOperand + Debug + Send + Sync, D: Dimension + Send + Sync>
1085 AdamOptimizerWrapper<A, D>
1086{
1087 fn new(_lr: f64, beta1: f64, beta2: f64, eps: f64, weightdecay: f64) -> Self {
1088 Self {
1089 inner: crate::optimizers::Adam::new_with_config(
1090 scalar_or(_lr, A::zero()),
1091 scalar_or(beta1, A::zero()),
1092 scalar_or(beta2, A::zero()),
1093 scalar_or(eps, A::zero()),
1094 scalar_or(weightdecay, A::zero()),
1095 ),
1096 _phantom: std::marker::PhantomData,
1097 }
1098 }
1099}
1100
1101impl<A: Float + ScalarOperand + Debug + Send + Sync + 'static, D: Dimension + 'static>
1102 OptimizerTrait<A, D> for AdamOptimizerWrapper<A, D>
1103{
1104 fn name(&self) -> &str {
1105 "Adam"
1106 }
1107
1108 fn step(&mut self, params: &mut [Array<A, D>], grads: &[Array<A, D>]) -> Result<()> {
1109 if params.len() != grads.len() {
1110 return Err(crate::error::OptimError::InvalidParameter(
1111 "Mismatched number of parameters and gradients".to_string(),
1112 ));
1113 }
1114
1115 for (param, grad) in params.iter_mut().zip(grads.iter()) {
1116 let updated = self.inner.step(param, grad)?;
1117 *param = updated;
1118 }
1119 Ok(())
1120 }
1121
1122 fn learning_rate(&self) -> A {
1123 self.inner.learning_rate()
1124 }
1125
1126 fn set_learning_rate(&mut self, lr: A) {
1127 <crate::optimizers::Adam<A> as crate::optimizers::Optimizer<A, D>>::set_learning_rate(
1128 &mut self.inner,
1129 lr,
1130 );
1131 }
1132
1133 fn get_state(&self) -> HashMap<String, Vec<u8>> {
1138 HashMap::new()
1139 }
1140
1141 fn set_state(&mut self, state: HashMap<String, Vec<u8>>) -> Result<()> {
1142 if state.is_empty() {
1143 return Ok(());
1144 }
1145 Err(OptimError::UnsupportedOperation(format!(
1146 "{} does not expose serializable moment state, so a {}-entry state \
1147 snapshot cannot be restored; the optimizer starts from a fresh state",
1148 self.name(),
1149 state.len()
1150 )))
1151 }
1152
1153 fn clone_optimizer(&self) -> Box<dyn OptimizerTrait<A, D>> {
1154 Box::new(AdamOptimizerWrapper {
1155 inner: self.inner.clone(),
1156 _phantom: std::marker::PhantomData,
1157 })
1158 }
1159}
1160
1161struct SGDOptimizerWrapper<A: Float + ScalarOperand + Debug, D: Dimension> {
1162 inner: crate::optimizers::SGD<A>,
1163 _phantom: std::marker::PhantomData<D>,
1164}
1165
1166impl<A: Float + ScalarOperand + Debug + Send + Sync, D: Dimension + Send + Sync>
1167 SGDOptimizerWrapper<A, D>
1168{
1169 fn new(lr: f64, momentum: f64, weightdecay: f64) -> Self {
1177 Self {
1178 inner: crate::optimizers::SGD::new_with_config(
1179 scalar_or(lr, A::zero()),
1180 scalar_or(momentum, A::zero()),
1181 scalar_or(weightdecay, A::zero()),
1182 ),
1183 _phantom: std::marker::PhantomData,
1184 }
1185 }
1186}
1187
1188impl<A: Float + ScalarOperand + Debug + Send + Sync + 'static, D: Dimension + 'static>
1189 OptimizerTrait<A, D> for SGDOptimizerWrapper<A, D>
1190{
1191 fn name(&self) -> &str {
1192 "SGD"
1193 }
1194
1195 fn step(&mut self, params: &mut [Array<A, D>], grads: &[Array<A, D>]) -> Result<()> {
1196 if params.len() != grads.len() {
1197 return Err(crate::error::OptimError::InvalidParameter(
1198 "Mismatched number of parameters and gradients".to_string(),
1199 ));
1200 }
1201
1202 for (param, grad) in params.iter_mut().zip(grads.iter()) {
1203 let updated = self.inner.step(param, grad)?;
1204 *param = updated;
1205 }
1206 Ok(())
1207 }
1208
1209 fn learning_rate(&self) -> A {
1210 self.inner.learning_rate()
1211 }
1212
1213 fn set_learning_rate(&mut self, lr: A) {
1214 <crate::optimizers::SGD<A> as crate::optimizers::Optimizer<A, D>>::set_learning_rate(
1215 &mut self.inner,
1216 lr,
1217 );
1218 }
1219
1220 fn get_state(&self) -> HashMap<String, Vec<u8>> {
1225 HashMap::new()
1226 }
1227
1228 fn set_state(&mut self, state: HashMap<String, Vec<u8>>) -> Result<()> {
1229 if state.is_empty() {
1230 return Ok(());
1231 }
1232 Err(OptimError::UnsupportedOperation(format!(
1233 "{} does not expose serializable moment state, so a {}-entry state \
1234 snapshot cannot be restored; the optimizer starts from a fresh state",
1235 self.name(),
1236 state.len()
1237 )))
1238 }
1239
1240 fn clone_optimizer(&self) -> Box<dyn OptimizerTrait<A, D>> {
1241 Box::new(SGDOptimizerWrapper {
1242 inner: self.inner.clone(),
1243 _phantom: std::marker::PhantomData,
1244 })
1245 }
1246}
1247
1248struct AdamWOptimizerWrapper<A: Float + ScalarOperand + Debug, D: Dimension> {
1249 inner: crate::optimizers::AdamW<A>,
1250 _phantom: std::marker::PhantomData<D>,
1251}
1252
1253impl<A: Float + ScalarOperand + Debug + Send + Sync, D: Dimension + Send + Sync>
1254 AdamWOptimizerWrapper<A, D>
1255{
1256 fn new(_lr: f64, beta1: f64, beta2: f64, eps: f64, weightdecay: f64) -> Self {
1257 Self {
1258 inner: crate::optimizers::AdamW::new_with_config(
1259 scalar_or(_lr, A::zero()),
1260 scalar_or(beta1, A::zero()),
1261 scalar_or(beta2, A::zero()),
1262 scalar_or(eps, A::zero()),
1263 scalar_or(weightdecay, A::zero()),
1264 ),
1265 _phantom: std::marker::PhantomData,
1266 }
1267 }
1268}
1269
1270impl<A: Float + ScalarOperand + Debug + Send + Sync + 'static, D: Dimension + 'static>
1271 OptimizerTrait<A, D> for AdamWOptimizerWrapper<A, D>
1272{
1273 fn name(&self) -> &str {
1274 "AdamW"
1275 }
1276
1277 fn step(&mut self, params: &mut [Array<A, D>], grads: &[Array<A, D>]) -> Result<()> {
1278 if params.len() != grads.len() {
1279 return Err(crate::error::OptimError::InvalidParameter(
1280 "Mismatched number of parameters and gradients".to_string(),
1281 ));
1282 }
1283
1284 for (param, grad) in params.iter_mut().zip(grads.iter()) {
1285 let updated = self.inner.step(param, grad)?;
1286 *param = updated;
1287 }
1288 Ok(())
1289 }
1290
1291 fn learning_rate(&self) -> A {
1292 self.inner.learning_rate()
1293 }
1294
1295 fn set_learning_rate(&mut self, lr: A) {
1296 <crate::optimizers::AdamW<A> as crate::optimizers::Optimizer<A, D>>::set_learning_rate(
1297 &mut self.inner,
1298 lr,
1299 );
1300 }
1301
1302 fn get_state(&self) -> HashMap<String, Vec<u8>> {
1307 HashMap::new()
1308 }
1309
1310 fn set_state(&mut self, state: HashMap<String, Vec<u8>>) -> Result<()> {
1311 if state.is_empty() {
1312 return Ok(());
1313 }
1314 Err(OptimError::UnsupportedOperation(format!(
1315 "{} does not expose serializable moment state, so a {}-entry state \
1316 snapshot cannot be restored; the optimizer starts from a fresh state",
1317 self.name(),
1318 state.len()
1319 )))
1320 }
1321
1322 fn clone_optimizer(&self) -> Box<dyn OptimizerTrait<A, D>> {
1323 Box::new(AdamWOptimizerWrapper {
1324 inner: self.inner.clone(),
1325 _phantom: std::marker::PhantomData,
1326 })
1327 }
1328}
1329
1330#[cfg(test)]
1331mod tests {
1332 use super::*;
1333 use scirs2_core::ndarray::Array1;
1334 use std::time::Duration;
1335
1336 #[test]
1337 fn test_self_tuning_config_default() {
1338 let config = SelfTuningConfig::default();
1339 assert_eq!(config.evaluation_window, 100);
1340 assert!(config.auto_lr_adjustment);
1341 assert!(config.auto_optimizer_selection);
1342 }
1343
1344 #[test]
1345 fn test_self_tuning_optimizer_creation() {
1346 let config = SelfTuningConfig::default();
1347 let optimizer: Result<SelfTuningOptimizer<f64, scirs2_core::ndarray::Ix1>> =
1348 SelfTuningOptimizer::new(config);
1349 assert!(optimizer.is_ok());
1350 }
1351
1352 #[test]
1353 fn test_performance_stats() {
1354 let stats = PerformanceStats {
1355 loss: 0.5,
1356 accuracy: Some(0.9),
1357 gradient_norm: 1.2,
1358 throughput: 100.0,
1359 memory_usage: 1024.0,
1360 step_time: Duration::from_millis(50),
1361 learning_rate: 0.001,
1362 optimizer_type: "Adam".to_string(),
1363 custom_metrics: HashMap::new(),
1364 };
1365
1366 assert_eq!(stats.loss, 0.5);
1367 assert_eq!(stats.accuracy, Some(0.9));
1368 }
1369
1370 #[test]
1371 fn test_optimizer_step() {
1372 let config = SelfTuningConfig::default();
1373 let mut optimizer: SelfTuningOptimizer<f64, scirs2_core::ndarray::Ix1> =
1374 SelfTuningOptimizer::new(config).expect("default config must construct");
1375
1376 let mut params = vec![Array1::zeros(10)];
1377 let grads = vec![Array1::ones(10)];
1378
1379 let stats = PerformanceStats {
1380 loss: 1.0,
1381 accuracy: None,
1382 gradient_norm: 1.0,
1383 throughput: 50.0,
1384 memory_usage: 512.0,
1385 step_time: Duration::from_millis(10),
1386 learning_rate: 0.001,
1387 optimizer_type: "Adam".to_string(),
1388 custom_metrics: HashMap::new(),
1389 };
1390
1391 let result = optimizer.step(&mut params, &grads, stats);
1392 assert!(result.is_ok());
1393
1394 let info = optimizer.get_optimizer_info();
1395 assert_eq!(info.name, "Adam");
1396 assert_eq!(info.step_count, 1);
1397 }
1398
1399 #[test]
1400 fn test_bandit_selection() {
1401 let config = SelfTuningConfig::default();
1402 let optimizer: SelfTuningOptimizer<f64, scirs2_core::ndarray::Ix1> =
1403 SelfTuningOptimizer::new(config).expect("default config must construct");
1404
1405 let selection = optimizer.select_ucb1();
1406 assert!(selection < optimizer.optimizer_candidates.len());
1407 }
1408
1409 #[test]
1410 fn test_performance_metric_extraction() {
1411 let config = SelfTuningConfig {
1412 target_metric: TargetMetric::Loss,
1413 ..Default::default()
1414 };
1415 let optimizer: SelfTuningOptimizer<f64, scirs2_core::ndarray::Ix1> =
1416 SelfTuningOptimizer::new(config).expect("default config must construct");
1417
1418 let stats = PerformanceStats {
1419 loss: 0.8,
1420 accuracy: Some(0.85),
1421 gradient_norm: 1.1,
1422 throughput: 75.0,
1423 memory_usage: 800.0,
1424 step_time: Duration::from_millis(20),
1425 learning_rate: 0.001,
1426 optimizer_type: "Adam".to_string(),
1427 custom_metrics: HashMap::new(),
1428 };
1429
1430 let metric = optimizer.extract_performance_metric(&stats);
1431 assert_eq!(metric, Some(0.8));
1432 }
1433
1434 #[test]
1435 fn test_statistics() {
1436 let config = SelfTuningConfig::default();
1437 let optimizer: SelfTuningOptimizer<f64, scirs2_core::ndarray::Ix1> =
1438 SelfTuningOptimizer::new(config).expect("default config must construct");
1439
1440 let stats = optimizer.get_statistics();
1441 assert_eq!(stats.total_steps, 0);
1442 assert!(stats.optimizer_usage.contains_key("Adam"));
1443 }
1444 fn stats_with_loss(loss: f64) -> PerformanceStats {
1447 PerformanceStats {
1448 loss,
1449 accuracy: None,
1450 gradient_norm: 1.0,
1451 throughput: 50.0,
1452 memory_usage: 512.0,
1453 step_time: Duration::from_millis(10),
1454 learning_rate: 0.001,
1455 optimizer_type: "Adam".to_string(),
1456 custom_metrics: HashMap::new(),
1457 }
1458 }
1459
1460 #[test]
1464 fn observed_performance_reaches_the_active_candidate() {
1465 let mut optimizer: SelfTuningOptimizer<f64, scirs2_core::ndarray::Ix1> =
1466 SelfTuningOptimizer::new(SelfTuningConfig::default())
1467 .expect("default config must construct");
1468 let mut params = vec![Array1::zeros(4)];
1469 let grads = vec![Array1::ones(4)];
1470
1471 for loss in [1.0, 0.8, 0.6, 0.4] {
1472 optimizer
1473 .step(&mut params, &grads, stats_with_loss(loss))
1474 .expect("step");
1475 }
1476
1477 let active = &optimizer.optimizer_candidates[optimizer.current_candidate_idx];
1478 assert_eq!(
1479 active.performance_history.len(),
1480 4,
1481 "every reported observation must be attributed to the active candidate"
1482 );
1483 assert!(
1486 (active.average_reward - (-0.7)).abs() < 1e-12,
1487 "average reward must be the mean of the negated losses, got {}",
1488 active.average_reward
1489 );
1490 assert_ne!(
1491 active.average_reward, 0.0,
1492 "regression: candidate statistics are still frozen at their initial 0.0"
1493 );
1494 let (lo, hi) = active.confidence_interval;
1495 assert!(
1496 lo < active.average_reward && active.average_reward < hi,
1497 "the confidence interval must bracket the mean, got ({lo}, {hi})"
1498 );
1499 assert!(
1500 (optimizer.bandit_state.reward_estimates[optimizer.current_candidate_idx] - (-0.7))
1501 .abs()
1502 < 1e-12,
1503 "the bandit arm must see the same estimate as the candidate"
1504 );
1505 }
1506
1507 #[test]
1511 fn reward_orientation_follows_the_target_metric() {
1512 let loss_tuner: SelfTuningOptimizer<f64, scirs2_core::ndarray::Ix1> =
1513 SelfTuningOptimizer::new(SelfTuningConfig {
1514 target_metric: TargetMetric::Loss,
1515 ..Default::default()
1516 })
1517 .expect("construct");
1518 assert_eq!(loss_tuner.reward_from_metric(0.3), -0.3);
1519
1520 let acc_tuner: SelfTuningOptimizer<f64, scirs2_core::ndarray::Ix1> =
1521 SelfTuningOptimizer::new(SelfTuningConfig {
1522 target_metric: TargetMetric::Accuracy,
1523 ..Default::default()
1524 })
1525 .expect("construct");
1526 assert_eq!(acc_tuner.reward_from_metric(0.3), 0.3);
1527 }
1528
1529 #[test]
1531 fn non_finite_observations_are_ignored() {
1532 let mut optimizer: SelfTuningOptimizer<f64, scirs2_core::ndarray::Ix1> =
1533 SelfTuningOptimizer::new(SelfTuningConfig::default()).expect("construct");
1534 let mut params = vec![Array1::zeros(2)];
1535 let grads = vec![Array1::ones(2)];
1536
1537 optimizer
1538 .step(&mut params, &grads, stats_with_loss(1.0))
1539 .expect("step");
1540 optimizer
1541 .step(&mut params, &grads, stats_with_loss(f64::NAN))
1542 .expect("step");
1543
1544 let active = &optimizer.optimizer_candidates[optimizer.current_candidate_idx];
1545 assert_eq!(active.performance_history.len(), 1);
1546 assert!(active.average_reward.is_finite());
1547 }
1548
1549 #[test]
1552 fn every_selection_strategy_is_reachable() {
1553 let mut optimizer: SelfTuningOptimizer<f64, scirs2_core::ndarray::Ix1> =
1554 SelfTuningOptimizer::new(SelfTuningConfig::default()).expect("construct");
1555
1556 for strategy in [
1557 OptimizerSelectionStrategy::MultiArmedBandit {
1558 algorithm: BanditAlgorithm::EpsilonGreedy,
1559 },
1560 OptimizerSelectionStrategy::MultiArmedBandit {
1561 algorithm: BanditAlgorithm::ThompsonSampling,
1562 },
1563 OptimizerSelectionStrategy::MultiArmedBandit {
1564 algorithm: BanditAlgorithm::LinUCB,
1565 },
1566 OptimizerSelectionStrategy::PerformanceBased {
1567 min_difference: 0.01,
1568 },
1569 OptimizerSelectionStrategy::RoundRobin { current_index: 0 },
1570 OptimizerSelectionStrategy::MetaLearning {
1571 problem_features: vec![0.0; 5],
1572 optimizer_mappings: HashMap::new(),
1573 },
1574 ] {
1575 optimizer.set_selection_strategy(strategy);
1576 let picked = optimizer.adapt_optimizer(&stats_with_loss(1.0));
1577 assert!(picked.is_ok(), "strategy must be usable end to end");
1578 assert!(optimizer.current_candidate_idx < optimizer.optimizer_candidates.len());
1579 }
1580 }
1581
1582 #[test]
1585 fn performance_based_selection_picks_the_best_measured_candidate() {
1586 let mut optimizer: SelfTuningOptimizer<f64, scirs2_core::ndarray::Ix1> =
1587 SelfTuningOptimizer::new(SelfTuningConfig::default()).expect("construct");
1588 optimizer.optimizer_candidates[0].average_reward = -1.0;
1590 optimizer.optimizer_candidates[1].average_reward = -0.1;
1591 optimizer.optimizer_candidates[2].average_reward = -0.5;
1592
1593 assert_eq!(optimizer.select_optimizer_performance_based(), 1);
1594 }
1595 #[test]
1603 fn an_observation_is_credited_to_the_optimizer_that_produced_it() {
1604 let mut optimizer: SelfTuningOptimizer<f64, scirs2_core::ndarray::Ix1> =
1605 SelfTuningOptimizer::new(SelfTuningConfig {
1606 warmup_steps: 0,
1607 evaluation_window: 4,
1608 max_switches_per_epoch: 10,
1609 min_adaptation_interval: Duration::ZERO,
1610 improvement_threshold: 10.0, ..Default::default()
1612 })
1613 .expect("construct");
1614 optimizer
1617 .set_selection_strategy(OptimizerSelectionStrategy::RoundRobin { current_index: 0 });
1618
1619 let mut params = vec![Array1::zeros(3)];
1620 let grads = vec![Array1::ones(3)];
1621
1622 let mut switched_on: Option<(usize, usize, f64)> = None;
1626 for i in 0..40 {
1627 let active_before = optimizer.current_candidate_idx;
1628 let loss = 1.0 + i as f64;
1629 optimizer
1630 .step(&mut params, &grads, stats_with_loss(loss))
1631 .expect("step");
1632 if optimizer.current_candidate_idx != active_before {
1633 switched_on = Some((active_before, optimizer.current_candidate_idx, -loss));
1634 break;
1635 }
1636 }
1637
1638 let (produced_by, switched_to, reward) =
1639 switched_on.expect("no switch occurred, so the ordering is not under test");
1640 assert_ne!(produced_by, switched_to);
1641 assert_eq!(
1642 optimizer.optimizer_candidates[produced_by]
1643 .performance_history
1644 .last()
1645 .copied(),
1646 Some(reward),
1647 "the observation must sit on the candidate that was active when it \
1648 was measured (candidate {produced_by}), not on the one switched to"
1649 );
1650 assert_ne!(
1651 optimizer.optimizer_candidates[switched_to]
1652 .performance_history
1653 .last()
1654 .copied(),
1655 Some(reward),
1656 "the candidate switched *to* (candidate {switched_to}) must not be \
1657 credited with the outgoing optimizer's result"
1658 );
1659 }
1660}