use super::support::{
denormalize, normalize, sample_parameter, snap_to_discrete, sorted_categorical_names,
sorted_parameter_names, standard_normal, StrategyRng, SuggestedParameters,
};
use super::{HyperparameterConfiguration, HyperparameterSpace};
use crate::error::{OptimError, Result};
use scirs2_core::numeric::Float;
use std::collections::HashMap;
#[derive(Debug, Clone, Copy)]
pub struct TpeConfig {
pub startup_trials: usize,
pub gamma: f64,
pub max_good: usize,
pub candidates: usize,
pub prior_weight: f64,
pub min_bandwidth: f64,
}
impl Default for TpeConfig {
fn default() -> Self {
Self {
startup_trials: 10,
gamma: 0.25,
max_good: 25,
candidates: 24,
prior_weight: 1.0,
min_bandwidth: 0.05,
}
}
}
impl TpeConfig {
pub fn good_count(&self, n: usize) -> usize {
if n < 2 {
return n.min(1);
}
let raw = (self.gamma * n as f64).ceil() as usize;
raw.clamp(1, self.max_good.min(n - 1))
}
}
#[derive(Debug, Clone)]
pub struct ParzenEstimator {
centres: Vec<f64>,
bandwidths: Vec<f64>,
prior_weight: f64,
}
impl ParzenEstimator {
pub fn fit(observations: &[f64], config: &TpeConfig) -> Self {
let mut sorted: Vec<f64> = observations
.iter()
.copied()
.filter(|v| v.is_finite())
.map(|v| v.clamp(0.0, 1.0))
.collect();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let mut bandwidths = Vec::with_capacity(sorted.len());
for i in 0..sorted.len() {
let left = if i == 0 {
sorted[i] - 0.0
} else {
sorted[i] - sorted[i - 1]
};
let right = if i + 1 == sorted.len() {
1.0 - sorted[i]
} else {
sorted[i + 1] - sorted[i]
};
bandwidths.push(left.max(right).max(config.min_bandwidth));
}
Self {
centres: sorted,
bandwidths,
prior_weight: config.prior_weight.max(0.0),
}
}
pub fn density(&self, x: f64) -> f64 {
let mut total = self.prior_weight * gaussian(x, 0.5, 1.0);
let mut weight = self.prior_weight;
for (centre, bandwidth) in self.centres.iter().zip(self.bandwidths.iter()) {
total += gaussian(x, *centre, *bandwidth);
weight += 1.0;
}
if weight <= 0.0 {
return f64::MIN_POSITIVE;
}
(total / weight).max(f64::MIN_POSITIVE)
}
pub fn sample(&self, rng: &mut StrategyRng) -> f64 {
let total_weight = self.prior_weight + self.centres.len() as f64;
if total_weight <= 0.0 {
return rng.gen_range(0.0..1.0);
}
for _ in 0..32 {
let pick = rng.gen_range(0.0..total_weight);
let (mean, sigma) = if pick < self.prior_weight {
(0.5, 1.0)
} else {
let index = ((pick - self.prior_weight) as usize).min(self.centres.len() - 1);
(self.centres[index], self.bandwidths[index])
};
let draw = mean + standard_normal(rng) * sigma;
if (0.0..=1.0).contains(&draw) {
return draw;
}
}
rng.gen_range(0.0..1.0)
}
pub fn observation_count(&self) -> usize {
self.centres.len()
}
}
fn gaussian(x: f64, mean: f64, sigma: f64) -> f64 {
let sigma = sigma.max(1e-12);
let z = (x - mean) / sigma;
(-0.5 * z * z).exp() / (sigma * (std::f64::consts::TAU).sqrt())
}
#[derive(Debug, Clone)]
pub struct CategoricalEstimator {
weights: Vec<f64>,
total: f64,
}
impl CategoricalEstimator {
pub fn fit(observed: &[usize], num_categories: usize, config: &TpeConfig) -> Self {
let smoothing = if num_categories > 0 {
config.prior_weight.max(0.0) / num_categories as f64
} else {
0.0
};
let mut weights = vec![smoothing; num_categories];
for index in observed {
if *index < num_categories {
weights[*index] += 1.0;
}
}
let total = weights.iter().sum::<f64>().max(f64::MIN_POSITIVE);
Self { weights, total }
}
pub fn probability(&self, index: usize) -> f64 {
self.weights
.get(index)
.map(|w| (w / self.total).max(f64::MIN_POSITIVE))
.unwrap_or(f64::MIN_POSITIVE)
}
pub fn sample(&self, rng: &mut StrategyRng) -> usize {
if self.weights.is_empty() {
return 0;
}
let target = rng.gen_range(0.0..self.total);
let mut cumulative = 0.0;
for (index, weight) in self.weights.iter().enumerate() {
cumulative += weight;
if cumulative >= target {
return index;
}
}
self.weights.len() - 1
}
}
pub fn split_by_quantile<T: Float>(
scored: &[(&HyperparameterConfiguration<T>, f64)],
config: &TpeConfig,
) -> (Vec<usize>, Vec<usize>) {
let mut order: Vec<usize> = (0..scored.len()).collect();
order.sort_by(|a, b| {
scored[*b]
.1
.partial_cmp(&scored[*a].1)
.unwrap_or(std::cmp::Ordering::Equal)
});
let good_count = config.good_count(scored.len());
let good = order[..good_count.min(order.len())].to_vec();
let bad = order[good_count.min(order.len())..].to_vec();
(good, bad)
}
pub fn suggest<T: Float>(
space: &HyperparameterSpace<T>,
scored: &[(&HyperparameterConfiguration<T>, f64)],
config: &TpeConfig,
rng: &mut StrategyRng,
) -> Result<SuggestedParameters<T>> {
if scored.len() < config.startup_trials.max(2) {
return Err(OptimError::InvalidConfig(format!(
"TPE needs at least {} scored observations to fit its good/bad density \
estimators; only {} are available (run the random initial design first)",
config.startup_trials.max(2),
scored.len()
)));
}
let (good, bad) = split_by_quantile(scored, config);
if good.is_empty() || bad.is_empty() {
return Err(OptimError::InvalidConfig(
"TPE could not form both a good and a bad observation set; scores may all be equal"
.to_string(),
));
}
let mut parameters = HashMap::new();
for name in sorted_parameter_names(space) {
let Some(range) = space.parameter_ranges().get(&name) else {
continue;
};
let collect = |indices: &[usize]| -> Vec<f64> {
indices
.iter()
.filter_map(|&i| scored[i].0.parameters.get(&name))
.map(|v| normalize(range, v.to_f64().unwrap_or(0.0)))
.collect()
};
let good_values = collect(&good);
let bad_values = collect(&bad);
if good_values.is_empty() {
let raw = snap_to_discrete(range, sample_parameter(range, rng));
parameters.insert(
name,
scirs2_core::numeric::NumCast::from(raw).unwrap_or_else(T::zero),
);
continue;
}
let good_kde = ParzenEstimator::fit(&good_values, config);
let bad_kde = ParzenEstimator::fit(&bad_values, config);
let mut best_unit = good_kde.sample(rng);
let mut best_ratio = f64::NEG_INFINITY;
for _ in 0..config.candidates.max(1) {
let unit = good_kde.sample(rng);
let ratio = good_kde.density(unit).ln() - bad_kde.density(unit).ln();
if ratio > best_ratio {
best_ratio = ratio;
best_unit = unit;
}
}
let raw = snap_to_discrete(range, denormalize(range, best_unit));
parameters.insert(
name,
scirs2_core::numeric::NumCast::from(raw).unwrap_or_else(T::zero),
);
}
let mut categorical = HashMap::new();
for name in sorted_categorical_names(space) {
let Some(options) = space.categorical_options().get(&name) else {
continue;
};
if options.is_empty() {
continue;
}
let index_of = |value: &str| options.iter().position(|option| option == value);
let collect = |indices: &[usize]| -> Vec<usize> {
indices
.iter()
.filter_map(|&i| scored[i].0.categorical_parameters.get(&name))
.filter_map(|value| index_of(value))
.collect()
};
let good_counts = CategoricalEstimator::fit(&collect(&good), options.len(), config);
let bad_counts = CategoricalEstimator::fit(&collect(&bad), options.len(), config);
let mut best_index = good_counts.sample(rng);
let mut best_ratio = f64::NEG_INFINITY;
for _ in 0..config.candidates.max(1) {
let index = good_counts.sample(rng);
let ratio = good_counts.probability(index).ln() - bad_counts.probability(index).ln();
if ratio > best_ratio {
best_ratio = ratio;
best_index = index;
}
}
categorical.insert(name, options[best_index.min(options.len() - 1)].clone());
}
Ok((parameters, categorical))
}
#[cfg(test)]
mod tests {
use super::super::{DistributionType, ParameterRange};
use super::*;
use scirs2_core::random::Random;
fn unit_space() -> HyperparameterSpace<f64> {
let mut space: HyperparameterSpace<f64> = HyperparameterSpace::new();
space.add_parameter(
"x".to_string(),
ParameterRange {
name: "x".to_string(),
min_value: 0.0,
max_value: 1.0,
distribution: DistributionType::Uniform,
log_scale: false,
discrete_values: None,
},
);
space.add_categorical_parameter(
"kind".to_string(),
vec!["good_kind".to_string(), "bad_kind".to_string()],
);
space
}
fn observations() -> Vec<HyperparameterConfiguration<f64>> {
let mut configs = Vec::new();
for i in 0..30 {
let x = i as f64 / 29.0;
let good_kind = i % 2 == 0;
let score = -(x - 0.8).abs() + if good_kind { 0.5 } else { 0.0 };
let mut parameters = HashMap::new();
parameters.insert("x".to_string(), x);
let mut categorical = HashMap::new();
categorical.insert(
"kind".to_string(),
if good_kind { "good_kind" } else { "bad_kind" }.to_string(),
);
configs.push(HyperparameterConfiguration {
id: format!("obs_{i}"),
parameters,
categorical_parameters: categorical,
score: Some(score),
metadata: HashMap::new(),
});
}
configs
}
fn scored(
configs: &[HyperparameterConfiguration<f64>],
) -> Vec<(&HyperparameterConfiguration<f64>, f64)> {
configs
.iter()
.map(|c| (c, c.score.unwrap_or(0.0)))
.collect()
}
#[test]
fn parzen_density_is_higher_near_observations() {
let config = TpeConfig::default();
let estimator = ParzenEstimator::fit(&[0.8, 0.82, 0.79], &config);
let near = estimator.density(0.8);
let far = estimator.density(0.1);
assert!(near > far, "near = {near}, far = {far}");
assert!(
far > 0.0,
"the prior must keep the density strictly positive"
);
}
#[test]
fn parzen_bandwidths_are_adaptive() {
let config = TpeConfig {
min_bandwidth: 0.001,
..TpeConfig::default()
};
let estimator = ParzenEstimator::fit(&[0.0, 0.40, 0.50, 0.51, 0.52, 1.0], &config);
assert_eq!(estimator.observation_count(), 6);
let cluster_interior = estimator.bandwidths[3];
assert!(
cluster_interior < estimator.bandwidths[0],
"cluster bandwidth {cluster_interior} should be below the left tail's {}",
estimator.bandwidths[0]
);
assert!(
cluster_interior < estimator.bandwidths[5],
"cluster bandwidth {cluster_interior} should be below the right tail's {}",
estimator.bandwidths[5]
);
for bandwidth in &estimator.bandwidths {
assert!(*bandwidth >= config.min_bandwidth);
}
}
#[test]
fn quantile_split_puts_the_best_observations_in_the_good_set() {
let configs = observations();
let scored = scored(&configs);
let config = TpeConfig::default();
let (good, bad) = split_by_quantile(&scored, &config);
assert!(!good.is_empty() && !bad.is_empty());
let worst_good = good
.iter()
.map(|&i| scored[i].1)
.fold(f64::INFINITY, f64::min);
let best_bad = bad
.iter()
.map(|&i| scored[i].1)
.fold(f64::NEG_INFINITY, f64::max);
assert!(
worst_good >= best_bad,
"the good set must dominate the bad set: {worst_good} vs {best_bad}"
);
assert_eq!(good.len(), config.good_count(scored.len()));
}
#[test]
fn tpe_concentrates_on_the_high_scoring_region() {
let configs = observations();
let scored = scored(&configs);
let config = TpeConfig::default();
let space = unit_space();
let mut rng = Random::seed(20_240_817);
let mut suggestions = Vec::new();
for _ in 0..40 {
let (parameters, categorical) =
suggest(&space, &scored, &config, &mut rng).expect("tpe suggestion");
suggestions.push((parameters["x"], categorical["kind"].clone()));
}
let mean_x = suggestions.iter().map(|(x, _)| *x).sum::<f64>() / suggestions.len() as f64;
assert!(
mean_x > 0.6,
"TPE suggestions centred at {mean_x}, which is indistinguishable from random search"
);
let good_fraction = suggestions
.iter()
.filter(|(_, kind)| kind == "good_kind")
.count() as f64
/ suggestions.len() as f64;
assert!(
good_fraction > 0.65,
"TPE picked the rewarding category only {:.0}% of the time",
good_fraction * 100.0
);
}
#[test]
fn tpe_refuses_to_run_before_the_initial_design_is_done() {
let configs = observations();
let short = scored(&configs)[..3].to_vec();
let mut rng = Random::seed(1);
let error = suggest(&unit_space(), &short, &TpeConfig::default(), &mut rng)
.expect_err("TPE must not silently fall back to random search");
assert!(
format!("{error}").contains("TPE needs at least"),
"unexpected error: {error}"
);
}
#[test]
fn tpe_is_reproducible_for_a_given_seed() {
let configs = observations();
let scored = scored(&configs);
let space = unit_space();
let config = TpeConfig::default();
let mut a = Random::seed(4242);
let mut b = Random::seed(4242);
let first = suggest(&space, &scored, &config, &mut a).expect("suggest");
let second = suggest(&space, &scored, &config, &mut b).expect("suggest");
assert_eq!(first, second);
}
#[test]
fn categorical_estimator_smooths_unseen_categories() {
let config = TpeConfig::default();
let estimator = CategoricalEstimator::fit(&[0, 0, 0], 3, &config);
assert!(estimator.probability(0) > estimator.probability(1));
assert!(
estimator.probability(2) > 0.0,
"an unseen category must keep a non-zero probability"
);
assert!(
estimator.probability(99) > 0.0,
"out of range must not panic"
);
}
#[test]
fn tpe_respects_a_log_axis() {
let mut space: HyperparameterSpace<f64> = HyperparameterSpace::new();
space.add_parameter(
"lr".to_string(),
ParameterRange {
name: "lr".to_string(),
min_value: 1e-5,
max_value: 1e-1,
distribution: DistributionType::Uniform,
log_scale: true,
discrete_values: None,
},
);
let configs: Vec<HyperparameterConfiguration<f64>> = (0..24)
.map(|i| {
let lr = 1e-5 * 10f64.powf(4.0 * i as f64 / 23.0);
let mut parameters = HashMap::new();
parameters.insert("lr".to_string(), lr);
HyperparameterConfiguration {
id: format!("o{i}"),
parameters,
categorical_parameters: HashMap::new(),
score: Some(-lr.log10()),
metadata: HashMap::new(),
}
})
.collect();
let scored: Vec<(&HyperparameterConfiguration<f64>, f64)> = configs
.iter()
.map(|c| (c, c.score.unwrap_or(0.0)))
.collect();
let mut rng = Random::seed(77);
let mut values = Vec::new();
for _ in 0..30 {
let (parameters, _) =
suggest(&space, &scored, &TpeConfig::default(), &mut rng).expect("suggest");
let lr = parameters["lr"];
assert!((1e-5..=1e-1).contains(&lr), "lr {lr} escaped the range");
values.push(lr);
}
let median_exponent = {
let mut exponents: Vec<f64> = values.iter().map(|v| v.log10()).collect();
exponents.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
exponents[exponents.len() / 2]
};
assert!(
median_exponent < -3.3,
"TPE median exponent {median_exponent} shows no preference for small rates"
);
}
}