Skip to main content

somatize_study/pruner/
percentile.rs

1//! Judged against the trials that already finished.
2
3use super::judging::{latest, not_a_number, quantile};
4use super::{Pruner, Reason, Verdict};
5use crate::Goal;
6use std::fmt;
7
8/// Prune what is doing worse at this step than `p` percent of the others.
9///
10/// **The median pruner is `p = 50`**, which is why there is no `Median` scheme —
11/// [`median`](Percentile::median) is a constructor. What is compared is each
12/// trial's **best so far** and not its latest: one bad epoch is noise.
13#[derive(Debug, Clone, PartialEq)]
14pub struct Percentile {
15    /// Between `0` and `100`, and **the share that is kept** — a smaller one
16    /// prunes more, the way round optuna reads it. At `50` the better half
17    /// survives; at `0` everything but the best does not.
18    pub p: f64,
19    /// Which way is better.
20    pub goal: Goal,
21    /// No verdict before this many reports, however bad it looks. What buys a
22    /// slow starter the epochs it needs.
23    pub warmup: usize,
24    /// No verdict until this many other trials have reached this step. Without
25    /// it the first trial to finish becomes the bar for everybody.
26    pub startup: usize,
27}
28
29impl Percentile {
30    /// The median: `p = 50`.
31    pub fn median(goal: Goal, warmup: usize, startup: usize) -> Self {
32        Self {
33            p: 50.0,
34            goal,
35            warmup,
36            startup,
37        }
38    }
39
40    /// Continue, or the reason not to.
41    pub fn verdict(&self, mine: &[f64], others: &[Vec<f64>]) -> Verdict {
42        let Some((at, value)) = latest(mine) else {
43            return Verdict::Continue;
44        };
45        if let Some(why) = not_a_number(at, value) {
46            return Verdict::Prune(why);
47        }
48        if mine.len() <= self.warmup {
49            return Verdict::Continue;
50        }
51
52        // Only the ones that got this far: a trial that stopped at step 2 says
53        // nothing about what is good at step 7.
54        let mut bar: Vec<f64> = others
55            .iter()
56            .filter(|curve| curve.len() > at)
57            .filter_map(|curve| self.goal.best_of(&curve[..=at]))
58            .collect();
59        if bar.len() < self.startup.max(1) {
60            return Verdict::Continue;
61        }
62        bar.sort_by(|one, other| one.total_cmp(other));
63
64        // From the good end, whichever end that is: at `p = 50` both readings
65        // are the median, and the two directions stay symmetric away from it.
66        let bar = quantile(
67            &bar,
68            match self.goal {
69                Goal::Minimize => self.p / 100.0,
70                Goal::Maximize => 1.0 - self.p / 100.0,
71            },
72        );
73
74        match self.goal.best_of(mine) {
75            Some(best) if self.goal.better(bar, best) => {
76                Verdict::Prune(Reason::Worse { than: bar, at })
77            }
78            _ => Verdict::Continue,
79        }
80    }
81}
82
83impl fmt::Display for Percentile {
84    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85        write!(
86            f,
87            "percentile:{}:{}:warmup:{}:startup:{}",
88            self.p, self.goal, self.warmup, self.startup
89        )
90    }
91}
92
93impl From<Percentile> for Pruner {
94    fn from(rule: Percentile) -> Self {
95        Self::Percentile(rule)
96    }
97}