diff --git a/ml/src/hyperopt/early_stopping.rs b/ml/src/hyperopt/early_stopping.rs index bdf47c133..2e2fbf22c 100644 --- a/ml/src/hyperopt/early_stopping.rs +++ b/ml/src/hyperopt/early_stopping.rs @@ -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)" + ); + } }