fugue/inference/abc.rs
1//! Approximate Bayesian Computation (ABC) - likelihood-free inference methods.
2//!
3//! ABC methods enable Bayesian inference for models where the likelihood function
4//! is intractable or unavailable, but forward simulation from the model is possible.
5//! Instead of computing likelihoods directly, ABC compares simulated data to observed
6//! data using distance functions and accepts samples that produce "similar" outcomes.
7//!
8//! ## Method Overview
9//!
10//! ABC algorithms follow this general pattern:
11//! 1. Sample parameters from the prior distribution
12//! 2. Simulate data using the model with those parameters
13//! 3. Compare simulated data to observed data using a distance function
14//! 4. Accept samples where the distance is below a threshold ε
15//!
16//! As ε → 0, the ABC posterior approaches the true posterior distribution.
17//!
18//! ## Available Methods
19//!
20//! - [`abc_rejection`]: Basic rejection ABC
21//! - [`abc_smc`]: Sequential Monte Carlo ABC for improved efficiency
22//! - [`abc_scalar_summary`]: ABC with scalar summary statistics
23//!
24//! ## Distance Functions
25//!
26//! The quality of ABC inference depends heavily on the choice of distance function:
27//! - [`EuclideanDistance`]: L2 norm for continuous data vectors
28//! - [`ManhattanDistance`]: L1 norm for robust distance computation
29//! - Custom distance functions via the [`DistanceFunction`] trait
30//!
31//! # Examples
32//!
33//! ```rust
34//! use fugue::*;
35//! use rand::rngs::StdRng;
36//! use rand::SeedableRng;
37//! use rand::Rng;
38//!
39//! // Simple ABC example for illustration
40//! let mut rng = StdRng::seed_from_u64(42);
41//! let observed_data = vec![2.0];
42//!
43//! let samples = abc_scalar_summary(
44//! &mut rng,
45//! || sample(addr!("mu"), Normal::new(0.0, 2.0).unwrap()),
46//! |trace| {
47//! if let Some(choice) = trace.choices.get(&addr!("mu")) {
48//! if let ChoiceValue::F64(mu) = choice.value {
49//! mu
50//! } else { 0.0 }
51//! } else { 0.0 }
52//! },
53//! 2.0, // observed summary
54//! 0.5, // tolerance
55//! 10 // max samples
56//! );
57//!
58//! assert!(!samples.is_empty());
59//! ```
60
61use crate::core::address::Address;
62use crate::core::distribution::{Distribution, Normal};
63use crate::core::model::Model;
64use crate::core::numerical::log_sum_exp;
65use crate::runtime::handler::run;
66use crate::runtime::interpreters::{PriorHandler, ScoreGivenTrace};
67use crate::runtime::trace::{ChoiceValue, Trace};
68use rand::Rng;
69
70/// Trait for computing distances between observed and simulated data.
71///
72/// Distance functions are crucial for ABC methods as they determine how
73/// "similarity" between datasets is measured. The choice of distance function
74/// significantly affects the quality of ABC approximations.
75///
76/// # Type Parameter
77///
78/// * `T` - Type of data being compared (e.g., `Vec<f64>`, scalar values)
79///
80/// # Examples
81///
82/// ```rust
83/// use fugue::*;
84///
85/// // Use built-in Euclidean distance
86/// let euclidean = EuclideanDistance;
87/// let dist = euclidean.distance(&vec![1.0, 2.0], &vec![1.1, 2.1]);
88///
89/// // Implement custom distance function
90/// struct ScalarDistance;
91/// impl DistanceFunction<f64> for ScalarDistance {
92/// fn distance(&self, observed: &f64, simulated: &f64) -> f64 {
93/// (observed - simulated).abs()
94/// }
95/// }
96/// ```
97pub trait DistanceFunction<T> {
98 /// Compute the distance between observed and simulated data.
99 ///
100 /// # Arguments
101 ///
102 /// * `observed` - The actual observed data
103 /// * `simulated` - Data simulated from the model
104 ///
105 /// # Returns
106 ///
107 /// A non-negative distance value. Smaller values indicate greater similarity.
108 fn distance(&self, observed: &T, simulated: &T) -> f64;
109}
110
111/// Euclidean (L2) distance function for vector data.
112///
113/// Computes the standard Euclidean distance between two vectors:
114/// √(Σ(xᵢ - yᵢ)²)
115///
116/// This is appropriate for continuous data where the magnitude of differences
117/// matters and the data dimensions have similar scales.
118///
119/// # Examples
120///
121/// ```rust
122/// use fugue::*;
123///
124/// let euclidean = EuclideanDistance;
125/// let observed = vec![1.0, 2.0, 3.0];
126/// let simulated = vec![1.1, 2.1, 2.9];
127/// let distance = euclidean.distance(&observed, &simulated);
128/// assert!((distance - 0.173).abs() < 0.01); // ≈ 0.173
129/// ```
130pub struct EuclideanDistance;
131
132impl DistanceFunction<Vec<f64>> for EuclideanDistance {
133 fn distance(&self, observed: &Vec<f64>, simulated: &Vec<f64>) -> f64 {
134 if observed.len() != simulated.len() {
135 return f64::INFINITY;
136 }
137
138 observed
139 .iter()
140 .zip(simulated.iter())
141 .map(|(&o, &s)| (o - s).powi(2))
142 .sum::<f64>()
143 .sqrt()
144 }
145}
146
147/// Manhattan (L1) distance function for vector data.
148///
149/// Computes the Manhattan distance between two vectors:
150/// Σ|xᵢ - yᵢ|
151///
152/// This distance is more robust to outliers than Euclidean distance and is
153/// appropriate when you want to treat each dimension independently.
154///
155/// # Examples
156///
157/// ```rust
158/// use fugue::inference::abc::{ManhattanDistance, DistanceFunction};
159///
160/// let manhattan = ManhattanDistance;
161/// let observed = vec![1.0, 2.0, 3.0];
162/// let simulated = vec![1.5, 1.5, 3.5];
163/// let distance = manhattan.distance(&observed, &simulated);
164/// assert!((distance - 1.5).abs() < 0.001); // |1.0-1.5| + |2.0-1.5| + |3.0-3.5| = 0.5 + 0.5 + 0.5 = 1.5
165/// ```
166pub struct ManhattanDistance;
167
168impl DistanceFunction<Vec<f64>> for ManhattanDistance {
169 fn distance(&self, observed: &Vec<f64>, simulated: &Vec<f64>) -> f64 {
170 if observed.len() != simulated.len() {
171 return f64::INFINITY;
172 }
173
174 observed
175 .iter()
176 .zip(simulated.iter())
177 .map(|(&o, &s)| (o - s).abs())
178 .sum::<f64>()
179 }
180}
181
182/// Summary statistics distance.
183pub struct SummaryStatsDistance {
184 pub weights: Vec<f64>,
185}
186
187impl SummaryStatsDistance {
188 pub fn new(weights: Vec<f64>) -> Self {
189 Self { weights }
190 }
191
192 fn compute_stats(data: &[f64]) -> Vec<f64> {
193 if data.is_empty() {
194 return vec![0.0, 0.0, 0.0];
195 }
196
197 let mean = data.iter().sum::<f64>() / data.len() as f64;
198 let variance = data.iter().map(|&x| (x - mean).powi(2)).sum::<f64>() / data.len() as f64;
199 let std = variance.sqrt();
200
201 let mut sorted = data.to_vec();
202 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
203 let median = if sorted.len().is_multiple_of(2) {
204 (sorted[sorted.len() / 2 - 1] + sorted[sorted.len() / 2]) / 2.0
205 } else {
206 sorted[sorted.len() / 2]
207 };
208
209 vec![mean, std, median]
210 }
211}
212
213impl DistanceFunction<Vec<f64>> for SummaryStatsDistance {
214 fn distance(&self, observed: &Vec<f64>, simulated: &Vec<f64>) -> f64 {
215 let obs_stats = Self::compute_stats(observed);
216 let sim_stats = Self::compute_stats(simulated);
217
218 obs_stats
219 .iter()
220 .zip(sim_stats.iter())
221 .zip(&self.weights)
222 .map(|((&o, &s), &w)| w * (o - s).powi(2))
223 .sum::<f64>()
224 .sqrt()
225 }
226}
227
228/// Basic ABC rejection sampling algorithm.
229///
230/// The simplest ABC method: repeatedly sample from the prior, simulate data,
231/// and accept samples where the distance to observed data is below a tolerance.
232/// This method is straightforward but can be inefficient for small tolerances.
233///
234/// # Algorithm
235///
236/// 1. Sample parameters from the prior using `model_fn()`
237/// 2. Simulate data using `simulator(trace)`
238/// 3. Compute distance between simulated and observed data
239/// 4. Accept if distance ≤ tolerance
240/// 5. Repeat until `max_samples` accepted or too many attempts
241///
242/// # Arguments
243///
244/// * `rng` - Random number generator
245/// * `model_fn` - Function that creates a model instance (contains priors)
246/// * `simulator` - Function that simulates data given a trace of parameter values
247/// * `observed_data` - The actual observed data to match
248/// * `distance_fn` - Function for measuring similarity between datasets
249/// * `tolerance` - Maximum allowed distance for acceptance
250/// * `max_samples` - Maximum number of samples to accept
251///
252/// # Returns
253///
254/// Vector of accepted traces (parameter samples that produced similar data).
255///
256/// # Examples
257///
258/// ```rust
259/// use fugue::*;
260/// use rand::rngs::StdRng;
261/// use rand::SeedableRng;
262///
263/// // Simple ABC rejection example
264/// let mut rng = StdRng::seed_from_u64(42);
265/// let observed_data = vec![2.0];
266///
267/// let samples = abc_scalar_summary(
268/// &mut rng,
269/// || sample(addr!("mu"), Normal::new(0.0, 2.0).unwrap()),
270/// |trace| {
271/// if let Some(choice) = trace.choices.get(&addr!("mu")) {
272/// if let ChoiceValue::F64(mu) = choice.value {
273/// mu
274/// } else { 0.0 }
275/// } else { 0.0 }
276/// },
277/// 2.0, // observed summary
278/// 0.5, // tolerance
279/// 5 // max samples (small for test)
280/// );
281/// assert!(!samples.is_empty());
282/// ```
283pub fn abc_rejection<A, T, R: Rng>(
284 rng: &mut R,
285 model_fn: impl Fn() -> Model<A>,
286 simulator: impl Fn(&Trace) -> T,
287 observed_data: &T,
288 distance_fn: &dyn DistanceFunction<T>,
289 tolerance: f64,
290 max_samples: usize,
291) -> Vec<Trace> {
292 let mut accepted = Vec::new();
293 let mut attempts = 0;
294
295 while accepted.len() < max_samples && attempts < max_samples * 100 {
296 // Sample from prior
297 let (_a, trace) = run(
298 PriorHandler {
299 rng,
300 trace: Trace::default(),
301 },
302 model_fn(),
303 );
304
305 // Simulate data
306 let simulated_data = simulator(&trace);
307
308 // Check distance
309 let dist = distance_fn.distance(observed_data, &simulated_data);
310
311 if dist <= tolerance {
312 accepted.push(trace);
313 }
314
315 attempts += 1;
316 }
317
318 if accepted.is_empty() {
319 eprintln!(
320 "Warning: No samples accepted in ABC. Consider increasing tolerance or max_samples."
321 );
322 }
323
324 accepted
325}
326
327/// Sequential Monte Carlo ABC with adaptive tolerance scheduling.
328///
329/// An importance-weighted ABC-SMC (Beaumont 2009 / Toni et al. 2009) that
330/// iteratively reduces the tolerance, giving better posterior approximations than
331/// rejection ABC at stringent tolerances. See [`abc_smc`] (equally-weighted
332/// population) and [`abc_smc_weighted`] (weighted population with typed errors).
333///
334/// # Algorithm
335///
336/// 1. Start with the initial tolerance and generate a population using rejection ABC.
337/// 2. For each subsequent tolerance level:
338/// - draw a base particle from the previous population proportional to its
339/// importance weight,
340/// - perturb its continuous coordinates with a Gaussian kernel scaled by the
341/// weighted sample variance,
342/// - reject out-of-support proposals and accept those within the new tolerance,
343/// - weight each accepted particle by `pi(theta) / sum_j w_j K(theta | theta_j)`.
344/// 3. Final particles approximate the posterior at the strictest tolerance.
345///
346/// # Arguments
347///
348/// * `rng` - Random number generator
349/// * `model_fn` - Function that creates a model instance
350/// * `simulator` - Function that simulates data given a trace
351/// * `observed_data` - The observed data to match
352/// * `distance_fn` - Distance function for comparing datasets
353/// * `config` - Initial tolerance, decreasing tolerance schedule, population size
354///
355/// # Returns
356///
357/// Vector of traces from the final SMC population.
358///
359/// # Examples
360///
361/// ```rust
362/// use fugue::{inference::abc::ABCSMCConfig, *};
363/// use rand::rngs::StdRng;
364/// use rand::SeedableRng;
365///
366/// // Simple SMC-ABC example with small numbers for testing
367/// let observed = vec![2.0];
368/// let mut rng = StdRng::seed_from_u64(42);
369///
370/// let samples = abc_smc(
371/// &mut rng,
372/// || sample(addr!("mu"), Normal::new(0.0, 1.0).unwrap()),
373/// |trace| {
374/// if let Some(choice) = trace.choices.get(&addr!("mu")) {
375/// if let ChoiceValue::F64(mu) = choice.value {
376/// vec![mu]
377/// } else { vec![0.0] }
378/// } else { vec![0.0] }
379/// },
380/// &observed,
381/// &EuclideanDistance,
382/// ABCSMCConfig {
383/// initial_tolerance: 1.0,
384/// tolerance_schedule: vec![0.5],
385/// particles_per_round: 5,
386/// },
387/// );
388/// assert!(!samples.is_empty());
389/// ```
390/// Configuration for ABC-SMC algorithm.
391#[derive(Debug, Clone)]
392pub struct ABCSMCConfig {
393 /// Initial tolerance for distance threshold
394 pub initial_tolerance: f64,
395 /// Schedule of decreasing tolerances across rounds
396 pub tolerance_schedule: Vec<f64>,
397 /// Number of particles to generate per round
398 pub particles_per_round: usize,
399}
400
401/// Default per-stage attempt budget as a multiple of the population size,
402/// mirroring the `max_samples * 100` bound used by [`abc_rejection`].
403pub const ABC_SMC_DEFAULT_ATTEMPT_FACTOR: usize = 100;
404
405/// Errors that can occur during a bounded ABC-SMC run (finding FG-34).
406#[derive(Debug, Clone, PartialEq)]
407pub enum ABCError {
408 /// The initial rejection round accepted zero particles within its attempt
409 /// budget, so there is nothing to perturb. Previously this panicked in
410 /// `rng.gen_range(0..0)`.
411 EmptyInitialPopulation {
412 /// The initial tolerance that admitted no samples.
413 tolerance: f64,
414 /// Number of prior draws attempted before giving up.
415 attempts: usize,
416 },
417 /// A tolerance stage could not be filled within its attempt budget.
418 /// Previously the inner loop had no cap and could spin forever.
419 StageExhausted {
420 /// The tolerance level that could not be reached.
421 tolerance: f64,
422 /// Number of particles accepted before the budget was exhausted.
423 accepted: usize,
424 /// Number of particles requested for the stage.
425 requested: usize,
426 /// Attempt budget that was exhausted.
427 attempts: usize,
428 },
429}
430
431impl std::fmt::Display for ABCError {
432 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
433 match self {
434 ABCError::EmptyInitialPopulation {
435 tolerance,
436 attempts,
437 } => write!(
438 f,
439 "ABC-SMC initial population is empty: no draw fell within tolerance {tolerance} in {attempts} attempts"
440 ),
441 ABCError::StageExhausted {
442 tolerance,
443 accepted,
444 requested,
445 attempts,
446 } => write!(
447 f,
448 "ABC-SMC stage at tolerance {tolerance} exhausted its budget of {attempts} attempts with only {accepted}/{requested} particles accepted"
449 ),
450 }
451 }
452}
453
454impl std::error::Error for ABCError {}
455
456/// A weighted ABC-SMC particle: a parameter trace with its importance weight.
457#[derive(Debug, Clone)]
458pub struct ABCParticle {
459 /// The accepted parameter trace.
460 pub trace: Trace,
461 /// Normalized importance weight within the population.
462 pub weight: f64,
463}
464
465/// Result of a correct ABC-SMC run: a weighted posterior population.
466#[derive(Debug, Clone)]
467pub struct ABCSMCResult {
468 /// The final weighted particle population (weights sum to 1).
469 pub particles: Vec<ABCParticle>,
470 /// The tolerance level of the final population.
471 pub final_tolerance: f64,
472}
473
474impl ABCSMCResult {
475 /// Weighted posterior mean of the f64 value at `addr`, if present.
476 pub fn weighted_mean(&self, addr: &Address) -> Option<f64> {
477 let mut num = 0.0;
478 let mut den = 0.0;
479 for p in &self.particles {
480 let v = p.trace.get_f64(addr)?;
481 num += p.weight * v;
482 den += p.weight;
483 }
484 if den > 0.0 {
485 Some(num / den)
486 } else {
487 None
488 }
489 }
490}
491
492/// Sequential Monte Carlo ABC (Beaumont 2009 / Toni et al. 2009).
493///
494/// This is the correct, importance-weighted ABC-SMC. It fixes finding FG-09:
495/// each population after the first is generated by
496///
497/// 1. drawing a base particle from the previous population *proportional to its
498/// importance weight*,
499/// 2. perturbing its continuous coordinates with a Gaussian kernel whose
500/// per-component bandwidth is `sqrt(2 · weighted-variance)` of the previous
501/// population (Beaumont et al. 2009),
502/// 3. rejecting proposals with zero prior density (out of support), and
503/// 4. accepting proposals within the new tolerance, then weighting each accepted
504/// particle by `w_i ∝ π(θ_i) / Σ_j w_j K(θ_i | θ_j)` — the prior/kernel
505/// correction that the previous single-site prior-replacement heuristic
506/// omitted.
507///
508/// Each stage is bounded by `max_attempts_per_stage` attempts (finding FG-34);
509/// an empty initial population and an exhausted stage are reported as typed
510/// [`ABCError`]s instead of panicking / looping forever.
511///
512/// The perturbation kernel acts on the model's continuous (f64) sites; discrete
513/// sites are carried through from the base particle unchanged, so the importance
514/// correction is exact for continuous parameters.
515///
516/// # Returns
517///
518/// An [`ABCSMCResult`] with the weighted posterior population at the final
519/// tolerance, or an [`ABCError`] if a stage could not be completed.
520pub fn abc_smc_weighted<A, T, R: Rng>(
521 rng: &mut R,
522 model_fn: impl Fn() -> Model<A>,
523 simulator: impl Fn(&Trace) -> T,
524 observed_data: &T,
525 distance_fn: &dyn DistanceFunction<T>,
526 config: ABCSMCConfig,
527 max_attempts_per_stage: usize,
528) -> Result<ABCSMCResult, ABCError> {
529 let n = config.particles_per_round;
530
531 // ----- Population 0: bounded rejection ABC at the initial tolerance -----
532 let mut current: Vec<ABCParticle> = Vec::with_capacity(n);
533 let mut attempts = 0usize;
534 while current.len() < n && attempts < max_attempts_per_stage {
535 attempts += 1;
536 let (_a, trace) = run(
537 PriorHandler {
538 rng,
539 trace: Trace::default(),
540 },
541 model_fn(),
542 );
543 let dist = distance_fn.distance(observed_data, &simulator(&trace));
544 if dist <= config.initial_tolerance {
545 current.push(ABCParticle { trace, weight: 0.0 });
546 }
547 }
548 if current.is_empty() {
549 return Err(ABCError::EmptyInitialPopulation {
550 tolerance: config.initial_tolerance,
551 attempts,
552 });
553 }
554 // Population 0 carries uniform weights.
555 let uniform = 1.0 / current.len() as f64;
556 for p in &mut current {
557 p.weight = uniform;
558 }
559 let mut current_tolerance = config.initial_tolerance;
560
561 // Continuous coordinate addresses shared by the population.
562 let coord_addrs = f64_addresses(¤t[0].trace);
563
564 // ----- Sequential rounds with decreasing tolerance -----
565 for &new_tolerance in &config.tolerance_schedule {
566 if new_tolerance >= current_tolerance {
567 continue; // Skip non-decreasing tolerances.
568 }
569
570 // Kernel bandwidth per continuous component: sqrt(2 * weighted variance).
571 let kernel_std = kernel_bandwidths(¤t, &coord_addrs);
572 let prev_coords: Vec<Vec<f64>> = current
573 .iter()
574 .map(|p| coords_of(&p.trace, &coord_addrs))
575 .collect();
576 let prev_weights: Vec<f64> = current.iter().map(|p| p.weight).collect();
577
578 let mut next: Vec<ABCParticle> = Vec::with_capacity(n);
579 let mut log_weights: Vec<f64> = Vec::with_capacity(n);
580 let mut stage_attempts = 0usize;
581
582 while next.len() < n && stage_attempts < max_attempts_per_stage {
583 stage_attempts += 1;
584
585 // (1) Draw a base particle proportional to its importance weight.
586 let j = sample_index(rng, &prev_weights);
587 let mut proposed = current[j].trace.clone();
588
589 // (2) Perturb continuous coordinates with the Gaussian kernel.
590 for (c, addr) in coord_addrs.iter().enumerate() {
591 if let Some(v) = proposed.get_f64(addr) {
592 let z = Normal::new(0.0, 1.0).unwrap().sample(rng);
593 let new_v = v + kernel_std[c] * z;
594 if let Some(choice) = proposed.choices.get_mut(addr) {
595 choice.value = ChoiceValue::F64(new_v);
596 }
597 }
598 }
599
600 // (3) Reject proposals with zero prior density (out of support).
601 let log_prior = score_log_prior(&model_fn, &proposed);
602 if !log_prior.is_finite() {
603 continue;
604 }
605
606 // (4) Accept within tolerance.
607 let dist = distance_fn.distance(observed_data, &simulator(&proposed));
608 if dist > new_tolerance {
609 continue;
610 }
611
612 // Importance weight: log w = log π(θ) - log Σ_j w_j K(θ | θ_j).
613 let prop_coords = coords_of(&proposed, &coord_addrs);
614 let log_denom =
615 kernel_mixture_log_density(&prop_coords, &prev_coords, &prev_weights, &kernel_std);
616 log_weights.push(log_prior - log_denom);
617 next.push(ABCParticle {
618 trace: proposed,
619 weight: 0.0,
620 });
621 }
622
623 if next.is_empty() || next.len() < n {
624 return Err(ABCError::StageExhausted {
625 tolerance: new_tolerance,
626 accepted: next.len(),
627 requested: n,
628 attempts: max_attempts_per_stage,
629 });
630 }
631
632 // Normalize the importance weights (stable log-sum-exp).
633 let log_norm = log_sum_exp(&log_weights);
634 for (p, &lw) in next.iter_mut().zip(&log_weights) {
635 p.weight = if log_norm.is_finite() {
636 (lw - log_norm).exp()
637 } else {
638 1.0 / n as f64
639 };
640 }
641
642 current = next;
643 current_tolerance = new_tolerance;
644 }
645
646 Ok(ABCSMCResult {
647 particles: current,
648 final_tolerance: current_tolerance,
649 })
650}
651
652/// Sequential Monte Carlo ABC returning an equally-weighted trace population.
653///
654/// This is the correct ABC-SMC of [`abc_smc_weighted`] (fixing finding FG-09),
655/// wrapped for the common case: it runs the weighted algorithm with the default
656/// per-stage attempt budget (`ABC_SMC_DEFAULT_ATTEMPT_FACTOR * particles_per_round`,
657/// finding FG-34) and then resamples the final weighted population down to an
658/// equally-weighted set of traces, so the returned traces can be summarized
659/// directly (e.g. by an unweighted posterior mean).
660///
661/// Unlike the previous implementation, it never panics on an empty initial
662/// population and never loops forever: on any [`ABCError`] it emits a warning and
663/// returns an empty vector. Use [`abc_smc_weighted`] for the weighted population,
664/// a configurable attempt budget, and typed error handling.
665///
666/// # Examples
667///
668/// ```rust
669/// use fugue::{inference::abc::ABCSMCConfig, *};
670/// use rand::rngs::StdRng;
671/// use rand::SeedableRng;
672///
673/// let observed = vec![2.0];
674/// let mut rng = StdRng::seed_from_u64(42);
675///
676/// let samples = abc_smc(
677/// &mut rng,
678/// || sample(addr!("mu"), Normal::new(0.0, 1.0).unwrap()),
679/// |trace| {
680/// if let Some(choice) = trace.choices.get(&addr!("mu")) {
681/// if let ChoiceValue::F64(mu) = choice.value {
682/// vec![mu]
683/// } else { vec![0.0] }
684/// } else { vec![0.0] }
685/// },
686/// &observed,
687/// &EuclideanDistance,
688/// ABCSMCConfig {
689/// initial_tolerance: 1.0,
690/// tolerance_schedule: vec![0.5],
691/// particles_per_round: 20,
692/// },
693/// );
694/// assert!(!samples.is_empty());
695/// ```
696pub fn abc_smc<A, T, R: Rng>(
697 rng: &mut R,
698 model_fn: impl Fn() -> Model<A>,
699 simulator: impl Fn(&Trace) -> T,
700 observed_data: &T,
701 distance_fn: &dyn DistanceFunction<T>,
702 config: ABCSMCConfig,
703) -> Vec<Trace> {
704 let n = config.particles_per_round;
705 let max_attempts = ABC_SMC_DEFAULT_ATTEMPT_FACTOR.saturating_mul(n.max(1));
706 match abc_smc_weighted(
707 rng,
708 model_fn,
709 simulator,
710 observed_data,
711 distance_fn,
712 config,
713 max_attempts,
714 ) {
715 Ok(result) => {
716 // Resample the weighted population to an equally-weighted trace set,
717 // so the returned traces are a valid unweighted posterior sample.
718 let weights: Vec<f64> = result.particles.iter().map(|p| p.weight).collect();
719 (0..result.particles.len())
720 .map(|_| result.particles[sample_index(rng, &weights)].trace.clone())
721 .collect()
722 }
723 Err(e) => {
724 eprintln!("Warning: ABC-SMC did not complete: {e}. Returning empty population.");
725 Vec::new()
726 }
727 }
728}
729
730/// Ordered list of continuous (f64) sample-site addresses in a trace.
731fn f64_addresses(trace: &Trace) -> Vec<Address> {
732 trace
733 .choices
734 .iter()
735 .filter(|(_, c)| matches!(c.value, ChoiceValue::F64(_)))
736 .map(|(a, _)| a.clone())
737 .collect()
738}
739
740/// Extract the f64 coordinate vector of a trace at the given addresses.
741fn coords_of(trace: &Trace, addrs: &[Address]) -> Vec<f64> {
742 addrs
743 .iter()
744 .map(|a| trace.get_f64(a).unwrap_or(0.0))
745 .collect()
746}
747
748/// Per-component kernel bandwidth `sqrt(2 · weighted variance)` of the population
749/// (Beaumont et al. 2009). Falls back to a small positive value for degenerate
750/// (zero-variance) components so the kernel never collapses to a point mass.
751fn kernel_bandwidths(population: &[ABCParticle], addrs: &[Address]) -> Vec<f64> {
752 let mut std = vec![0.0; addrs.len()];
753 let total_w: f64 = population.iter().map(|p| p.weight).sum();
754 if total_w <= 0.0 {
755 return vec![1e-3; addrs.len()];
756 }
757 for (c, addr) in addrs.iter().enumerate() {
758 let mut mean = 0.0;
759 for p in population {
760 mean += p.weight * p.trace.get_f64(addr).unwrap_or(0.0);
761 }
762 mean /= total_w;
763 let mut var = 0.0;
764 for p in population {
765 let d = p.trace.get_f64(addr).unwrap_or(0.0) - mean;
766 var += p.weight * d * d;
767 }
768 var /= total_w;
769 let bw = (2.0 * var).sqrt();
770 std[c] = if bw > 1e-12 { bw } else { 1e-3 };
771 }
772 std
773}
774
775/// log Σ_j w_j K(x | θ_j) for a component-wise Gaussian kernel with std `kernel_std`.
776fn kernel_mixture_log_density(
777 x: &[f64],
778 centers: &[Vec<f64>],
779 weights: &[f64],
780 kernel_std: &[f64],
781) -> f64 {
782 let terms: Vec<f64> = centers
783 .iter()
784 .zip(weights)
785 .map(|(center, &w)| w.ln() + gaussian_log_density(x, center, kernel_std))
786 .collect();
787 log_sum_exp(&terms)
788}
789
790/// Component-wise Gaussian log density Σ_c log N(x_c; mean_c, std_c).
791fn gaussian_log_density(x: &[f64], mean: &[f64], std: &[f64]) -> f64 {
792 let mut lp = 0.0;
793 for ((&xi, &mi), &si) in x.iter().zip(mean).zip(std) {
794 let s = si.max(1e-12);
795 let z = (xi - mi) / s;
796 lp += -0.5 * z * z - s.ln() - 0.5 * (2.0 * std::f64::consts::PI).ln();
797 }
798 lp
799}
800
801/// Score a trace under the model and return its log prior density log π(θ).
802///
803/// Returns `-inf` when any perturbed value falls outside its support.
804fn score_log_prior<A>(model_fn: &impl Fn() -> Model<A>, trace: &Trace) -> f64 {
805 let (_a, scored) = run(
806 ScoreGivenTrace {
807 base: trace.clone(),
808 trace: Trace::default(),
809 },
810 model_fn(),
811 );
812 scored.log_prior
813}
814
815/// Sample an index in `0..weights.len()` proportional to `weights`.
816fn sample_index<R: Rng>(rng: &mut R, weights: &[f64]) -> usize {
817 let total: f64 = weights.iter().sum();
818 if total <= 0.0 {
819 return rng.gen_range(0..weights.len());
820 }
821 let u = rng.gen::<f64>() * total;
822 let mut cum = 0.0;
823 for (i, &w) in weights.iter().enumerate() {
824 cum += w;
825 if u <= cum {
826 return i;
827 }
828 }
829 weights.len() - 1
830}
831
832/// ABC rejection sampling using scalar summary statistics.
833///
834/// A convenience function for ABC when both observed and simulated data can be
835/// reduced to scalar summary statistics. This is often more efficient than
836/// comparing full datasets and can focus inference on specific aspects of the data.
837///
838/// This function is equivalent to `abc_rejection` but operates on scalar summaries
839/// instead of vector data, making it easier to use for simple cases.
840///
841/// # Arguments
842///
843/// * `rng` - Random number generator
844/// * `model_fn` - Function that creates a model instance
845/// * `simulator` - Function that computes a scalar summary from a trace
846/// * `observed_summary` - Scalar summary of the observed data
847/// * `tolerance` - Maximum allowed absolute difference for acceptance
848/// * `max_samples` - Maximum number of samples to accept
849///
850/// # Returns
851///
852/// Vector of accepted traces that produced summaries within tolerance.
853///
854/// # Examples
855///
856/// ```rust
857/// use fugue::*;
858/// use rand::rngs::StdRng;
859/// use rand::SeedableRng;
860///
861/// // ABC for estimating mean when we only observe sample mean
862/// let observed_mean = 2.0;
863/// let mut rng = StdRng::seed_from_u64(42);
864///
865/// let samples = abc_scalar_summary(
866/// &mut rng,
867/// || sample(addr!("mu"), Normal::new(0.0, 2.0).unwrap()),
868/// |trace| {
869/// // Extract mu parameter and return it as summary
870/// if let Some(choice) = trace.choices.get(&addr!("mu")) {
871/// if let ChoiceValue::F64(mu) = choice.value {
872/// mu // The summary statistic is just the parameter
873/// } else { 0.0 }
874/// } else { 0.0 }
875/// },
876/// observed_mean,
877/// 0.5, // tolerance (larger for easier acceptance)
878/// 5, // max samples (small for test)
879/// );
880/// assert!(!samples.is_empty());
881/// ```
882pub fn abc_scalar_summary<A, R: Rng>(
883 rng: &mut R,
884 model_fn: impl Fn() -> Model<A>,
885 simulator: impl Fn(&Trace) -> f64,
886 observed_summary: f64,
887 tolerance: f64,
888 max_samples: usize,
889) -> Vec<Trace> {
890 abc_rejection(
891 rng,
892 model_fn,
893 |trace| vec![simulator(trace)],
894 &vec![observed_summary],
895 &EuclideanDistance,
896 tolerance,
897 max_samples,
898 )
899}
900
901#[cfg(test)]
902mod tests {
903 use super::*;
904 use crate::addr;
905 use crate::core::distribution::*;
906 use crate::core::model::sample;
907
908 use rand::rngs::StdRng;
909 use rand::SeedableRng;
910
911 #[test]
912 fn distance_functions_work() {
913 let eu = EuclideanDistance;
914 let man = ManhattanDistance;
915 let a = vec![1.0, 2.0, 3.0];
916 let b = vec![1.1, 2.1, 2.9];
917 let d_eu = eu.distance(&a, &b);
918 let d_man = man.distance(&a, &b);
919 assert!(d_eu > 0.0);
920 assert!(d_man > 0.0);
921 // Euclidean should be <= Manhattan for same vectors
922 assert!(d_eu <= d_man + 1e-12);
923 }
924
925 #[test]
926 fn abc_scalar_summary_accepts_with_large_tolerance() {
927 let mut rng = StdRng::seed_from_u64(42);
928 let samples = abc_scalar_summary(
929 &mut rng,
930 || sample(addr!("mu"), Normal::new(0.0, 2.0).unwrap()),
931 |trace| trace.get_f64(&addr!("mu")).unwrap_or(0.0),
932 0.0, // observed summary
933 10.0, // large tolerance to ensure acceptance
934 3,
935 );
936 assert!(!samples.is_empty());
937 }
938
939 #[test]
940 fn abc_rejection_can_return_empty_with_tight_tolerance() {
941 let mut rng = StdRng::seed_from_u64(43);
942 let observed = vec![1000.0]; // far from prior mean 0
943 let res = abc_rejection(
944 &mut rng,
945 || sample(addr!("mu"), Normal::new(0.0, 1.0).unwrap()),
946 |trace| vec![trace.get_f64(&addr!("mu")).unwrap_or(0.0)],
947 &observed,
948 &EuclideanDistance,
949 1e-6, // extremely tight
950 3,
951 );
952 assert!(res.is_empty());
953 }
954
955 #[test]
956 fn abc_smc_respects_tolerance_schedule() {
957 let mut rng = StdRng::seed_from_u64(44);
958 let observed = vec![0.0];
959 let config = ABCSMCConfig {
960 initial_tolerance: 2.0,
961 tolerance_schedule: vec![1.0, 0.5],
962 particles_per_round: 4,
963 };
964 let res = abc_smc(
965 &mut rng,
966 || sample(addr!("mu"), Normal::new(0.0, 1.0).unwrap()),
967 |trace| vec![trace.get_f64(&addr!("mu")).unwrap_or(0.0)],
968 &observed,
969 &EuclideanDistance,
970 config,
971 );
972 assert_eq!(res.len(), 4);
973 }
974}