Skip to main content

somatize_study/sampler/
tpe.rs

1//! Drawn from what already worked.
2
3use super::drawing::{bell, coordinate, settle, span, stream, unit};
4use super::{Random, Sampler};
5use crate::{Dimension, Goal, Point, Setting, Space};
6
7/// Tree-structured Parzen Estimator: model what the good trials did, model what
8/// the bad ones did, and propose where the first is likely and the second is not.
9///
10/// The one that looks at **what already happened**, which is also its one honest
11/// cost: it cannot derive trial 7 from the seed and the index alone, so a study
12/// spread over a folder gets a different search than one in a single process.
13/// That is what being guided means, not a bug to fix.
14#[derive(Debug, Clone, PartialEq)]
15pub struct Tpe {
16    /// Which way is better.
17    pub goal: Goal,
18    /// Draw at random until this many trials have finished. Below two there is
19    /// nothing to split into good and bad, so two is the floor whatever is said.
20    pub startup: usize,
21    /// How many places to consider before proposing one. More is a better
22    /// proposal and costs nothing but arithmetic — no trial is run for it.
23    pub candidates: usize,
24    /// What share of the finished trials counts as good. `0.25` keeps the best
25    /// quarter as the thing to imitate.
26    pub quantile: f64,
27    /// The seed of the draws.
28    pub seed: u64,
29}
30
31impl Tpe {
32    /// The `trial`-th point. Random until `startup`, guided after.
33    pub fn ask(&self, space: &Space, trial: usize, seen: &[(Point, Option<f64>)]) -> Option<Point> {
34        if space.is_empty() {
35            return None;
36        }
37        // A trial that reported nothing comparable says nothing about where to
38        // look: it is dropped rather than counted as terrible.
39        let scored: Vec<(&Point, f64)> = seen
40            .iter()
41            .filter_map(|(point, at)| at.filter(|at| !at.is_nan()).map(|at| (point, at)))
42            .collect();
43        if scored.len() < self.startup.max(2) {
44            return Random { seed: self.seed }.ask(space, trial, seen);
45        }
46
47        let (good, mut bad) = self.split(&scored);
48        // In flight: somebody is trying it and nobody knows how it will do. It
49        // goes in the pile to keep away from — that is *constant liar* — but it
50        // **does not vote on how big the other pile is**. Counted, it would push
51        // the quantile up and promote a trial out of the bad pile; if that trial
52        // sat next to the one in flight, the warning would pull the search
53        // towards it. Measured: one proposal in two hundred became thirty-nine.
54        bad.extend(
55            seen.iter()
56                .filter(|(_, at)| at.is_none())
57                .map(|(point, _)| point),
58        );
59        let mut state = stream(self.seed, trial);
60        let mut best: Option<Point> = None;
61        let mut best_gain = f64::NEG_INFINITY;
62
63        for _ in 0..self.candidates.max(1) {
64            let mut settings = Vec::with_capacity(space.len());
65            let mut gain = 0.0;
66            for (name, dimension) in space.dimensions() {
67                let (setting, said) = propose(
68                    dimension,
69                    &placed(&good, name, dimension),
70                    &placed(&bad, name, dimension),
71                    &mut state,
72                );
73                gain += said;
74                settings.push((name.clone(), setting));
75            }
76            if gain > best_gain {
77                best_gain = gain;
78                best = Some(Point::of(settings));
79            }
80        }
81        best
82    }
83
84    /// The finished trials in two piles: the ones worth imitating and the rest.
85    /// Both piles are non-empty — with everything good there is nothing to
86    /// prefer it to.
87    fn split<'a>(&self, scored: &[(&'a Point, f64)]) -> (Vec<&'a Point>, Vec<&'a Point>) {
88        let mut order: Vec<(&Point, f64)> = scored.to_vec();
89        order.sort_by(|(_, one), (_, other)| match self.goal {
90            Goal::Minimize => one.total_cmp(other),
91            Goal::Maximize => other.total_cmp(one),
92        });
93        let many = (self.quantile.clamp(0.0, 1.0) * order.len() as f64).ceil() as usize;
94        let many = many.clamp(1, order.len() - 1);
95        let (good, bad) = order.split_at(many);
96        (
97            good.iter().map(|(point, _)| *point).collect(),
98            bad.iter().map(|(point, _)| *point).collect(),
99        )
100    }
101}
102
103/// Where on this knob's line each of those trials sat. A trial that has no value
104/// for it, or one of another kind — a point recorded against a space that has
105/// since changed — is simply not there.
106fn placed(points: &[&Point], name: &str, dimension: &Dimension) -> Vec<f64> {
107    points
108        .iter()
109        .filter_map(|point| point.get(name))
110        .filter_map(|setting| coordinate(dimension, setting))
111        .collect()
112}
113
114/// A value for this knob, and how much better the good pile likes it than the
115/// bad one. Summed over the knobs, that is what picks the candidate.
116fn propose(dimension: &Dimension, good: &[f64], bad: &[f64], state: &mut u64) -> (Setting, f64) {
117    if good.is_empty() {
118        // Nothing to imitate for this knob: draw it from the space and let the
119        // others decide the candidate.
120        let (from, to) = span(dimension);
121        return (settle(dimension, from + unit(state) * (to - from)), 0.0);
122    }
123    match dimension {
124        Dimension::Choice(options) => among(options, good, bad, state),
125        _ => along(dimension, good, bad, state),
126    }
127}
128
129/// A knob whose values lie on a line: two Parzen windows, one per pile.
130fn along(dimension: &Dimension, good: &[f64], bad: &[f64], state: &mut u64) -> (Setting, f64) {
131    let (from, to) = span(dimension);
132    let place = drawn_from(good, from, to, state);
133    let gain = density(good, from, to, place).ln() - density(bad, from, to, place).ln();
134    (settle(dimension, place), gain)
135}
136
137/// A knob that is a list of names: counts, with one imaginary observation of
138/// each so an option nobody tried is unlikely rather than impossible.
139fn among(options: &[String], good: &[f64], bad: &[f64], state: &mut u64) -> (Setting, f64) {
140    let tally = |seen: &[f64]| {
141        let mut counts = vec![1.0; options.len()];
142        for &which in seen {
143            counts[(which as usize).min(options.len() - 1)] += 1.0;
144        }
145        let total: f64 = counts.iter().sum();
146        (counts, total)
147    };
148    let (liked, liked_total) = tally(good);
149    let (disliked, disliked_total) = tally(bad);
150
151    let mut left = unit(state) * liked_total;
152    let mut which = options.len() - 1;
153    for (option, count) in liked.iter().enumerate() {
154        if left < *count {
155            which = option;
156            break;
157        }
158        left -= count;
159    }
160
161    let gain = (liked[which] / liked_total).ln() - (disliked[which] / disliked_total).ln();
162    (Setting::Choice(options[which].clone()), gain)
163}
164
165/// One draw from the window the good pile makes: pick an observation and land
166/// near it, or fall back on the prior that keeps the whole range reachable.
167fn drawn_from(values: &[f64], from: f64, to: f64, state: &mut u64) -> f64 {
168    let prior = 1.0 / (values.len() as f64 + 1.0);
169    let place = if unit(state) < prior {
170        bell(state, (from + to) / 2.0, (to - from) / 2.0)
171    } else {
172        let which = ((unit(state) * values.len() as f64) as usize).min(values.len() - 1);
173        bell(state, values[which], width_of(values, to - from))
174    };
175    place.clamp(from, to)
176}
177
178/// How likely that place is under the window those values make. The prior — a
179/// wide bell over the range, weighted as one extra observation — is what stops
180/// three trials declaring the rest of the space impossible.
181fn density(values: &[f64], from: f64, to: f64, place: f64) -> f64 {
182    if values.is_empty() {
183        return bell_at(place, (from + to) / 2.0, (to - from) / 2.0);
184    }
185    let many = values.len() as f64;
186    let prior = 1.0 / (many + 1.0);
187    let width = width_of(values, to - from);
188    let mut how = prior * bell_at(place, (from + to) / 2.0, (to - from) / 2.0);
189    for &value in values {
190        how += (1.0 - prior) / many * bell_at(place, value, width);
191    }
192    how.max(f64::MIN_POSITIVE)
193}
194
195/// How wide each bell is: Scott's rule, floored so identical observations do not
196/// make a spike of zero width and ceilinged at the range so a single one does
197/// not flatten into nothing.
198fn width_of(values: &[f64], span: f64) -> f64 {
199    let many = values.len() as f64;
200    let mean = values.iter().sum::<f64>() / many;
201    let spread = (values.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / many).sqrt();
202    (1.06 * spread * many.powf(-0.2)).clamp(span / 100.0, span)
203}
204
205/// The height of a bell of that width, centred there.
206fn bell_at(place: f64, centre: f64, width: f64) -> f64 {
207    let from_centre = (place - centre) / width;
208    (-0.5 * from_centre * from_centre).exp() / (width * std::f64::consts::TAU.sqrt())
209}
210
211impl From<Tpe> for Sampler {
212    fn from(how: Tpe) -> Self {
213        Self::Tpe(how)
214    }
215}