#![expect(
// Copyright 2026 Thomas Santerre and Moderately AI Inc.
//
// SPDX-License-Identifier: MIT OR Apache-2.0
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "TPE is a statistical sampler in f64 space. usize <-> f64 conversions are the \
core arithmetic (trial counts and category counts feed densities, weights, and \
expected-improvement ratios); precision loss above 2^53 would require more \
completed trials than anyone will ever run. Covers `.ceil() as usize` in \
`gamma()` where the result is clamped to [1, 25] immediately."
)]
use rand::{
SeedableRng,
distr::{Distribution, weighted::WeightedIndex},
rngs::StdRng,
};
use crate::trial::{Direction, FrozenTrial, ParamValue};
fn default_gamma(n: usize) -> usize {
let g = (0.25 * (n as f64).sqrt()).ceil() as usize;
g.clamp(1, 25)
}
pub struct TpeSamplerConfig {
pub seed: u64,
pub n_startup_trials: usize,
pub prior_weight: f64,
}
impl TpeSamplerConfig {
pub const DEFAULT_N_STARTUP_TRIALS: usize = 10;
pub const DEFAULT_PRIOR_WEIGHT: f64 = 1.0;
}
pub enum GammaStrategy {
Default,
Custom(Box<dyn Fn(usize) -> usize + Send + Sync>),
}
impl GammaStrategy {
fn into_boxed_fn(self) -> Box<dyn Fn(usize) -> usize + Send + Sync> {
match self {
Self::Default => Box::new(default_gamma),
Self::Custom(f) => f,
}
}
}
pub struct TpeSamplerDeps {
pub gamma_strategy: GammaStrategy,
}
pub struct TpeSampler {
rng: StdRng,
n_startup_trials: usize,
prior_weight: f64,
gamma_fn: Box<dyn Fn(usize) -> usize + Send + Sync>,
}
impl TpeSampler {
#[must_use]
pub fn new(deps: TpeSamplerDeps, config: TpeSamplerConfig) -> Self {
let TpeSamplerConfig {
seed,
n_startup_trials,
prior_weight,
} = config;
Self {
rng: StdRng::seed_from_u64(seed),
n_startup_trials,
prior_weight,
gamma_fn: deps.gamma_strategy.into_boxed_fn(),
}
}
pub(crate) fn sample_categorical(
&mut self,
param_name: &str,
num_choices: usize,
completed_trials: &[FrozenTrial],
direction: Direction,
) -> usize {
assert!(num_choices > 0, "num_choices must be > 0");
if num_choices == 1 {
return 0;
}
let observed_trials = completed_trials
.iter()
.filter(|trial| !trial.value.is_nan())
.count();
if observed_trials == 0 || observed_trials < self.n_startup_trials {
return self.sample_uniform(num_choices);
}
self.sample_tpe(param_name, num_choices, completed_trials, direction)
}
fn sample_uniform(&mut self, num_choices: usize) -> usize {
rand::Rng::random_range(&mut self.rng, 0..num_choices)
}
fn sample_tpe(
&mut self,
param_name: &str,
num_choices: usize,
completed_trials: &[FrozenTrial],
direction: Direction,
) -> usize {
let mut sorted: Vec<&FrozenTrial> = completed_trials
.iter()
.filter(|trial| !trial.value.is_nan())
.collect();
match direction {
Direction::Maximize => {
sorted.sort_by(|a, b| b.value.total_cmp(&a.value));
}
Direction::Minimize => {
sorted.sort_by(|a, b| a.value.total_cmp(&b.value));
}
}
let n = sorted.len();
let n_good = (self.gamma_fn)(n).clamp(1, n.saturating_sub(1).max(1));
let good = &sorted[..n_good];
let bad = &sorted[n_good..];
let n_bad = bad.len();
let prior = self.prior_weight / num_choices as f64;
let mut weights = vec![0.0_f64; num_choices];
for (c, weight) in weights.iter_mut().enumerate() {
let count_good = count_categorical(good, param_name, c);
let count_bad = count_categorical(bad, param_name, c);
let l_c = (count_good as f64 + prior) / (n_good as f64 + self.prior_weight);
let g_c = (count_bad as f64 + prior) / (n_bad as f64 + self.prior_weight);
*weight = if g_c > 0.0 { l_c / g_c } else { l_c };
}
match WeightedIndex::new(&weights) {
Ok(dist) => dist.sample(&mut self.rng),
Err(_) => self.sample_uniform(num_choices),
}
}
}
fn count_categorical(trials: &[&FrozenTrial], param_name: &str, choice: usize) -> usize {
let needle = ParamValue::Categorical(u32::try_from(choice).unwrap_or(u32::MAX));
trials
.iter()
.filter(|t| t.params.get(param_name) == Some(&needle))
.count()
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use super::*;
fn sampler_with_seed(seed: u64) -> TpeSampler {
TpeSampler::new(
TpeSamplerDeps {
gamma_strategy: GammaStrategy::Default,
},
TpeSamplerConfig {
seed,
n_startup_trials: TpeSamplerConfig::DEFAULT_N_STARTUP_TRIALS,
prior_weight: TpeSamplerConfig::DEFAULT_PRIOR_WEIGHT,
},
)
}
fn sampler_with_startup(seed: u64, n_startup_trials: usize) -> TpeSampler {
TpeSampler::new(
TpeSamplerDeps {
gamma_strategy: GammaStrategy::Default,
},
TpeSamplerConfig {
seed,
n_startup_trials,
prior_weight: TpeSamplerConfig::DEFAULT_PRIOR_WEIGHT,
},
)
}
fn make_trial(number: usize, params: &[(&str, usize)], value: f64) -> FrozenTrial {
FrozenTrial {
number,
params: params
.iter()
.map(|(k, v)| {
(
(*k).to_string(),
ParamValue::Categorical(u32::try_from(*v).unwrap()),
)
})
.collect(),
value,
}
}
#[test]
fn single_choice_always_returns_zero() {
let mut sampler = sampler_with_seed(42);
for _ in 0..10 {
assert_eq!(
sampler.sample_categorical("x", 1, &[], Direction::Maximize),
0
);
}
}
#[test]
fn zero_startup_trials_samples_uniformly_before_any_observation() {
let mut sampler = sampler_with_startup(42, 0);
let choice = sampler.sample_categorical("x", 5, &[], Direction::Maximize);
assert!(choice < 5);
}
#[test]
fn nan_trials_do_not_enter_tpe_densities() {
let trials = vec![
make_trial(0, &[("x", 0)], f64::NAN),
make_trial(1, &[("x", 1)], 1.0),
];
let mut sampler = sampler_with_startup(42, 1);
for _ in 0..10 {
assert!(sampler.sample_categorical("x", 2, &trials, Direction::Maximize) < 2);
}
}
#[test]
fn startup_uses_random_sampling() {
let mut seen = [false; 5];
for seed in 0..100 {
let mut s = sampler_with_startup(seed, 5);
let choice = s.sample_categorical("x", 5, &[], Direction::Maximize);
assert!(choice < 5);
seen[choice] = true;
}
assert!(
seen.iter().all(|&s| s),
"not all choices appeared during startup"
);
}
#[test]
fn deterministic_with_seed() {
let trials: Vec<FrozenTrial> = (0..15)
.map(|i| make_trial(i, &[("x", i % 5)], if i % 5 == 2 { 1.0 } else { 0.1 }))
.collect();
let mut s1 = sampler_with_startup(99, 5);
let mut s2 = sampler_with_startup(99, 5);
for _ in 0..10 {
let a = s1.sample_categorical("x", 5, &trials, Direction::Maximize);
let b = s2.sample_categorical("x", 5, &trials, Direction::Maximize);
assert_eq!(a, b, "same seed must produce same sequence");
}
}
#[test]
fn converges_to_best_choice_maximize() {
let mut trials: Vec<FrozenTrial> = (0..15)
.map(|i| make_trial(i, &[("x", i % 5)], if i % 5 == 2 { 1.0 } else { 0.1 }))
.collect();
let mut sampler = sampler_with_startup(42, 5);
let mut counts = [0usize; 5];
for i in 0..50 {
let choice = sampler.sample_categorical("x", 5, &trials, Direction::Maximize);
counts[choice] += 1;
let value = if choice == 2 { 1.0 } else { 0.1 };
trials.push(make_trial(15 + i, &[("x", choice)], value));
}
let max_idx = counts
.iter()
.enumerate()
.max_by_key(|(_, c)| **c)
.unwrap()
.0;
assert_eq!(
max_idx, 2,
"TPE should converge to choice 2, counts: {counts:?}"
);
}
#[test]
fn converges_to_best_choice_minimize() {
let mut trials: Vec<FrozenTrial> = (0..15)
.map(|i| make_trial(i, &[("x", i % 5)], if i % 5 == 1 { 0.0 } else { 1.0 }))
.collect();
let mut sampler = sampler_with_startup(42, 5);
let mut counts = [0usize; 5];
for i in 0..50 {
let choice = sampler.sample_categorical("x", 5, &trials, Direction::Minimize);
counts[choice] += 1;
let value = if choice == 1 { 0.0 } else { 1.0 };
trials.push(make_trial(15 + i, &[("x", choice)], value));
}
let max_idx = counts
.iter()
.enumerate()
.max_by_key(|(_, c)| **c)
.unwrap()
.0;
assert_eq!(
max_idx, 1,
"TPE should converge to choice 1 for minimize, counts: {counts:?}"
);
}
#[test]
fn two_parameters_converge_independently() {
let mut trials: Vec<FrozenTrial> = (0..20)
.map(|i| {
let x = i % 5;
let y = i % 3;
let value = if x == 3 { 0.5 } else { 0.0 } + if y == 1 { 0.5 } else { 0.0 };
make_trial(i, &[("x", x), ("y", y)], value)
})
.collect();
let mut sampler = sampler_with_startup(42, 10);
let mut x_counts = [0usize; 5];
let mut y_counts = [0usize; 3];
for i in 0..50 {
let x = sampler.sample_categorical("x", 5, &trials, Direction::Maximize);
let y = sampler.sample_categorical("y", 3, &trials, Direction::Maximize);
x_counts[x] += 1;
y_counts[y] += 1;
let value = if x == 3 { 0.5 } else { 0.0 } + if y == 1 { 0.5 } else { 0.0 };
trials.push(make_trial(20 + i, &[("x", x), ("y", y)], value));
}
let best_x = x_counts
.iter()
.enumerate()
.max_by_key(|(_, c)| **c)
.unwrap()
.0;
let best_y = y_counts
.iter()
.enumerate()
.max_by_key(|(_, c)| **c)
.unwrap()
.0;
assert_eq!(best_x, 3, "x should converge to 3, counts: {x_counts:?}");
assert_eq!(best_y, 1, "y should converge to 1, counts: {y_counts:?}");
}
#[test]
fn prior_smoothing_gives_unseen_choices_nonzero_probability() {
let trials: Vec<FrozenTrial> = (0..15).map(|i| make_trial(i, &[("x", 0)], 1.0)).collect();
let mut sampler = sampler_with_startup(42, 5);
let mut saw_nonzero = false;
for _ in 0..200 {
let choice = sampler.sample_categorical("x", 5, &trials, Direction::Maximize);
if choice != 0 {
saw_nonzero = true;
break;
}
}
assert!(
saw_nonzero,
"prior smoothing should allow unseen choices to be sampled"
);
}
#[test]
fn trials_missing_param_are_skipped() {
let trials = vec![
FrozenTrial {
number: 0,
params: BTreeMap::from([("x".into(), ParamValue::Categorical(2))]),
value: 1.0,
},
FrozenTrial {
number: 1,
params: BTreeMap::new(), value: 0.5,
},
];
let mut sampler = sampler_with_startup(42, 0);
let _choice = sampler.sample_categorical("x", 5, &trials, Direction::Maximize);
}
#[test]
fn default_gamma_values() {
assert_eq!(default_gamma(1), 1);
assert_eq!(default_gamma(4), 1);
assert_eq!(default_gamma(6), 1); assert_eq!(default_gamma(12), 1); assert_eq!(default_gamma(18), 2); assert_eq!(default_gamma(100), 3);
assert_eq!(default_gamma(10000), 25);
}
}