fugue_evo/hyperparameter/
bayesian.rs1use rand::Rng;
23use rand_distr::{Beta, Distribution, Gamma};
24
25use crate::operators::mutation::{
26 BitFlipMutation, GaussianMutation, PolynomialMutation, UniformMutation,
27};
28
29#[derive(Clone, Debug)]
36pub struct BetaPosterior {
37 pub alpha: f64,
39 pub beta: f64,
41 pub alpha0: f64,
43 pub beta0: f64,
45}
46
47impl BetaPosterior {
48 pub fn uniform() -> Self {
50 Self::new(1.0, 1.0)
51 }
52
53 pub fn jeffreys() -> Self {
55 Self::new(0.5, 0.5)
56 }
57
58 pub fn new(alpha: f64, beta: f64) -> Self {
60 Self {
61 alpha,
62 beta,
63 alpha0: alpha,
64 beta0: beta,
65 }
66 }
67
68 pub fn observe_success(&mut self) {
70 self.alpha += 1.0;
71 }
72
73 pub fn observe_failure(&mut self) {
75 self.beta += 1.0;
76 }
77
78 pub fn observe(&mut self, success: bool) {
80 if success {
81 self.observe_success();
82 } else {
83 self.observe_failure();
84 }
85 }
86
87 pub fn mean(&self) -> f64 {
89 self.alpha / (self.alpha + self.beta)
90 }
91
92 pub fn mode(&self) -> Option<f64> {
94 if self.alpha > 1.0 && self.beta > 1.0 {
95 Some((self.alpha - 1.0) / (self.alpha + self.beta - 2.0))
96 } else {
97 None
98 }
99 }
100
101 pub fn variance(&self) -> f64 {
103 let sum = self.alpha + self.beta;
104 (self.alpha * self.beta) / (sum * sum * (sum + 1.0))
105 }
106
107 pub fn std_dev(&self) -> f64 {
109 self.variance().sqrt()
110 }
111
112 pub fn sample<R: Rng>(&self, rng: &mut R) -> f64 {
114 Beta::new(self.alpha, self.beta)
115 .expect("Invalid Beta parameters")
116 .sample(rng)
117 }
118
119 pub fn credible_interval(&self, probability: f64) -> (f64, f64) {
121 let mean = self.mean();
122 let std = self.std_dev();
123 let z = normal_quantile((1.0 + probability) / 2.0);
124 let lower = (mean - z * std).max(0.0);
125 let upper = (mean + z * std).min(1.0);
126 (lower, upper)
127 }
128
129 pub fn observations(&self) -> f64 {
134 (self.alpha - self.alpha0) + (self.beta - self.beta0)
135 }
136
137 pub fn decay(&mut self, factor: f64) {
139 self.alpha = self.alpha0 + factor * (self.alpha - self.alpha0);
140 self.beta = self.beta0 + factor * (self.beta - self.beta0);
141 }
142}
143
144impl Default for BetaPosterior {
145 fn default() -> Self {
146 Self::uniform()
147 }
148}
149
150#[derive(Clone, Debug)]
167pub struct GammaPosterior {
168 pub shape: f64,
170 pub rate: f64,
172 pub shape0: f64,
174 pub rate0: f64,
176}
177
178impl GammaPosterior {
179 pub fn vague() -> Self {
181 Self::new(1.0, 0.01)
182 }
183
184 pub fn new(shape: f64, rate: f64) -> Self {
186 Self {
187 shape,
188 rate,
189 shape0: shape,
190 rate0: rate,
191 }
192 }
193
194 pub fn observe(&mut self, value: f64) {
196 self.shape += 1.0;
197 self.rate += value;
198 }
199
200 pub fn mean(&self) -> f64 {
202 self.shape / self.rate
203 }
204
205 pub fn posterior_mean_of_mean(&self) -> Option<f64> {
211 if self.shape > 1.0 {
212 Some(self.rate / (self.shape - 1.0))
213 } else {
214 None
215 }
216 }
217
218 pub fn mode(&self) -> Option<f64> {
220 if self.shape >= 1.0 {
221 Some((self.shape - 1.0) / self.rate)
222 } else {
223 None
224 }
225 }
226
227 pub fn variance(&self) -> f64 {
229 self.shape / (self.rate * self.rate)
230 }
231
232 pub fn observations(&self) -> f64 {
234 self.shape - self.shape0
235 }
236
237 pub fn sample<R: Rng>(&self, rng: &mut R) -> f64 {
239 Gamma::new(self.shape, 1.0 / self.rate)
240 .expect("Invalid Gamma parameters")
241 .sample(rng)
242 }
243
244 pub fn decay(&mut self, factor: f64) {
246 self.shape = self.shape0 + factor * (self.shape - self.shape0);
247 self.rate = self.rate0 + factor * (self.rate - self.rate0);
248 }
249}
250
251impl Default for GammaPosterior {
252 fn default() -> Self {
253 Self::vague()
254 }
255}
256
257#[derive(Clone, Debug, Default)]
268pub struct RunningLogMoments {
269 mean_log: f64,
271 m2: f64,
273 n: usize,
275}
276
277impl RunningLogMoments {
278 pub fn new() -> Self {
280 Self::default()
281 }
282
283 pub fn observe(&mut self, x: f64) {
285 if x <= 0.0 {
286 return;
287 }
288 let log_x = x.ln();
289 self.n += 1;
290 let delta = log_x - self.mean_log;
291 self.mean_log += delta / self.n as f64;
292 let delta2 = log_x - self.mean_log;
293 self.m2 += delta * delta2;
294 }
295
296 pub fn count(&self) -> usize {
298 self.n
299 }
300
301 pub fn mean_log(&self) -> f64 {
303 self.mean_log
304 }
305
306 pub fn var_log(&self) -> f64 {
308 if self.n >= 1 {
309 self.m2 / self.n as f64
310 } else {
311 0.0
312 }
313 }
314
315 pub fn sample_var_log(&self) -> Option<f64> {
317 if self.n >= 2 {
318 Some(self.m2 / (self.n as f64 - 1.0))
319 } else {
320 None
321 }
322 }
323
324 pub fn mean(&self) -> f64 {
326 (self.mean_log + self.var_log() / 2.0).exp()
327 }
328
329 pub fn mode(&self) -> f64 {
331 (self.mean_log - self.var_log()).exp()
332 }
333
334 pub fn sample<R: Rng>(&self, rng: &mut R) -> f64 {
336 use rand_distr::StandardNormal;
337 let z: f64 = rng.sample(StandardNormal);
338 (self.mean_log + self.var_log().sqrt() * z).exp()
339 }
340}
341
342pub trait TunableMutation {
345 fn set_mutation_probability(&mut self, probability: f64);
349}
350
351macro_rules! impl_tunable_mutation {
352 ($($ty:ty),+ $(,)?) => {
353 $(
354 impl TunableMutation for $ty {
355 fn set_mutation_probability(&mut self, probability: f64) {
356 self.mutation_probability = Some(probability.clamp(0.0, 1.0));
357 }
358 }
359 )+
360 };
361}
362
363impl_tunable_mutation!(
364 PolynomialMutation,
365 GaussianMutation,
366 UniformMutation,
367 BitFlipMutation,
368);
369
370#[derive(Clone, Debug)]
373pub struct BanditArm {
374 pub value: f64,
376 pub posterior: BetaPosterior,
378 pub selections: u64,
380}
381
382#[derive(Clone, Debug)]
385pub struct BanditParameter {
386 pub name: String,
388 arms: Vec<BanditArm>,
389 last_selected: Option<usize>,
390}
391
392impl BanditParameter {
393 pub fn new(name: impl Into<String>, values: Vec<f64>) -> Self {
395 Self::with_prior(name, values, BetaPosterior::uniform())
396 }
397
398 pub fn with_prior(name: impl Into<String>, values: Vec<f64>, prior: BetaPosterior) -> Self {
400 assert!(
401 !values.is_empty(),
402 "BanditParameter requires at least one arm value"
403 );
404 let arms = values
405 .into_iter()
406 .map(|value| BanditArm {
407 value,
408 posterior: prior.clone(),
409 selections: 0,
410 })
411 .collect();
412 Self {
413 name: name.into(),
414 arms,
415 last_selected: None,
416 }
417 }
418
419 pub fn select<R: Rng>(&mut self, rng: &mut R) -> f64 {
425 let mut best_idx = 0;
426 let mut best_draw = f64::NEG_INFINITY;
427 for (i, arm) in self.arms.iter().enumerate() {
428 let draw = arm.posterior.sample(rng);
429 if draw > best_draw {
430 best_draw = draw;
431 best_idx = i;
432 }
433 }
434 self.last_selected = Some(best_idx);
435 self.arms[best_idx].selections += 1;
436 self.arms[best_idx].value
437 }
438
439 pub fn observe(&mut self, improved: bool) {
441 if let Some(idx) = self.last_selected {
442 self.arms[idx].posterior.observe(improved);
443 }
444 }
445
446 pub fn arms(&self) -> &[BanditArm] {
448 &self.arms
449 }
450
451 pub fn values(&self) -> Vec<f64> {
453 self.arms.iter().map(|a| a.value).collect()
454 }
455
456 pub fn posterior_means(&self) -> Vec<f64> {
458 self.arms.iter().map(|a| a.posterior.mean()).collect()
459 }
460
461 pub fn selection_counts(&self) -> Vec<u64> {
463 self.arms.iter().map(|a| a.selections).collect()
464 }
465
466 pub fn selected_value(&self) -> Option<f64> {
468 self.last_selected.map(|i| self.arms[i].value)
469 }
470
471 pub fn selected_index(&self) -> Option<usize> {
473 self.last_selected
474 }
475
476 pub fn best_index(&self) -> usize {
478 self.arms
479 .iter()
480 .enumerate()
481 .max_by(|(_, a), (_, b)| {
482 a.posterior
483 .mean()
484 .partial_cmp(&b.posterior.mean())
485 .unwrap_or(std::cmp::Ordering::Equal)
486 })
487 .map(|(i, _)| i)
488 .unwrap_or(0)
489 }
490
491 pub fn best_value(&self) -> f64 {
493 self.arms[self.best_index()].value
494 }
495
496 pub fn total_observations(&self) -> f64 {
498 self.arms.iter().map(|a| a.posterior.observations()).sum()
499 }
500}
501
502pub const PARAM_MUTATION_RATE: &str = "mutation_rate";
504pub const PARAM_CROSSOVER_PROB: &str = "crossover_prob";
506
507#[derive(Clone, Debug)]
509pub struct ThompsonConfig {
510 pub mutation_rate_arms: Vec<f64>,
512 pub crossover_prob_arms: Vec<f64>,
514 pub prior: BetaPosterior,
516 pub record_history: bool,
518}
519
520impl Default for ThompsonConfig {
521 fn default() -> Self {
522 Self {
523 mutation_rate_arms: vec![0.01, 0.05, 0.1, 0.2, 0.4],
524 crossover_prob_arms: vec![0.5, 0.7, 0.9],
525 prior: BetaPosterior::uniform(),
526 record_history: false,
527 }
528 }
529}
530
531impl ThompsonConfig {
532 pub fn build_tuner(&self) -> ThompsonSamplingTuner {
534 ThompsonSamplingTuner::from_config(self)
535 }
536}
537
538#[derive(Clone, Debug)]
540pub struct TunerSnapshot {
541 pub generation: usize,
543 pub parameters: Vec<(String, Option<f64>, Vec<f64>)>,
545}
546
547#[derive(Clone, Debug)]
556pub struct ThompsonSamplingTuner {
557 parameters: Vec<BanditParameter>,
558 record_history: bool,
559 history: Vec<TunerSnapshot>,
560 observations: u64,
561}
562
563impl ThompsonSamplingTuner {
564 pub fn new(parameters: Vec<BanditParameter>) -> Self {
566 Self {
567 parameters,
568 record_history: false,
569 history: Vec::new(),
570 observations: 0,
571 }
572 }
573
574 pub fn from_config(cfg: &ThompsonConfig) -> Self {
576 let mut parameters = Vec::new();
577 if !cfg.mutation_rate_arms.is_empty() {
578 parameters.push(BanditParameter::with_prior(
579 PARAM_MUTATION_RATE,
580 cfg.mutation_rate_arms.clone(),
581 cfg.prior.clone(),
582 ));
583 }
584 if !cfg.crossover_prob_arms.is_empty() {
585 parameters.push(BanditParameter::with_prior(
586 PARAM_CROSSOVER_PROB,
587 cfg.crossover_prob_arms.clone(),
588 cfg.prior.clone(),
589 ));
590 }
591 Self {
592 parameters,
593 record_history: cfg.record_history,
594 history: Vec::new(),
595 observations: 0,
596 }
597 }
598
599 pub fn with_history(mut self, on: bool) -> Self {
601 self.record_history = on;
602 self
603 }
604
605 pub fn parameters(&self) -> &[BanditParameter] {
607 &self.parameters
608 }
609
610 pub fn parameter(&self, name: &str) -> Option<&BanditParameter> {
612 self.parameters.iter().find(|p| p.name == name)
613 }
614
615 pub fn parameter_mut(&mut self, name: &str) -> Option<&mut BanditParameter> {
617 self.parameters.iter_mut().find(|p| p.name == name)
618 }
619
620 pub fn is_empty(&self) -> bool {
622 self.parameters.is_empty()
623 }
624
625 pub fn select_all<R: Rng>(&mut self, rng: &mut R) {
627 for p in &mut self.parameters {
628 p.select(rng);
629 }
630 }
631
632 pub fn selected(&self, name: &str) -> Option<f64> {
634 self.parameter(name).and_then(|p| p.selected_value())
635 }
636
637 pub fn observe(&mut self, improved: bool) {
639 for p in &mut self.parameters {
640 p.observe(improved);
641 }
642 self.observations += 1;
643 }
644
645 pub fn total_observations(&self) -> u64 {
647 self.observations
648 }
649
650 pub fn snapshot(&mut self, generation: usize) {
652 if !self.record_history {
653 return;
654 }
655 let parameters = self
656 .parameters
657 .iter()
658 .map(|p| (p.name.clone(), p.selected_value(), p.posterior_means()))
659 .collect();
660 self.history.push(TunerSnapshot {
661 generation,
662 parameters,
663 });
664 }
665
666 pub fn history(&self) -> &[TunerSnapshot] {
668 &self.history
669 }
670}
671
672fn normal_quantile(p: f64) -> f64 {
674 if p <= 0.0 {
677 return f64::NEG_INFINITY;
678 }
679 if p >= 1.0 {
680 return f64::INFINITY;
681 }
682
683 let t = if p < 0.5 {
684 (-2.0 * p.ln()).sqrt()
685 } else {
686 (-2.0 * (1.0 - p).ln()).sqrt()
687 };
688
689 let c0 = 2.515517;
690 let c1 = 0.802853;
691 let c2 = 0.010328;
692 let d1 = 1.432788;
693 let d2 = 0.189269;
694 let d3 = 0.001308;
695
696 let q = t - (c0 + c1 * t + c2 * t * t) / (1.0 + d1 * t + d2 * t * t + d3 * t * t * t);
697
698 if p < 0.5 {
699 -q
700 } else {
701 q
702 }
703}
704
705#[cfg(test)]
706mod tests {
707 use super::*;
708 use rand::rngs::StdRng;
709 use rand::SeedableRng;
710
711 #[test]
712 fn test_beta_posterior_uniform_prior() {
713 let posterior = BetaPosterior::uniform();
714 assert!((posterior.mean() - 0.5).abs() < 1e-10);
715 }
716
717 #[test]
718 fn test_beta_posterior_update() {
719 let mut posterior = BetaPosterior::uniform();
720
721 for _ in 0..7 {
723 posterior.observe_success();
724 }
725 for _ in 0..3 {
726 posterior.observe_failure();
727 }
728
729 assert!((posterior.mean() - 0.667).abs() < 0.01);
731 }
732
733 #[test]
734 fn test_beta_posterior_sample() {
735 let posterior = BetaPosterior::new(5.0, 5.0);
736 let mut rng = rand::thread_rng();
737
738 for _ in 0..100 {
739 let sample = posterior.sample(&mut rng);
740 assert!((0.0..=1.0).contains(&sample));
741 }
742 }
743
744 #[test]
747 fn test_beta_observations_uses_stored_prior() {
748 let mut uniform = BetaPosterior::uniform();
750 for i in 0..10 {
751 uniform.observe(i % 2 == 0);
752 }
753 assert!((uniform.observations() - 10.0).abs() < 1e-12);
754
755 let mut jeffreys = BetaPosterior::jeffreys();
758 for _ in 0..5 {
759 jeffreys.observe_success();
760 }
761 for _ in 0..5 {
762 jeffreys.observe_failure();
763 }
764 assert!((jeffreys.observations() - 10.0).abs() < 1e-12);
765
766 let mut informative = BetaPosterior::new(2.0, 2.0);
768 for _ in 0..4 {
769 informative.observe_success();
770 }
771 assert!((informative.observations() - 4.0).abs() < 1e-12);
772 }
773
774 #[test]
777 fn test_gamma_rate_and_mean_of_mean() {
778 let mut posterior = GammaPosterior::new(2.0, 1.0);
781 for x in [1.0, 2.0, 3.0] {
782 posterior.observe(x);
783 }
784 assert!((posterior.shape - 5.0).abs() < 1e-12);
785 assert!((posterior.rate - 7.0).abs() < 1e-12);
786 assert!((posterior.mean() - 5.0 / 7.0).abs() < 1e-12);
788 assert!((posterior.posterior_mean_of_mean().unwrap() - 1.75).abs() < 1e-12);
790 assert!((posterior.observations() - 3.0).abs() < 1e-12);
791 }
792
793 #[test]
797 fn test_gamma_recovers_mean_not_reciprocal() {
798 let mut posterior = GammaPosterior::vague();
799 for _ in 0..1000 {
800 posterior.observe(20.0);
801 }
802 assert!((posterior.mean() - 0.05).abs() < 0.005, "rate ~ 1/20");
803 let mean = posterior.posterior_mean_of_mean().unwrap();
804 assert!((mean - 20.0).abs() < 0.5, "mean-of-mean ~ 20, got {mean}");
805 }
806
807 #[test]
811 fn test_running_log_moments_no_prior_contamination() {
812 let mut moments = RunningLogMoments::new();
813
814 moments.observe(std::f64::consts::E); assert!((moments.var_log()).abs() < 1e-12);
817 assert!((moments.mean_log() - 1.0).abs() < 1e-12);
818
819 moments.observe(std::f64::consts::E.powi(3)); assert!((moments.mean_log() - 2.0).abs() < 1e-12);
823 assert!((moments.var_log() - 1.0).abs() < 1e-12);
824 assert!((moments.sample_var_log().unwrap() - 2.0).abs() < 1e-12);
825 }
826
827 #[test]
828 fn test_running_log_moments_mean_original_space() {
829 let mut moments = RunningLogMoments::new();
830 for _ in 0..10 {
831 moments.observe(0.1);
832 }
833 assert!((moments.mean() - 0.1).abs() < 1e-12);
835 }
836
837 #[test]
838 fn test_bandit_parameter_thompson_selects_a_value() {
839 let mut param = BanditParameter::new(PARAM_MUTATION_RATE, vec![0.01, 0.1, 0.3]);
840 let mut rng = StdRng::seed_from_u64(1);
841 let v = param.select(&mut rng);
842 assert!([0.01, 0.1, 0.3].contains(&v));
843 param.observe(true);
844 assert!((param.total_observations() - 1.0).abs() < 1e-12);
845 }
846
847 #[test]
853 fn test_bandit_concentrates_on_better_arm() {
854 let mut rng = StdRng::seed_from_u64(20260710);
855 let mut param = BanditParameter::new(PARAM_MUTATION_RATE, vec![0.01, 0.3]);
856
857 let true_p = |v: f64| if v >= 0.3 { 0.55 } else { 0.20 };
859
860 let total_rounds = 2000;
861 let late_start = 1500;
862 let mut late_good = 0u32;
863 let mut late_total = 0u32;
864
865 for round in 0..total_rounds {
866 let value = param.select(&mut rng);
867 let improved = rng.gen::<f64>() < true_p(value);
868 param.observe(improved);
869 if round >= late_start {
870 late_total += 1;
871 if value >= 0.3 {
872 late_good += 1;
873 }
874 }
875 }
876
877 let frac = late_good as f64 / late_total as f64;
878 assert!(
879 frac > 0.70,
880 "expected >70% of late pulls on the better arm, got {:.2}",
881 frac
882 );
883 assert!((param.best_value() - 0.3).abs() < 1e-12);
885 }
886
887 #[test]
888 fn test_thompson_tuner_from_config() {
889 let cfg = ThompsonConfig::default();
890 let mut tuner = cfg.build_tuner();
891 assert!(tuner.parameter(PARAM_MUTATION_RATE).is_some());
892 assert!(tuner.parameter(PARAM_CROSSOVER_PROB).is_some());
893
894 let mut rng = StdRng::seed_from_u64(7);
895 tuner.select_all(&mut rng);
896 assert!(tuner.selected(PARAM_MUTATION_RATE).is_some());
897 assert!(tuner.selected(PARAM_CROSSOVER_PROB).is_some());
898
899 tuner.observe(true);
900 tuner.observe(false);
901 assert_eq!(tuner.total_observations(), 2);
902 }
903
904 #[test]
907 fn test_thompson_tuner_parameters_are_independent() {
908 let cfg = ThompsonConfig {
909 mutation_rate_arms: vec![0.1, 0.3],
910 crossover_prob_arms: vec![0.5, 0.9],
911 prior: BetaPosterior::uniform(),
912 record_history: false,
913 };
914 let mut tuner = cfg.build_tuner();
915 let mut rng = StdRng::seed_from_u64(99);
916 for _ in 0..50 {
917 tuner.select_all(&mut rng);
918 tuner.observe(true);
919 }
920 let mr = tuner.parameter(PARAM_MUTATION_RATE).unwrap();
921 let cx = tuner.parameter(PARAM_CROSSOVER_PROB).unwrap();
922 assert_eq!(mr.values(), vec![0.1, 0.3]);
924 assert_eq!(cx.values(), vec![0.5, 0.9]);
925 }
926
927 #[test]
928 fn test_tunable_mutation_sets_probability() {
929 let mut m = GaussianMutation::new(0.1);
930 m.set_mutation_probability(0.25);
931 assert_eq!(m.mutation_probability, Some(0.25));
932 m.set_mutation_probability(5.0);
934 assert_eq!(m.mutation_probability, Some(1.0));
935 }
936
937 #[test]
938 fn test_credible_interval() {
939 let posterior = BetaPosterior::new(50.0, 50.0);
940 let (lower, upper) = posterior.credible_interval(0.95);
941
942 assert!(lower < 0.5);
943 assert!(upper > 0.5);
944 assert!(lower > 0.0);
945 assert!(upper < 1.0);
946 }
947}