feat(hyperopt): implement Successive Halving early stopping strategy

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-02-21 11:54:17 +01:00
parent 4ff4205a4b
commit 41afb20b37

View File

@@ -1056,10 +1056,22 @@ impl EarlyStoppingObserver {
let strategy = PercentilePrunerStrategy::new(*percentile, *warmup_steps);
strategy.should_stop(epoch, val_loss, &self.trial_best_losses)
},
EarlyStoppingStrategy::SuccessiveHalving { .. } => {
// TODO: Implement successive halving
warn!("SuccessiveHalving not yet implemented, defaulting to Continue");
false
EarlyStoppingStrategy::SuccessiveHalving { reduction_factor } => {
// Need at least reduction_factor completed trials for meaningful comparison
if self.trial_best_losses.len() < *reduction_factor {
return false;
}
// Sort completed trial losses ascending (NaN-safe)
let mut sorted = self.trial_best_losses.clone();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
// Keep top 1/eta fraction, prune the rest
let keep_count = (sorted.len() / reduction_factor).max(1);
let threshold = sorted[keep_count - 1];
// Prune if current trial is worse than the threshold
val_loss > threshold
},
EarlyStoppingStrategy::Hyperband { .. } => {
// TODO: Implement hyperband
@@ -1216,4 +1228,86 @@ mod tests {
// Above 25th percentile (0.3 < 0.4, so not pruned)
assert!(!strategy.should_stop(5, 0.3, &history));
}
#[test]
fn test_successive_halving_prunes_bottom_trials() {
// 9 completed trials with losses 0.1..=0.9, reduction_factor=3
// keep_count = max(1, 9/3) = 3
// sorted: [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9]
// threshold = sorted[2] = 0.3
// New trial with val_loss=0.5 > 0.3 => should be pruned (StopTrial)
let config = EarlyStoppingConfig {
patience_epochs: 10,
min_delta: 1e-4,
min_epochs: 0,
strategy: EarlyStoppingStrategy::SuccessiveHalving {
reduction_factor: 3,
},
..Default::default()
};
let mut observer = EarlyStoppingObserver::new(config);
// Register 9 completed trials
for i in 0..9 {
observer.on_trial_start(i, &format!("trial_{}", i));
let loss = (i as f64 + 1.0) / 10.0; // 0.1, 0.2, ..., 0.9
observer.on_trial_complete(i, loss);
}
// Start a new trial (trial 9) with a bad loss
observer.on_trial_start(9, "trial_9");
let metrics = EpochMetrics {
epoch: 1,
train_loss: 0.5,
val_loss: 0.5, // worse than threshold 0.3
timestamp: 1.0,
};
let decision = observer.on_epoch_complete(9, 1, &metrics);
assert_eq!(
decision,
ObserverDecision::StopTrial,
"Trial with val_loss=0.5 should be pruned (threshold=0.3)"
);
}
#[test]
fn test_successive_halving_keeps_top_trials() {
// 9 completed trials with losses 0.1..=0.9, reduction_factor=3
// keep_count = max(1, 9/3) = 3
// sorted: [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9]
// threshold = sorted[2] = 0.3
// New trial with val_loss=0.2 <= 0.3 => should continue
let config = EarlyStoppingConfig {
patience_epochs: 10,
min_delta: 1e-4,
min_epochs: 0,
strategy: EarlyStoppingStrategy::SuccessiveHalving {
reduction_factor: 3,
},
..Default::default()
};
let mut observer = EarlyStoppingObserver::new(config);
// Register 9 completed trials
for i in 0..9 {
observer.on_trial_start(i, &format!("trial_{}", i));
let loss = (i as f64 + 1.0) / 10.0; // 0.1, 0.2, ..., 0.9
observer.on_trial_complete(i, loss);
}
// Start a new trial (trial 9) with a good loss
observer.on_trial_start(9, "trial_9");
let metrics = EpochMetrics {
epoch: 1,
train_loss: 0.2,
val_loss: 0.2, // within top 1/3 (threshold=0.3)
timestamp: 1.0,
};
let decision = observer.on_epoch_complete(9, 1, &metrics);
assert_eq!(
decision,
ObserverDecision::Continue,
"Trial with val_loss=0.2 should continue (threshold=0.3)"
);
}
}