fix(ml): improve TPE exploitation with Scott bandwidth, best-trial injection
- Replace Silverman's bandwidth (h = 1.06σn^(-1/5)) with Scott's rule (h = 0.7σn^(-1/(d+4))) for tighter kernels in high-D parameter spaces - Add best-trial injection: always evaluate EI at best known point plus 5 small perturbations (±5%), preventing optimizer from forgetting peaks - Scale n_candidates dynamically: max(256, 8*n_dims) instead of fixed 100 - Reduce gamma from 0.25 to 0.15 when trials < 50 for tighter exploitation - Wire model_name through PSO/TPE paths for per-trial Prometheus metrics Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -169,13 +169,14 @@ fn run_dqn_hyperopt(args: &Args, parallel: usize, gpu_devices: &[candle_core::De
|
||||
let start = Instant::now();
|
||||
let result = if args.optimizer.as_str() == "tpe" {
|
||||
info!("Using TPE (Tree-Parzen Estimator) optimizer");
|
||||
ml::hyperopt::optimize_with_tpe(trainer, args.trials, args.n_initial, Some(args.seed))
|
||||
ml::hyperopt::optimize_with_tpe(trainer, args.trials, args.n_initial, Some(args.seed), Some("dqn"))
|
||||
.context("DQN TPE hyperopt optimization failed")?
|
||||
} else {
|
||||
let optimizer = ArgminOptimizer::builder()
|
||||
.max_trials(args.trials)
|
||||
.n_initial(args.n_initial)
|
||||
.seed(args.seed)
|
||||
.model_name("dqn")
|
||||
.build();
|
||||
|
||||
if parallel > 1 {
|
||||
@@ -249,13 +250,14 @@ fn run_ppo_hyperopt(args: &Args, parallel: usize, gpu_devices: &[candle_core::De
|
||||
let start = Instant::now();
|
||||
let result = if args.optimizer.as_str() == "tpe" {
|
||||
info!("Using TPE (Tree-Parzen Estimator) optimizer");
|
||||
ml::hyperopt::optimize_with_tpe(trainer, args.trials, args.n_initial, Some(args.seed))
|
||||
ml::hyperopt::optimize_with_tpe(trainer, args.trials, args.n_initial, Some(args.seed), Some("ppo"))
|
||||
.context("PPO TPE hyperopt optimization failed")?
|
||||
} else {
|
||||
let optimizer = ArgminOptimizer::builder()
|
||||
.max_trials(args.trials)
|
||||
.n_initial(args.n_initial)
|
||||
.seed(args.seed)
|
||||
.model_name("ppo")
|
||||
.build();
|
||||
|
||||
if parallel > 1 {
|
||||
|
||||
@@ -136,6 +136,7 @@ fn run_tft_hyperopt(args: &Args) -> Result<Value> {
|
||||
.max_trials(args.trials)
|
||||
.n_initial(args.n_initial)
|
||||
.seed(args.seed)
|
||||
.model_name("tft")
|
||||
.build();
|
||||
|
||||
training_metrics::set_hyperopt_trial("tft", 0.0, args.trials as f64);
|
||||
@@ -195,6 +196,7 @@ fn run_mamba2_hyperopt(args: &Args) -> Result<Value> {
|
||||
.max_trials(args.trials)
|
||||
.n_initial(args.n_initial)
|
||||
.seed(args.seed)
|
||||
.model_name("mamba2")
|
||||
.build();
|
||||
|
||||
training_metrics::set_hyperopt_trial("mamba2", 0.0, args.trials as f64);
|
||||
@@ -255,6 +257,7 @@ fn run_liquid_hyperopt(args: &Args) -> Result<Value> {
|
||||
.max_trials(args.trials)
|
||||
.n_initial(args.n_initial)
|
||||
.seed(args.seed)
|
||||
.model_name("liquid")
|
||||
.build();
|
||||
|
||||
training_metrics::set_hyperopt_trial("liquid", 0.0, args.trials as f64);
|
||||
@@ -315,6 +318,7 @@ fn run_tggn_hyperopt(args: &Args) -> Result<Value> {
|
||||
.max_trials(args.trials)
|
||||
.n_initial(args.n_initial)
|
||||
.seed(args.seed)
|
||||
.model_name("tggn")
|
||||
.build();
|
||||
|
||||
training_metrics::set_hyperopt_trial("tggn", 0.0, args.trials as f64);
|
||||
@@ -375,6 +379,7 @@ fn run_tlob_hyperopt(args: &Args) -> Result<Value> {
|
||||
.max_trials(args.trials)
|
||||
.n_initial(args.n_initial)
|
||||
.seed(args.seed)
|
||||
.model_name("tlob")
|
||||
.build();
|
||||
|
||||
training_metrics::set_hyperopt_trial("tlob", 0.0, args.trials as f64);
|
||||
@@ -435,6 +440,7 @@ fn run_kan_hyperopt(args: &Args) -> Result<Value> {
|
||||
.max_trials(args.trials)
|
||||
.n_initial(args.n_initial)
|
||||
.seed(args.seed)
|
||||
.model_name("kan")
|
||||
.build();
|
||||
|
||||
training_metrics::set_hyperopt_trial("kan", 0.0, args.trials as f64);
|
||||
@@ -495,6 +501,7 @@ fn run_xlstm_hyperopt(args: &Args) -> Result<Value> {
|
||||
.max_trials(args.trials)
|
||||
.n_initial(args.n_initial)
|
||||
.seed(args.seed)
|
||||
.model_name("xlstm")
|
||||
.build();
|
||||
|
||||
training_metrics::set_hyperopt_trial("xlstm", 0.0, args.trials as f64);
|
||||
@@ -555,6 +562,7 @@ fn run_diffusion_hyperopt(args: &Args) -> Result<Value> {
|
||||
.max_trials(args.trials)
|
||||
.n_initial(args.n_initial)
|
||||
.seed(args.seed)
|
||||
.model_name("diffusion")
|
||||
.build();
|
||||
|
||||
training_metrics::set_hyperopt_trial("diffusion", 0.0, args.trials as f64);
|
||||
|
||||
@@ -92,6 +92,8 @@ pub struct ArgminOptimizer {
|
||||
pub(crate) seed: Option<u64>,
|
||||
/// Maximum iterations per restart
|
||||
pub(crate) max_iters_per_restart: usize,
|
||||
/// Model name for Prometheus metrics progress reporting (e.g. "dqn", "ppo")
|
||||
pub(crate) model_name: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for ArgminOptimizer {
|
||||
@@ -102,6 +104,7 @@ impl Default for ArgminOptimizer {
|
||||
n_particles: 20,
|
||||
seed: None,
|
||||
max_iters_per_restart: 50,
|
||||
model_name: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -134,6 +137,7 @@ impl ArgminOptimizer {
|
||||
n_particles: 20,
|
||||
seed: None,
|
||||
max_iters_per_restart: 50,
|
||||
model_name: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,6 +300,13 @@ impl ArgminOptimizer {
|
||||
&trial_counter,
|
||||
¶m_names,
|
||||
)?;
|
||||
if let Some(ref name) = self.model_name {
|
||||
common::metrics::training_metrics::set_hyperopt_trial(
|
||||
name,
|
||||
(i + 1) as f64,
|
||||
self.max_trials as f64,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Create trial budget observer BEFORE creating cost function
|
||||
@@ -310,6 +321,8 @@ impl ArgminOptimizer {
|
||||
param_names: param_names.clone(),
|
||||
bounds: bounds.clone(),
|
||||
observer: observer.clone(),
|
||||
model_name: self.model_name.clone(),
|
||||
max_trials: self.max_trials,
|
||||
};
|
||||
|
||||
// Find best initial point to start optimization
|
||||
@@ -702,6 +715,15 @@ impl ArgminOptimizer {
|
||||
current, strategy.max_concurrent_trials);
|
||||
concurrency.store(strategy.max_concurrent_trials, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
|
||||
if let Some(ref name) = self.model_name {
|
||||
let done = trial_counter.load(std::sync::atomic::Ordering::Relaxed);
|
||||
common::metrics::training_metrics::set_hyperopt_trial(
|
||||
name,
|
||||
done as f64,
|
||||
self.max_trials as f64,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Create trial budget observer BEFORE creating cost function
|
||||
@@ -715,6 +737,8 @@ impl ArgminOptimizer {
|
||||
param_names: param_names.clone(),
|
||||
bounds: bounds.clone(),
|
||||
observer: observer.clone(),
|
||||
model_name: self.model_name.clone(),
|
||||
max_trials: self.max_trials,
|
||||
};
|
||||
|
||||
// Find best initial point
|
||||
@@ -847,6 +871,7 @@ pub fn optimize_with_tpe<M>(
|
||||
max_trials: usize,
|
||||
n_initial: usize,
|
||||
seed: Option<u64>,
|
||||
model_name: Option<&str>,
|
||||
) -> Result<OptimizationResult<M::Params>>
|
||||
where
|
||||
M: HyperparameterOptimizable + Send,
|
||||
@@ -877,8 +902,10 @@ where
|
||||
info!(" Max Trials: {}", max_trials);
|
||||
info!(" Initial LHS Samples: {}", n_initial);
|
||||
info!(" Parameters: {}", n_params);
|
||||
info!(" Gamma (good quantile): 0.25");
|
||||
info!(" EI Candidates: 100");
|
||||
let n_candidates = 256_usize.max(8 * n_params);
|
||||
let gamma = if max_trials < 50 { 0.15 } else { 0.25 };
|
||||
info!(" Gamma (good quantile): {:.2}", gamma);
|
||||
info!(" EI Candidates: {} (scaled for {}D)", n_candidates, n_params);
|
||||
|
||||
let param_names = M::Params::param_names();
|
||||
for (i, name) in param_names.iter().enumerate() {
|
||||
@@ -891,8 +918,8 @@ where
|
||||
n_dims: n_params,
|
||||
max_trials,
|
||||
n_initial,
|
||||
gamma: 0.25,
|
||||
n_candidates: 100,
|
||||
gamma,
|
||||
n_candidates,
|
||||
seed,
|
||||
};
|
||||
let mut tpe = TpeOptimizer::new(tpe_config);
|
||||
@@ -932,6 +959,14 @@ where
|
||||
last.objective
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(name) = model_name {
|
||||
common::metrics::training_metrics::set_hyperopt_trial(
|
||||
name,
|
||||
(trial_idx + 1) as f64,
|
||||
max_trials as f64,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract results
|
||||
@@ -990,6 +1025,8 @@ where
|
||||
param_names: Vec<&'static str>,
|
||||
bounds: Vec<(f64, f64)>,
|
||||
observer: crate::hyperopt::TrialBudgetObserver,
|
||||
model_name: Option<String>,
|
||||
max_trials: usize,
|
||||
}
|
||||
|
||||
impl<M> CostFunction for ObjectiveFunction<M>
|
||||
@@ -1085,6 +1122,14 @@ where
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(ref name) = self.model_name {
|
||||
common::metrics::training_metrics::set_hyperopt_trial(
|
||||
name,
|
||||
trial_num as f64,
|
||||
self.max_trials as f64,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(objective)
|
||||
}
|
||||
}
|
||||
@@ -1110,6 +1155,8 @@ where
|
||||
param_names: Vec<&'static str>,
|
||||
bounds: Vec<(f64, f64)>,
|
||||
observer: crate::hyperopt::TrialBudgetObserver,
|
||||
model_name: Option<String>,
|
||||
max_trials: usize,
|
||||
}
|
||||
|
||||
impl<M> CostFunction for ParallelObjectiveFunction<M>
|
||||
@@ -1200,6 +1247,14 @@ where
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(ref name) = self.model_name {
|
||||
common::metrics::training_metrics::set_hyperopt_trial(
|
||||
name,
|
||||
trial_num as f64,
|
||||
self.max_trials as f64,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(objective)
|
||||
}
|
||||
}
|
||||
@@ -1227,6 +1282,7 @@ pub struct ArgminOptimizerBuilder {
|
||||
n_particles: usize,
|
||||
seed: Option<u64>,
|
||||
max_iters_per_restart: usize,
|
||||
model_name: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for ArgminOptimizerBuilder {
|
||||
@@ -1244,6 +1300,7 @@ impl ArgminOptimizerBuilder {
|
||||
n_particles: 20,
|
||||
seed: None,
|
||||
max_iters_per_restart: 50,
|
||||
model_name: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1277,6 +1334,12 @@ impl ArgminOptimizerBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
/// Set model name for per-trial Prometheus progress metrics
|
||||
pub fn model_name(mut self, name: impl Into<String>) -> Self {
|
||||
self.model_name = Some(name.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Build the optimizer
|
||||
///
|
||||
/// # Panics
|
||||
@@ -1296,6 +1359,7 @@ impl ArgminOptimizerBuilder {
|
||||
n_particles: self.n_particles,
|
||||
seed: self.seed,
|
||||
max_iters_per_restart: self.max_iters_per_restart,
|
||||
model_name: self.model_name,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,6 +128,42 @@ impl TpeOptimizer {
|
||||
}
|
||||
}
|
||||
|
||||
// Best-trial injection: always evaluate EI at (and around) the best known point.
|
||||
// Prevents the optimizer from "forgetting" a good trial when KDE is too wide.
|
||||
let best_params_opt: Option<Vec<f64>> = self
|
||||
.trials
|
||||
.iter()
|
||||
.min_by(|a, b| {
|
||||
a.objective
|
||||
.partial_cmp(&b.objective)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
})
|
||||
.map(|t| t.params.clone());
|
||||
|
||||
if let Some(best_params) = best_params_opt {
|
||||
let ei = Self::expected_improvement_static(&best_params, &good_refs, &bad_refs, bounds);
|
||||
if ei > best_ei {
|
||||
best_ei = ei;
|
||||
best_candidate = best_params.clone();
|
||||
}
|
||||
// Small perturbations (±5% per dimension) around best trial
|
||||
for _ in 0..5 {
|
||||
let mut perturbed = best_params.clone();
|
||||
for (d, val) in perturbed.iter_mut().enumerate() {
|
||||
if let Some(&(lo, hi)) = bounds.get(d) {
|
||||
let noise = self.rng.gen_range(-0.05..0.05) * (hi - lo);
|
||||
*val = (*val + noise).clamp(lo, hi);
|
||||
}
|
||||
}
|
||||
let ei =
|
||||
Self::expected_improvement_static(&perturbed, &good_refs, &bad_refs, bounds);
|
||||
if ei > best_ei {
|
||||
best_ei = ei;
|
||||
best_candidate = perturbed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
best_candidate
|
||||
}
|
||||
|
||||
@@ -214,8 +250,8 @@ impl TpeOptimizer {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Compute bandwidth via Silverman's rule: h = 1.06 * std * n^(-1/5)
|
||||
let bandwidth = Self::silverman_bandwidth(&values, bounds.get(d).map(|&(lo, hi)| hi - lo));
|
||||
// Scott's rule bandwidth (dimension-aware, tighter in high-D)
|
||||
let bandwidth = Self::scott_bandwidth(&values, bounds.get(d).map(|&(lo, hi)| hi - lo), n_dims);
|
||||
|
||||
// Compute log-PDF at point[d] using log-sum-exp
|
||||
let x = match point.get(d) {
|
||||
@@ -302,11 +338,11 @@ impl TpeOptimizer {
|
||||
|
||||
// --- Private helpers ---
|
||||
|
||||
/// Compute Silverman's bandwidth for a set of 1-D samples.
|
||||
/// Compute KDE bandwidth using Scott's rule (dimension-aware).
|
||||
///
|
||||
/// h = 1.06 * std * n^(-1/5), with a floor of range/100 to avoid
|
||||
/// degenerate zero-bandwidth when all samples are identical.
|
||||
fn silverman_bandwidth(values: &[f64], range: Option<f64>) -> f64 {
|
||||
/// h = 0.7 * σ * n^(-1/(d+4)), tighter than Silverman's in high-D spaces.
|
||||
/// Falls back to range/100 floor when all samples are identical.
|
||||
fn scott_bandwidth(values: &[f64], range: Option<f64>, n_dims: usize) -> f64 {
|
||||
let n = values.len() as f64;
|
||||
if n < 1.0 {
|
||||
return 1.0;
|
||||
@@ -316,7 +352,9 @@ impl TpeOptimizer {
|
||||
let variance = values.iter().map(|&v| (v - mean) * (v - mean)).sum::<f64>() / n;
|
||||
let std_dev = variance.sqrt();
|
||||
|
||||
let bandwidth = 1.06 * std_dev * n.powf(-0.2);
|
||||
// Scott's rule with 0.7 prefactor for tighter exploitation
|
||||
let exponent = -1.0 / (n_dims as f64 + 4.0);
|
||||
let bandwidth = 0.7 * std_dev * n.powf(exponent);
|
||||
|
||||
// Floor: avoid zero bandwidth (when all samples are the same)
|
||||
let floor = match range {
|
||||
@@ -350,7 +388,7 @@ impl TpeOptimizer {
|
||||
///
|
||||
/// For each dimension independently:
|
||||
/// 1. Pick a random sample (kernel center)
|
||||
/// 2. Add Gaussian noise with Silverman bandwidth
|
||||
/// 2. Add Gaussian noise with Scott bandwidth
|
||||
/// 3. Clamp to bounds
|
||||
fn sample_from_kde(&mut self, samples: &[&Vec<f64>], bounds: &[(f64, f64)]) -> Vec<f64> {
|
||||
let n_dims = bounds.len();
|
||||
@@ -380,7 +418,7 @@ impl TpeOptimizer {
|
||||
continue;
|
||||
}
|
||||
|
||||
let bandwidth = Self::silverman_bandwidth(&values, Some(hi - lo));
|
||||
let bandwidth = Self::scott_bandwidth(&values, Some(hi - lo), n_dims);
|
||||
|
||||
// Pick a random kernel center
|
||||
let center_idx = self.rng.gen_range(0..values.len());
|
||||
@@ -643,10 +681,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tpe_silverman_bandwidth() {
|
||||
fn test_tpe_scott_bandwidth() {
|
||||
// Identical values should produce floor bandwidth
|
||||
let values = vec![5.0, 5.0, 5.0];
|
||||
let bw = TpeOptimizer::silverman_bandwidth(&values, Some(10.0));
|
||||
let bw = TpeOptimizer::scott_bandwidth(&values, Some(10.0), 2);
|
||||
assert!(
|
||||
(bw - 0.1).abs() < 1e-10,
|
||||
"Zero-std should use floor bandwidth, got {bw}"
|
||||
@@ -654,7 +692,7 @@ mod tests {
|
||||
|
||||
// Spread values should produce reasonable bandwidth
|
||||
let values2 = vec![0.0, 5.0, 10.0];
|
||||
let bw2 = TpeOptimizer::silverman_bandwidth(&values2, Some(10.0));
|
||||
let bw2 = TpeOptimizer::scott_bandwidth(&values2, Some(10.0), 2);
|
||||
assert!(bw2 > 0.1, "Spread values should have larger bandwidth: {bw2}");
|
||||
assert!(bw2 < 10.0, "Bandwidth should be smaller than range: {bw2}");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user