somatize_study/pruner/mod.rs
1//! Whether a trial that is going badly is worth another epoch.
2//!
3//! Each scheme is a type of its own with its own `verdict`, and [`Pruner`] is the
4//! family for when the scheme arrives as data.
5//!
6//! | scheme | judged against | needs other trials |
7//! |---|---|---|
8//! | [`Percentile`] | **the others** at the same step | yes |
9//! | [`Threshold`] | **a constant** you already know is hopeless | no |
10//! | [`Patience`] | **itself**: it has stopped improving | no |
11//!
12//! `Median` is not a fourth: it is [`Percentile`] with `p = 50`. Successive
13//! halving and Hyperband are deliberately not here — they are not verdicts on a
14//! trial but a way of handing budget out across the whole population, which is
15//! the shape of the loop, and the loop belongs to whoever writes it.
16//!
17//! A pruner does not stop anything. It answers, and **the loop stops calling**:
18//!
19//! ```python
20//! for epoch in range(50):
21//! reported.append(trainer.fit(data, epochs=1).loss)
22//! if why := pruner.verdict(reported, finished):
23//! break
24//! ```
25//!
26//! So this adds **zero lines to level 2**. A trainer that had to be told to stop
27//! would be a callback crossing the boundary.
28
29mod judging;
30mod patience;
31mod percentile;
32mod threshold;
33
34pub use patience::Patience;
35pub use percentile::Percentile;
36pub use threshold::Threshold;
37
38use std::fmt;
39
40/// What a pruner answers.
41#[derive(Debug, Clone, PartialEq)]
42pub enum Verdict {
43 /// Worth another epoch.
44 Continue,
45 /// Not, and why.
46 Prune(Reason),
47}
48
49impl Verdict {
50 /// Whether this trial is to be dropped.
51 pub fn is_prune(&self) -> bool {
52 matches!(self, Self::Prune(_))
53 }
54
55 /// Why, or `None` if it is to carry on.
56 pub fn reason(&self) -> Option<&Reason> {
57 match self {
58 Self::Continue => None,
59 Self::Prune(why) => Some(why),
60 }
61 }
62}
63
64/// Why a trial is not worth another epoch. Structured and not a string, because
65/// *how many were pruned, and for which of the three reasons* is the question
66/// you ask of a search that pruned too much.
67#[derive(Debug, Clone, PartialEq)]
68pub enum Reason {
69 /// It reported something that is not a number. Every scheme prunes this.
70 NotANumber {
71 /// Which report.
72 at: usize,
73 },
74 /// Worse at this step than the bar the others set.
75 Worse {
76 /// The bar it did not clear.
77 than: f64,
78 /// Which report.
79 at: usize,
80 },
81 /// Outside the bounds that were declared hopeless.
82 OutOfBounds {
83 /// What it reported.
84 value: f64,
85 /// The bound it crossed.
86 bound: f64,
87 },
88 /// It has not improved on its own best for long enough.
89 NotImproving {
90 /// The report its best is from.
91 since: usize,
92 /// How many without an improvement were allowed.
93 steps: usize,
94 },
95}
96
97impl fmt::Display for Reason {
98 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99 match self {
100 Self::NotANumber { at } => {
101 write!(f, "report {at} is not a number: this one diverged")
102 }
103 Self::Worse { than, at } => write!(
104 f,
105 "at report {at} it is behind the bar the finished trials set, {than}"
106 ),
107 Self::OutOfBounds { value, bound } => {
108 write!(
109 f,
110 "{value} is past the bound {bound} that was called hopeless"
111 )
112 }
113 Self::NotImproving { since, steps } => write!(
114 f,
115 "its best is still the one from report {since}, and {steps} without an \
116 improvement was the allowance"
117 ),
118 }
119 }
120}
121
122/// Whichever of the schemes a pruner is.
123#[derive(Debug, Clone, PartialEq)]
124pub enum Pruner {
125 /// [`Percentile`]: behind the others.
126 Percentile(Percentile),
127 /// [`Threshold`]: past a bound.
128 Threshold(Threshold),
129 /// [`Patience`]: going nowhere.
130 Patience(Patience),
131}
132
133impl Pruner {
134 /// Continue, or the reason not to. `mine` is what this trial has reported so
135 /// far, in order; `others` the same for those that finished. A step is **the
136 /// n-th report**, so trials have to report on the same schedule — true of
137 /// every pruner that compares across trials, optuna's included.
138 pub fn verdict(&self, mine: &[f64], others: &[Vec<f64>]) -> Verdict {
139 match self {
140 Self::Percentile(rule) => rule.verdict(mine, others),
141 Self::Threshold(rule) => rule.verdict(mine, others),
142 Self::Patience(rule) => rule.verdict(mine, others),
143 }
144 }
145}
146
147impl fmt::Display for Pruner {
148 /// As text, which is the form that goes into the record of a run. Written by
149 /// the scheme itself, because the name belongs with the thing it names.
150 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
151 match self {
152 Self::Percentile(rule) => rule.fmt(f),
153 Self::Threshold(rule) => rule.fmt(f),
154 Self::Patience(rule) => rule.fmt(f),
155 }
156 }
157}