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.
155///
156/// # What the prior is used for
157///
158/// This is a genetic algorithm, not a posterior sampler: the
159/// [`GenomePrior`] draws the **initial population** and defines the
160/// **feasible region** — a child whose encoding falls outside the prior's
161/// support (a [`UniformBoxPrior`](super::prior::UniformBoxPrior)'s box, a
162/// grammar's depth limit) is discarded before evaluation and its parent
163/// keeps the slot, counted as a failed trial. The prior's *density* does not
164/// otherwise enter selection; the Bayesian content is the conjugate
165/// operator-selection model. For the Boltzmann posterior itself use
166/// [`EvolutionChain`](super::mh::EvolutionChain) or
167/// [`EvolutionSMC`](super::smc::EvolutionSMC).
168pub struct BayesianAdaptiveGA<P, F>
169where
170    P: GenomePrior,
171    F: Fitness<Genome = P::Genome, Value = f64> + Clone + Send + Sync + 'static,
172{
173    model: EvolutionModel<P, super::likelihood::FactorFitness<F>>,
174    population_size: usize,
175    generations: usize,
176    tournament_size: usize,
177    mutation_rate: f64,
178    arms: Vec<OperatorArm>,
179    improvement_rate: GammaRatePosterior,
180}
181
182impl<P, F> BayesianAdaptiveGA<P, F>
183where
184    P: GenomePrior,
185    F: Fitness<Genome = P::Genome, Value = f64> + Clone + Send + Sync + 'static,
186{
187    /// Create a new adaptive GA with a default set of mutation step sizes.
188    pub fn new(prior: P, fitness: F, population_size: usize, generations: usize) -> Self {
189        Self {
190            model: EvolutionModel::new(prior, fitness),
191            population_size,
192            generations,
193            tournament_size: 3,
194            mutation_rate: 0.5,
195            arms: vec![
196                OperatorArm::new(0.05),
197                OperatorArm::new(0.2),
198                OperatorArm::new(0.5),
199                OperatorArm::new(1.0),
200            ],
201            improvement_rate: GammaRatePosterior::new(1.0, 1.0),
202        }
203    }
204
205    /// Replace the operator step sizes (each gets a fresh `Beta(1, 1)` prior).
206    pub fn with_step_sizes(mut self, sigmas: Vec<f64>) -> Self {
207        self.arms = sigmas.into_iter().map(OperatorArm::new).collect();
208        self
209    }
210
211    /// Set the coordinate mutation probability.
212    pub fn with_mutation_rate(mut self, rate: f64) -> Self {
213        self.mutation_rate = rate;
214        self
215    }
216
217    /// Set the tournament size for parent selection.
218    pub fn with_tournament_size(mut self, size: usize) -> Self {
219        self.tournament_size = size.max(1);
220        self
221    }
222
223    /// Thompson-sample one `θ̃_k` from each arm's current posterior and return
224    /// the index of the arm with the largest draw.
225    fn thompson_select<R: Rng>(&self, rng: &mut R) -> usize {
226        let mut best_idx = 0;
227        let mut best_draw = f64::NEG_INFINITY;
228        for (i, arm) in self.arms.iter().enumerate() {
229            let draw = arm.posterior.sample(rng);
230            if draw > best_draw {
231                best_draw = draw;
232                best_idx = i;
233            }
234        }
235        best_idx
236    }
237
238    /// Run the adaptive GA.
239    pub fn run<R: Rng>(&mut self, rng: &mut R) -> BayesianAdaptiveGAResult<P::Genome> {
240        let mut population: Vec<P::Genome> = (0..self.population_size)
241            .map(|_| self.model.sample_prior(rng))
242            .collect();
243        let mut fitnesses: Vec<f64> = population
244            .iter()
245            .map(|g| self.model.fitness_value(g))
246            .collect();
247
248        let mut best_genome = population[0].clone();
249        let mut best_fitness = fitnesses[0];
250        for (g, &f) in population.iter().zip(fitnesses.iter()) {
251            if f > best_fitness {
252                best_fitness = f;
253                best_genome = g.clone();
254            }
255        }
256
257        let mut fitness_history = Vec::with_capacity(self.generations);
258        let mut selected_arm_history = Vec::with_capacity(self.generations);
259
260        for _ in 0..self.generations {
261            // (1) Thompson-sample the operator to use this generation.
262            let arm_idx = self.thompson_select(rng);
263            self.arms[arm_idx].times_selected += 1;
264            selected_arm_history.push(arm_idx);
265            let sigma = self.arms[arm_idx].sigma;
266
267            // (2) Produce the next generation via tournament selection + the
268            // chosen mutation operator, recording improvement events.
269            let mut next_population = Vec::with_capacity(self.population_size);
270            let mut next_fitness = Vec::with_capacity(self.population_size);
271            let mut successes: u64 = 0;
272            let mut failures: u64 = 0;
273
274            for _ in 0..self.population_size {
275                let parent_idx = self.tournament(&fitnesses, rng);
276                let parent = &population[parent_idx];
277                let parent_fitness = fitnesses[parent_idx];
278
279                let mutant = gaussian_trace_mutation(parent, self.mutation_rate, sigma, rng);
280                // The prior defines the feasible region: a mutant that leaves
281                // its support (a `UniformBoxPrior`'s box, a grammar's depth
282                // limit) is discarded before any fitness evaluation and the
283                // parent keeps its slot, counted as a failed trial (EV-N5).
284                let (child, child_fitness) = if self.in_prior_support(&mutant) {
285                    let f = self.model.fitness_value(&mutant);
286                    (mutant, f)
287                } else {
288                    (parent.clone(), parent_fitness)
289                };
290
291                if child_fitness > parent_fitness {
292                    successes += 1;
293                } else {
294                    failures += 1;
295                }
296
297                if child_fitness > best_fitness {
298                    best_fitness = child_fitness;
299                    best_genome = child.clone();
300                }
301
302                next_population.push(child);
303                next_fitness.push(child_fitness);
304            }
305
306            // (3) Conjugate Bayesian updates from the observed events.
307            self.arms[arm_idx].posterior.update(successes, failures);
308            self.improvement_rate.observe(successes, 1.0);
309
310            population = next_population;
311            fitnesses = next_fitness;
312
313            let mean_fitness = fitnesses.iter().sum::<f64>() / fitnesses.len() as f64;
314            fitness_history.push(mean_fitness);
315        }
316
317        BayesianAdaptiveGAResult {
318            best_genome,
319            best_fitness,
320            fitness_history,
321            selected_arm_history,
322            operator_posteriors: self.arms.clone(),
323            improvement_rate: self.improvement_rate,
324        }
325    }
326
327    /// Whether `genome` has positive density under the prior program: its
328    /// encoding has the prior's shape ([`GenomePrior::validate`]) and replays
329    /// through [`GenomePrior::model`] with a finite `log_prior`.
330    fn in_prior_support(&self, genome: &P::Genome) -> bool {
331        let prior = self.model.prior();
332        prior.validate(genome).is_ok()
333            && super::model::score_complete(prior.trace_of(genome), prior.model())
334                .map(|(_g, t)| t.log_prior.is_finite())
335                .unwrap_or(false)
336    }
337
338    fn tournament<R: Rng>(&self, fitnesses: &[f64], rng: &mut R) -> usize {
339        let mut best = rng.gen_range(0..fitnesses.len());
340        for _ in 1..self.tournament_size {
341            let challenger = rng.gen_range(0..fitnesses.len());
342            if fitnesses[challenger] > fitnesses[best] {
343                best = challenger;
344            }
345        }
346        best
347    }
348}
349
350/// Gaussian trace-space mutation used as the GA's variation operator: each
351/// `F64` choice of the genome's canonical trace is perturbed with probability
352/// `rate` by `N(0, sigma²)` noise (non-real sites pass through unchanged).
353/// This replaces the deleted `EvolutionStep::propose`, which existed only to
354/// provide exactly this perturbation.
355fn gaussian_trace_mutation<G: TraceGenome, R: Rng>(
356    genome: &G,
357    rate: f64,
358    sigma: f64,
359    rng: &mut R,
360) -> G {
361    use fugue::{ChoiceValue, Trace};
362    let normal = rand_distr::Normal::new(0.0, sigma.max(1e-12)).expect("valid mutation sigma");
363    let trace = genome.to_trace();
364    let mut new_trace = Trace::default();
365    for (addr, choice) in &trace.choices {
366        let value = match &choice.value {
367            ChoiceValue::F64(v) if rng.gen::<f64>() < rate => {
368                ChoiceValue::F64(v + normal.sample(rng))
369            }
370            other => other.clone(),
371        };
372        new_trace.insert_choice(addr.clone(), value, 0.0);
373    }
374    G::from_trace(&new_trace).unwrap_or_else(|_| genome.clone())
375}
376
377/// Result of a [`BayesianAdaptiveGA`] run.
378pub struct BayesianAdaptiveGAResult<G> {
379    /// Best genome found.
380    pub best_genome: G,
381    /// Best fitness value.
382    pub best_fitness: f64,
383    /// Mean fitness over generations.
384    pub fitness_history: Vec<f64>,
385    /// Index of the operator arm selected in each generation.
386    pub selected_arm_history: Vec<usize>,
387    /// Final per-operator success posteriors.
388    pub operator_posteriors: Vec<OperatorArm>,
389    /// Final `Gamma` posterior over the improvement-event rate.
390    pub improvement_rate: GammaRatePosterior,
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396    use crate::fitness::benchmarks::Sphere;
397    use crate::genome::bounds::MultiBounds;
398    use crate::inference::prior::UniformBoxPrior;
399    use rand::rngs::StdRng;
400    use rand::SeedableRng;
401
402    #[test]
403    fn test_beta_posterior_conjugate_update() {
404        // regression: EV-53 — the posterior is a genuine conjugate Beta update,
405        // not a prior collapsed to its mean.
406        let mut post = BetaSuccessPosterior::new(2.0, 8.0);
407        assert!((post.mean() - 0.2).abs() < 1e-12);
408        post.update(5, 3);
409        assert_eq!(post.alpha, 7.0);
410        assert_eq!(post.beta, 11.0);
411        assert!((post.mean() - 7.0 / 18.0).abs() < 1e-12);
412    }
413
414    #[test]
415    fn test_beta_posterior_sampling_matches_beta_moments() {
416        // regression: EV-53 — draws are true Beta(α,β) samples. The old HBGA
417        // returned mean + U(-0.05, 0.05), whose std ≈ 0.029 would fail here;
418        // Beta(2,8) has std ≈ 0.1206.
419        let post = BetaSuccessPosterior::new(2.0, 8.0);
420        let mut rng = StdRng::seed_from_u64(2024);
421        let draws: Vec<f64> = (0..20000).map(|_| post.sample(&mut rng)).collect();
422        let mean = draws.iter().sum::<f64>() / draws.len() as f64;
423        let var = draws.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / draws.len() as f64;
424        assert!((mean - post.mean()).abs() < 0.01, "mean {}", mean);
425        assert!(
426            (var.sqrt() - post.variance().sqrt()).abs() < 0.02,
427            "std {} vs analytic {}",
428            var.sqrt(),
429            post.variance().sqrt()
430        );
431        // Distinguishes a real Beta draw from the old collapsed sampler.
432        assert!(
433            var.sqrt() > 0.06,
434            "std too small for a Beta draw: {}",
435            var.sqrt()
436        );
437    }
438
439    #[test]
440    fn test_gamma_posterior_conjugate_update() {
441        let mut post = GammaRatePosterior::new(2.0, 1.0);
442        post.observe(5, 1.0);
443        assert_eq!(post.shape, 7.0);
444        assert_eq!(post.rate, 2.0);
445        assert!((post.mean() - 3.5).abs() < 1e-12);
446    }
447
448    #[test]
449    fn test_adaptive_ga_updates_posteriors_and_improves() {
450        // regression: EV-53 — running the GA performs real posterior updates
451        // (total evidence grows) and Thompson sampling drives optimisation.
452        // Sphere::evaluate returns -Σx² (higher is better, optimum 0 at origin).
453        let fit = Sphere::new(3);
454        let bounds = MultiBounds::symmetric(5.0, 3);
455        let mut ga = BayesianAdaptiveGA::new(UniformBoxPrior::new(bounds), fit, 40, 60);
456        let mut rng = StdRng::seed_from_u64(7);
457        let result = ga.run(&mut rng);
458
459        // Every generation contributes population_size trials to some arm.
460        let total_evidence: f64 = result
461            .operator_posteriors
462            .iter()
463            .map(|a| a.posterior.total() - 2.0) // subtract Beta(1,1) prior mass
464            .sum();
465        assert!(
466            total_evidence >= (60 * 40) as f64 - 1.0,
467            "posteriors did not accumulate the expected evidence: {}",
468            total_evidence
469        );
470
471        // At least one arm was actually exercised (Thompson selection ran).
472        assert!(result
473            .operator_posteriors
474            .iter()
475            .any(|a| a.times_selected > 0));
476
477        // The improvement-rate Gamma posterior was updated away from its prior.
478        assert!(result.improvement_rate.shape > 1.0);
479
480        // Optimisation made real progress toward the sphere optimum (0).
481        assert!(
482            result.best_fitness > -1.0,
483            "best fitness {} did not converge",
484            result.best_fitness
485        );
486    }
487
488    /// EV-N5: children never leave the prior's support. With a fitness that
489    /// rewards running away from the origin, an unconstrained mutation walk
490    /// would leave the `[-0.5, 0.5]²` box within a few generations; the GA
491    /// must keep its best (and every evaluated child) inside it.
492    #[test]
493    fn test_children_stay_inside_prior_support() {
494        use crate::genome::real_vector::RealVector;
495        use crate::genome::traits::RealValuedGenome;
496        #[derive(Clone, Copy)]
497        struct Outward;
498        impl Fitness for Outward {
499            type Genome = RealVector;
500            type Value = f64;
501            fn evaluate(&self, g: &RealVector) -> f64 {
502                g.genes().iter().sum()
503            }
504        }
505        let bounds = MultiBounds::symmetric(0.5, 2);
506        let mut ga = BayesianAdaptiveGA::new(UniformBoxPrior::new(bounds), Outward, 30, 40)
507            .with_step_sizes(vec![0.3]);
508        let mut rng = StdRng::seed_from_u64(3);
509        let result = ga.run(&mut rng);
510        for x in result.best_genome.genes() {
511            assert!(
512                (-0.5..=0.5).contains(x),
513                "best genome left the prior's box: {x}"
514            );
515        }
516        // The optimum of x0 + x1 over the box is the corner (0.5, 0.5).
517        assert!(result.best_fitness <= 1.0 + 1e-12);
518        assert!(
519            result.best_fitness > 0.8,
520            "did not approach the box corner: {}",
521            result.best_fitness
522        );
523    }
524
525    #[test]
526    fn test_thompson_prefers_better_operator() {
527        // On a problem near the optimum, small steps improve far more often than
528        // huge ones, so the small-σ arm should earn a higher posterior mean.
529        let fit = Sphere::new(2);
530        let bounds = MultiBounds::symmetric(0.5, 2);
531        let mut ga = BayesianAdaptiveGA::new(UniformBoxPrior::new(bounds), fit, 50, 80)
532            .with_step_sizes(vec![0.02, 2.0]);
533        let mut rng = StdRng::seed_from_u64(11);
534        let result = ga.run(&mut rng);
535
536        let small = result.operator_posteriors[0].posterior.mean();
537        let large = result.operator_posteriors[1].posterior.mean();
538        assert!(
539            small > large,
540            "small-step success posterior {} should exceed large-step {}",
541            small,
542            large
543        );
544    }
545}