Skip to main content

fugue_evo/inference/
bayesian_ga.rs

1//! A single-level Bayesian adaptive genetic algorithm
2//!
3//! This module replaces the former "HBGA" heuristic (which collapsed its priors
4//! to their means and adapted with a fixed `×1.05 / ×0.95` rule) with a genuine
5//! Bayesian treatment of the operator hyperparameters.
6//!
7//! # The model
8//!
9//! Each mutation operator `k` (a coordinate-wise Gaussian with its own step
10//! size `σ_k`) has an unknown per-application **success probability** `θ_k` —
11//! the probability that applying it to a parent yields a fitter child. We place
12//! a conjugate `Beta(α_k, β_k)` prior on `θ_k` and treat each offspring as a
13//! Bernoulli improvement trial, so the posterior is the exact conjugate update
14//!
15//! ```text
16//!     α_k ← α_k + (# improving children),   β_k ← β_k + (# non-improving).
17//! ```
18//!
19//! Operator selection is **Thompson sampling**: every generation we draw one
20//! `θ̃_k ~ Beta(α_k, β_k)` from each *current* posterior and apply the operator
21//! with the largest draw. This replaces the fixed heuristic with hyperparameters
22//! sampled from the current posterior each generation.
23//!
24//! In addition, a `Gamma(shape, rate)` posterior tracks the rate `λ` of
25//! improvement events per generation via the conjugate Gamma–Poisson update
26//! (`shape ← shape + count`, `rate ← rate + 1` each generation). It is reported
27//! as a learned diagnostic of how "improvable" the search currently is.
28//!
29//! This is a **single-level** Bayesian model (independent conjugate posteriors,
30//! no hyperprior over the `Beta`/`Gamma` parameters), hence the honest name
31//! `BayesianAdaptiveGA` rather than "hierarchical Bayesian".
32
33use rand::Rng;
34use rand_distr::{Beta, Distribution, Gamma};
35
36use super::model::EvolutionModel;
37use super::prior::GenomePrior;
38use crate::fitness::traits::Fitness;
39use crate::genome::trace_genome::TraceGenome;
40
41/// Conjugate `Beta(α, β)` posterior over a Bernoulli success probability.
42#[derive(Clone, Copy, Debug, PartialEq)]
43pub struct BetaSuccessPosterior {
44    /// Pseudo-count of successes (`α`).
45    pub alpha: f64,
46    /// Pseudo-count of failures (`β`).
47    pub beta: f64,
48}
49
50impl BetaSuccessPosterior {
51    /// Create a `Beta(α, β)` prior. Both parameters must be positive.
52    pub fn new(alpha: f64, beta: f64) -> Self {
53        Self {
54            alpha: alpha.max(1e-6),
55            beta: beta.max(1e-6),
56        }
57    }
58
59    /// Conjugate update from observed Bernoulli trials.
60    pub fn update(&mut self, successes: u64, failures: u64) {
61        self.alpha += successes as f64;
62        self.beta += failures as f64;
63    }
64
65    /// Posterior mean `α / (α + β)`.
66    pub fn mean(&self) -> f64 {
67        self.alpha / (self.alpha + self.beta)
68    }
69
70    /// Posterior variance `αβ / ((α+β)² (α+β+1))`.
71    pub fn variance(&self) -> f64 {
72        let s = self.alpha + self.beta;
73        (self.alpha * self.beta) / (s * s * (s + 1.0))
74    }
75
76    /// Total observed evidence `α + β`.
77    pub fn total(&self) -> f64 {
78        self.alpha + self.beta
79    }
80
81    /// Draw `θ ~ Beta(α, β)` from the current posterior (Thompson sampling).
82    pub fn sample<R: Rng>(&self, rng: &mut R) -> f64 {
83        Beta::new(self.alpha, self.beta)
84            .expect("valid Beta parameters")
85            .sample(rng)
86    }
87}
88
89/// Conjugate `Gamma(shape, rate)` posterior over a Poisson rate `λ`.
90#[derive(Clone, Copy, Debug, PartialEq)]
91pub struct GammaRatePosterior {
92    /// Shape parameter.
93    pub shape: f64,
94    /// Rate parameter (inverse scale).
95    pub rate: f64,
96}
97
98impl GammaRatePosterior {
99    /// Create a `Gamma(shape, rate)` prior. Both parameters must be positive.
100    pub fn new(shape: f64, rate: f64) -> Self {
101        Self {
102            shape: shape.max(1e-6),
103            rate: rate.max(1e-6),
104        }
105    }
106
107    /// Conjugate Gamma–Poisson update: observe `count` events over `exposure`
108    /// units of exposure (`shape += count`, `rate += exposure`).
109    pub fn observe(&mut self, count: u64, exposure: f64) {
110        self.shape += count as f64;
111        self.rate += exposure;
112    }
113
114    /// Posterior mean `shape / rate`.
115    pub fn mean(&self) -> f64 {
116        self.shape / self.rate
117    }
118
119    /// Draw `λ ~ Gamma(shape, rate)` from the current posterior.
120    pub fn sample<R: Rng>(&self, rng: &mut R) -> f64 {
121        // rand_distr's Gamma is (shape, scale); ours is (shape, rate).
122        Gamma::new(self.shape, 1.0 / self.rate)
123            .expect("valid Gamma parameters")
124            .sample(rng)
125    }
126}
127
128/// A mutation operator arm: a Gaussian step size with its own success posterior.
129#[derive(Clone, Copy, Debug)]
130pub struct OperatorArm {
131    /// Gaussian mutation standard deviation.
132    pub sigma: f64,
133    /// Posterior over this operator's per-application success probability.
134    pub posterior: BetaSuccessPosterior,
135    /// Number of generations this arm was selected.
136    pub times_selected: usize,
137}
138
139impl OperatorArm {
140    /// Create an arm with the given step size and a `Beta(1, 1)` prior.
141    pub fn new(sigma: f64) -> Self {
142        Self {
143            sigma,
144            posterior: BetaSuccessPosterior::new(1.0, 1.0),
145            times_selected: 0,
146        }
147    }
148}
149
150/// A single-level Bayesian adaptive genetic algorithm.
151///
152/// See the [module docs](self) for the model. Operator step sizes are selected
153/// by Thompson sampling over per-operator `Beta` success posteriors, which are
154/// updated by conjugate Bayesian updates from observed improvement events.
155pub struct BayesianAdaptiveGA<P, F>
156where
157    P: GenomePrior,
158    F: Fitness<Genome = P::Genome, Value = f64> + Clone + Send + Sync + 'static,
159{
160    model: EvolutionModel<P, super::likelihood::FactorFitness<F>>,
161    population_size: usize,
162    generations: usize,
163    tournament_size: usize,
164    mutation_rate: f64,
165    arms: Vec<OperatorArm>,
166    improvement_rate: GammaRatePosterior,
167}
168
169impl<P, F> BayesianAdaptiveGA<P, F>
170where
171    P: GenomePrior,
172    F: Fitness<Genome = P::Genome, Value = f64> + Clone + Send + Sync + 'static,
173{
174    /// Create a new adaptive GA with a default set of mutation step sizes.
175    pub fn new(prior: P, fitness: F, population_size: usize, generations: usize) -> Self {
176        Self {
177            model: EvolutionModel::new(prior, fitness),
178            population_size,
179            generations,
180            tournament_size: 3,
181            mutation_rate: 0.5,
182            arms: vec![
183                OperatorArm::new(0.05),
184                OperatorArm::new(0.2),
185                OperatorArm::new(0.5),
186                OperatorArm::new(1.0),
187            ],
188            improvement_rate: GammaRatePosterior::new(1.0, 1.0),
189        }
190    }
191
192    /// Replace the operator step sizes (each gets a fresh `Beta(1, 1)` prior).
193    pub fn with_step_sizes(mut self, sigmas: Vec<f64>) -> Self {
194        self.arms = sigmas.into_iter().map(OperatorArm::new).collect();
195        self
196    }
197
198    /// Set the coordinate mutation probability.
199    pub fn with_mutation_rate(mut self, rate: f64) -> Self {
200        self.mutation_rate = rate;
201        self
202    }
203
204    /// Set the tournament size for parent selection.
205    pub fn with_tournament_size(mut self, size: usize) -> Self {
206        self.tournament_size = size.max(1);
207        self
208    }
209
210    /// Thompson-sample one `θ̃_k` from each arm's current posterior and return
211    /// the index of the arm with the largest draw.
212    fn thompson_select<R: Rng>(&self, rng: &mut R) -> usize {
213        let mut best_idx = 0;
214        let mut best_draw = f64::NEG_INFINITY;
215        for (i, arm) in self.arms.iter().enumerate() {
216            let draw = arm.posterior.sample(rng);
217            if draw > best_draw {
218                best_draw = draw;
219                best_idx = i;
220            }
221        }
222        best_idx
223    }
224
225    /// Run the adaptive GA.
226    pub fn run<R: Rng>(&mut self, rng: &mut R) -> BayesianAdaptiveGAResult<P::Genome> {
227        let mut population: Vec<P::Genome> = (0..self.population_size)
228            .map(|_| self.model.sample_prior(rng))
229            .collect();
230        let mut fitnesses: Vec<f64> = population
231            .iter()
232            .map(|g| self.model.fitness_value(g))
233            .collect();
234
235        let mut best_genome = population[0].clone();
236        let mut best_fitness = fitnesses[0];
237        for (g, &f) in population.iter().zip(fitnesses.iter()) {
238            if f > best_fitness {
239                best_fitness = f;
240                best_genome = g.clone();
241            }
242        }
243
244        let mut fitness_history = Vec::with_capacity(self.generations);
245        let mut selected_arm_history = Vec::with_capacity(self.generations);
246
247        for _ in 0..self.generations {
248            // (1) Thompson-sample the operator to use this generation.
249            let arm_idx = self.thompson_select(rng);
250            self.arms[arm_idx].times_selected += 1;
251            selected_arm_history.push(arm_idx);
252            let sigma = self.arms[arm_idx].sigma;
253
254            // (2) Produce the next generation via tournament selection + the
255            // chosen mutation operator, recording improvement events.
256            let mut next_population = Vec::with_capacity(self.population_size);
257            let mut next_fitness = Vec::with_capacity(self.population_size);
258            let mut successes: u64 = 0;
259            let mut failures: u64 = 0;
260
261            for _ in 0..self.population_size {
262                let parent_idx = self.tournament(&fitnesses, rng);
263                let parent = &population[parent_idx];
264                let parent_fitness = fitnesses[parent_idx];
265
266                let child = gaussian_trace_mutation(parent, self.mutation_rate, sigma, rng);
267                let child_fitness = self.model.fitness_value(&child);
268
269                if child_fitness > parent_fitness {
270                    successes += 1;
271                } else {
272                    failures += 1;
273                }
274
275                if child_fitness > best_fitness {
276                    best_fitness = child_fitness;
277                    best_genome = child.clone();
278                }
279
280                next_population.push(child);
281                next_fitness.push(child_fitness);
282            }
283
284            // (3) Conjugate Bayesian updates from the observed events.
285            self.arms[arm_idx].posterior.update(successes, failures);
286            self.improvement_rate.observe(successes, 1.0);
287
288            population = next_population;
289            fitnesses = next_fitness;
290
291            let mean_fitness = fitnesses.iter().sum::<f64>() / fitnesses.len() as f64;
292            fitness_history.push(mean_fitness);
293        }
294
295        BayesianAdaptiveGAResult {
296            best_genome,
297            best_fitness,
298            fitness_history,
299            selected_arm_history,
300            operator_posteriors: self.arms.clone(),
301            improvement_rate: self.improvement_rate,
302        }
303    }
304
305    fn tournament<R: Rng>(&self, fitnesses: &[f64], rng: &mut R) -> usize {
306        let mut best = rng.gen_range(0..fitnesses.len());
307        for _ in 1..self.tournament_size {
308            let challenger = rng.gen_range(0..fitnesses.len());
309            if fitnesses[challenger] > fitnesses[best] {
310                best = challenger;
311            }
312        }
313        best
314    }
315}
316
317/// Gaussian trace-space mutation used as the GA's variation operator: each
318/// `F64` choice of the genome's canonical trace is perturbed with probability
319/// `rate` by `N(0, sigma²)` noise (non-real sites pass through unchanged).
320/// This replaces the deleted `EvolutionStep::propose`, which existed only to
321/// provide exactly this perturbation.
322fn gaussian_trace_mutation<G: TraceGenome, R: Rng>(
323    genome: &G,
324    rate: f64,
325    sigma: f64,
326    rng: &mut R,
327) -> G {
328    use fugue::{ChoiceValue, Trace};
329    let normal = rand_distr::Normal::new(0.0, sigma.max(1e-12)).expect("valid mutation sigma");
330    let trace = genome.to_trace();
331    let mut new_trace = Trace::default();
332    for (addr, choice) in &trace.choices {
333        let value = match &choice.value {
334            ChoiceValue::F64(v) if rng.gen::<f64>() < rate => {
335                ChoiceValue::F64(v + normal.sample(rng))
336            }
337            other => other.clone(),
338        };
339        new_trace.insert_choice(addr.clone(), value, 0.0);
340    }
341    G::from_trace(&new_trace).unwrap_or_else(|_| genome.clone())
342}
343
344/// Result of a [`BayesianAdaptiveGA`] run.
345pub struct BayesianAdaptiveGAResult<G> {
346    /// Best genome found.
347    pub best_genome: G,
348    /// Best fitness value.
349    pub best_fitness: f64,
350    /// Mean fitness over generations.
351    pub fitness_history: Vec<f64>,
352    /// Index of the operator arm selected in each generation.
353    pub selected_arm_history: Vec<usize>,
354    /// Final per-operator success posteriors.
355    pub operator_posteriors: Vec<OperatorArm>,
356    /// Final `Gamma` posterior over the improvement-event rate.
357    pub improvement_rate: GammaRatePosterior,
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363    use crate::fitness::benchmarks::Sphere;
364    use crate::genome::bounds::MultiBounds;
365    use crate::inference::prior::UniformBoxPrior;
366    use rand::rngs::StdRng;
367    use rand::SeedableRng;
368
369    #[test]
370    fn test_beta_posterior_conjugate_update() {
371        // regression: EV-53 — the posterior is a genuine conjugate Beta update,
372        // not a prior collapsed to its mean.
373        let mut post = BetaSuccessPosterior::new(2.0, 8.0);
374        assert!((post.mean() - 0.2).abs() < 1e-12);
375        post.update(5, 3);
376        assert_eq!(post.alpha, 7.0);
377        assert_eq!(post.beta, 11.0);
378        assert!((post.mean() - 7.0 / 18.0).abs() < 1e-12);
379    }
380
381    #[test]
382    fn test_beta_posterior_sampling_matches_beta_moments() {
383        // regression: EV-53 — draws are true Beta(α,β) samples. The old HBGA
384        // returned mean + U(-0.05, 0.05), whose std ≈ 0.029 would fail here;
385        // Beta(2,8) has std ≈ 0.1206.
386        let post = BetaSuccessPosterior::new(2.0, 8.0);
387        let mut rng = StdRng::seed_from_u64(2024);
388        let draws: Vec<f64> = (0..20000).map(|_| post.sample(&mut rng)).collect();
389        let mean = draws.iter().sum::<f64>() / draws.len() as f64;
390        let var = draws.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / draws.len() as f64;
391        assert!((mean - post.mean()).abs() < 0.01, "mean {}", mean);
392        assert!(
393            (var.sqrt() - post.variance().sqrt()).abs() < 0.02,
394            "std {} vs analytic {}",
395            var.sqrt(),
396            post.variance().sqrt()
397        );
398        // Distinguishes a real Beta draw from the old collapsed sampler.
399        assert!(
400            var.sqrt() > 0.06,
401            "std too small for a Beta draw: {}",
402            var.sqrt()
403        );
404    }
405
406    #[test]
407    fn test_gamma_posterior_conjugate_update() {
408        let mut post = GammaRatePosterior::new(2.0, 1.0);
409        post.observe(5, 1.0);
410        assert_eq!(post.shape, 7.0);
411        assert_eq!(post.rate, 2.0);
412        assert!((post.mean() - 3.5).abs() < 1e-12);
413    }
414
415    #[test]
416    fn test_adaptive_ga_updates_posteriors_and_improves() {
417        // regression: EV-53 — running the GA performs real posterior updates
418        // (total evidence grows) and Thompson sampling drives optimisation.
419        // Sphere::evaluate returns -Σx² (higher is better, optimum 0 at origin).
420        let fit = Sphere::new(3);
421        let bounds = MultiBounds::symmetric(5.0, 3);
422        let mut ga = BayesianAdaptiveGA::new(UniformBoxPrior::new(bounds), fit, 40, 60);
423        let mut rng = StdRng::seed_from_u64(7);
424        let result = ga.run(&mut rng);
425
426        // Every generation contributes population_size trials to some arm.
427        let total_evidence: f64 = result
428            .operator_posteriors
429            .iter()
430            .map(|a| a.posterior.total() - 2.0) // subtract Beta(1,1) prior mass
431            .sum();
432        assert!(
433            total_evidence >= (60 * 40) as f64 - 1.0,
434            "posteriors did not accumulate the expected evidence: {}",
435            total_evidence
436        );
437
438        // At least one arm was actually exercised (Thompson selection ran).
439        assert!(result
440            .operator_posteriors
441            .iter()
442            .any(|a| a.times_selected > 0));
443
444        // The improvement-rate Gamma posterior was updated away from its prior.
445        assert!(result.improvement_rate.shape > 1.0);
446
447        // Optimisation made real progress toward the sphere optimum (0).
448        assert!(
449            result.best_fitness > -1.0,
450            "best fitness {} did not converge",
451            result.best_fitness
452        );
453    }
454
455    #[test]
456    fn test_thompson_prefers_better_operator() {
457        // On a problem near the optimum, small steps improve far more often than
458        // huge ones, so the small-σ arm should earn a higher posterior mean.
459        let fit = Sphere::new(2);
460        let bounds = MultiBounds::symmetric(0.5, 2);
461        let mut ga = BayesianAdaptiveGA::new(UniformBoxPrior::new(bounds), fit, 50, 80)
462            .with_step_sizes(vec![0.02, 2.0]);
463        let mut rng = StdRng::seed_from_u64(11);
464        let result = ga.run(&mut rng);
465
466        let small = result.operator_posteriors[0].posterior.mean();
467        let large = result.operator_posteriors[1].posterior.mean();
468        assert!(
469            small > large,
470            "small-step success posterior {} should exceed large-step {}",
471            small,
472            large
473        );
474    }
475}