Skip to main content

optirs_core/adaptive_selection/
mod.rs

1// Adaptive optimization algorithm selection
2//
3// This module provides automatic selection of the most appropriate optimization algorithm
4// based on problem characteristics, performance monitoring, and learned patterns.
5
6use crate::error::{OptimError, Result};
7use scirs2_core::ndarray::{Array1, Array2, ScalarOperand};
8use scirs2_core::numeric::Float;
9use scirs2_core::random::thread_rng;
10use std::collections::{HashMap, VecDeque};
11use std::fmt::Debug;
12
13/// Types of optimization algorithms available for selection
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
15pub enum OptimizerType {
16    /// Stochastic Gradient Descent
17    SGD,
18    /// SGD with momentum
19    SGDMomentum,
20    /// Adam optimizer
21    Adam,
22    /// AdamW (Adam with decoupled weight decay)
23    AdamW,
24    /// RMSprop optimizer
25    RMSprop,
26    /// AdaGrad optimizer
27    AdaGrad,
28    /// RAdam (Rectified Adam)
29    RAdam,
30    /// Lookahead wrapper
31    Lookahead,
32    /// LAMB (Layer-wise Adaptive Moments)
33    LAMB,
34    /// LARS (Layer-wise Adaptive Rate Scaling)
35    LARS,
36    /// L-BFGS (Limited-memory BFGS)
37    LBFGS,
38    /// SAM (Sharpness-Aware Minimization)
39    SAM,
40}
41
42/// Problem characteristics for optimizer selection
43#[derive(Debug, Clone)]
44pub struct ProblemCharacteristics {
45    /// Dataset size
46    pub dataset_size: usize,
47    /// Input dimensionality
48    pub input_dim: usize,
49    /// Output dimensionality
50    pub output_dim: usize,
51    /// Problem type (classification, regression, etc.)
52    pub problem_type: ProblemType,
53    /// Gradient sparsity (0.0 = dense, 1.0 = very sparse)
54    pub gradient_sparsity: f64,
55    /// Noise level in gradients
56    pub gradient_noise: f64,
57    /// Memory constraints (bytes available)
58    pub memory_budget: usize,
59    /// Computational budget (time constraints)
60    pub time_budget: f64,
61    /// Batch size being used
62    pub batch_size: usize,
63    /// Learning rate range preference
64    pub lr_sensitivity: f64,
65    /// Regularization requirements
66    pub regularization_strength: f64,
67    /// Architecture type (if applicable)
68    pub architecture_type: Option<String>,
69}
70
71/// Types of machine learning problems
72#[derive(Debug, Clone, Copy, PartialEq)]
73pub enum ProblemType {
74    /// Classification task
75    Classification,
76    /// Regression task
77    Regression,
78    /// Unsupervised learning
79    Unsupervised,
80    /// Reinforcement learning
81    ReinforcementLearning,
82    /// Time series forecasting
83    TimeSeries,
84    /// Computer vision
85    ComputerVision,
86    /// Natural language processing
87    NaturalLanguage,
88    /// Recommendation systems
89    Recommendation,
90}
91
92/// Performance metrics for optimizer evaluation
93#[derive(Debug, Clone)]
94pub struct PerformanceMetrics {
95    /// Final loss/error achieved
96    pub final_loss: f64,
97    /// Convergence speed (steps to reach target)
98    pub convergence_steps: usize,
99    /// Training time taken
100    pub training_time: f64,
101    /// Memory usage
102    pub memory_usage: usize,
103    /// Validation performance
104    pub validation_performance: f64,
105    /// Stability (variance in loss)
106    pub stability: f64,
107    /// Generalization (validation - training performance)
108    pub generalization_gap: f64,
109}
110
111/// Selection strategy for adaptive optimization
112#[derive(Debug, Clone)]
113pub enum SelectionStrategy {
114    /// Rule-based selection using expert knowledge
115    RuleBased,
116    /// Learning-based selection using historical data
117    LearningBased,
118    /// Ensemble selection trying multiple optimizers
119    Ensemble {
120        /// Number of optimizers to try
121        num_candidates: usize,
122        /// Number of steps for evaluation
123        evaluation_steps: usize,
124    },
125    /// Bandit-based selection with exploration/exploitation
126    Bandit {
127        /// Exploration parameter
128        epsilon: f64,
129        /// UCB confidence parameter
130        confidence: f64,
131    },
132    /// Meta-learning based selection
133    MetaLearning {
134        /// Feature extractor for problems
135        feature_dim: usize,
136        /// Number of similar problems to consider
137        k_nearest: usize,
138    },
139}
140
141/// Adaptive optimizer selector
142#[derive(Debug)]
143pub struct AdaptiveOptimizerSelector<A: Float> {
144    /// Selection strategy
145    strategy: SelectionStrategy,
146    /// Historical performance data
147    performance_history: HashMap<OptimizerType, Vec<PerformanceMetrics>>,
148    /// Problem-optimizer mapping for learning
149    problem_optimizer_map: Vec<(ProblemCharacteristics, OptimizerType, PerformanceMetrics)>,
150    /// Current problem characteristics
151    current_problem: Option<ProblemCharacteristics>,
152    /// Bandit arm statistics (if using bandit strategy)
153    arm_counts: HashMap<OptimizerType, usize>,
154    arm_rewards: HashMap<OptimizerType, f64>,
155    /// Neural network for learning-based selection
156    selection_network: Option<SelectionNetwork<A>>,
157    /// Available optimizers
158    available_optimizers: Vec<OptimizerType>,
159    /// Performance tracking
160    current_performance: VecDeque<f64>,
161    /// Selection confidence
162    last_confidence: f64,
163}
164
165/// Neural network for optimizer selection
166///
167/// A single-hidden-layer perceptron with ReLU activation and a softmax output,
168/// trained with cross-entropy loss. [`SelectionNetwork::train`] backpropagates
169/// through **both** layers, so the hidden representation is learned rather than
170/// frozen at its initialization.
171#[derive(Debug)]
172pub struct SelectionNetwork<A: Float> {
173    /// Input weights (problem features -> hidden)
174    input_weights: Array2<A>,
175    /// Output weights (hidden -> optimizer probabilities)
176    output_weights: Array2<A>,
177    /// Input biases
178    input_bias: Array1<A>,
179    /// Output biases
180    output_bias: Array1<A>,
181    /// Hidden layer size
182    hidden_size: usize,
183}
184
185impl<A: Float + ScalarOperand + Debug + scirs2_core::numeric::FromPrimitive + Send + Sync>
186    SelectionNetwork<A>
187{
188    /// Create a new selection network
189    pub fn new(input_size: usize, hidden_size: usize, num_optimizers: usize) -> Self {
190        let mut rng = thread_rng();
191
192        // Small uniform init in [-0.05, 0.05); the arithmetic is done in f64 so a
193        // single infallible-in-practice conversion is needed per weight.
194        let input_weights = Array2::from_shape_fn((hidden_size, input_size), |_| {
195            A::from(rng.random::<f64>() * 0.1 - 0.05).unwrap_or_else(A::zero)
196        });
197
198        let output_weights = Array2::from_shape_fn((num_optimizers, hidden_size), |_| {
199            A::from(rng.random::<f64>() * 0.1 - 0.05).unwrap_or_else(A::zero)
200        });
201
202        let input_bias = Array1::zeros(hidden_size);
203        let output_bias = Array1::zeros(num_optimizers);
204
205        Self {
206            input_weights,
207            output_weights,
208            input_bias,
209            output_bias,
210            hidden_size,
211        }
212    }
213
214    /// Build a network from explicit parameters
215    ///
216    /// Useful for reproducible experiments, checkpoint restore, and gradient
217    /// verification, where the pseudo-random initialization of
218    /// [`SelectionNetwork::new`] is not acceptable.
219    ///
220    /// # Errors
221    ///
222    /// Returns an error if the four parameter arrays do not describe a
223    /// consistent `input -> hidden -> output` topology.
224    pub fn from_parameters(
225        input_weights: Array2<A>,
226        input_bias: Array1<A>,
227        output_weights: Array2<A>,
228        output_bias: Array1<A>,
229    ) -> Result<Self> {
230        let hidden_size = input_weights.nrows();
231        if input_bias.len() != hidden_size {
232            return Err(OptimError::InvalidConfig(format!(
233                "Input bias length {} does not match the hidden size {hidden_size}",
234                input_bias.len()
235            )));
236        }
237        if output_weights.ncols() != hidden_size {
238            return Err(OptimError::InvalidConfig(format!(
239                "Output weights have {} columns but the hidden size is {hidden_size}",
240                output_weights.ncols()
241            )));
242        }
243        if output_bias.len() != output_weights.nrows() {
244            return Err(OptimError::InvalidConfig(format!(
245                "Output bias length {} does not match the {} output units",
246                output_bias.len(),
247                output_weights.nrows()
248            )));
249        }
250
251        Ok(Self {
252            input_weights,
253            output_weights,
254            input_bias,
255            output_bias,
256            hidden_size,
257        })
258    }
259
260    /// Size of the hidden layer
261    pub fn hidden_size(&self) -> usize {
262        self.hidden_size
263    }
264
265    /// Number of input features the network expects
266    pub fn input_size(&self) -> usize {
267        self.input_weights.ncols()
268    }
269
270    /// Number of optimizer classes the network scores
271    pub fn num_outputs(&self) -> usize {
272        self.output_weights.nrows()
273    }
274
275    /// Read-only view of the hidden-layer (input -> hidden) weights
276    pub fn input_weights(&self) -> &Array2<A> {
277        &self.input_weights
278    }
279
280    /// Read-only view of the output-layer (hidden -> logits) weights
281    pub fn output_weights(&self) -> &Array2<A> {
282        &self.output_weights
283    }
284
285    /// Read-only view of the hidden-layer biases
286    pub fn input_bias(&self) -> &Array1<A> {
287        &self.input_bias
288    }
289
290    /// Read-only view of the output-layer biases
291    pub fn output_bias(&self) -> &Array1<A> {
292        &self.output_bias
293    }
294
295    /// Forward pass keeping the intermediate activations needed for training.
296    ///
297    /// Returns `(pre_activation, hidden_activation, probabilities)` where
298    /// `pre_activation` is the hidden layer before ReLU (needed for the ReLU
299    /// derivative during backpropagation).
300    fn forward_with_activations(
301        &self,
302        features: &Array1<A>,
303    ) -> Result<(Array1<A>, Array1<A>, Array1<A>)> {
304        if features.len() != self.input_weights.ncols() {
305            return Err(OptimError::InvalidConfig(format!(
306                "Feature vector has length {} but the network expects {}",
307                features.len(),
308                self.input_weights.ncols()
309            )));
310        }
311
312        // Hidden layer (pre-activation), then ReLU.
313        let pre_activation = self.input_weights.dot(features) + &self.input_bias;
314        let hidden_activated = pre_activation.mapv(|x| {
315            // ReLU activation
316            if x > A::zero() {
317                x
318            } else {
319                A::zero()
320            }
321        });
322
323        // Output layer
324        let output = self.output_weights.dot(&hidden_activated) + &self.output_bias;
325
326        // Softmax activation (max-shifted for numerical stability)
327        let max_val = output.iter().fold(A::neg_infinity(), |a, &b| A::max(a, b));
328        let exp_output = output.mapv(|x| A::exp(x - max_val));
329        let sum_exp = exp_output.sum();
330        let probabilities = if sum_exp > A::zero() {
331            exp_output.mapv(|x| x / sum_exp)
332        } else {
333            // Degenerate case (empty or non-finite logits): fall back to uniform.
334            let n = A::from(output.len().max(1)).unwrap_or_else(A::one);
335            Array1::from_elem(output.len(), A::one() / n)
336        };
337
338        Ok((pre_activation, hidden_activated, probabilities))
339    }
340
341    /// Forward pass to get optimizer probabilities
342    pub fn forward(&self, features: &Array1<A>) -> Result<Array1<A>> {
343        let (_, _, probabilities) = self.forward_with_activations(features)?;
344        Ok(probabilities)
345    }
346
347    /// Average cross-entropy loss over a labelled dataset
348    ///
349    /// Useful for monitoring that [`SelectionNetwork::train`] is actually
350    /// reducing the objective. Returns zero for an empty dataset.
351    pub fn average_loss(&self, features: &[Array1<A>], optimizer_labels: &[usize]) -> Result<A> {
352        Self::validate_dataset(features, optimizer_labels, self.output_weights.nrows())?;
353        if features.is_empty() {
354            return Ok(A::zero());
355        }
356
357        // Floor the probability so a saturated softmax cannot produce -inf.
358        let floor = A::epsilon();
359        let mut total = A::zero();
360        for (feature, &label) in features.iter().zip(optimizer_labels.iter()) {
361            let probabilities = self.forward(feature)?;
362            let target = probabilities[label];
363            let clamped = if target > floor { target } else { floor };
364            total = total - A::ln(clamped);
365        }
366
367        let count = A::from(features.len()).unwrap_or_else(A::one);
368        Ok(total / count)
369    }
370
371    /// Validate that a feature/label dataset is well formed.
372    fn validate_dataset(
373        features: &[Array1<A>],
374        optimizer_labels: &[usize],
375        num_outputs: usize,
376    ) -> Result<()> {
377        if features.len() != optimizer_labels.len() {
378            return Err(OptimError::InvalidConfig(format!(
379                "Feature/label count mismatch: {} features vs {} labels",
380                features.len(),
381                optimizer_labels.len()
382            )));
383        }
384        if let Some(&bad) = optimizer_labels.iter().find(|&&l| l >= num_outputs) {
385            return Err(OptimError::InvalidConfig(format!(
386                "Optimizer label {bad} is out of range for {num_outputs} output units"
387            )));
388        }
389        Ok(())
390    }
391
392    /// Train the network on historical data with full backpropagation
393    ///
394    /// Runs plain SGD on the cross-entropy loss for `epochs` passes over the
395    /// data. Every parameter is updated: the output layer from the softmax
396    /// delta `p − onehot(label)`, and the hidden layer from that delta
397    /// propagated back through `W₂ᵀ` and gated by the ReLU derivative
398    /// (`1` where the pre-activation is positive, `0` elsewhere).
399    ///
400    /// # Arguments
401    ///
402    /// * `features` - Input feature vectors
403    /// * `optimizer_labels` - Index of the correct optimizer for each feature vector
404    /// * `learning_rate` - SGD step size
405    /// * `epochs` - Number of passes over the dataset
406    ///
407    /// # Errors
408    ///
409    /// Returns an error if the feature and label counts disagree, if a label is
410    /// out of range, or if a feature vector has the wrong length.
411    pub fn train(
412        &mut self,
413        features: &[Array1<A>],
414        optimizer_labels: &[usize],
415        learning_rate: A,
416        epochs: usize,
417    ) -> Result<()> {
418        let num_outputs = self.output_weights.nrows();
419        Self::validate_dataset(features, optimizer_labels, num_outputs)?;
420
421        let hidden_units = self.output_weights.ncols();
422        let input_units = self.input_weights.ncols();
423
424        for _ in 0..epochs {
425            for (feature, &label) in features.iter().zip(optimizer_labels.iter()) {
426                // Forward pass, keeping the pre-activation for the ReLU derivative.
427                let (pre_activation, hidden_activated, probabilities) =
428                    self.forward_with_activations(feature)?;
429
430                // Output-layer delta of softmax + cross-entropy: p - onehot(label).
431                let mut output_delta = probabilities;
432                output_delta[label] = output_delta[label] - A::one();
433
434                // Hidden-layer delta: (W2ᵀ · output_delta) ⊙ relu'(pre_activation).
435                // Computed from the *pre-update* output weights, as backprop requires.
436                let mut hidden_delta: Array1<A> = Array1::zeros(hidden_units);
437                for j in 0..hidden_units {
438                    if pre_activation[j] > A::zero() {
439                        let mut acc = A::zero();
440                        for i in 0..num_outputs {
441                            acc = acc + self.output_weights[[i, j]] * output_delta[i];
442                        }
443                        hidden_delta[j] = acc;
444                    }
445                    // ReLU derivative is 0 for non-positive pre-activations, so
446                    // hidden_delta[j] stays at its initialized zero there.
447                }
448
449                // Update output weights and biases.
450                for i in 0..num_outputs {
451                    let delta = output_delta[i];
452                    for j in 0..hidden_units {
453                        self.output_weights[[i, j]] = self.output_weights[[i, j]]
454                            - learning_rate * delta * hidden_activated[j];
455                    }
456                    self.output_bias[i] = self.output_bias[i] - learning_rate * delta;
457                }
458
459                // Update hidden weights and biases (this is what used to be missing).
460                for j in 0..hidden_units {
461                    let delta = hidden_delta[j];
462                    if delta == A::zero() {
463                        continue;
464                    }
465                    for k in 0..input_units {
466                        self.input_weights[[j, k]] =
467                            self.input_weights[[j, k]] - learning_rate * delta * feature[k];
468                    }
469                    self.input_bias[j] = self.input_bias[j] - learning_rate * delta;
470                }
471            }
472        }
473        Ok(())
474    }
475}
476
477impl<A: Float + ScalarOperand + Debug + scirs2_core::numeric::FromPrimitive + Send + Sync>
478    AdaptiveOptimizerSelector<A>
479{
480    /// Create a new adaptive optimizer selector
481    pub fn new(strategy: SelectionStrategy) -> Self {
482        let available_optimizers = vec![
483            OptimizerType::SGD,
484            OptimizerType::SGDMomentum,
485            OptimizerType::Adam,
486            OptimizerType::AdamW,
487            OptimizerType::RMSprop,
488            OptimizerType::AdaGrad,
489            OptimizerType::RAdam,
490            OptimizerType::LAMB,
491        ];
492
493        let mut arm_counts = HashMap::new();
494        let mut arm_rewards = HashMap::new();
495        for &optimizer in &available_optimizers {
496            arm_counts.insert(optimizer, 0);
497            arm_rewards.insert(optimizer, 0.0);
498        }
499
500        Self {
501            strategy,
502            performance_history: HashMap::new(),
503            problem_optimizer_map: Vec::new(),
504            current_problem: None,
505            arm_counts,
506            arm_rewards,
507            selection_network: None,
508            available_optimizers,
509            current_performance: VecDeque::new(),
510            last_confidence: 0.0,
511        }
512    }
513
514    /// Set the current problem characteristics
515    pub fn set_problem(&mut self, problem: ProblemCharacteristics) {
516        self.current_problem = Some(problem);
517    }
518
519    /// Select the best optimizer for the current problem
520    pub fn select_optimizer(&mut self) -> Result<OptimizerType> {
521        let problem = self.current_problem.clone().ok_or_else(|| {
522            OptimError::InvalidConfig("No problem characteristics set".to_string())
523        })?;
524
525        match &self.strategy {
526            SelectionStrategy::RuleBased => self.rule_based_selection(&problem),
527            SelectionStrategy::LearningBased => self.learning_based_selection(&problem),
528            SelectionStrategy::Ensemble {
529                num_candidates,
530                evaluation_steps,
531            } => self.ensemble_selection(&problem, *num_candidates, *evaluation_steps),
532            SelectionStrategy::Bandit {
533                epsilon,
534                confidence,
535            } => self.bandit_selection(&problem, *epsilon, *confidence),
536            SelectionStrategy::MetaLearning {
537                feature_dim,
538                k_nearest: _,
539            } => self.meta_learning_selection(&problem, *feature_dim),
540        }
541    }
542
543    /// Rule-based optimizer selection using expert knowledge
544    fn rule_based_selection(&self, problem: &ProblemCharacteristics) -> Result<OptimizerType> {
545        // Large dataset, use adaptive optimizers
546        if problem.dataset_size > 100000 {
547            match problem.problem_type {
548                ProblemType::ComputerVision => return Ok(OptimizerType::AdamW),
549                ProblemType::NaturalLanguage => return Ok(OptimizerType::AdamW),
550                _ => return Ok(OptimizerType::Adam),
551            }
552        }
553
554        // Small dataset, use SGD with momentum
555        if problem.dataset_size < 1000 {
556            return Ok(OptimizerType::LBFGS);
557        }
558
559        // Sparse gradients
560        if problem.gradient_sparsity > 0.5 {
561            return Ok(OptimizerType::AdaGrad);
562        }
563
564        // Large batch training
565        if problem.batch_size > 256 {
566            return Ok(OptimizerType::LAMB);
567        }
568
569        // Memory constrained
570        if problem.memory_budget < 1_000_000 {
571            return Ok(OptimizerType::SGD);
572        }
573
574        // High noise
575        if problem.gradient_noise > 0.3 {
576            return Ok(OptimizerType::RMSprop);
577        }
578
579        // Default choice
580        Ok(OptimizerType::Adam)
581    }
582
583    /// Learning-based selection using historical performance
584    fn learning_based_selection(
585        &mut self,
586        problem: &ProblemCharacteristics,
587    ) -> Result<OptimizerType> {
588        if self.problem_optimizer_map.is_empty() {
589            // No historical data, fall back to rule-based
590            return self.rule_based_selection(problem);
591        }
592
593        // Find most similar problem in history
594        let mut best_similarity = -1.0;
595        let mut best_optimizer = OptimizerType::Adam;
596
597        for (hist_problem, optimizer, metrics) in &self.problem_optimizer_map {
598            let similarity = self.compute_problem_similarity(problem, hist_problem);
599
600            // Weight by performance
601            let weighted_similarity = similarity * metrics.validation_performance;
602
603            if weighted_similarity > best_similarity {
604                best_similarity = weighted_similarity;
605                best_optimizer = *optimizer;
606            }
607        }
608
609        self.last_confidence = best_similarity;
610        Ok(best_optimizer)
611    }
612
613    /// Ensemble selection by trying multiple optimizers
614    fn ensemble_selection(
615        &self,
616        _problem: &ProblemCharacteristics,
617        num_candidates: usize,
618        _evaluation_steps: usize,
619    ) -> Result<OptimizerType> {
620        // Select top _candidates based on historical performance
621        let mut candidates = self.available_optimizers.clone();
622        candidates.truncate(num_candidates.min(candidates.len()));
623
624        // For simplicity, return the first candidate
625        // In practice, you would evaluate each for evaluation_steps
626        Ok(candidates[0])
627    }
628
629    /// Bandit-based selection with epsilon-greedy strategy
630    fn bandit_selection(
631        &self,
632        _problem: &ProblemCharacteristics,
633        epsilon: f64,
634        confidence: f64,
635    ) -> Result<OptimizerType> {
636        let mut rng = thread_rng();
637
638        // Epsilon-greedy exploration
639        if rng.random::<f64>() < epsilon {
640            // Explore: random selection
641            let idx = rng.gen_range(0..self.available_optimizers.len());
642            return Ok(self.available_optimizers[idx]);
643        }
644
645        // Exploit: UCB (Upper Confidence Bound) selection
646        let mut best_ucb = f64::NEG_INFINITY;
647        let mut best_optimizer = OptimizerType::Adam;
648        let total_counts: usize = self.arm_counts.values().sum();
649
650        for &optimizer in &self.available_optimizers {
651            let count = self.arm_counts[&optimizer] as f64;
652            let reward = if count > 0.0 {
653                self.arm_rewards[&optimizer] / count
654            } else {
655                0.0
656            };
657
658            let ucb = if count > 0.0 {
659                reward + confidence * ((total_counts as f64).ln() / count).sqrt()
660            } else {
661                f64::INFINITY // Prefer unvisited arms
662            };
663
664            if ucb > best_ucb {
665                best_ucb = ucb;
666                best_optimizer = optimizer;
667            }
668        }
669
670        Ok(best_optimizer)
671    }
672
673    /// Meta-learning based selection
674    fn meta_learning_selection(
675        &mut self,
676        problem: &ProblemCharacteristics,
677        k_nearest: usize,
678    ) -> Result<OptimizerType> {
679        // Extract features from problem
680        let features = self.extract_problem_features(problem);
681
682        // If we have a trained network, use it
683        if let Some(network) = &self.selection_network {
684            let probabilities = network.forward(&features)?;
685
686            // Select optimizer with highest probability
687            let mut best_prob = A::neg_infinity();
688            let mut best_idx = 0;
689
690            for (i, &prob) in probabilities.iter().enumerate() {
691                if prob > best_prob {
692                    best_prob = prob;
693                    best_idx = i;
694                }
695            }
696
697            if best_idx < self.available_optimizers.len() {
698                return Ok(self.available_optimizers[best_idx]);
699            }
700        }
701
702        // k-NN fallback
703        if self.problem_optimizer_map.len() >= k_nearest {
704            let mut similarities = Vec::new();
705
706            for (hist_problem, optimizer, metrics) in &self.problem_optimizer_map {
707                let similarity = self.compute_problem_similarity(problem, hist_problem);
708                similarities.push((similarity, *optimizer, metrics.validation_performance));
709            }
710
711            // Sort by similarity
712            // NaN similarities compare Equal instead of panicking.
713            similarities.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
714
715            // Take k _nearest and vote
716            let mut votes: HashMap<OptimizerType, f64> = HashMap::new();
717            for (similarity, optimizer, performance) in similarities.iter().take(k_nearest) {
718                let weight = similarity * performance;
719                *votes.entry(*optimizer).or_insert(0.0) += weight;
720            }
721
722            // Return optimizer with highest weighted vote
723            let best_optimizer = votes
724                .iter()
725                .max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
726                .map(|(optimizer_, _)| *optimizer_)
727                .unwrap_or(OptimizerType::Adam);
728
729            return Ok(best_optimizer);
730        }
731
732        // Fall back to rule-based
733        self.rule_based_selection(problem)
734    }
735
736    /// Update selector with performance feedback
737    pub fn update_performance(
738        &mut self,
739        optimizer: OptimizerType,
740        metrics: PerformanceMetrics,
741    ) -> Result<()> {
742        // Update performance history
743        self.performance_history
744            .entry(optimizer)
745            .or_default()
746            .push(metrics.clone());
747
748        // Update bandit statistics
749        *self.arm_counts.entry(optimizer).or_insert(0) += 1;
750        *self.arm_rewards.entry(optimizer).or_insert(0.0) += metrics.validation_performance;
751
752        // Store problem-optimizer mapping
753        if let Some(problem) = &self.current_problem {
754            self.problem_optimizer_map
755                .push((problem.clone(), optimizer, metrics.clone()));
756        }
757
758        // Update current performance tracking
759        self.current_performance
760            .push_back(metrics.validation_performance);
761        if self.current_performance.len() > 100 {
762            self.current_performance.pop_front();
763        }
764
765        Ok(())
766    }
767
768    /// Train the selection network if using learning-based strategy
769    pub fn train_selection_network(&mut self, learning_rate: A, epochs: usize) -> Result<()> {
770        if self.problem_optimizer_map.is_empty() {
771            return Ok(()); // No data to train on
772        }
773
774        // Extract features and labels
775        let mut features = Vec::new();
776        let mut labels = Vec::new();
777
778        for (problem, optimizer_, _metrics) in &self.problem_optimizer_map {
779            // Convert optimizer to label; skip samples whose optimizer is not in
780            // the candidate set so features and labels stay aligned 1:1.
781            let Some(label) = self
782                .available_optimizers
783                .iter()
784                .position(|&opt| opt == *optimizer_)
785            else {
786                continue;
787            };
788
789            features.push(self.extract_problem_features(problem));
790            labels.push(label);
791        }
792
793        if features.is_empty() {
794            return Ok(()); // No usable training samples
795        }
796
797        // Create network if it doesn't exist
798        if self.selection_network.is_none() {
799            let feature_dim = features[0].len();
800            let num_optimizers = self.available_optimizers.len();
801            self.selection_network = Some(SelectionNetwork::new(feature_dim, 32, num_optimizers));
802        }
803
804        // Train the network
805        if let Some(network) = &mut self.selection_network {
806            network.train(&features, &labels, learning_rate, epochs)?;
807        }
808
809        Ok(())
810    }
811
812    /// Compute similarity between two problems
813    fn compute_problem_similarity(
814        &self,
815        problem1: &ProblemCharacteristics,
816        problem2: &ProblemCharacteristics,
817    ) -> f64 {
818        let mut similarity = 0.0;
819        let mut weight_sum = 0.0;
820
821        // Dataset size similarity (log scale)
822        let size_sim = 1.0
823            - ((problem1.dataset_size as f64).ln() - (problem2.dataset_size as f64).ln()).abs()
824                / 10.0;
825        similarity += size_sim.max(0.0) * 0.2;
826        weight_sum += 0.2;
827
828        // Problem type similarity
829        if problem1.problem_type == problem2.problem_type {
830            similarity += 0.3;
831        }
832        weight_sum += 0.3;
833
834        // Batch size similarity
835        let batch_sim = 1.0
836            - ((problem1.batch_size as f64 - problem2.batch_size as f64).abs() / 256.0).min(1.0);
837        similarity += batch_sim * 0.1;
838        weight_sum += 0.1;
839
840        // Gradient characteristics similarity
841        let sparsity_sim = 1.0 - (problem1.gradient_sparsity - problem2.gradient_sparsity).abs();
842        let noise_sim = 1.0 - (problem1.gradient_noise - problem2.gradient_noise).abs();
843        similarity += (sparsity_sim + noise_sim) * 0.2;
844        weight_sum += 0.4;
845
846        similarity / weight_sum
847    }
848
849    /// Extract numerical features from problem characteristics
850    fn extract_problem_features(&self, problem: &ProblemCharacteristics) -> Array1<A> {
851        Array1::from_vec(vec![
852            A::from((problem.dataset_size as f64).ln()).unwrap_or_else(A::zero),
853            A::from((problem.input_dim as f64).ln()).unwrap_or_else(A::zero),
854            A::from((problem.output_dim as f64).ln()).unwrap_or_else(A::zero),
855            A::from(problem.problem_type as u8 as f64).unwrap_or_else(A::zero),
856            A::from(problem.gradient_sparsity).unwrap_or_else(A::zero),
857            A::from(problem.gradient_noise).unwrap_or_else(A::zero),
858            A::from((problem.memory_budget as f64).ln()).unwrap_or_else(A::zero),
859            A::from(problem.time_budget.ln()).unwrap_or_else(A::zero),
860            A::from((problem.batch_size as f64).ln()).unwrap_or_else(A::zero),
861            A::from(problem.lr_sensitivity).unwrap_or_else(A::zero),
862            A::from(problem.regularization_strength).unwrap_or_else(A::zero),
863        ])
864    }
865
866    /// Get performance statistics for an optimizer
867    pub fn get_optimizer_statistics(
868        &self,
869        optimizer: OptimizerType,
870    ) -> Option<OptimizerStatistics> {
871        if let Some(history) = self.performance_history.get(&optimizer) {
872            if history.is_empty() {
873                return None;
874            }
875
876            let performances: Vec<f64> = history.iter().map(|m| m.validation_performance).collect();
877            let mean = performances.iter().sum::<f64>() / performances.len() as f64;
878            let variance = performances.iter().map(|p| (p - mean).powi(2)).sum::<f64>()
879                / performances.len() as f64;
880            let std_dev = variance.sqrt();
881
882            Some(OptimizerStatistics {
883                optimizer,
884                num_trials: history.len(),
885                mean_performance: mean,
886                std_performance: std_dev,
887                best_performance: performances
888                    .iter()
889                    .copied()
890                    .fold(f64::NEG_INFINITY, f64::max),
891                worst_performance: performances.iter().copied().fold(f64::INFINITY, f64::min),
892                success_rate: performances.iter().filter(|&&p| p > 0.7).count() as f64
893                    / performances.len() as f64,
894            })
895        } else {
896            None
897        }
898    }
899
900    /// Get all optimizer statistics
901    pub fn get_all_statistics(&self) -> Vec<OptimizerStatistics> {
902        self.available_optimizers
903            .iter()
904            .filter_map(|&opt| self.get_optimizer_statistics(opt))
905            .collect()
906    }
907
908    /// Get current confidence in selection
909    pub fn get_selection_confidence(&self) -> f64 {
910        self.last_confidence
911    }
912
913    /// Reset selector state
914    pub fn reset(&mut self) {
915        self.performance_history.clear();
916        self.problem_optimizer_map.clear();
917        self.current_problem = None;
918        for count in self.arm_counts.values_mut() {
919            *count = 0;
920        }
921        for reward in self.arm_rewards.values_mut() {
922            *reward = 0.0;
923        }
924        self.current_performance.clear();
925        self.last_confidence = 0.0;
926    }
927}
928
929/// Statistics for an optimizer's performance
930#[derive(Debug, Clone)]
931pub struct OptimizerStatistics {
932    /// Optimizer type
933    pub optimizer: OptimizerType,
934    /// Number of trials
935    pub num_trials: usize,
936    /// Mean performance
937    pub mean_performance: f64,
938    /// Standard deviation of performance
939    pub std_performance: f64,
940    /// Best performance achieved
941    pub best_performance: f64,
942    /// Worst performance achieved
943    pub worst_performance: f64,
944    /// Success rate (performance > threshold)
945    pub success_rate: f64,
946}
947
948#[cfg(test)]
949mod tests {
950    use super::*;
951    use approx::assert_relative_eq;
952
953    #[test]
954    fn test_problem_characteristics() {
955        let problem = ProblemCharacteristics {
956            dataset_size: 10000,
957            input_dim: 784,
958            output_dim: 10,
959            problem_type: ProblemType::Classification,
960            gradient_sparsity: 0.1,
961            gradient_noise: 0.05,
962            memory_budget: 1_000_000,
963            time_budget: 3600.0,
964            batch_size: 64,
965            lr_sensitivity: 0.5,
966            regularization_strength: 0.01,
967            architecture_type: Some("CNN".to_string()),
968        };
969
970        assert_eq!(problem.dataset_size, 10000);
971        assert_eq!(problem.problem_type, ProblemType::Classification);
972    }
973
974    #[test]
975    fn test_rule_based_selection() {
976        let mut selector = AdaptiveOptimizerSelector::<f64>::new(SelectionStrategy::RuleBased);
977
978        // Large dataset -> Adam/AdamW
979        let large_problem = ProblemCharacteristics {
980            dataset_size: 100001,
981            input_dim: 224,
982            output_dim: 1000,
983            problem_type: ProblemType::ComputerVision,
984            gradient_sparsity: 0.1,
985            gradient_noise: 0.05,
986            memory_budget: 10_000_000,
987            time_budget: 7200.0,
988            batch_size: 32,
989            lr_sensitivity: 0.5,
990            regularization_strength: 0.01,
991            architecture_type: Some("ResNet".to_string()),
992        };
993
994        selector.set_problem(large_problem);
995        let optimizer = selector
996            .select_optimizer()
997            .expect("selector.select_optimizer succeeds in test_rule_based_selection");
998        assert_eq!(optimizer, OptimizerType::AdamW);
999    }
1000
1001    #[test]
1002    fn test_selection_network() {
1003        let network = SelectionNetwork::<f64>::new(5, 10, 3);
1004        let features = Array1::from_vec(vec![1.0, 0.5, 2.0, 0.8, 1.5]);
1005
1006        let probabilities = network
1007            .forward(&features)
1008            .expect("network.forward succeeds in test_selection_network");
1009        assert_eq!(probabilities.len(), 3);
1010
1011        // Probabilities should sum to 1
1012        let sum: f64 = probabilities.iter().sum();
1013        assert_relative_eq!(sum, 1.0, epsilon = 1e-6);
1014
1015        // All probabilities should be non-negative
1016        for &prob in probabilities.iter() {
1017            assert!(prob >= 0.0);
1018        }
1019    }
1020
1021    #[test]
1022    fn test_bandit_selection() {
1023        let mut selector = AdaptiveOptimizerSelector::<f64>::new(SelectionStrategy::Bandit {
1024            epsilon: 0.1,
1025            confidence: 2.0,
1026        });
1027
1028        let problem = ProblemCharacteristics {
1029            dataset_size: 1000,
1030            input_dim: 10,
1031            output_dim: 2,
1032            problem_type: ProblemType::Classification,
1033            gradient_sparsity: 0.0,
1034            gradient_noise: 0.1,
1035            memory_budget: 1_000_000,
1036            time_budget: 600.0,
1037            batch_size: 32,
1038            lr_sensitivity: 0.5,
1039            regularization_strength: 0.01,
1040            architecture_type: None,
1041        };
1042
1043        selector.set_problem(problem);
1044
1045        // Should select an optimizer (any is valid initially)
1046        let optimizer = selector
1047            .select_optimizer()
1048            .expect("selector.select_optimizer succeeds in test_bandit_selection");
1049        assert!(selector.available_optimizers.contains(&optimizer));
1050    }
1051
1052    #[test]
1053    fn test_performance_update() {
1054        let mut selector = AdaptiveOptimizerSelector::<f64>::new(SelectionStrategy::RuleBased);
1055
1056        let metrics = PerformanceMetrics {
1057            final_loss: 0.1,
1058            convergence_steps: 100,
1059            training_time: 60.0,
1060            memory_usage: 500_000,
1061            validation_performance: 0.95,
1062            stability: 0.02,
1063            generalization_gap: 0.05,
1064        };
1065
1066        selector
1067            .update_performance(OptimizerType::Adam, metrics)
1068            .expect("update_performance succeeds in test_performance_update");
1069
1070        let stats = selector
1071            .get_optimizer_statistics(OptimizerType::Adam)
1072            .expect("get_optimizer_statistics succeeds in test_performance_update");
1073        assert_eq!(stats.num_trials, 1);
1074        assert_relative_eq!(stats.mean_performance, 0.95, epsilon = 1e-6);
1075    }
1076
1077    #[test]
1078    fn test_problem_similarity() {
1079        let selector = AdaptiveOptimizerSelector::<f64>::new(SelectionStrategy::RuleBased);
1080
1081        let problem1 = ProblemCharacteristics {
1082            dataset_size: 1000,
1083            input_dim: 10,
1084            output_dim: 2,
1085            problem_type: ProblemType::Classification,
1086            gradient_sparsity: 0.1,
1087            gradient_noise: 0.05,
1088            memory_budget: 1_000_000,
1089            time_budget: 600.0,
1090            batch_size: 32,
1091            lr_sensitivity: 0.5,
1092            regularization_strength: 0.01,
1093            architecture_type: None,
1094        };
1095
1096        let problem2 = problem1.clone();
1097        let similarity = selector.compute_problem_similarity(&problem1, &problem2);
1098        assert_relative_eq!(similarity, 1.0, epsilon = 1e-6);
1099
1100        let mut problem3 = problem1.clone();
1101        problem3.problem_type = ProblemType::Regression;
1102        let similarity = selector.compute_problem_similarity(&problem1, &problem3);
1103        assert!(similarity < 1.0);
1104    }
1105}