somatize_runtime/sampler/
bayesian.rs1use 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
8pub struct BayesianSampler {
17 n_trials: usize,
18 n_startup: usize,
19 seed: u64,
20 history: Vec<(HashMap<String, serde_json::Value>, f64)>,
22 gamma: f64,
24}
25
26impl BayesianSampler {
27 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, }
38 }
39
40 pub fn record(&mut self, params: HashMap<String, serde_json::Value>, metric: f64) {
42 self.history.push((params, metric));
43 }
44
45 fn sample_tpe(
47 &self,
48 space: &SearchSpace,
49 trial_index: usize,
50 ) -> HashMap<String, serde_json::Value> {
51 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 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 self.sample_uniform(dim, t)
80 } else {
81 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 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 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 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 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 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 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 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; sampler.record(params, metric);
248 }
249
250 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 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(¶ms, 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 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(¶ms, 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 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}