pub mod evolution;
pub mod grid;
pub mod support;
pub mod surrogate;
pub mod tpe;
use crate::error::{OptimError, Result};
use scirs2_core::numeric::Float;
use scirs2_core::random::Random;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fmt;
use support::StrategyRng;
#[derive(Debug)]
pub struct HyperparameterOptimizer<T: Float> {
search_space: HyperparameterSpace<T>,
strategy: OptimizationStrategy,
evaluation_history: Vec<HyperparameterEvaluation<T>>,
best_config: Option<HyperparameterConfiguration<T>>,
state: OptimizerState<T>,
rng: StrategyRng,
grid_resolution: usize,
tpe_config: tpe::TpeConfig,
surrogate_config: surrogate::SurrogateConfig,
evolution_config: evolution::EvolutionConfig,
acquisition: AcquisitionFunction,
last_strategy_used: OptimizationStrategy,
}
#[derive(Debug, Clone)]
pub struct HyperparameterSpace<T: Float> {
parameters: HashMap<String, ParameterRange<T>>,
dependencies: Vec<ParameterDependency>,
constraints: Vec<HyperparameterConstraint<T>>,
categorical_parameters: HashMap<String, Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ParameterRange<T: Float> {
pub name: String,
pub min_value: T,
pub max_value: T,
pub distribution: DistributionType,
pub log_scale: bool,
pub discrete_values: Option<Vec<T>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DistributionType {
Uniform,
Normal,
LogNormal,
Beta,
Exponential,
}
#[derive(Debug, Clone)]
pub struct ParameterDependency {
pub dependent: String,
pub parent: String,
pub dependency_type: DependencyType,
pub condition: DependencyCondition,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DependencyType {
Conditional,
Derived,
RangeDependent,
}
#[derive(Debug, Clone)]
pub enum DependencyCondition {
Equals(String),
GreaterThan(f64),
LessThan(f64),
InRange(f64, f64),
InSet(Vec<String>),
}
#[derive(Debug, Clone)]
pub struct HyperparameterConstraint<T: Float> {
pub name: String,
pub parameters: Vec<String>,
pub constraint_type: ConstraintType<T>,
pub penalty: T,
}
#[derive(Debug, Clone)]
pub enum ConstraintType<T: Float> {
Linear { coefficients: Vec<T>, bound: T },
NonLinear { function_name: String },
MutualExclusion,
Ordering,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HyperparameterConfiguration<T: Float> {
pub id: String,
pub parameters: HashMap<String, T>,
pub categorical_parameters: HashMap<String, String>,
pub score: Option<T>,
pub metadata: HashMap<String, String>,
}
#[derive(Debug, Clone)]
pub struct HyperparameterEvaluation<T: Float> {
pub configuration: HyperparameterConfiguration<T>,
pub metrics: EvaluationMetrics<T>,
pub duration_seconds: f64,
pub status: EvaluationStatus,
pub error_message: Option<String>,
}
#[derive(Debug, Clone)]
pub struct EvaluationMetrics<T: Float> {
pub primary_objective: T,
pub secondary_objectives: HashMap<String, T>,
pub validation_score: Option<T>,
pub training_time: f64,
pub memory_usage: f64,
pub converged: bool,
pub convergence_iterations: Option<u32>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EvaluationStatus {
Success,
Failed,
Terminated,
Running,
Timeout,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OptimizationStrategy {
Random,
Grid,
Bayesian,
Evolutionary,
ParticleSwarm,
TPE,
SuccessiveHalving,
Hyperband,
BOHB,
}
#[derive(Debug)]
pub struct OptimizerState<T: Float> {
pub iteration: u32,
pub num_evaluations: u32,
pub best_score: Option<T>,
pub population: Vec<HyperparameterConfiguration<T>>,
pub surrogate_model: Option<SurrogateModel<T>>,
pub early_stopping: EarlyStoppingState<T>,
}
#[derive(Debug)]
pub struct SurrogateModel<T: Float> {
pub model_type: SurrogateModelType,
pub training_data: Vec<(Vec<T>, T)>,
pub hyperparameters: HashMap<String, f64>,
pub acquisition_function: AcquisitionFunction,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SurrogateModelType {
KernelRegression,
GaussianProcess,
RandomForest,
NeuralNetwork,
Polynomial,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AcquisitionFunction {
ExpectedImprovement,
UpperConfidenceBound,
ProbabilityOfImprovement,
EntropySearch,
}
#[derive(Debug)]
pub struct EarlyStoppingState<T: Float> {
pub enabled: bool,
pub patience: u32,
pub patience_counter: u32,
pub min_improvement: T,
pub best_score_for_stopping: Option<T>,
}
impl<T: Float + Default + Clone + Send + Sync> HyperparameterOptimizer<T> {
pub fn new(search_space: HyperparameterSpace<T>, strategy: OptimizationStrategy) -> Self {
Self::with_seed(search_space, strategy, scirs2_core::random::random::<u64>())
}
pub fn with_seed(
search_space: HyperparameterSpace<T>,
strategy: OptimizationStrategy,
seed: u64,
) -> Self {
Self {
search_space,
strategy,
evaluation_history: Vec::new(),
best_config: None,
rng: Random::seed(seed),
grid_resolution: grid::DEFAULT_GRID_RESOLUTION,
tpe_config: tpe::TpeConfig::default(),
surrogate_config: surrogate::SurrogateConfig::default(),
evolution_config: evolution::EvolutionConfig::default(),
acquisition: AcquisitionFunction::ExpectedImprovement,
last_strategy_used: strategy,
state: OptimizerState {
iteration: 0,
num_evaluations: 0,
best_score: None,
population: Vec::new(),
surrogate_model: None,
early_stopping: EarlyStoppingState {
enabled: false,
patience: 50,
patience_counter: 0,
min_improvement: T::from(0.001).unwrap_or_else(T::zero),
best_score_for_stopping: None,
},
},
}
}
pub fn suggest_configuration(&mut self) -> Result<HyperparameterConfiguration<T>> {
if self.search_space.is_empty() {
return Err(OptimError::InvalidConfig(
"hyperparameter search requires a non-empty search space".to_string(),
));
}
let (parameters, categorical_parameters, used) = match self.strategy {
OptimizationStrategy::Random => {
let (parameters, categorical) = self.sample_from_prior();
(parameters, categorical, OptimizationStrategy::Random)
}
OptimizationStrategy::Grid => {
let index = self.state.num_evaluations as usize;
let (parameters, categorical) = grid::suggest(
&self.search_space,
index,
self.grid_resolution,
&mut self.rng,
)?;
(parameters, categorical, OptimizationStrategy::Grid)
}
OptimizationStrategy::TPE => {
let scored = Self::scored_observations(&self.evaluation_history);
match tpe::suggest(&self.search_space, &scored, &self.tpe_config, &mut self.rng) {
Ok((parameters, categorical)) => {
(parameters, categorical, OptimizationStrategy::TPE)
}
Err(_) if scored.len() < self.tpe_config.startup_trials.max(2) => {
drop(scored);
let (parameters, categorical) = self.sample_from_prior();
(parameters, categorical, OptimizationStrategy::Random)
}
Err(error) => return Err(error),
}
}
OptimizationStrategy::Bayesian => {
let scored = Self::scored_observations(&self.evaluation_history);
match surrogate::suggest(
&self.search_space,
&scored,
self.acquisition,
&self.surrogate_config,
&mut self.rng,
) {
Ok((parameters, categorical)) => {
(parameters, categorical, OptimizationStrategy::Bayesian)
}
Err(_) if scored.len() < self.surrogate_config.startup_trials.max(2) => {
drop(scored);
let (parameters, categorical) = self.sample_from_prior();
(parameters, categorical, OptimizationStrategy::Random)
}
Err(error) => return Err(error),
}
}
OptimizationStrategy::Evolutionary => {
let population: Vec<(&HyperparameterConfiguration<T>, f64)> = self
.state
.population
.iter()
.filter_map(|config| config.score.and_then(|s| s.to_f64()).map(|s| (config, s)))
.collect();
if population.is_empty() {
drop(population);
let (parameters, categorical) = self.sample_from_prior();
(parameters, categorical, OptimizationStrategy::Random)
} else {
let (parameters, categorical) = evolution::breed(
&self.search_space,
&population,
&self.evolution_config,
&mut self.rng,
)?;
(parameters, categorical, OptimizationStrategy::Evolutionary)
}
}
unimplemented => {
return Err(OptimError::NotImplemented(format!(
"hyperparameter strategy {:?} is not implemented in optirs-nas; \
available strategies are Random, Grid, TPE, Bayesian and Evolutionary",
unimplemented
)))
}
};
self.last_strategy_used = used;
Ok(HyperparameterConfiguration {
id: format!("config_{}", self.state.num_evaluations),
parameters,
categorical_parameters,
score: None,
metadata: HashMap::from([("strategy".to_string(), format!("{:?}", used))]),
})
}
pub fn last_strategy_used(&self) -> OptimizationStrategy {
self.last_strategy_used
}
pub fn strategy(&self) -> OptimizationStrategy {
self.strategy
}
pub fn grid_resolution(&self) -> usize {
self.grid_resolution
}
pub fn set_grid_resolution(&mut self, resolution: usize) {
self.grid_resolution = resolution.max(1);
}
pub fn grid_size(&self) -> Option<usize> {
grid::grid_size(&self.search_space, self.grid_resolution)
}
pub fn set_acquisition_function(&mut self, acquisition: AcquisitionFunction) {
self.acquisition = acquisition;
}
pub fn set_tpe_config(&mut self, config: tpe::TpeConfig) {
self.tpe_config = config;
}
pub fn set_surrogate_config(&mut self, config: surrogate::SurrogateConfig) {
self.surrogate_config = config;
}
pub fn set_evolution_config(&mut self, config: evolution::EvolutionConfig) {
self.evolution_config = config;
}
pub fn get_search_space(&self) -> &HyperparameterSpace<T> {
&self.search_space
}
pub fn population(&self) -> &[HyperparameterConfiguration<T>] {
&self.state.population
}
pub fn surrogate_model(&self) -> Option<&SurrogateModel<T>> {
self.state.surrogate_model.as_ref()
}
fn scored_observations(
history: &[HyperparameterEvaluation<T>],
) -> Vec<(&HyperparameterConfiguration<T>, f64)> {
history
.iter()
.filter(|evaluation| matches!(evaluation.status, EvaluationStatus::Success))
.filter_map(|evaluation| {
evaluation
.configuration
.score
.and_then(|score| score.to_f64())
.map(|score| (&evaluation.configuration, score))
})
.collect()
}
pub fn record_evaluation(&mut self, evaluation: HyperparameterEvaluation<T>) {
self.state.num_evaluations += 1;
if let Some(score) = evaluation.configuration.score {
let is_better = match self.best_config.as_ref().and_then(|best| best.score) {
Some(best) => score > best,
None => true,
};
if is_better {
self.best_config = Some(evaluation.configuration.clone());
self.state.best_score = Some(score);
}
}
if self.state.early_stopping.enabled {
self.update_early_stopping(&evaluation);
}
self.evaluation_history.push(evaluation);
self.update_strategy_state();
}
pub fn should_stop_early(&self) -> bool {
if !self.state.early_stopping.enabled {
return false;
}
self.state.early_stopping.patience_counter >= self.state.early_stopping.patience
}
pub fn get_best_configuration(&self) -> Option<&HyperparameterConfiguration<T>> {
self.best_config.as_ref()
}
pub fn get_statistics(&self) -> OptimizationStatistics<T> {
let scores: Vec<T> = self
.evaluation_history
.iter()
.filter_map(|eval| eval.configuration.score)
.collect();
let mean_score = if scores.is_empty() {
T::zero()
} else {
scores.iter().fold(T::zero(), |acc, &x| acc + x)
/ T::from(scores.len()).unwrap_or_else(|| T::one())
};
OptimizationStatistics {
num_evaluations: self.state.num_evaluations,
best_score: self.state.best_score,
mean_score: Some(mean_score),
num_successful_evaluations: self
.evaluation_history
.iter()
.filter(|eval| matches!(eval.status, EvaluationStatus::Success))
.count() as u32,
convergence_iteration: self.get_convergence_iteration(),
}
}
fn sample_from_prior(&mut self) -> (HashMap<String, T>, HashMap<String, String>) {
let mut parameters = HashMap::new();
let mut categorical_parameters = HashMap::new();
for name in support::sorted_parameter_names(&self.search_space) {
let Some(range) = self.search_space.parameters.get(&name).cloned() else {
continue;
};
let raw =
support::snap_to_discrete(&range, support::sample_parameter(&range, &mut self.rng));
parameters.insert(
name,
scirs2_core::numeric::NumCast::from(raw).unwrap_or(range.min_value),
);
}
for name in support::sorted_categorical_names(&self.search_space) {
let Some(categories) = self.search_space.categorical_parameters.get(&name).cloned()
else {
continue;
};
if categories.is_empty() {
continue;
}
let idx = self.rng.gen_range(0..categories.len());
categorical_parameters.insert(name, categories[idx].clone());
}
(parameters, categorical_parameters)
}
fn update_early_stopping(&mut self, evaluation: &HyperparameterEvaluation<T>) {
if let Some(score) = evaluation.configuration.score {
if let Some(best_score) = self.state.early_stopping.best_score_for_stopping {
if score > best_score + self.state.early_stopping.min_improvement {
self.state.early_stopping.best_score_for_stopping = Some(score);
self.state.early_stopping.patience_counter = 0;
} else {
self.state.early_stopping.patience_counter += 1;
}
} else {
self.state.early_stopping.best_score_for_stopping = Some(score);
}
}
}
fn update_strategy_state(&mut self) {
self.state.iteration += 1;
match self.strategy {
OptimizationStrategy::Evolutionary => {
let scored: Vec<HyperparameterConfiguration<T>> = self
.evaluation_history
.iter()
.filter(|evaluation| {
matches!(evaluation.status, EvaluationStatus::Success)
&& evaluation.configuration.score.is_some()
})
.map(|evaluation| evaluation.configuration.clone())
.collect();
self.state.population = scored;
evolution::survivor_selection(&mut self.state.population, &self.evolution_config);
}
OptimizationStrategy::Bayesian => {
let continuous = support::sorted_parameter_names(&self.search_space);
let training_data: Vec<(Vec<T>, T)> = self
.evaluation_history
.iter()
.filter_map(|evaluation| {
let score = evaluation.configuration.score?;
let vector = continuous
.iter()
.map(|name| {
evaluation
.configuration
.parameters
.get(name)
.copied()
.unwrap_or_else(T::zero)
})
.collect();
Some((vector, score))
})
.collect();
let mut hyperparameters = HashMap::new();
hyperparameters.insert("bandwidth".to_string(), self.surrogate_config.bandwidth);
hyperparameters.insert("ucb_beta".to_string(), self.surrogate_config.ucb_beta);
self.state.surrogate_model = Some(SurrogateModel {
model_type: surrogate::IMPLEMENTED_SURROGATE,
training_data,
hyperparameters,
acquisition_function: self.acquisition,
});
}
_ => {}
}
}
fn get_convergence_iteration(&self) -> Option<u32> {
let window_size = 10;
if self.evaluation_history.len() < window_size * 2 {
return None;
}
let recent_scores: Vec<T> = self
.evaluation_history
.iter()
.rev()
.take(window_size)
.filter_map(|eval| eval.configuration.score)
.collect();
let older_scores: Vec<T> = self
.evaluation_history
.iter()
.rev()
.skip(window_size)
.take(window_size)
.filter_map(|eval| eval.configuration.score)
.collect();
if recent_scores.len() == window_size && older_scores.len() == window_size {
let recent_mean = recent_scores.iter().fold(T::zero(), |acc, &x| acc + x)
/ T::from(window_size).unwrap_or_else(|| T::one());
let older_mean = older_scores.iter().fold(T::zero(), |acc, &x| acc + x)
/ T::from(window_size).unwrap_or_else(|| T::one());
let improvement = recent_mean - older_mean;
let threshold = T::from(0.001).unwrap_or_else(T::zero);
if improvement < threshold {
return Some(self.state.iteration - window_size as u32);
}
}
None
}
}
#[derive(Debug, Clone)]
pub struct OptimizationStatistics<T: Float> {
pub num_evaluations: u32,
pub best_score: Option<T>,
pub mean_score: Option<T>,
pub num_successful_evaluations: u32,
pub convergence_iteration: Option<u32>,
}
impl<T: Float> HyperparameterSpace<T> {
pub fn new() -> Self {
Self {
parameters: HashMap::new(),
dependencies: Vec::new(),
constraints: Vec::new(),
categorical_parameters: HashMap::new(),
}
}
pub fn parameter_ranges(&self) -> &HashMap<String, ParameterRange<T>> {
&self.parameters
}
pub fn categorical_options(&self) -> &HashMap<String, Vec<String>> {
&self.categorical_parameters
}
pub fn dependencies(&self) -> &[ParameterDependency] {
&self.dependencies
}
pub fn constraints(&self) -> &[HyperparameterConstraint<T>] {
&self.constraints
}
pub fn is_empty(&self) -> bool {
self.parameters.is_empty() && self.categorical_parameters.is_empty()
}
pub fn add_parameter(&mut self, name: String, range: ParameterRange<T>) {
self.parameters.insert(name, range);
}
pub fn add_categorical_parameter(&mut self, name: String, categories: Vec<String>) {
self.categorical_parameters.insert(name, categories);
}
pub fn add_dependency(&mut self, dependency: ParameterDependency) {
self.dependencies.push(dependency);
}
pub fn add_constraint(&mut self, constraint: HyperparameterConstraint<T>) {
self.constraints.push(constraint);
}
pub fn validate_configuration(&self, config: &HyperparameterConfiguration<T>) -> bool {
for (name, value) in &config.parameters {
if let Some(range) = self.parameters.get(name) {
if *value < range.min_value || *value > range.max_value {
return false;
}
}
}
for (name, value) in &config.categorical_parameters {
if let Some(categories) = self.categorical_parameters.get(name) {
if !categories.contains(value) {
return false;
}
}
}
for constraint in &self.constraints {
if !self.check_constraint(constraint, config) {
return false;
}
}
true
}
fn check_constraint(
&self,
constraint: &HyperparameterConstraint<T>,
config: &HyperparameterConfiguration<T>,
) -> bool {
match &constraint.constraint_type {
ConstraintType::Linear {
coefficients,
bound,
} => {
let mut sum = T::zero();
for (i, param_name) in constraint.parameters.iter().enumerate() {
if let Some(value) = config.parameters.get(param_name) {
sum = sum + coefficients[i] * *value;
}
}
sum <= *bound
}
ConstraintType::MutualExclusion => {
let active_count = constraint
.parameters
.iter()
.filter(|param| config.parameters.contains_key(*param))
.count();
active_count <= 1
}
ConstraintType::Ordering => {
let mut values = Vec::new();
for param_name in &constraint.parameters {
if let Some(value) = config.parameters.get(param_name) {
values.push(*value);
}
}
for i in 1..values.len() {
if values[i - 1] > values[i] {
return false;
}
}
true
}
ConstraintType::NonLinear { .. } => {
true
}
}
}
}
impl<T: Float> Default for HyperparameterSpace<T> {
fn default() -> Self {
Self::new()
}
}
impl<T: Float + fmt::Debug> fmt::Display for HyperparameterConfiguration<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(f, "Configuration: {}", self.id)?;
for (name, value) in &self.parameters {
writeln!(f, " {}: {:?}", name, value)?;
}
for (name, value) in &self.categorical_parameters {
writeln!(f, " {}: {}", name, value)?;
}
if let Some(score) = self.score {
writeln!(f, " Score: {:?}", score)?;
}
Ok(())
}
}
#[cfg(test)]
mod tests;