use hyperopt_core::{Direction, Distribution, Sampler, StudyState, Trial, Value};
use rand::rngs::StdRng;
use rand::{RngExt, SeedableRng};
use tpe::density_estimation::DefaultEstimatorBuilder;
use tpe::{categorical_range, histogram_estimator, parzen_estimator, range, TpeOptimizer};
use crate::random::sample_value;
pub struct TpeSampler {
rng: StdRng,
n_startup_trials: usize,
}
impl TpeSampler {
pub fn new() -> Self {
let mut seeder = rand::rng();
TpeSampler {
rng: StdRng::seed_from_u64(seeder.random()),
n_startup_trials: 10,
}
}
pub fn seeded(seed: u64) -> Self {
TpeSampler {
rng: StdRng::seed_from_u64(seed),
n_startup_trials: 10,
}
}
pub fn n_startup_trials(mut self, n: usize) -> Self {
self.n_startup_trials = n;
self
}
fn observations(
study_state: &StudyState,
name: &str,
distribution: &Distribution,
) -> Vec<(f64, f64)> {
let direction = study_state.direction();
let mut obs = Vec::new();
for t in study_state.completed_trials() {
let (Some(record), Some(value)) = (
t.params.iter().find(|p| p.name == name),
t.value,
) else {
continue;
};
if let Some(coord) = encode(distribution, &record.value) {
let objective = match direction {
Direction::Minimize => value,
Direction::Maximize => -value,
};
obs.push((coord, objective));
}
}
obs
}
}
impl Default for TpeSampler {
fn default() -> Self {
Self::new()
}
}
impl Sampler for TpeSampler {
fn suggest(
&mut self,
study_state: &StudyState,
_trial: &Trial,
param_name: &str,
distribution: &Distribution,
) -> Value {
let obs = Self::observations(study_state, param_name, distribution);
if obs.len() < self.n_startup_trials {
return sample_value(&mut self.rng, distribution);
}
let Some((estimator, tpe_range)) = build_estimator(distribution) else {
return sample_value(&mut self.rng, distribution);
};
let mut optim: TpeOptimizer<DefaultEstimatorBuilder> =
TpeOptimizer::new(estimator, tpe_range);
for (coord, value) in &obs {
let _ = optim.tell(*coord, *value);
}
match optim.ask(&mut self.rng) {
Ok(raw) => decode(distribution, raw),
Err(_) => sample_value(&mut self.rng, distribution),
}
}
}
fn build_estimator(
distribution: &Distribution,
) -> Option<(DefaultEstimatorBuilder, tpe::range::Range)> {
match distribution {
Distribution::Uniform { low, high } => {
Some((parzen_estimator(), range(*low, *high).ok()?))
}
Distribution::LogUniform { low, high } => {
if *low <= 0.0 {
return None;
}
Some((parzen_estimator(), range(low.ln(), high.ln()).ok()?))
}
Distribution::IntUniform { low, high } => {
Some((parzen_estimator(), range(*low as f64, *high as f64 + 1.0).ok()?))
}
Distribution::Categorical { choices } => {
Some((histogram_estimator(), categorical_range(choices.len()).ok()?))
}
}
}
fn encode(distribution: &Distribution, value: &Value) -> Option<f64> {
match distribution {
Distribution::Uniform { .. } => value.as_float(),
Distribution::LogUniform { .. } => value.as_float().map(f64::ln),
Distribution::IntUniform { .. } => value.as_int().map(|i| i as f64),
Distribution::Categorical { choices } => {
let label = value.as_categorical()?;
choices.iter().position(|c| c == label).map(|i| i as f64)
}
}
}
fn decode(distribution: &Distribution, raw: f64) -> Value {
match distribution {
Distribution::Uniform { .. } => Value::Float(raw),
Distribution::LogUniform { .. } => Value::Float(raw.exp()),
Distribution::IntUniform { low, high } => {
let i = (raw.floor() as i64).clamp(*low, *high);
Value::Int(i)
}
Distribution::Categorical { choices } => {
if choices.is_empty() {
return Value::Categorical(String::new());
}
let idx = (raw.floor() as usize).min(choices.len() - 1);
Value::Categorical(choices[idx].clone())
}
}
}