use crate::automl::params::ParamKey;
use crate::automl::search::{ParamValue, Rng, SearchSpace, SearchStrategy, Trial, TrialResult};
#[derive(Debug, Clone)]
pub struct TPEConfig {
pub gamma: f32,
pub n_candidates: usize,
pub n_startup_trials: usize,
}
impl Default for TPEConfig {
fn default() -> Self {
Self {
gamma: 0.25,
n_candidates: 24,
n_startup_trials: 10,
}
}
}
#[derive(Debug, Clone)]
struct Observation {
values: Vec<f64>,
score: f64,
}
#[derive(Debug, Clone)]
pub struct TPE {
config: TPEConfig,
n_trials: usize,
history: Vec<Observation>,
trials_suggested: usize,
seed: u64,
}
impl TPE {
#[must_use]
pub fn new(n_trials: usize) -> Self {
Self {
config: TPEConfig::default(),
n_trials,
history: Vec::new(),
trials_suggested: 0,
seed: 42,
}
}
#[must_use]
pub fn with_seed(mut self, seed: u64) -> Self {
self.seed = seed;
self
}
#[must_use]
pub fn with_gamma(mut self, gamma: f32) -> Self {
self.config.gamma = gamma.clamp(0.01, 0.5);
self
}
#[must_use]
pub fn with_startup_trials(mut self, n: usize) -> Self {
self.config.n_startup_trials = n;
self
}
#[must_use]
pub fn remaining(&self) -> usize {
self.n_trials.saturating_sub(self.trials_suggested)
}
#[must_use]
pub fn n_observations(&self) -> usize {
self.history.len()
}
fn should_use_model(&self) -> bool {
self.history.len() >= self.config.n_startup_trials
}
fn kde_density(samples: &[f64], point: f64, bandwidth: f64) -> f64 {
if samples.is_empty() {
return 1.0; }
let n = samples.len() as f64;
let sum: f64 = samples
.iter()
.map(|&x| {
let z = (point - x) / bandwidth;
(-0.5 * z * z).exp()
})
.sum();
let norm = (2.0 * std::f64::consts::PI).sqrt() * bandwidth * n;
sum / norm
}
fn compute_bandwidth(samples: &[f64]) -> f64 {
if samples.len() < 2 {
return 1.0;
}
let n = samples.len() as f64;
let mean = samples.iter().sum::<f64>() / n;
let variance = samples.iter().map(|&x| (x - mean).powi(2)).sum::<f64>() / n;
let std = variance.sqrt().max(0.01);
std * n.powf(-0.2)
}
fn split_observations(&self) -> (Vec<&Observation>, Vec<&Observation>) {
if self.history.is_empty() {
return (Vec::new(), Vec::new());
}
let mut sorted: Vec<&Observation> = self.history.iter().collect();
sorted.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
let n_good = ((self.history.len() as f32) * self.config.gamma).ceil() as usize;
let n_good = n_good.max(1).min(sorted.len() - 1);
let good = sorted[..n_good].to_vec();
let bad = sorted[n_good..].to_vec();
(good, bad)
}
fn compute_ei_ratio(candidate: &[f64], good: &[&Observation], bad: &[&Observation]) -> f64 {
if candidate.is_empty() {
return 0.0;
}
let mut l_density = 1.0;
for (dim, &x) in candidate.iter().enumerate() {
let good_samples: Vec<f64> = good
.iter()
.filter_map(|o| o.values.get(dim).copied())
.collect();
let bandwidth = Self::compute_bandwidth(&good_samples);
l_density *= Self::kde_density(&good_samples, x, bandwidth);
}
let mut g_density = 1.0;
for (dim, &x) in candidate.iter().enumerate() {
let bad_samples: Vec<f64> = bad
.iter()
.filter_map(|o| o.values.get(dim).copied())
.collect();
let bandwidth = Self::compute_bandwidth(&bad_samples);
g_density *= Self::kde_density(&bad_samples, x, bandwidth);
}
l_density / (g_density + 1e-10)
}
fn sample_candidate<R: Rng>(n_dims: usize, rng: &mut R) -> Vec<f64> {
(0..n_dims).map(|_| rng.gen_f64()).collect()
}
fn denormalize_candidate<P: ParamKey>(
candidate: &[f64],
space: &SearchSpace<P>,
param_order: &[P],
) -> std::collections::HashMap<P, ParamValue> {
let mut values = std::collections::HashMap::new();
for (i, key) in param_order.iter().enumerate() {
if let (Some(&norm_val), Some(param)) = (candidate.get(i), space.get(key)) {
let value = match param {
crate::automl::search::HyperParam::Continuous {
low,
high,
log_scale,
} => {
let v = if *log_scale {
let log_low = low.ln();
let log_high = high.ln();
(log_low + norm_val * (log_high - log_low)).exp()
} else {
low + norm_val * (high - low)
};
ParamValue::Float(v)
}
crate::automl::search::HyperParam::Integer { low, high } => {
let range = (high - low + 1) as f64;
let v = *low + (norm_val * range).floor() as i64;
let v = v.min(*high).max(*low);
ParamValue::Int(v)
}
crate::automl::search::HyperParam::Categorical { choices } => {
let idx = (norm_val * choices.len() as f64).floor() as usize;
let idx = idx.min(choices.len().saturating_sub(1));
choices[idx].clone()
}
};
values.insert(*key, value);
}
}
values
}
}
impl<P: ParamKey> SearchStrategy<P> for TPE {
fn suggest(&mut self, space: &SearchSpace<P>, n: usize) -> Vec<Trial<P>> {
let n = n.min(self.remaining());
if n == 0 {
return Vec::new();
}
let mut rng = crate::automl::search::XorShift64::new(
self.seed.wrapping_add(self.trials_suggested as u64),
);
let param_order: Vec<P> = space.iter().map(|(k, _)| *k).collect();
let n_dims = param_order.len();
let trials: Vec<Trial<P>> = if !self.should_use_model() || n_dims == 0 {
(0..n).map(|_| space.sample(&mut rng)).collect()
} else {
let (good, bad) = self.split_observations();
(0..n)
.map(|_| {
let mut best_candidate = Self::sample_candidate(n_dims, &mut rng);
let mut best_ei = Self::compute_ei_ratio(&best_candidate, &good, &bad);
for _ in 1..self.config.n_candidates {
let candidate = Self::sample_candidate(n_dims, &mut rng);
let ei = Self::compute_ei_ratio(&candidate, &good, &bad);
if ei > best_ei {
best_ei = ei;
best_candidate = candidate;
}
}
let values = Self::denormalize_candidate(&best_candidate, space, ¶m_order);
Trial { values }
})
.collect()
};
self.trials_suggested += trials.len();
trials
}
fn update(&mut self, results: &[TrialResult<P>]) {
if results.is_empty() {
return;
}
for result in results {
let param_order: Vec<P> = result.trial.values.keys().copied().collect();
let values: Vec<f64> = result
.trial
.values
.values()
.filter_map(ParamValue::as_f64)
.collect();
let normalized = if !param_order.is_empty() && !values.is_empty() {
values
} else {
Vec::new()
};
self.history.push(Observation {
values: normalized,
score: result.score,
});
}
}
}
include!("tpe_tests.rs");
include!("tpe_proptests.rs");