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}