hyperopt_pruners/successive_halving.rs
1use hyperopt_core::{Pruner, StudyState, Trial};
2
3/// ASHA-style (Asynchronous Successive Halving) pruner — the "advanced" option.
4///
5/// Trials are compared at a ladder of resource **rungs**
6/// `min_resource * reduction_factor^k` (starting at exponent
7/// `min_early_stopping_rate`). When a trial crosses a rung, only the top
8/// `1/reduction_factor` fraction of the trials that reached that rung are
9/// promoted to continue; the rest are pruned. Compared to [`MedianPruner`],
10/// this allocates a shrinking budget across rungs rather than a single
11/// median cut, and is asynchronous (each trial is judged against whichever
12/// peers have reached its rung so far), which suits parallel execution.
13///
14/// Implemented second and treated as the advanced choice; [`MedianPruner`] is
15/// the simpler default.
16#[derive(Debug, Clone)]
17pub struct SuccessiveHalvingPruner {
18 min_resource: usize,
19 reduction_factor: usize,
20 min_early_stopping_rate: u32,
21}
22
23impl Default for SuccessiveHalvingPruner {
24 fn default() -> Self {
25 SuccessiveHalvingPruner {
26 min_resource: 1,
27 reduction_factor: 4,
28 min_early_stopping_rate: 0,
29 }
30 }
31}
32
33impl SuccessiveHalvingPruner {
34 /// Defaults: `min_resource = 1`, `reduction_factor = 4`,
35 /// `min_early_stopping_rate = 0`.
36 pub fn new() -> Self {
37 Self::default()
38 }
39
40 /// Resource (step count) of the first rung.
41 pub fn min_resource(mut self, r: usize) -> Self {
42 self.min_resource = r.max(1);
43 self
44 }
45
46 /// `eta`: the fraction `1/eta` of trials promoted at each rung, and the
47 /// factor by which rung resources grow. Must be `>= 2`.
48 pub fn reduction_factor(mut self, eta: usize) -> Self {
49 self.reduction_factor = eta.max(2);
50 self
51 }
52
53 /// Starting rung exponent — skip the earliest, cheapest rungs.
54 pub fn min_early_stopping_rate(mut self, s: u32) -> Self {
55 self.min_early_stopping_rate = s;
56 self
57 }
58
59 /// The highest rung resource `<= step`, or `None` if `step` hasn't reached
60 /// the first rung yet.
61 fn rung_resource_for(&self, step: usize) -> Option<usize> {
62 let eta = self.reduction_factor as u64;
63 let first = (self.min_resource as u64) * eta.pow(self.min_early_stopping_rate);
64 if (step as u64) < first {
65 return None;
66 }
67 let mut rung = first;
68 loop {
69 let next = rung.saturating_mul(eta);
70 if next <= step as u64 {
71 rung = next;
72 } else {
73 break;
74 }
75 }
76 Some(rung as usize)
77 }
78}
79
80impl Pruner for SuccessiveHalvingPruner {
81 fn should_prune(&self, study_state: &StudyState, trial: &Trial) -> bool {
82 let Some((step, value)) = trial.last_intermediate() else {
83 return false;
84 };
85 let Some(rung) = self.rung_resource_for(step) else {
86 return false;
87 };
88
89 // Peers that have reached this rung (the current trial is not in the
90 // snapshot, so it is counted separately below).
91 let peers = study_state.values_at_or_after(rung);
92 let eta = self.reduction_factor;
93
94 // A rung must be "full enough" before anyone is promoted or cut.
95 if peers.len() + 1 < eta {
96 return false;
97 }
98
99 let direction = study_state.direction();
100 let better_than_current = peers
101 .iter()
102 .filter(|&&p| direction.is_better(p, value))
103 .count();
104
105 // Total trials at this rung including the current one.
106 let total = peers.len() + 1;
107 let top_k = (total / eta).max(1);
108
109 // Promote (keep) if the current trial ranks within the top 1/eta.
110 let rank = better_than_current; // number strictly better than current
111 rank >= top_k
112 }
113}