Skip to main content

optirs_nas/hyperparameter/
mod.rs

1//! Hyperparameter optimization for Neural Architecture Search.
2//!
3//! This module provides hyperparameter optimization capabilities for both
4//! the NAS process itself and the discovered architectures.
5//!
6//! Every strategy [`HyperparameterOptimizer`] dispatches to is a real algorithm:
7//!
8//! | [`OptimizationStrategy`] | implementation |
9//! |---|---|
10//! | `Random` | prior sampling, honoring each parameter's [`DistributionType`] |
11//! | `Grid` | exhaustive mixed-radix enumeration ([`grid`]) |
12//! | `TPE` | two adaptive Parzen estimators over a good/bad quantile split ([`tpe`]) |
13//! | `Bayesian` | GP-free kernel-regression surrogate + acquisition maximization ([`surrogate`]) |
14//! | `Evolutionary` | tournament selection, crossover and bounded mutation ([`evolution`]) |
15//!
16//! `Grid`, `Bayesian`, `TPE` and `Evolutionary` previously all returned
17//! `self.random_search()`, so selecting any of them silently performed random
18//! search. The model-based strategies now require a random *initial design* before
19//! they can be fitted (which is part of the published algorithms); that phase is
20//! reported through [`HyperparameterOptimizer::last_strategy_used`] instead of
21//! being hidden, and [`ParticleSwarm`](OptimizationStrategy::ParticleSwarm),
22//! `SuccessiveHalving`, `Hyperband` and `BOHB` — which have no implementation
23//! here — return an error rather than quietly becoming random search.
24
25pub mod evolution;
26pub mod grid;
27pub mod support;
28pub mod surrogate;
29pub mod tpe;
30
31use crate::error::{OptimError, Result};
32use scirs2_core::numeric::Float;
33use scirs2_core::random::Random;
34use serde::{Deserialize, Serialize};
35use std::collections::HashMap;
36use std::fmt;
37
38use support::StrategyRng;
39
40/// Hyperparameter optimizer for NAS and discovered architectures
41#[derive(Debug)]
42pub struct HyperparameterOptimizer<T: Float> {
43    /// Search space definition
44    search_space: HyperparameterSpace<T>,
45
46    /// Optimization strategy
47    strategy: OptimizationStrategy,
48
49    /// Evaluation history
50    evaluation_history: Vec<HyperparameterEvaluation<T>>,
51
52    /// Current best configuration
53    best_config: Option<HyperparameterConfiguration<T>>,
54
55    /// Optimization state
56    state: OptimizerState<T>,
57
58    /// RNG shared by every stochastic strategy, so a seeded optimizer is
59    /// reproducible end to end.
60    rng: StrategyRng,
61
62    /// Number of points per continuous grid axis.
63    grid_resolution: usize,
64
65    /// TPE tunables.
66    tpe_config: tpe::TpeConfig,
67
68    /// Surrogate (Bayesian) tunables.
69    surrogate_config: surrogate::SurrogateConfig,
70
71    /// Evolutionary tunables.
72    evolution_config: evolution::EvolutionConfig,
73
74    /// Acquisition function used by the Bayesian strategy.
75    acquisition: AcquisitionFunction,
76
77    /// Which strategy actually produced the most recent suggestion. Model-based
78    /// strategies need a random initial design first; this records honestly which
79    /// of the two ran instead of leaving the caller to assume.
80    last_strategy_used: OptimizationStrategy,
81}
82
83/// Hyperparameter search space definition
84#[derive(Debug, Clone)]
85pub struct HyperparameterSpace<T: Float> {
86    /// Individual hyperparameter ranges
87    parameters: HashMap<String, ParameterRange<T>>,
88
89    /// Parameter dependencies
90    dependencies: Vec<ParameterDependency>,
91
92    /// Constraints on parameter combinations
93    constraints: Vec<HyperparameterConstraint<T>>,
94
95    /// Categorical parameters
96    categorical_parameters: HashMap<String, Vec<String>>,
97}
98
99/// Range specification for a continuous hyperparameter
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct ParameterRange<T: Float> {
102    /// Parameter name
103    pub name: String,
104
105    /// Minimum value
106    pub min_value: T,
107
108    /// Maximum value
109    pub max_value: T,
110
111    /// Distribution type for sampling
112    pub distribution: DistributionType,
113
114    /// Whether to use log scale
115    pub log_scale: bool,
116
117    /// Discrete values (if applicable)
118    pub discrete_values: Option<Vec<T>>,
119}
120
121/// Type of probability distribution for parameter sampling
122#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
123pub enum DistributionType {
124    /// Uniform distribution
125    Uniform,
126    /// Normal (Gaussian) distribution
127    Normal,
128    /// Log-normal distribution
129    LogNormal,
130    /// Beta distribution
131    Beta,
132    /// Exponential distribution
133    Exponential,
134}
135
136/// Dependency between parameters
137#[derive(Debug, Clone)]
138pub struct ParameterDependency {
139    /// Dependent parameter name
140    pub dependent: String,
141
142    /// Parent parameter name
143    pub parent: String,
144
145    /// Dependency type
146    pub dependency_type: DependencyType,
147
148    /// Condition for activation
149    pub condition: DependencyCondition,
150}
151
152/// Type of parameter dependency
153#[derive(Debug, Clone, Copy, PartialEq, Eq)]
154pub enum DependencyType {
155    /// Parameter is only active when condition is met
156    Conditional,
157    /// Parameter value is derived from parent
158    Derived,
159    /// Parameter range depends on parent value
160    RangeDependent,
161}
162
163/// Condition for parameter dependency
164#[derive(Debug, Clone)]
165pub enum DependencyCondition {
166    /// Parent equals specific value
167    Equals(String),
168    /// Parent greater than value
169    GreaterThan(f64),
170    /// Parent less than value
171    LessThan(f64),
172    /// Parent in range
173    InRange(f64, f64),
174    /// Parent in set of values
175    InSet(Vec<String>),
176}
177
178/// Constraint on hyperparameter combinations
179#[derive(Debug, Clone)]
180pub struct HyperparameterConstraint<T: Float> {
181    /// Constraint name
182    pub name: String,
183
184    /// Parameters involved in constraint
185    pub parameters: Vec<String>,
186
187    /// Constraint type
188    pub constraint_type: ConstraintType<T>,
189
190    /// Violation penalty
191    pub penalty: T,
192}
193
194/// Type of hyperparameter constraint
195#[derive(Debug, Clone)]
196pub enum ConstraintType<T: Float> {
197    /// Linear constraint: sum(coeffs * params) <= bound
198    Linear { coefficients: Vec<T>, bound: T },
199    /// Nonlinear constraint with custom function
200    NonLinear { function_name: String },
201    /// Mutual exclusion: only one parameter can be active
202    MutualExclusion,
203    /// Ordering constraint: param1 <= param2 <= ... <= paramN
204    Ordering,
205}
206
207/// Hyperparameter configuration
208#[derive(Debug, Clone, Serialize, Deserialize)]
209pub struct HyperparameterConfiguration<T: Float> {
210    /// Configuration ID
211    pub id: String,
212
213    /// Parameter values
214    pub parameters: HashMap<String, T>,
215
216    /// Categorical parameter values
217    pub categorical_parameters: HashMap<String, String>,
218
219    /// Configuration score/performance
220    pub score: Option<T>,
221
222    /// Evaluation metadata
223    pub metadata: HashMap<String, String>,
224}
225
226/// Evaluation result for a hyperparameter configuration
227#[derive(Debug, Clone)]
228pub struct HyperparameterEvaluation<T: Float> {
229    /// Configuration that was evaluated
230    pub configuration: HyperparameterConfiguration<T>,
231
232    /// Performance metrics
233    pub metrics: EvaluationMetrics<T>,
234
235    /// Evaluation duration
236    pub duration_seconds: f64,
237
238    /// Success/failure status
239    pub status: EvaluationStatus,
240
241    /// Error message (if failed)
242    pub error_message: Option<String>,
243}
244
245/// Performance metrics from hyperparameter evaluation
246#[derive(Debug, Clone)]
247pub struct EvaluationMetrics<T: Float> {
248    /// Primary objective value
249    pub primary_objective: T,
250
251    /// Secondary objectives
252    pub secondary_objectives: HashMap<String, T>,
253
254    /// Validation score
255    pub validation_score: Option<T>,
256
257    /// Training time
258    pub training_time: f64,
259
260    /// Memory usage
261    pub memory_usage: f64,
262
263    /// Convergence information
264    pub converged: bool,
265
266    /// Number of iterations to convergence
267    pub convergence_iterations: Option<u32>,
268}
269
270/// Status of hyperparameter evaluation
271#[derive(Debug, Clone, Copy, PartialEq, Eq)]
272pub enum EvaluationStatus {
273    /// Evaluation completed successfully
274    Success,
275    /// Evaluation failed due to error
276    Failed,
277    /// Evaluation was terminated early
278    Terminated,
279    /// Evaluation is still running
280    Running,
281    /// Evaluation timed out
282    Timeout,
283}
284
285/// Optimization strategies for hyperparameter search
286#[derive(Debug, Clone, Copy, PartialEq, Eq)]
287pub enum OptimizationStrategy {
288    /// Random search
289    Random,
290    /// Grid search
291    Grid,
292    /// Bayesian optimization
293    Bayesian,
294    /// Evolutionary algorithms
295    Evolutionary,
296    /// Particle swarm optimization
297    ParticleSwarm,
298    /// Tree-structured Parzen Estimator (TPE)
299    TPE,
300    /// Successive halving
301    SuccessiveHalving,
302    /// Hyperband
303    Hyperband,
304    /// BOHB (Bayesian Optimization and HyperBand)
305    BOHB,
306}
307
308/// Internal state of the hyperparameter optimizer
309#[derive(Debug)]
310pub struct OptimizerState<T: Float> {
311    /// Current iteration/generation
312    pub iteration: u32,
313
314    /// Number of evaluations performed
315    pub num_evaluations: u32,
316
317    /// Best score found so far
318    pub best_score: Option<T>,
319
320    /// Population (for evolutionary strategies)
321    pub population: Vec<HyperparameterConfiguration<T>>,
322
323    /// Gaussian process model (for Bayesian optimization)
324    pub surrogate_model: Option<SurrogateModel<T>>,
325
326    /// Early stopping information
327    pub early_stopping: EarlyStoppingState<T>,
328}
329
330/// Surrogate model for Bayesian optimization
331#[derive(Debug)]
332pub struct SurrogateModel<T: Float> {
333    /// Model type
334    pub model_type: SurrogateModelType,
335
336    /// Training data points
337    pub training_data: Vec<(Vec<T>, T)>,
338
339    /// Model hyperparameters
340    pub hyperparameters: HashMap<String, f64>,
341
342    /// Acquisition function
343    pub acquisition_function: AcquisitionFunction,
344}
345
346/// Type of surrogate model
347#[derive(Debug, Clone, Copy, PartialEq, Eq)]
348pub enum SurrogateModelType {
349    /// Nadaraya-Watson kernel regression — the surrogate this crate actually
350    /// implements (see [`surrogate::KernelSurrogate`]). It needs no linear algebra
351    /// and no kernel-hyperparameter fitting, which is why it is the default.
352    KernelRegression,
353    /// Gaussian Process. **Not implemented in this module**; kept because it is
354    /// part of the public type and callers may record their own model choice.
355    /// [`HyperparameterOptimizer`] never fits one.
356    GaussianProcess,
357    /// Random Forest. Not implemented here; see [`SurrogateModelType::GaussianProcess`].
358    RandomForest,
359    /// Neural Network. Not implemented here; see [`SurrogateModelType::GaussianProcess`].
360    NeuralNetwork,
361    /// Polynomial regression. Not implemented here; see
362    /// [`SurrogateModelType::GaussianProcess`].
363    Polynomial,
364}
365
366/// Acquisition function for Bayesian optimization
367#[derive(Debug, Clone, Copy, PartialEq, Eq)]
368pub enum AcquisitionFunction {
369    /// Expected Improvement
370    ExpectedImprovement,
371    /// Upper Confidence Bound
372    UpperConfidenceBound,
373    /// Probability of Improvement
374    ProbabilityOfImprovement,
375    /// Entropy Search
376    EntropySearch,
377}
378
379/// Early stopping state
380#[derive(Debug)]
381pub struct EarlyStoppingState<T: Float> {
382    /// Whether early stopping is enabled
383    pub enabled: bool,
384
385    /// Patience (iterations without improvement)
386    pub patience: u32,
387
388    /// Current patience counter
389    pub patience_counter: u32,
390
391    /// Minimum improvement threshold
392    pub min_improvement: T,
393
394    /// Best score for early stopping comparison
395    pub best_score_for_stopping: Option<T>,
396}
397
398impl<T: Float + Default + Clone + Send + Sync> HyperparameterOptimizer<T> {
399    /// Create a new hyperparameter optimizer, seeded from OS entropy.
400    pub fn new(search_space: HyperparameterSpace<T>, strategy: OptimizationStrategy) -> Self {
401        Self::with_seed(search_space, strategy, scirs2_core::random::random::<u64>())
402    }
403
404    /// Create a fully reproducible optimizer.
405    pub fn with_seed(
406        search_space: HyperparameterSpace<T>,
407        strategy: OptimizationStrategy,
408        seed: u64,
409    ) -> Self {
410        Self {
411            search_space,
412            strategy,
413            evaluation_history: Vec::new(),
414            best_config: None,
415            rng: Random::seed(seed),
416            grid_resolution: grid::DEFAULT_GRID_RESOLUTION,
417            tpe_config: tpe::TpeConfig::default(),
418            surrogate_config: surrogate::SurrogateConfig::default(),
419            evolution_config: evolution::EvolutionConfig::default(),
420            acquisition: AcquisitionFunction::ExpectedImprovement,
421            last_strategy_used: strategy,
422            state: OptimizerState {
423                iteration: 0,
424                num_evaluations: 0,
425                best_score: None,
426                population: Vec::new(),
427                surrogate_model: None,
428                early_stopping: EarlyStoppingState {
429                    enabled: false,
430                    patience: 50,
431                    patience_counter: 0,
432                    min_improvement: T::from(0.001).unwrap_or_else(T::zero),
433                    best_score_for_stopping: None,
434                },
435            },
436        }
437    }
438
439    /// Generate the next hyperparameter configuration to evaluate.
440    ///
441    /// Returns an error for a strategy this module does not implement
442    /// ([`OptimizationStrategy::ParticleSwarm`], `SuccessiveHalving`, `Hyperband`,
443    /// `BOHB`) rather than silently performing random search, and for a search
444    /// space that declares no parameters at all.
445    ///
446    /// The model-based strategies (`Bayesian`, `TPE`, `Evolutionary`) need a
447    /// random initial design first. During that phase they sample from the prior
448    /// and set [`HyperparameterOptimizer::last_strategy_used`] to
449    /// [`OptimizationStrategy::Random`], so the caller can see which algorithm
450    /// actually produced the suggestion.
451    pub fn suggest_configuration(&mut self) -> Result<HyperparameterConfiguration<T>> {
452        if self.search_space.is_empty() {
453            return Err(OptimError::InvalidConfig(
454                "hyperparameter search requires a non-empty search space".to_string(),
455            ));
456        }
457
458        let (parameters, categorical_parameters, used) = match self.strategy {
459            OptimizationStrategy::Random => {
460                let (parameters, categorical) = self.sample_from_prior();
461                (parameters, categorical, OptimizationStrategy::Random)
462            }
463            OptimizationStrategy::Grid => {
464                let index = self.state.num_evaluations as usize;
465                let (parameters, categorical) = grid::suggest(
466                    &self.search_space,
467                    index,
468                    self.grid_resolution,
469                    &mut self.rng,
470                )?;
471                (parameters, categorical, OptimizationStrategy::Grid)
472            }
473            OptimizationStrategy::TPE => {
474                let scored = Self::scored_observations(&self.evaluation_history);
475                match tpe::suggest(&self.search_space, &scored, &self.tpe_config, &mut self.rng) {
476                    Ok((parameters, categorical)) => {
477                        (parameters, categorical, OptimizationStrategy::TPE)
478                    }
479                    Err(_) if scored.len() < self.tpe_config.startup_trials.max(2) => {
480                        // Documented initial design: TPE cannot fit two density
481                        // estimators before it has observations.
482                        drop(scored);
483                        let (parameters, categorical) = self.sample_from_prior();
484                        (parameters, categorical, OptimizationStrategy::Random)
485                    }
486                    Err(error) => return Err(error),
487                }
488            }
489            OptimizationStrategy::Bayesian => {
490                let scored = Self::scored_observations(&self.evaluation_history);
491                match surrogate::suggest(
492                    &self.search_space,
493                    &scored,
494                    self.acquisition,
495                    &self.surrogate_config,
496                    &mut self.rng,
497                ) {
498                    Ok((parameters, categorical)) => {
499                        (parameters, categorical, OptimizationStrategy::Bayesian)
500                    }
501                    Err(_) if scored.len() < self.surrogate_config.startup_trials.max(2) => {
502                        drop(scored);
503                        let (parameters, categorical) = self.sample_from_prior();
504                        (parameters, categorical, OptimizationStrategy::Random)
505                    }
506                    Err(error) => return Err(error),
507                }
508            }
509            OptimizationStrategy::Evolutionary => {
510                let population: Vec<(&HyperparameterConfiguration<T>, f64)> = self
511                    .state
512                    .population
513                    .iter()
514                    .filter_map(|config| config.score.and_then(|s| s.to_f64()).map(|s| (config, s)))
515                    .collect();
516                if population.is_empty() {
517                    drop(population);
518                    let (parameters, categorical) = self.sample_from_prior();
519                    (parameters, categorical, OptimizationStrategy::Random)
520                } else {
521                    let (parameters, categorical) = evolution::breed(
522                        &self.search_space,
523                        &population,
524                        &self.evolution_config,
525                        &mut self.rng,
526                    )?;
527                    (parameters, categorical, OptimizationStrategy::Evolutionary)
528                }
529            }
530            unimplemented => {
531                return Err(OptimError::NotImplemented(format!(
532                    "hyperparameter strategy {:?} is not implemented in optirs-nas; \
533                     available strategies are Random, Grid, TPE, Bayesian and Evolutionary",
534                    unimplemented
535                )))
536            }
537        };
538
539        self.last_strategy_used = used;
540        Ok(HyperparameterConfiguration {
541            id: format!("config_{}", self.state.num_evaluations),
542            parameters,
543            categorical_parameters,
544            score: None,
545            metadata: HashMap::from([("strategy".to_string(), format!("{:?}", used))]),
546        })
547    }
548
549    /// Which strategy produced the most recent suggestion. Differs from
550    /// [`HyperparameterOptimizer::strategy`] exactly while a model-based strategy
551    /// is still running its random initial design.
552    pub fn last_strategy_used(&self) -> OptimizationStrategy {
553        self.last_strategy_used
554    }
555
556    /// The configured strategy.
557    pub fn strategy(&self) -> OptimizationStrategy {
558        self.strategy
559    }
560
561    /// Number of points per continuous grid axis.
562    pub fn grid_resolution(&self) -> usize {
563        self.grid_resolution
564    }
565
566    /// Set the number of points per continuous grid axis.
567    pub fn set_grid_resolution(&mut self, resolution: usize) {
568        self.grid_resolution = resolution.max(1);
569    }
570
571    /// Total number of distinct grid points, or `None` if the product overflows.
572    pub fn grid_size(&self) -> Option<usize> {
573        grid::grid_size(&self.search_space, self.grid_resolution)
574    }
575
576    /// Choose the acquisition function used by the `Bayesian` strategy.
577    pub fn set_acquisition_function(&mut self, acquisition: AcquisitionFunction) {
578        self.acquisition = acquisition;
579    }
580
581    /// Override the TPE tunables.
582    pub fn set_tpe_config(&mut self, config: tpe::TpeConfig) {
583        self.tpe_config = config;
584    }
585
586    /// Override the Bayesian surrogate tunables.
587    pub fn set_surrogate_config(&mut self, config: surrogate::SurrogateConfig) {
588        self.surrogate_config = config;
589    }
590
591    /// Override the evolutionary tunables.
592    pub fn set_evolution_config(&mut self, config: evolution::EvolutionConfig) {
593        self.evolution_config = config;
594    }
595
596    /// The search space this optimizer explores.
597    pub fn get_search_space(&self) -> &HyperparameterSpace<T> {
598        &self.search_space
599    }
600
601    /// The evolutionary population (fittest first), which is now genuinely read by
602    /// [`OptimizationStrategy::Evolutionary`] rather than being write-only state.
603    pub fn population(&self) -> &[HyperparameterConfiguration<T>] {
604        &self.state.population
605    }
606
607    /// The fitted surrogate's recorded training data, or `None` before the
608    /// Bayesian strategy has run.
609    pub fn surrogate_model(&self) -> Option<&SurrogateModel<T>> {
610        self.state.surrogate_model.as_ref()
611    }
612
613    /// Scored observations from the evaluation history, newest last.
614    fn scored_observations(
615        history: &[HyperparameterEvaluation<T>],
616    ) -> Vec<(&HyperparameterConfiguration<T>, f64)> {
617        history
618            .iter()
619            .filter(|evaluation| matches!(evaluation.status, EvaluationStatus::Success))
620            .filter_map(|evaluation| {
621                evaluation
622                    .configuration
623                    .score
624                    .and_then(|score| score.to_f64())
625                    .map(|score| (&evaluation.configuration, score))
626            })
627            .collect()
628    }
629
630    /// Record evaluation result
631    pub fn record_evaluation(&mut self, evaluation: HyperparameterEvaluation<T>) {
632        self.state.num_evaluations += 1;
633
634        // Update best configuration
635        if let Some(score) = evaluation.configuration.score {
636            let is_better = match self.best_config.as_ref().and_then(|best| best.score) {
637                Some(best) => score > best,
638                None => true,
639            };
640            if is_better {
641                self.best_config = Some(evaluation.configuration.clone());
642                self.state.best_score = Some(score);
643            }
644        }
645
646        // Update early stopping state
647        if self.state.early_stopping.enabled {
648            self.update_early_stopping(&evaluation);
649        }
650
651        // Store evaluation
652        self.evaluation_history.push(evaluation);
653
654        // Update strategy-specific state
655        self.update_strategy_state();
656    }
657
658    /// Check if optimization should stop early
659    pub fn should_stop_early(&self) -> bool {
660        if !self.state.early_stopping.enabled {
661            return false;
662        }
663
664        self.state.early_stopping.patience_counter >= self.state.early_stopping.patience
665    }
666
667    /// Get the best configuration found so far
668    pub fn get_best_configuration(&self) -> Option<&HyperparameterConfiguration<T>> {
669        self.best_config.as_ref()
670    }
671
672    /// Get optimization statistics
673    pub fn get_statistics(&self) -> OptimizationStatistics<T> {
674        let scores: Vec<T> = self
675            .evaluation_history
676            .iter()
677            .filter_map(|eval| eval.configuration.score)
678            .collect();
679
680        let mean_score = if scores.is_empty() {
681            T::zero()
682        } else {
683            scores.iter().fold(T::zero(), |acc, &x| acc + x)
684                / T::from(scores.len()).unwrap_or_else(|| T::one())
685        };
686
687        OptimizationStatistics {
688            num_evaluations: self.state.num_evaluations,
689            best_score: self.state.best_score,
690            mean_score: Some(mean_score),
691            num_successful_evaluations: self
692                .evaluation_history
693                .iter()
694                .filter(|eval| matches!(eval.status, EvaluationStatus::Success))
695                .count() as u32,
696            convergence_iteration: self.get_convergence_iteration(),
697        }
698    }
699
700    // Private methods for different optimization strategies
701
702    /// Draw one value per parameter from its declared prior. Shared by the
703    /// `Random` strategy and by the initial-design phase of the model-based
704    /// strategies.
705    ///
706    /// Unlike the previous version this honors every [`DistributionType`] (the old
707    /// code had a `_ => uniform` arm covering four of five variants), respects
708    /// `discrete_values`, and contains no `expect`: a value that cannot be
709    /// converted falls back to the range's own bound rather than aborting.
710    fn sample_from_prior(&mut self) -> (HashMap<String, T>, HashMap<String, String>) {
711        let mut parameters = HashMap::new();
712        let mut categorical_parameters = HashMap::new();
713
714        for name in support::sorted_parameter_names(&self.search_space) {
715            let Some(range) = self.search_space.parameters.get(&name).cloned() else {
716                continue;
717            };
718            let raw =
719                support::snap_to_discrete(&range, support::sample_parameter(&range, &mut self.rng));
720            parameters.insert(
721                name,
722                scirs2_core::numeric::NumCast::from(raw).unwrap_or(range.min_value),
723            );
724        }
725
726        for name in support::sorted_categorical_names(&self.search_space) {
727            let Some(categories) = self.search_space.categorical_parameters.get(&name).cloned()
728            else {
729                continue;
730            };
731            if categories.is_empty() {
732                continue;
733            }
734            let idx = self.rng.gen_range(0..categories.len());
735            categorical_parameters.insert(name, categories[idx].clone());
736        }
737
738        (parameters, categorical_parameters)
739    }
740
741    fn update_early_stopping(&mut self, evaluation: &HyperparameterEvaluation<T>) {
742        if let Some(score) = evaluation.configuration.score {
743            if let Some(best_score) = self.state.early_stopping.best_score_for_stopping {
744                if score > best_score + self.state.early_stopping.min_improvement {
745                    self.state.early_stopping.best_score_for_stopping = Some(score);
746                    self.state.early_stopping.patience_counter = 0;
747                } else {
748                    self.state.early_stopping.patience_counter += 1;
749                }
750            } else {
751                self.state.early_stopping.best_score_for_stopping = Some(score);
752            }
753        }
754    }
755
756    /// Refresh the state each strategy reads between suggestions.
757    ///
758    /// * The evolutionary population becomes a real, bounded, elitist pool
759    ///   (previously it was filled once with random configurations and never read).
760    /// * The Bayesian surrogate's recorded training data is rebuilt from the scored
761    ///   history, so `state.surrogate_model` stops being permanently `None`.
762    fn update_strategy_state(&mut self) {
763        self.state.iteration += 1;
764
765        match self.strategy {
766            OptimizationStrategy::Evolutionary => {
767                let scored: Vec<HyperparameterConfiguration<T>> = self
768                    .evaluation_history
769                    .iter()
770                    .filter(|evaluation| {
771                        matches!(evaluation.status, EvaluationStatus::Success)
772                            && evaluation.configuration.score.is_some()
773                    })
774                    .map(|evaluation| evaluation.configuration.clone())
775                    .collect();
776                self.state.population = scored;
777                evolution::survivor_selection(&mut self.state.population, &self.evolution_config);
778            }
779            OptimizationStrategy::Bayesian => {
780                let continuous = support::sorted_parameter_names(&self.search_space);
781                let training_data: Vec<(Vec<T>, T)> = self
782                    .evaluation_history
783                    .iter()
784                    .filter_map(|evaluation| {
785                        let score = evaluation.configuration.score?;
786                        let vector = continuous
787                            .iter()
788                            .map(|name| {
789                                evaluation
790                                    .configuration
791                                    .parameters
792                                    .get(name)
793                                    .copied()
794                                    .unwrap_or_else(T::zero)
795                            })
796                            .collect();
797                        Some((vector, score))
798                    })
799                    .collect();
800                let mut hyperparameters = HashMap::new();
801                hyperparameters.insert("bandwidth".to_string(), self.surrogate_config.bandwidth);
802                hyperparameters.insert("ucb_beta".to_string(), self.surrogate_config.ucb_beta);
803                self.state.surrogate_model = Some(SurrogateModel {
804                    model_type: surrogate::IMPLEMENTED_SURROGATE,
805                    training_data,
806                    hyperparameters,
807                    acquisition_function: self.acquisition,
808                });
809            }
810            _ => {}
811        }
812    }
813
814    fn get_convergence_iteration(&self) -> Option<u32> {
815        // Simple convergence detection based on improvement rate
816        let window_size = 10;
817        if self.evaluation_history.len() < window_size * 2 {
818            return None;
819        }
820
821        let recent_scores: Vec<T> = self
822            .evaluation_history
823            .iter()
824            .rev()
825            .take(window_size)
826            .filter_map(|eval| eval.configuration.score)
827            .collect();
828
829        let older_scores: Vec<T> = self
830            .evaluation_history
831            .iter()
832            .rev()
833            .skip(window_size)
834            .take(window_size)
835            .filter_map(|eval| eval.configuration.score)
836            .collect();
837
838        if recent_scores.len() == window_size && older_scores.len() == window_size {
839            let recent_mean = recent_scores.iter().fold(T::zero(), |acc, &x| acc + x)
840                / T::from(window_size).unwrap_or_else(|| T::one());
841            let older_mean = older_scores.iter().fold(T::zero(), |acc, &x| acc + x)
842                / T::from(window_size).unwrap_or_else(|| T::one());
843
844            let improvement = recent_mean - older_mean;
845            let threshold = T::from(0.001).unwrap_or_else(T::zero);
846
847            if improvement < threshold {
848                return Some(self.state.iteration - window_size as u32);
849            }
850        }
851
852        None
853    }
854}
855
856/// Statistics about the optimization process
857#[derive(Debug, Clone)]
858pub struct OptimizationStatistics<T: Float> {
859    /// Total number of evaluations
860    pub num_evaluations: u32,
861
862    /// Best score achieved
863    pub best_score: Option<T>,
864
865    /// Mean score across all evaluations
866    pub mean_score: Option<T>,
867
868    /// Number of successful evaluations
869    pub num_successful_evaluations: u32,
870
871    /// Iteration at which convergence was detected
872    pub convergence_iteration: Option<u32>,
873}
874
875impl<T: Float> HyperparameterSpace<T> {
876    /// Create a new hyperparameter search space
877    pub fn new() -> Self {
878        Self {
879            parameters: HashMap::new(),
880            dependencies: Vec::new(),
881            constraints: Vec::new(),
882            categorical_parameters: HashMap::new(),
883        }
884    }
885
886    /// Continuous parameter ranges, keyed by name.
887    pub fn parameter_ranges(&self) -> &HashMap<String, ParameterRange<T>> {
888        &self.parameters
889    }
890
891    /// Categorical parameter option lists, keyed by name.
892    pub fn categorical_options(&self) -> &HashMap<String, Vec<String>> {
893        &self.categorical_parameters
894    }
895
896    /// Declared parameter dependencies.
897    pub fn dependencies(&self) -> &[ParameterDependency] {
898        &self.dependencies
899    }
900
901    /// Declared cross-parameter constraints.
902    pub fn constraints(&self) -> &[HyperparameterConstraint<T>] {
903        &self.constraints
904    }
905
906    /// Whether the space declares no parameters at all.
907    pub fn is_empty(&self) -> bool {
908        self.parameters.is_empty() && self.categorical_parameters.is_empty()
909    }
910
911    /// Add a continuous parameter to the search space
912    pub fn add_parameter(&mut self, name: String, range: ParameterRange<T>) {
913        self.parameters.insert(name, range);
914    }
915
916    /// Add a categorical parameter to the search space
917    pub fn add_categorical_parameter(&mut self, name: String, categories: Vec<String>) {
918        self.categorical_parameters.insert(name, categories);
919    }
920
921    /// Add a dependency between parameters
922    pub fn add_dependency(&mut self, dependency: ParameterDependency) {
923        self.dependencies.push(dependency);
924    }
925
926    /// Add a constraint on parameter combinations
927    pub fn add_constraint(&mut self, constraint: HyperparameterConstraint<T>) {
928        self.constraints.push(constraint);
929    }
930
931    /// Validate a configuration against the search space
932    pub fn validate_configuration(&self, config: &HyperparameterConfiguration<T>) -> bool {
933        // Check parameter ranges
934        for (name, value) in &config.parameters {
935            if let Some(range) = self.parameters.get(name) {
936                if *value < range.min_value || *value > range.max_value {
937                    return false;
938                }
939            }
940        }
941
942        // Check categorical parameters
943        for (name, value) in &config.categorical_parameters {
944            if let Some(categories) = self.categorical_parameters.get(name) {
945                if !categories.contains(value) {
946                    return false;
947                }
948            }
949        }
950
951        // Check constraints
952        for constraint in &self.constraints {
953            if !self.check_constraint(constraint, config) {
954                return false;
955            }
956        }
957
958        true
959    }
960
961    fn check_constraint(
962        &self,
963        constraint: &HyperparameterConstraint<T>,
964        config: &HyperparameterConfiguration<T>,
965    ) -> bool {
966        match &constraint.constraint_type {
967            ConstraintType::Linear {
968                coefficients,
969                bound,
970            } => {
971                let mut sum = T::zero();
972                for (i, param_name) in constraint.parameters.iter().enumerate() {
973                    if let Some(value) = config.parameters.get(param_name) {
974                        sum = sum + coefficients[i] * *value;
975                    }
976                }
977                sum <= *bound
978            }
979            ConstraintType::MutualExclusion => {
980                let active_count = constraint
981                    .parameters
982                    .iter()
983                    .filter(|param| config.parameters.contains_key(*param))
984                    .count();
985                active_count <= 1
986            }
987            ConstraintType::Ordering => {
988                let mut values = Vec::new();
989                for param_name in &constraint.parameters {
990                    if let Some(value) = config.parameters.get(param_name) {
991                        values.push(*value);
992                    }
993                }
994
995                for i in 1..values.len() {
996                    if values[i - 1] > values[i] {
997                        return false;
998                    }
999                }
1000                true
1001            }
1002            ConstraintType::NonLinear { .. } => {
1003                // Custom constraint functions would be implemented here
1004                true
1005            }
1006        }
1007    }
1008}
1009
1010impl<T: Float> Default for HyperparameterSpace<T> {
1011    fn default() -> Self {
1012        Self::new()
1013    }
1014}
1015
1016impl<T: Float + fmt::Debug> fmt::Display for HyperparameterConfiguration<T> {
1017    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1018        writeln!(f, "Configuration: {}", self.id)?;
1019        for (name, value) in &self.parameters {
1020            writeln!(f, "  {}: {:?}", name, value)?;
1021        }
1022        for (name, value) in &self.categorical_parameters {
1023            writeln!(f, "  {}: {}", name, value)?;
1024        }
1025        if let Some(score) = self.score {
1026            writeln!(f, "  Score: {:?}", score)?;
1027        }
1028        Ok(())
1029    }
1030}
1031
1032#[cfg(test)]
1033mod tests;