diff --git a/crates/ml-hyperopt/src/optimizer.rs b/crates/ml-hyperopt/src/optimizer.rs index d8ec488a5..c72e7903e 100644 --- a/crates/ml-hyperopt/src/optimizer.rs +++ b/crates/ml-hyperopt/src/optimizer.rs @@ -354,12 +354,15 @@ impl ArgminOptimizer { let trials_used = trials.len(); let remaining_trials = self.max_trials.saturating_sub(trials_used); - // For small budgets (< 2× swarm size), PSO can't complete a full iteration. - // Fall back to additional LHS samples which give better space coverage. + // Algorithm selection by budget: + // - Budget < 2× swarm → LHS (space-filling, optimal for small budgets) + // - Budget ≥ 2× swarm → PSO (swarm optimization, needs full iterations) + // PSO with a partial swarm is worse than random — 75% of particles + // have no cost value, so gbest is computed from incomplete data. if remaining_trials < self.n_particles * 2 { info!( - "Budget too small for PSO ({} remaining < {} particles × 2). Using additional LHS.", - remaining_trials, self.n_particles + "Using LHS for remaining {} trials (budget < {} particles × 2 = need PSO budget ≥ {})", + remaining_trials, self.n_particles, self.n_particles * 2 ); let extra_samples = Self::latin_hypercube_sampling(remaining_trials, &bounds, &mut rng); let mut model_guard = cost_fn.model.lock().map_err(|e| anyhow::anyhow!("lock: {e}"))?; @@ -379,7 +382,7 @@ impl ArgminOptimizer { let max_iters_by_budget = ((remaining_trials as f64) / (self.n_particles as f64)).ceil() as usize; let max_iters = if remaining_trials < self.n_particles * 2 { - 0 // Already handled by LHS fallback above + 0 // LHS already handled above } else { max_iters_by_budget.min(self.max_iters_per_restart) };