1use std::marker::PhantomData;
19
20use fugue::runtime::handler::run;
21use fugue::runtime::interpreters::ScoreGivenTrace;
22use fugue::{
23 adaptive_smc, adaptive_smc_with_kernel, decode_particle, CrossoverKernel, Model, Particle,
24 ResamplingMethod, SMCConfig, Trace,
25};
26use rand::Rng;
27
28use super::likelihood::GenomeLikelihood;
29use super::model::EvolutionModel;
30use super::prior::GenomePrior;
31use crate::fitness::traits::Fitness;
32use crate::genome::trace_genome::{gene_address, TraceGenome};
33
34#[derive(Clone, Debug)]
36pub struct CrossoverConfig {
37 pub n_pairs: usize,
39 pub swap_probability: f64,
41}
42
43impl Default for CrossoverConfig {
44 fn default() -> Self {
45 Self {
46 n_pairs: 32,
47 swap_probability: 0.5,
48 }
49 }
50}
51
52pub struct EvoSmcConfig {
54 pub num_particles: usize,
56 pub ess_threshold: f64,
59 pub resampling: ResamplingMethod,
61 pub rejuvenation_steps: usize,
63 pub crossover: Option<CrossoverConfig>,
65}
66
67impl Default for EvoSmcConfig {
68 fn default() -> Self {
69 Self {
70 num_particles: 500,
71 ess_threshold: 0.5,
72 resampling: ResamplingMethod::Systematic,
73 rejuvenation_steps: 3,
74 crossover: Some(CrossoverConfig::default()),
75 }
76 }
77}
78
79pub struct EvolutionPosterior<G: TraceGenome> {
87 pub particles: Vec<Particle>,
89 pub log_evidence: f64,
92 _g: PhantomData<G>,
93}
94
95impl<G: TraceGenome> EvolutionPosterior<G> {
96 pub fn genome(&self, particle: &Particle, model_fn: &impl Fn() -> Model<G>) -> G {
98 decode_particle(particle, model_fn)
99 }
100
101 pub fn genomes(&self, model_fn: &impl Fn() -> Model<G>) -> Vec<(G, f64)> {
103 self.particles
104 .iter()
105 .map(|p| (decode_particle(p, model_fn), p.weight))
106 .collect()
107 }
108
109 pub fn weighted_mean(&self, coord: usize) -> f64 {
111 let addr = gene_address(G::trace_prefix(), coord);
112 let mut total_w = 0.0;
113 let mut mean = 0.0;
114 for p in &self.particles {
115 if let Some(x) = p.trace.get_f64(&addr) {
116 mean += p.weight * x;
117 total_w += p.weight;
118 }
119 }
120 if total_w > 0.0 {
121 mean / total_w
122 } else {
123 0.0
124 }
125 }
126
127 pub fn weighted_variance(&self, coord: usize) -> f64 {
129 let addr = gene_address(G::trace_prefix(), coord);
130 let mean = self.weighted_mean(coord);
131 let mut total_w = 0.0;
132 let mut var = 0.0;
133 for p in &self.particles {
134 if let Some(x) = p.trace.get_f64(&addr) {
135 var += p.weight * (x - mean).powi(2);
136 total_w += p.weight;
137 }
138 }
139 if total_w > 0.0 {
140 var / total_w
141 } else {
142 0.0
143 }
144 }
145
146 pub fn best<F>(&self, fitness: &F, model_fn: &impl Fn() -> Model<G>) -> Option<(G, f64)>
149 where
150 F: Fitness<Genome = G, Value = f64>,
151 {
152 self.particles
153 .iter()
154 .map(|p| {
155 let g = decode_particle(p, model_fn);
156 let f = fitness.evaluate(&g);
157 (g, f)
158 })
159 .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
160 }
161}
162
163pub struct EvolutionSMC;
165
166impl EvolutionSMC {
167 pub fn run<P, L, R>(
171 rng: &mut R,
172 model: &EvolutionModel<P, L>,
173 cfg: EvoSmcConfig,
174 ) -> EvolutionPosterior<P::Genome>
175 where
176 P: GenomePrior,
177 L: GenomeLikelihood<P::Genome>,
178 R: Rng,
179 {
180 let model_fn = model.smc_model();
181 let smc_cfg = SMCConfig {
182 resampling_method: cfg.resampling,
183 ess_threshold: cfg.ess_threshold,
184 rejuvenation_steps: cfg.rejuvenation_steps,
185 };
186 let result = match cfg.crossover {
187 None => adaptive_smc(rng, cfg.num_particles, &model_fn, smc_cfg),
188 Some(xcfg) => {
189 let p_swap = xcfg.swap_probability.clamp(0.0, 1.0);
190 let mut kernel = CrossoverKernel {
191 n_pairs: xcfg.n_pairs,
192 mask: Box::new(move |a: &Trace, _b: &Trace, rng: &mut dyn rand::RngCore| {
195 a.choices
196 .keys()
197 .filter(|_| rand::Rng::gen::<f64>(rng) < p_swap)
198 .cloned()
199 .collect()
200 }),
201 };
202 adaptive_smc_with_kernel(rng, cfg.num_particles, &model_fn, smc_cfg, &mut kernel)
203 }
204 };
205 EvolutionPosterior {
206 particles: result.particles,
207 log_evidence: result.log_evidence,
208 _g: PhantomData,
209 }
210 }
211}
212
213impl EvolutionSMC {
214 pub fn run_with_kernel<P, L, R, K>(
219 rng: &mut R,
220 model: &EvolutionModel<P, L>,
221 cfg: EvoSmcConfig,
222 kernel: &mut K,
223 ) -> EvolutionPosterior<P::Genome>
224 where
225 P: GenomePrior,
226 L: GenomeLikelihood<P::Genome>,
227 R: Rng,
228 K: fugue::PopulationKernel<P::Genome>,
229 {
230 let model_fn = model.smc_model();
231 let smc_cfg = SMCConfig {
232 resampling_method: cfg.resampling,
233 ess_threshold: cfg.ess_threshold,
234 rejuvenation_steps: cfg.rejuvenation_steps,
235 };
236 let result = adaptive_smc_with_kernel(rng, cfg.num_particles, &model_fn, smc_cfg, kernel);
237 EvolutionPosterior {
238 particles: result.particles,
239 log_evidence: result.log_evidence,
240 _g: PhantomData,
241 }
242 }
243}
244
245impl EvolutionSMC {
246 pub fn anneal<P, L, R>(
264 rng: &mut R,
265 model: &EvolutionModel<P, L>,
266 cfg: EvoSmcConfig,
267 beta_max: f64,
268 anneal_steps: usize,
269 ) -> EvolutionPosterior<P::Genome>
270 where
271 P: GenomePrior,
272 L: GenomeLikelihood<P::Genome>,
273 R: Rng,
274 {
275 use fugue::{normalize_particles, rejuvenate_particles, resample_particles};
276
277 let crossover = cfg.crossover.clone();
278 let rejuvenation_steps = cfg.rejuvenation_steps;
279 let resampling = cfg.resampling;
280 let mut result = Self::run(rng, model, cfg);
281 if beta_max <= 1.0 || anneal_steps == 0 {
282 return result;
283 }
284
285 let model_fn = model.smc_model();
286 let loglik = |t: &Trace| t.log_likelihood + t.log_factors;
287 let mut kernel = crossover.map(|xcfg| {
288 let p_swap = xcfg.swap_probability.clamp(0.0, 1.0);
289 CrossoverKernel {
290 n_pairs: xcfg.n_pairs,
291 mask: Box::new(move |a: &Trace, _b: &Trace, rng: &mut dyn rand::RngCore| {
292 a.choices
293 .keys()
294 .filter(|_| rand::Rng::gen::<f64>(rng) < p_swap)
295 .cloned()
296 .collect()
297 }),
298 }
299 });
300
301 let ln_bmax = beta_max.ln();
302 let mut prev_beta = 1.0;
303 for i in 1..=anneal_steps {
304 let beta = (ln_bmax * i as f64 / anneal_steps as f64).exp();
305 let d_beta = beta - prev_beta;
306
307 for p in &mut result.particles {
309 p.log_weight += d_beta * loglik(&p.trace);
310 }
311 normalize_particles(&mut result.particles);
312
313 result.particles = resample_particles(rng, &result.particles, resampling);
315
316 rejuvenate_particles(
318 rng,
319 &mut result.particles,
320 &model_fn,
321 beta,
322 rejuvenation_steps,
323 );
324 if let Some(k) = kernel.as_mut() {
325 fugue::PopulationKernel::<P::Genome>::sweep(
326 k,
327 rng as &mut dyn rand::RngCore,
328 &mut result.particles,
329 &model_fn,
330 beta,
331 );
332 }
333 prev_beta = beta;
334 }
335 normalize_particles(&mut result.particles);
336 result
337 }
338}
339
340pub fn score_genome<G: TraceGenome, A>(genome: &G, model: Model<A>) -> (A, Trace) {
343 run(
344 ScoreGivenTrace {
345 base: genome.to_trace(),
346 trace: Trace::default(),
347 },
348 model,
349 )
350}
351
352#[cfg(test)]
353mod tests {
354 use super::*;
355 use crate::genome::bounds::{Bounds, MultiBounds};
356 use crate::genome::real_vector::RealVector;
357 use crate::genome::traits::RealValuedGenome;
358 use crate::inference::model::tests::PtrFitness;
359 use crate::inference::prior::GaussianPrior;
360 use rand::rngs::StdRng;
361 use rand::SeedableRng;
362
363 fn quad_k1_c3(g: &RealVector) -> f64 {
364 -0.5 * g.genes().iter().map(|x| (x - 3.0).powi(2)).sum::<f64>()
365 }
366
367 #[test]
376 fn test_smc_matches_gaussian_conjugate_posterior() {
377 let prior = GaussianPrior::new(0.0, 2.0, 1);
378 let model = EvolutionModel::new(prior, PtrFitness(quad_k1_c3));
379 let mut rng = StdRng::seed_from_u64(42);
380 let result = EvolutionSMC::run(
381 &mut rng,
382 &model,
383 EvoSmcConfig {
384 num_particles: 4000,
385 ess_threshold: 0.5,
386 resampling: ResamplingMethod::Systematic,
387 rejuvenation_steps: 6,
388 crossover: None,
389 },
390 );
391
392 let mean = result.weighted_mean(0);
393 let var = result.weighted_variance(0);
394 assert!(
395 (mean - 2.4).abs() < 0.15,
396 "posterior mean {} vs analytic 2.4",
397 mean
398 );
399 assert!(
400 (var - 0.8).abs() < 0.2,
401 "posterior variance {} vs analytic 0.8",
402 var
403 );
404
405 let total: f64 = result.particles.iter().map(|p| p.weight).sum();
407 assert!((total - 1.0).abs() < 1e-6);
408
409 let analytic_log_z = 0.5 * ((0.8f64).ln() - (4.0f64).ln()) - 9.0 / (2.0 * 5.0);
412 assert!(
413 (result.log_evidence - analytic_log_z).abs() < 0.25,
414 "log evidence {} vs analytic {}",
415 result.log_evidence,
416 analytic_log_z
417 );
418 }
419
420 #[test]
424 fn test_smc_with_crossover_matches_conjugate_posterior() {
425 let prior = GaussianPrior::new(0.0, 2.0, 2);
426 let model = EvolutionModel::new(prior, PtrFitness(quad_k1_c3));
428 let mut rng = StdRng::seed_from_u64(1234);
429 let result = EvolutionSMC::run(
430 &mut rng,
431 &model,
432 EvoSmcConfig {
433 num_particles: 3000,
434 ess_threshold: 0.5,
435 resampling: ResamplingMethod::Systematic,
436 rejuvenation_steps: 4,
437 crossover: Some(CrossoverConfig {
438 n_pairs: 500,
439 swap_probability: 0.5,
440 }),
441 },
442 );
443 for coord in 0..2 {
444 let mean = result.weighted_mean(coord);
445 let var = result.weighted_variance(coord);
446 assert!(
447 (mean - 2.4).abs() < 0.15,
448 "coord {} posterior mean {} vs 2.4",
449 coord,
450 mean
451 );
452 assert!(
453 (var - 0.8).abs() < 0.25,
454 "coord {} posterior variance {} vs 0.8",
455 coord,
456 var
457 );
458 }
459 }
460
461 #[test]
464 fn test_decode_replay_recovers_genomes() {
465 let prior = GaussianPrior::new(0.0, 2.0, 1);
466 let model = EvolutionModel::new(prior, PtrFitness(quad_k1_c3));
467 let mut rng = StdRng::seed_from_u64(5);
468 let result = EvolutionSMC::run(
469 &mut rng,
470 &model,
471 EvoSmcConfig {
472 num_particles: 100,
473 rejuvenation_steps: 2,
474 crossover: None,
475 ..Default::default()
476 },
477 );
478 let model_fn = model.smc_model();
479 let decoded = result.genomes(&model_fn);
480 assert_eq!(decoded.len(), 100);
481 for (g, _w) in &decoded {
482 assert_eq!(g.genes().len(), 1);
483 }
484 let (best, best_f) = result.best(&PtrFitness(quad_k1_c3), &model_fn).unwrap();
485 assert!(best_f.is_finite());
486 assert!((quad_k1_c3(&best) - best_f).abs() < 1e-12);
487 }
488
489 #[test]
492 fn test_anneal_concentrates_on_optimum() {
493 let prior = GaussianPrior::new(0.0, 2.0, 2);
496 let model = EvolutionModel::new(prior, PtrFitness(super::tests::quad_origin_local));
497 let mut rng = StdRng::seed_from_u64(31);
498 let cfg = || EvoSmcConfig {
499 num_particles: 400,
500 ess_threshold: 0.5,
501 resampling: ResamplingMethod::Systematic,
502 rejuvenation_steps: 4,
503 crossover: Some(CrossoverConfig::default()),
504 };
505 let posterior = EvolutionSMC::run(&mut rng, &model, cfg());
506 let annealed = EvolutionSMC::anneal(&mut rng, &model, cfg(), 200.0, 12);
507
508 let spread = |r: &EvolutionPosterior<RealVector>| {
509 (r.weighted_variance(0) + r.weighted_variance(1)).sqrt()
510 };
511 assert!(
512 spread(&annealed) < 0.35 * spread(&posterior),
513 "annealed spread {} should be far below posterior spread {}",
514 spread(&annealed),
515 spread(&posterior)
516 );
517
518 let model_fn = model.smc_model();
519 let (best, best_f) = annealed
520 .best(&PtrFitness(super::tests::quad_origin_local), &model_fn)
521 .unwrap();
522 assert!(
523 best_f > -0.02,
524 "annealed best fitness {} (genome {:?}) not near optimum 0",
525 best_f,
526 best.genes()
527 );
528 }
529
530 pub(super) fn quad_origin_local(g: &RealVector) -> f64 {
531 -0.5 * g.genes().iter().map(|x| x * x).sum::<f64>()
532 }
533
534 #[test]
538 fn test_smc_respects_bounds() {
539 use crate::inference::prior::UniformBoxPrior;
540 let prior = UniformBoxPrior::new(MultiBounds::new(vec![Bounds::new(-2.0, 2.0)]));
541 let model = EvolutionModel::new(prior, PtrFitness(|g: &RealVector| g.genes()[0]));
542 let mut rng = StdRng::seed_from_u64(9);
543 let result = EvolutionSMC::run(
544 &mut rng,
545 &model,
546 EvoSmcConfig {
547 num_particles: 300,
548 rejuvenation_steps: 3,
549 crossover: Some(CrossoverConfig::default()),
550 ..Default::default()
551 },
552 );
553 for p in &result.particles {
554 let x = p.trace.get_f64(&fugue::addr!("gene", 0)).unwrap();
555 assert!((-2.0..=2.0).contains(&x), "particle escaped bounds: {}", x);
556 }
557 }
558}