feat(hyperopt): implement Successive Halving early stopping strategy
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user