Skip to main content

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}