fix(hyperopt): LHS fallback when budget < 2× swarm size

With 10 trials and n_initial=5, PSO got 5 remaining evals for a
20-particle swarm. Only 5 of 20 particles were evaluated before the
budget observer killed the run — 75% of the swarm had no cost value.
PSO can't compute a proper gbest from partial data.

Fix: when remaining_trials < n_particles × 2, skip PSO entirely and
use additional LHS samples instead. LHS gives better space coverage
than an incomplete swarm iteration.

For 10 trials: 5 initial LHS + 5 additional LHS = 10 independent
space-filling samples. Much better than 5 LHS + 5 broken PSO particles.

PSO still runs when budget is sufficient (≥40 remaining for 20 particles).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-03-29 19:12:47 +02:00
parent ad4e22e87d
commit 3e1f3fd0cc

View File

@@ -354,15 +354,35 @@ impl ArgminOptimizer {
let trials_used = trials.len();
let remaining_trials = self.max_trials.saturating_sub(trials_used);
// FIX: Each PSO iteration evaluates n_particles candidates (sequentially via mutex)
// PSO evaluates ALL particles in swarm per iteration, so divide remaining budget
// by swarm size to prevent trial count overflow (fixes 962 trial bug)
// CRITICAL FIX (2025-11-07): Use CEILING division to ensure all trials complete
// Example: 8 remaining ÷ 20 particles = 0.4 → ceil to 1 iteration (not 0)
// For small budgets (< 2× swarm size), PSO can't complete a full iteration.
// Fall back to additional LHS samples which give better space coverage.
if remaining_trials < self.n_particles * 2 {
info!(
"Budget too small for PSO ({} remaining < {} particles × 2). Using additional LHS.",
remaining_trials, self.n_particles
);
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}"))?;
for i in 0..remaining_trials {
let continuous_vec: Vec<f64> = extra_samples.row(i).to_vec();
Self::evaluate_point(
&continuous_vec,
&mut *model_guard,
&trial_results,
&trial_counter,
&param_names,
)?;
}
drop(model_guard);
}
let max_iters_by_budget =
((remaining_trials as f64) / (self.n_particles as f64)).ceil() as usize;
let max_iters = max_iters_by_budget.min(self.max_iters_per_restart);
let max_iters = if remaining_trials < self.n_particles * 2 {
0 // Already handled by LHS fallback above
} else {
max_iters_by_budget.min(self.max_iters_per_restart)
};
info!(
"PSO Budget: {} iterations ({} remaining trials ÷ {} particles = {} max iters)",