use std::marker::PhantomData;
use fugue::runtime::handler::run;
use fugue::runtime::interpreters::ScoreGivenTrace;
use fugue::{
adaptive_smc, adaptive_smc_with_kernel, decode_particle, CrossoverKernel, Model, Particle,
ResamplingMethod, SMCConfig, Trace,
};
use rand::Rng;
use super::likelihood::GenomeLikelihood;
use super::model::EvolutionModel;
use super::prior::GenomePrior;
use crate::fitness::traits::Fitness;
use crate::genome::trace_genome::{gene_address, TraceGenome};
#[derive(Clone, Debug)]
pub struct CrossoverConfig {
pub n_pairs: usize,
pub swap_probability: f64,
}
impl Default for CrossoverConfig {
fn default() -> Self {
Self {
n_pairs: 32,
swap_probability: 0.5,
}
}
}
pub struct EvoSmcConfig {
pub num_particles: usize,
pub ess_threshold: f64,
pub resampling: ResamplingMethod,
pub rejuvenation_steps: usize,
pub crossover: Option<CrossoverConfig>,
}
impl Default for EvoSmcConfig {
fn default() -> Self {
Self {
num_particles: 500,
ess_threshold: 0.5,
resampling: ResamplingMethod::Systematic,
rejuvenation_steps: 3,
crossover: Some(CrossoverConfig::default()),
}
}
}
pub struct EvolutionPosterior<G: TraceGenome> {
pub particles: Vec<Particle>,
pub log_evidence: f64,
_g: PhantomData<G>,
}
impl<G: TraceGenome> EvolutionPosterior<G> {
pub fn genome(&self, particle: &Particle, model_fn: &impl Fn() -> Model<G>) -> G {
decode_particle(particle, model_fn)
}
pub fn genomes(&self, model_fn: &impl Fn() -> Model<G>) -> Vec<(G, f64)> {
self.particles
.iter()
.map(|p| (decode_particle(p, model_fn), p.weight))
.collect()
}
pub fn weighted_mean(&self, coord: usize) -> f64 {
let addr = gene_address(G::trace_prefix(), coord);
let mut total_w = 0.0;
let mut mean = 0.0;
for p in &self.particles {
if let Some(x) = p.trace.get_f64(&addr) {
mean += p.weight * x;
total_w += p.weight;
}
}
if total_w > 0.0 {
mean / total_w
} else {
0.0
}
}
pub fn weighted_variance(&self, coord: usize) -> f64 {
let addr = gene_address(G::trace_prefix(), coord);
let mean = self.weighted_mean(coord);
let mut total_w = 0.0;
let mut var = 0.0;
for p in &self.particles {
if let Some(x) = p.trace.get_f64(&addr) {
var += p.weight * (x - mean).powi(2);
total_w += p.weight;
}
}
if total_w > 0.0 {
var / total_w
} else {
0.0
}
}
pub fn best<F>(&self, fitness: &F, model_fn: &impl Fn() -> Model<G>) -> Option<(G, f64)>
where
F: Fitness<Genome = G, Value = f64>,
{
self.particles
.iter()
.map(|p| {
let g = decode_particle(p, model_fn);
let f = fitness.evaluate(&g);
(g, f)
})
.max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
}
}
pub struct EvolutionSMC;
impl EvolutionSMC {
pub fn run<P, L, R>(
rng: &mut R,
model: &EvolutionModel<P, L>,
cfg: EvoSmcConfig,
) -> EvolutionPosterior<P::Genome>
where
P: GenomePrior,
L: GenomeLikelihood<P::Genome>,
R: Rng,
{
let model_fn = model.smc_model();
let smc_cfg = SMCConfig {
resampling_method: cfg.resampling,
ess_threshold: cfg.ess_threshold,
rejuvenation_steps: cfg.rejuvenation_steps,
};
let result = match cfg.crossover {
None => adaptive_smc(rng, cfg.num_particles, &model_fn, smc_cfg),
Some(xcfg) => {
let p_swap = xcfg.swap_probability.clamp(0.0, 1.0);
let mut kernel = CrossoverKernel {
n_pairs: xcfg.n_pairs,
mask: Box::new(move |a: &Trace, _b: &Trace, rng: &mut dyn rand::RngCore| {
a.choices
.keys()
.filter(|_| rand::Rng::gen::<f64>(rng) < p_swap)
.cloned()
.collect()
}),
};
adaptive_smc_with_kernel(rng, cfg.num_particles, &model_fn, smc_cfg, &mut kernel)
}
};
EvolutionPosterior {
particles: result.particles,
log_evidence: result.log_evidence,
_g: PhantomData,
}
}
}
impl EvolutionSMC {
pub fn run_with_kernel<P, L, R, K>(
rng: &mut R,
model: &EvolutionModel<P, L>,
cfg: EvoSmcConfig,
kernel: &mut K,
) -> EvolutionPosterior<P::Genome>
where
P: GenomePrior,
L: GenomeLikelihood<P::Genome>,
R: Rng,
K: fugue::PopulationKernel<P::Genome>,
{
let model_fn = model.smc_model();
let smc_cfg = SMCConfig {
resampling_method: cfg.resampling,
ess_threshold: cfg.ess_threshold,
rejuvenation_steps: cfg.rejuvenation_steps,
};
let result = adaptive_smc_with_kernel(rng, cfg.num_particles, &model_fn, smc_cfg, kernel);
EvolutionPosterior {
particles: result.particles,
log_evidence: result.log_evidence,
_g: PhantomData,
}
}
}
impl EvolutionSMC {
pub fn anneal<P, L, R>(
rng: &mut R,
model: &EvolutionModel<P, L>,
cfg: EvoSmcConfig,
beta_max: f64,
anneal_steps: usize,
) -> EvolutionPosterior<P::Genome>
where
P: GenomePrior,
L: GenomeLikelihood<P::Genome>,
R: Rng,
{
use fugue::{normalize_particles, rejuvenate_particles, resample_particles};
let crossover = cfg.crossover.clone();
let rejuvenation_steps = cfg.rejuvenation_steps;
let resampling = cfg.resampling;
let mut result = Self::run(rng, model, cfg);
if beta_max <= 1.0 || anneal_steps == 0 {
return result;
}
let model_fn = model.smc_model();
let loglik = |t: &Trace| t.log_likelihood + t.log_factors;
let mut kernel = crossover.map(|xcfg| {
let p_swap = xcfg.swap_probability.clamp(0.0, 1.0);
CrossoverKernel {
n_pairs: xcfg.n_pairs,
mask: Box::new(move |a: &Trace, _b: &Trace, rng: &mut dyn rand::RngCore| {
a.choices
.keys()
.filter(|_| rand::Rng::gen::<f64>(rng) < p_swap)
.cloned()
.collect()
}),
}
});
let ln_bmax = beta_max.ln();
let mut prev_beta = 1.0;
for i in 1..=anneal_steps {
let beta = (ln_bmax * i as f64 / anneal_steps as f64).exp();
let d_beta = beta - prev_beta;
for p in &mut result.particles {
p.log_weight += d_beta * loglik(&p.trace);
}
normalize_particles(&mut result.particles);
result.particles = resample_particles(rng, &result.particles, resampling);
rejuvenate_particles(
rng,
&mut result.particles,
&model_fn,
beta,
rejuvenation_steps,
);
if let Some(k) = kernel.as_mut() {
fugue::PopulationKernel::<P::Genome>::sweep(
k,
rng as &mut dyn rand::RngCore,
&mut result.particles,
&model_fn,
beta,
);
}
prev_beta = beta;
}
normalize_particles(&mut result.particles);
result
}
}
pub fn score_genome<G: TraceGenome, A>(genome: &G, model: Model<A>) -> (A, Trace) {
run(
ScoreGivenTrace {
base: genome.to_trace(),
trace: Trace::default(),
},
model,
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::genome::bounds::{Bounds, MultiBounds};
use crate::genome::real_vector::RealVector;
use crate::genome::traits::RealValuedGenome;
use crate::inference::model::tests::PtrFitness;
use crate::inference::prior::GaussianPrior;
use rand::rngs::StdRng;
use rand::SeedableRng;
fn quad_k1_c3(g: &RealVector) -> f64 {
-0.5 * g.genes().iter().map(|x| (x - 3.0).powi(2)).sum::<f64>()
}
#[test]
fn test_smc_matches_gaussian_conjugate_posterior() {
let prior = GaussianPrior::new(0.0, 2.0, 1);
let model = EvolutionModel::new(prior, PtrFitness(quad_k1_c3));
let mut rng = StdRng::seed_from_u64(42);
let result = EvolutionSMC::run(
&mut rng,
&model,
EvoSmcConfig {
num_particles: 4000,
ess_threshold: 0.5,
resampling: ResamplingMethod::Systematic,
rejuvenation_steps: 6,
crossover: None,
},
);
let mean = result.weighted_mean(0);
let var = result.weighted_variance(0);
assert!(
(mean - 2.4).abs() < 0.15,
"posterior mean {} vs analytic 2.4",
mean
);
assert!(
(var - 0.8).abs() < 0.2,
"posterior variance {} vs analytic 0.8",
var
);
let total: f64 = result.particles.iter().map(|p| p.weight).sum();
assert!((total - 1.0).abs() < 1e-6);
let analytic_log_z = 0.5 * ((0.8f64).ln() - (4.0f64).ln()) - 9.0 / (2.0 * 5.0);
assert!(
(result.log_evidence - analytic_log_z).abs() < 0.25,
"log evidence {} vs analytic {}",
result.log_evidence,
analytic_log_z
);
}
#[test]
fn test_smc_with_crossover_matches_conjugate_posterior() {
let prior = GaussianPrior::new(0.0, 2.0, 2);
let model = EvolutionModel::new(prior, PtrFitness(quad_k1_c3));
let mut rng = StdRng::seed_from_u64(1234);
let result = EvolutionSMC::run(
&mut rng,
&model,
EvoSmcConfig {
num_particles: 3000,
ess_threshold: 0.5,
resampling: ResamplingMethod::Systematic,
rejuvenation_steps: 4,
crossover: Some(CrossoverConfig {
n_pairs: 500,
swap_probability: 0.5,
}),
},
);
for coord in 0..2 {
let mean = result.weighted_mean(coord);
let var = result.weighted_variance(coord);
assert!(
(mean - 2.4).abs() < 0.15,
"coord {} posterior mean {} vs 2.4",
coord,
mean
);
assert!(
(var - 0.8).abs() < 0.25,
"coord {} posterior variance {} vs 0.8",
coord,
var
);
}
}
#[test]
fn test_decode_replay_recovers_genomes() {
let prior = GaussianPrior::new(0.0, 2.0, 1);
let model = EvolutionModel::new(prior, PtrFitness(quad_k1_c3));
let mut rng = StdRng::seed_from_u64(5);
let result = EvolutionSMC::run(
&mut rng,
&model,
EvoSmcConfig {
num_particles: 100,
rejuvenation_steps: 2,
crossover: None,
..Default::default()
},
);
let model_fn = model.smc_model();
let decoded = result.genomes(&model_fn);
assert_eq!(decoded.len(), 100);
for (g, _w) in &decoded {
assert_eq!(g.genes().len(), 1);
}
let (best, best_f) = result.best(&PtrFitness(quad_k1_c3), &model_fn).unwrap();
assert!(best_f.is_finite());
assert!((quad_k1_c3(&best) - best_f).abs() < 1e-12);
}
#[test]
fn test_anneal_concentrates_on_optimum() {
let prior = GaussianPrior::new(0.0, 2.0, 2);
let model = EvolutionModel::new(prior, PtrFitness(super::tests::quad_origin_local));
let mut rng = StdRng::seed_from_u64(31);
let cfg = || EvoSmcConfig {
num_particles: 400,
ess_threshold: 0.5,
resampling: ResamplingMethod::Systematic,
rejuvenation_steps: 4,
crossover: Some(CrossoverConfig::default()),
};
let posterior = EvolutionSMC::run(&mut rng, &model, cfg());
let annealed = EvolutionSMC::anneal(&mut rng, &model, cfg(), 200.0, 12);
let spread = |r: &EvolutionPosterior<RealVector>| {
(r.weighted_variance(0) + r.weighted_variance(1)).sqrt()
};
assert!(
spread(&annealed) < 0.35 * spread(&posterior),
"annealed spread {} should be far below posterior spread {}",
spread(&annealed),
spread(&posterior)
);
let model_fn = model.smc_model();
let (best, best_f) = annealed
.best(&PtrFitness(super::tests::quad_origin_local), &model_fn)
.unwrap();
assert!(
best_f > -0.02,
"annealed best fitness {} (genome {:?}) not near optimum 0",
best_f,
best.genes()
);
}
pub(super) fn quad_origin_local(g: &RealVector) -> f64 {
-0.5 * g.genes().iter().map(|x| x * x).sum::<f64>()
}
#[test]
fn test_smc_respects_bounds() {
use crate::inference::prior::UniformBoxPrior;
let prior = UniformBoxPrior::new(MultiBounds::new(vec![Bounds::new(-2.0, 2.0)]));
let model = EvolutionModel::new(prior, PtrFitness(|g: &RealVector| g.genes()[0]));
let mut rng = StdRng::seed_from_u64(9);
let result = EvolutionSMC::run(
&mut rng,
&model,
EvoSmcConfig {
num_particles: 300,
rejuvenation_steps: 3,
crossover: Some(CrossoverConfig::default()),
..Default::default()
},
);
for p in &result.particles {
let x = p.trace.get_f64(&fugue::addr!("gene", 0)).unwrap();
assert!((-2.0..=2.0).contains(&x), "particle escaped bounds: {}", x);
}
}
}