Skip to main content

somatize_runtime/sampler/
bayesian.rs

1//! [`BayesianSampler`] — simplified TPE (Tree-Parzen Estimator).
2
3use crate::sampler::{Sampler, hash_u64, pseudo_random, sample_float};
4use somatize_core::error::Result;
5use somatize_core::search::{SearchDimension, SearchSpace};
6use std::collections::HashMap;
7
8/// Bayesian optimization sampler using Tree-Parzen Estimator (TPE).
9///
10/// For the first `n_startup` trials, samples randomly. After that,
11/// uses the history of (params, metric) to model "good" vs "bad"
12/// parameter distributions and samples from the "good" distribution.
13///
14/// This is a simplified TPE: it splits trials into top/bottom quantiles
15/// and samples from the top quantile's parameter distributions.
16pub struct BayesianSampler {
17    n_trials: usize,
18    n_startup: usize,
19    seed: u64,
20    /// History: (params, metric_value) for completed trials.
21    history: Vec<(HashMap<String, serde_json::Value>, f64)>,
22    /// Quantile split: top gamma fraction is "good".
23    gamma: f64,
24}
25
26impl BayesianSampler {
27    /// A TPE sampler producing `n_trials` configurations, the first
28    /// `n_startup` (floored at 2 — TPE needs history to split) sampled
29    /// randomly. `None` seed defaults to 42, keeping runs reproducible.
30    pub fn new(n_trials: usize, n_startup: usize, seed: Option<u64>) -> Self {
31        Self {
32            n_trials,
33            n_startup: n_startup.max(2),
34            seed: seed.unwrap_or(42),
35            history: Vec::new(),
36            gamma: 0.25, // top 25% are "good"
37        }
38    }
39
40    /// Record a completed trial's result (for informing future samples).
41    pub fn record(&mut self, params: HashMap<String, serde_json::Value>, metric: f64) {
42        self.history.push((params, metric));
43    }
44
45    /// Sample using TPE: bias towards parameters seen in "good" trials.
46    fn sample_tpe(
47        &self,
48        space: &SearchSpace,
49        trial_index: usize,
50    ) -> HashMap<String, serde_json::Value> {
51        // Split history into good/bad by quantile
52        let mut sorted_history: Vec<(usize, f64)> = self
53            .history
54            .iter()
55            .enumerate()
56            .map(|(i, (_, v))| (i, *v))
57            .collect();
58        sorted_history.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
59
60        let n_good = (self.history.len() as f64 * self.gamma).ceil() as usize;
61        let n_good = n_good.max(1).min(self.history.len());
62        let good_indices: Vec<usize> = sorted_history[..n_good].iter().map(|(i, _)| *i).collect();
63
64        let mut params = HashMap::new();
65        for (dim_idx, dim) in space.active_dimensions().iter().enumerate() {
66            let rng_state = hash_u64(self.seed, trial_index as u64, dim_idx as u64);
67            let t = pseudo_random(rng_state);
68
69            // With 80% probability, sample near good trials' values for this dim.
70            // With 20% probability, sample uniformly (exploration).
71            let explore_prob = pseudo_random(hash_u64(
72                self.seed,
73                trial_index as u64,
74                dim_idx as u64 + 1000,
75            ));
76
77            let value = if explore_prob < 0.2 || good_indices.is_empty() {
78                // Explore: sample uniformly
79                self.sample_uniform(dim, t)
80            } else {
81                // Exploit: sample near a good trial's value
82                let good_idx = good_indices
83                    [((t * good_indices.len() as f64) as usize).min(good_indices.len() - 1)];
84                let good_params = &self.history[good_idx].0;
85
86                if let Some(good_val) = good_params.get(dim.name()) {
87                    self.sample_near(dim, good_val, rng_state)
88                } else {
89                    self.sample_uniform(dim, t)
90                }
91            };
92
93            params.insert(dim.name().to_string(), value);
94        }
95
96        params
97    }
98
99    fn sample_uniform(&self, dim: &SearchDimension, t: f64) -> serde_json::Value {
100        match dim {
101            SearchDimension::Float {
102                low, high, scale, ..
103            } => {
104                serde_json::json!(sample_float(*low, *high, *scale, t))
105            }
106            SearchDimension::Int { low, high, .. } => {
107                let range = (*high - *low + 1) as f64;
108                let val = *low + (t * range).floor() as i64;
109                serde_json::json!(val.min(*high))
110            }
111            SearchDimension::Categorical { choices, .. } => {
112                let idx = (t * choices.len() as f64).floor() as usize;
113                choices[idx.min(choices.len() - 1)].clone()
114            }
115            _ => serde_json::Value::Null,
116        }
117    }
118
119    /// Sample near a "good" value with gaussian-like perturbation.
120    fn sample_near(
121        &self,
122        dim: &SearchDimension,
123        center: &serde_json::Value,
124        rng_state: u64,
125    ) -> serde_json::Value {
126        let t = pseudo_random(hash_u64(rng_state, 777, 0));
127        let perturbation = (pseudo_random(hash_u64(rng_state, 888, 0)) - 0.5) * 0.3;
128
129        match dim {
130            SearchDimension::Float { low, high, .. } => {
131                if let Some(center_val) = center.as_f64() {
132                    let range = *high - *low;
133                    let new_val = (center_val + perturbation * range).clamp(*low, *high);
134                    serde_json::json!(new_val)
135                } else {
136                    self.sample_uniform(dim, t)
137                }
138            }
139            SearchDimension::Int { low, high, .. } => {
140                if let Some(center_val) = center.as_i64() {
141                    let range = (*high - *low) as f64;
142                    let new_val = (center_val as f64 + perturbation * range).round() as i64;
143                    serde_json::json!(new_val.clamp(*low, *high))
144                } else {
145                    self.sample_uniform(dim, t)
146                }
147            }
148            SearchDimension::Categorical { choices, .. } => {
149                // For categorical: mostly keep the good value, sometimes explore
150                if perturbation.abs() < 0.1 {
151                    center.clone()
152                } else {
153                    let idx = (t * choices.len() as f64).floor() as usize;
154                    choices[idx.min(choices.len() - 1)].clone()
155                }
156            }
157            _ => serde_json::Value::Null,
158        }
159    }
160}
161
162impl Sampler for BayesianSampler {
163    fn sample(
164        &mut self,
165        space: &SearchSpace,
166        trial_index: usize,
167    ) -> Result<Option<HashMap<String, serde_json::Value>>> {
168        if trial_index >= self.n_trials {
169            return Ok(None);
170        }
171
172        if trial_index < self.n_startup || self.history.is_empty() {
173            // Random startup phase
174            let mut params = HashMap::new();
175            for (i, dim) in space.active_dimensions().iter().enumerate() {
176                let rng_state = hash_u64(self.seed, trial_index as u64, i as u64);
177                let t = pseudo_random(rng_state);
178                params.insert(dim.name().to_string(), self.sample_uniform(dim, t));
179            }
180            Ok(Some(params))
181        } else {
182            Ok(Some(self.sample_tpe(space, trial_index)))
183        }
184    }
185
186    fn n_trials(&self) -> Option<usize> {
187        Some(self.n_trials)
188    }
189
190    /// Completed-trial feedback — this is what makes TPE model-based
191    /// instead of degenerating to random search.
192    fn record_result(&mut self, params: &HashMap<String, serde_json::Value>, value: f64) {
193        self.record(params.clone(), value);
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200    use somatize_core::search::Scale;
201
202    fn sample_space() -> SearchSpace {
203        let mut space = SearchSpace::new();
204        space.add(SearchDimension::Float {
205            name: "lr".into(),
206            low: 0.001,
207            high: 0.1,
208            scale: Scale::Log,
209            default: None,
210        });
211        space.add(SearchDimension::Categorical {
212            name: "kernel".into(),
213            choices: vec![serde_json::json!("rbf"), serde_json::json!("linear")],
214        });
215        space
216    }
217
218    #[test]
219    fn startup_phase_is_random() {
220        let mut sampler = BayesianSampler::new(20, 5, Some(42));
221        let space = sample_space();
222
223        // First 5 trials should all produce different params (random)
224        let mut samples = Vec::new();
225        for i in 0..5 {
226            let params = sampler.sample(&space, i).unwrap().unwrap();
227            assert!(params.contains_key("lr"));
228            assert!(params.contains_key("kernel"));
229            samples.push(params);
230        }
231
232        // Check they're not all identical
233        let lrs: Vec<f64> = samples.iter().map(|p| p["lr"].as_f64().unwrap()).collect();
234        assert!(lrs.windows(2).any(|w| (w[0] - w[1]).abs() > 1e-10));
235    }
236
237    #[test]
238    fn tpe_phase_after_recording_history() {
239        let mut sampler = BayesianSampler::new(20, 3, Some(42));
240        let space = sample_space();
241
242        // Record some history
243        for i in 0..5 {
244            let params = sampler.sample(&space, i).unwrap().unwrap();
245            let lr = params["lr"].as_f64().unwrap();
246            let metric = 1.0 - (lr - 0.01).abs() * 10.0; // best at lr=0.01
247            sampler.record(params, metric);
248        }
249
250        // Now sample in TPE mode (trial_index >= n_startup)
251        let params = sampler.sample(&space, 5).unwrap().unwrap();
252        assert!(params.contains_key("lr"));
253        let lr = params["lr"].as_f64().unwrap();
254        assert!((0.001..=0.1).contains(&lr));
255    }
256
257    #[test]
258    fn record_result_trait_method_feeds_the_model() {
259        // Through &mut dyn Sampler — the exact call path StudyRunner
260        // uses. A sampler whose history grew must sample differently
261        // from an identical one with no history at the same index.
262        use crate::sampler::Sampler as _;
263
264        let space = sample_space();
265        let mut fed = BayesianSampler::new(40, 3, Some(42));
266        let mut unfed = BayesianSampler::new(40, 3, Some(42));
267
268        {
269            let as_dyn: &mut dyn crate::sampler::Sampler = &mut fed;
270            for i in 0..10 {
271                let params = as_dyn.sample(&space, i).unwrap().unwrap();
272                let lr = params["lr"].as_f64().unwrap();
273                as_dyn.record_result(&params, 1.0 - (lr - 0.01).abs() * 10.0);
274            }
275        }
276        let with_history = fed.sample(&space, 15).unwrap().unwrap();
277        let without_history = unfed.sample(&space, 15).unwrap().unwrap();
278        assert_ne!(
279            with_history["lr"], without_history["lr"],
280            "history received via the trait method must change sampling"
281        );
282    }
283
284    #[test]
285    fn tpe_actually_biases_towards_good_regions() {
286        // Feed 20 observations peaked at lr = 0.01, then draw 20 TPE
287        // samples and compare against a no-history control: the median
288        // distance to the optimum must shrink. Deterministic (seeded).
289        use crate::sampler::Sampler as _;
290
291        let space = sample_space();
292        let mut tpe = BayesianSampler::new(200, 3, Some(7));
293        let mut control = BayesianSampler::new(200, 3, Some(7));
294
295        for i in 0..20 {
296            let params = tpe.sample(&space, i).unwrap().unwrap();
297            let lr = params["lr"].as_f64().unwrap();
298            tpe.record_result(&params, 1.0 - (lr - 0.01).abs() * 10.0);
299        }
300
301        let median_dist = |s: &mut BayesianSampler| -> f64 {
302            let mut dists: Vec<f64> = (100..120)
303                .map(|i| {
304                    let p = s.sample(&space, i).unwrap().unwrap();
305                    (p["lr"].as_f64().unwrap() - 0.01).abs()
306                })
307                .collect();
308            dists.sort_by(|a, b| a.partial_cmp(b).unwrap());
309            dists[dists.len() / 2]
310        };
311
312        let tpe_median = median_dist(&mut tpe);
313        // Control has an empty history → falls back to random sampling.
314        let control_median = median_dist(&mut control);
315        assert!(
316            tpe_median < control_median,
317            "TPE median distance {tpe_median:.4} must beat random {control_median:.4}"
318        );
319    }
320
321    #[test]
322    fn respects_n_trials_limit() {
323        let mut sampler = BayesianSampler::new(10, 3, Some(42));
324        let space = sample_space();
325
326        for i in 0..15 {
327            let result = sampler.sample(&space, i).unwrap();
328            if i < 10 {
329                assert!(result.is_some());
330            } else {
331                assert!(result.is_none());
332            }
333        }
334    }
335
336    #[test]
337    fn deterministic_with_seed() {
338        let space = sample_space();
339
340        let mut s1 = BayesianSampler::new(10, 3, Some(42));
341        let mut s2 = BayesianSampler::new(10, 3, Some(42));
342
343        for i in 0..5 {
344            let p1 = s1.sample(&space, i).unwrap().unwrap();
345            let p2 = s2.sample(&space, i).unwrap().unwrap();
346            assert_eq!(p1, p2);
347        }
348    }
349
350    #[test]
351    fn different_seeds_differ() {
352        let space = sample_space();
353
354        let mut s1 = BayesianSampler::new(10, 3, Some(42));
355        let mut s2 = BayesianSampler::new(10, 3, Some(99));
356
357        let p1 = s1.sample(&space, 0).unwrap().unwrap();
358        let p2 = s2.sample(&space, 0).unwrap().unwrap();
359        assert_ne!(p1["lr"], p2["lr"]);
360    }
361}