use crate::core::address::Address;
use crate::core::distribution::Distribution;
use crate::core::model::Model;
use crate::inference::mcmc_utils::DiminishingAdaptation;
use crate::runtime::handler::{run, Handler};
use crate::runtime::interpreters::{PriorHandler, ScoreGivenTrace};
use crate::runtime::trace::{Choice, ChoiceValue, Trace};
use rand::{Rng, RngCore};
use std::collections::HashMap;
const NEG_SUPPORT_PROBE: f64 = -1.0;
fn gaussian_z(rng: &mut dyn RngCore) -> f64 {
let u1: f64 = rng.gen::<f64>().max(1e-10); let u2: f64 = rng.gen();
(-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos()
}
fn normal_logpdf(x: f64, mean: f64, sd: f64) -> f64 {
let z = (x - mean) / sd;
-0.5 * z * z - sd.ln() - 0.5 * (2.0 * std::f64::consts::PI).ln()
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum SiteProposal {
Gaussian,
LogSpace,
Reflect {
lower: f64,
upper: f64,
},
PriorResample,
}
pub trait ProposalStrategy<T> {
fn propose(&self, current: T, scale: f64, rng: &mut dyn RngCore) -> T;
fn log_proposal_prob(&self, from: T, to: T, scale: f64) -> f64 {
let _ = (from, to, scale);
0.0 }
}
pub struct GaussianWalkProposal;
impl ProposalStrategy<f64> for GaussianWalkProposal {
fn propose(&self, current: f64, scale: f64, rng: &mut dyn RngCore) -> f64 {
current + scale * gaussian_z(rng)
}
}
pub struct LogSpaceWalkProposal;
impl ProposalStrategy<f64> for LogSpaceWalkProposal {
fn propose(&self, current: f64, scale: f64, rng: &mut dyn RngCore) -> f64 {
if current <= 0.0 {
return f64::MIN_POSITIVE;
}
let z = gaussian_z(rng);
let proposed = (current.ln() + scale * z).exp();
if proposed.is_finite() {
proposed.max(f64::MIN_POSITIVE)
} else {
f64::MAX
}
}
fn log_proposal_prob(&self, from: f64, to: f64, scale: f64) -> f64 {
if from <= 0.0 || to <= 0.0 {
return 0.0;
}
normal_logpdf(to.ln(), from.ln(), scale) - to.ln()
}
}
pub struct ReflectionWalkProposal {
pub lower_bound: f64,
pub upper_bound: f64,
}
impl ProposalStrategy<f64> for ReflectionWalkProposal {
fn propose(&self, current: f64, scale: f64, rng: &mut dyn RngCore) -> f64 {
let mut proposed = current + scale * gaussian_z(rng);
let range = self.upper_bound - self.lower_bound;
if range <= 0.0 {
return current; }
while proposed < self.lower_bound || proposed > self.upper_bound {
if proposed < self.lower_bound {
proposed = 2.0 * self.lower_bound - proposed;
}
if proposed > self.upper_bound {
proposed = 2.0 * self.upper_bound - proposed;
}
}
proposed.clamp(self.lower_bound, self.upper_bound)
}
}
pub struct FlipProposal;
impl ProposalStrategy<bool> for FlipProposal {
fn propose(&self, current: bool, _scale: f64, _rng: &mut dyn RngCore) -> bool {
!current
}
}
pub struct DiscreteWalkProposal;
impl ProposalStrategy<u64> for DiscreteWalkProposal {
fn propose(&self, current: u64, scale: f64, rng: &mut dyn RngCore) -> u64 {
let delta = (scale * gaussian_z(rng)).round() as i64;
let k = current as i64 + delta;
if k >= 0 {
k as u64
} else {
(-k - 1) as u64 }
}
}
struct SingleSiteProposalHandler<'a, R: RngCore> {
rng: &'a mut R,
base: &'a Trace,
target: &'a Address,
scale: f64,
overrides: &'a HashMap<Address, SiteProposal>,
kind_cache: &'a mut HashMap<Address, SiteProposal>,
log_q_forward: &'a mut f64,
log_q_reverse: &'a mut f64,
trace: Trace,
}
impl<'a, R: RngCore> SingleSiteProposalHandler<'a, R> {
fn f64_kind(
&mut self,
addr: &Address,
current: f64,
dist: &dyn Distribution<f64>,
) -> SiteProposal {
if let Some(&k) = self.overrides.get(addr) {
return k;
}
if let Some(&k) = self.kind_cache.get(addr) {
return k;
}
let kind = if current > 0.0 && !dist.log_prob(&NEG_SUPPORT_PROBE).is_finite() {
SiteProposal::LogSpace
} else {
SiteProposal::Gaussian
};
self.kind_cache.insert(addr.clone(), kind);
kind
}
}
impl<'a, R: RngCore> Handler for SingleSiteProposalHandler<'a, R> {
fn on_sample_f64(&mut self, addr: &Address, dist: &dyn Distribution<f64>) -> f64 {
if addr == self.target {
let current = self
.base
.get_f64(addr)
.unwrap_or_else(|| dist.sample(self.rng));
let kind = self.f64_kind(addr, current, dist);
let (proposed, lqf, lqr) = match kind {
SiteProposal::Gaussian => {
let s = GaussianWalkProposal;
let p = s.propose(current, self.scale, self.rng);
(
p,
s.log_proposal_prob(current, p, self.scale),
s.log_proposal_prob(p, current, self.scale),
)
}
SiteProposal::LogSpace => {
let s = LogSpaceWalkProposal;
let p = s.propose(current, self.scale, self.rng);
(
p,
s.log_proposal_prob(current, p, self.scale),
s.log_proposal_prob(p, current, self.scale),
)
}
SiteProposal::Reflect { lower, upper } => {
let s = ReflectionWalkProposal {
lower_bound: lower,
upper_bound: upper,
};
let p = s.propose(current, self.scale, self.rng);
(
p,
s.log_proposal_prob(current, p, self.scale),
s.log_proposal_prob(p, current, self.scale),
)
}
SiteProposal::PriorResample => {
let p = dist.sample(self.rng);
(p, dist.log_prob(&p), dist.log_prob(¤t))
}
};
*self.log_q_forward += lqf;
*self.log_q_reverse += lqr;
let lp = dist.log_prob(&proposed);
self.trace.log_prior += lp;
self.trace.choices.insert(
addr.clone(),
Choice {
addr: addr.clone(),
value: ChoiceValue::F64(proposed),
logp: lp,
},
);
proposed
} else {
let (x, born) = match self.base.get_f64(addr) {
Some(v) => (v, false),
None => (dist.sample(self.rng), true),
};
let lp = dist.log_prob(&x);
if born {
*self.log_q_forward += lp;
}
self.trace.log_prior += lp;
self.trace.choices.insert(
addr.clone(),
Choice {
addr: addr.clone(),
value: ChoiceValue::F64(x),
logp: lp,
},
);
x
}
}
fn on_sample_bool(&mut self, addr: &Address, dist: &dyn Distribution<bool>) -> bool {
let mut born = false;
let x = if addr == self.target {
let current = self
.base
.get_bool(addr)
.unwrap_or_else(|| dist.sample(self.rng));
FlipProposal.propose(current, self.scale, self.rng)
} else {
match self.base.get_bool(addr) {
Some(v) => v,
None => {
born = true;
dist.sample(self.rng)
}
}
};
let lp = dist.log_prob(&x);
if born {
*self.log_q_forward += lp;
}
self.trace.log_prior += lp;
self.trace.choices.insert(
addr.clone(),
Choice {
addr: addr.clone(),
value: ChoiceValue::Bool(x),
logp: lp,
},
);
x
}
fn on_sample_u64(&mut self, addr: &Address, dist: &dyn Distribution<u64>) -> u64 {
let mut born = false;
let x = if addr == self.target {
let current = self
.base
.get_u64(addr)
.unwrap_or_else(|| dist.sample(self.rng));
DiscreteWalkProposal.propose(current, self.scale, self.rng)
} else {
match self.base.get_u64(addr) {
Some(v) => v,
None => {
born = true;
dist.sample(self.rng)
}
}
};
let lp = dist.log_prob(&x);
if born {
*self.log_q_forward += lp;
}
self.trace.log_prior += lp;
self.trace.choices.insert(
addr.clone(),
Choice {
addr: addr.clone(),
value: ChoiceValue::U64(x),
logp: lp,
},
);
x
}
fn on_sample_usize(&mut self, addr: &Address, dist: &dyn Distribution<usize>) -> usize {
let mut born = false;
let x = if addr == self.target {
let current = self
.base
.get_usize(addr)
.unwrap_or_else(|| dist.sample(self.rng));
let proposed = dist.sample(self.rng);
*self.log_q_forward += dist.log_prob(&proposed);
*self.log_q_reverse += dist.log_prob(¤t);
proposed
} else {
match self.base.get_usize(addr) {
Some(v) => v,
None => {
born = true;
dist.sample(self.rng)
}
}
};
let lp = dist.log_prob(&x);
if born {
*self.log_q_forward += lp;
}
self.trace.log_prior += lp;
self.trace.choices.insert(
addr.clone(),
Choice {
addr: addr.clone(),
value: ChoiceValue::Usize(x),
logp: lp,
},
);
x
}
fn on_sample_i64(&mut self, addr: &Address, dist: &dyn Distribution<i64>) -> i64 {
let mut born = false;
let x = if addr == self.target {
let current = self
.base
.get_i64(addr)
.unwrap_or_else(|| dist.sample(self.rng));
let delta = (self.scale * gaussian_z(self.rng)).round() as i64;
current + delta
} else {
match self.base.get_i64(addr) {
Some(v) => v,
None => {
born = true;
dist.sample(self.rng)
}
}
};
let lp = dist.log_prob(&x);
if born {
*self.log_q_forward += lp;
}
self.trace.log_prior += lp;
self.trace.choices.insert(
addr.clone(),
Choice {
addr: addr.clone(),
value: ChoiceValue::I64(x),
logp: lp,
},
);
x
}
fn on_observe_f64(&mut self, _addr: &Address, dist: &dyn Distribution<f64>, value: f64) {
self.trace.log_likelihood += dist.log_prob(&value);
}
fn on_observe_bool(&mut self, _addr: &Address, dist: &dyn Distribution<bool>, value: bool) {
self.trace.log_likelihood += dist.log_prob(&value);
}
fn on_observe_u64(&mut self, _addr: &Address, dist: &dyn Distribution<u64>, value: u64) {
self.trace.log_likelihood += dist.log_prob(&value);
}
fn on_observe_usize(&mut self, _addr: &Address, dist: &dyn Distribution<usize>, value: usize) {
self.trace.log_likelihood += dist.log_prob(&value);
}
fn on_observe_i64(&mut self, _addr: &Address, dist: &dyn Distribution<i64>, value: i64) {
self.trace.log_likelihood += dist.log_prob(&value);
}
fn on_factor(&mut self, logw: f64) {
self.trace.log_factors += logw;
}
fn finish(self) -> Trace {
self.trace
}
}
#[allow(clippy::type_complexity)]
fn propose_and_score<A, F, R>(
rng: &mut R,
model_fn: &F,
current: &Trace,
target: &Address,
scale: f64,
overrides: &HashMap<Address, SiteProposal>,
kind_cache: &mut HashMap<Address, SiteProposal>,
) -> (A, Trace, f64, f64, f64, bool)
where
F: Fn() -> Model<A>,
R: Rng,
{
let mut lqf = 0.0;
let mut lqr = 0.0;
let (a, trace) = run(
SingleSiteProposalHandler {
rng,
base: current,
target,
scale,
overrides,
kind_cache,
log_q_forward: &mut lqf,
log_q_reverse: &mut lqr,
trace: Trace::default(),
},
model_fn(),
);
let mut died = 0usize;
for (addr, choice) in ¤t.choices {
if !trace.choices.contains_key(addr) {
lqr += choice.logp;
died += 1;
}
}
let born = trace.choices.len() + died - current.choices.len();
let structure_changed = born > 0 || died > 0;
let lw = trace.total_log_weight();
(a, trace, lw, lqf, lqr, structure_changed)
}
#[allow(clippy::too_many_arguments)]
fn single_site_mh_step<A, F, R>(
rng: &mut R,
model_fn: &F,
current: &Trace,
current_lw: f64,
sites: &[Address],
adaptation: &mut DiminishingAdaptation,
overrides: &HashMap<Address, SiteProposal>,
kind_cache: &mut HashMap<Address, SiteProposal>,
adapt: bool,
) -> Option<(A, Trace, f64, bool)>
where
F: Fn() -> Model<A>,
R: Rng,
{
if sites.is_empty() {
return None;
}
let target = sites[rng.gen_range(0..sites.len())].clone();
let scale = adaptation.get_scale(&target);
let (a_prop, prop_trace, prop_lw, lqf, lqr, structure_changed) = propose_and_score(
rng, model_fn, current, &target, scale, overrides, kind_cache,
);
let dim_term = (sites.len() as f64).ln() - (prop_trace.choices.len() as f64).ln();
let log_alpha = prop_lw - current_lw + (lqr - lqf) + dim_term;
let accept = log_alpha >= 0.0 || rng.gen::<f64>() < log_alpha.exp();
if adapt {
adaptation.update(&target, accept);
}
if accept {
Some((a_prop, prop_trace, prop_lw, structure_changed))
} else {
None
}
}
pub fn adaptive_single_site_mh<A, R: Rng>(
rng: &mut R,
model_fn: impl Fn() -> Model<A>,
current: &Trace,
adaptation: &mut DiminishingAdaptation,
) -> (A, Trace) {
let overrides: HashMap<Address, SiteProposal> = HashMap::new();
let mut kind_cache: HashMap<Address, SiteProposal> = HashMap::new();
if current.choices.is_empty() {
let (a, _) = run(
ScoreGivenTrace {
base: current.clone(),
trace: Trace::default(),
},
model_fn(),
);
return (a, current.clone());
}
let (a_cur, cur_scored) = run(
ScoreGivenTrace {
base: current.clone(),
trace: Trace::default(),
},
model_fn(),
);
let current_lw = cur_scored.total_log_weight();
let sites: Vec<Address> = current.choices.keys().cloned().collect();
let target = sites[rng.gen_range(0..sites.len())].clone();
let scale = adaptation.get_scale(&target);
let (a_prop, prop_trace, prop_lw, lqf, lqr, _structure_changed) = propose_and_score(
rng,
&model_fn,
current,
&target,
scale,
&overrides,
&mut kind_cache,
);
let dim_term = (sites.len() as f64).ln() - (prop_trace.choices.len() as f64).ln();
let log_alpha = prop_lw - current_lw + (lqr - lqf) + dim_term;
let accept = log_alpha >= 0.0 || rng.gen::<f64>() < log_alpha.exp();
adaptation.update(&target, accept);
if accept {
(a_prop, prop_trace)
} else {
(a_cur, current.clone())
}
}
pub fn adaptive_mcmc_chain<A: Clone, R: Rng>(
rng: &mut R,
model_fn: impl Fn() -> Model<A>,
n_samples: usize,
n_warmup: usize,
) -> Vec<(A, Trace)> {
let overrides: HashMap<Address, SiteProposal> = HashMap::new();
adaptive_mcmc_chain_with_overrides(rng, model_fn, n_samples, n_warmup, &overrides)
}
pub fn adaptive_mcmc_chain_with_overrides<A: Clone, R: Rng>(
rng: &mut R,
model_fn: impl Fn() -> Model<A>,
n_samples: usize,
n_warmup: usize,
overrides: &HashMap<Address, SiteProposal>,
) -> Vec<(A, Trace)> {
let mut samples = Vec::with_capacity(n_samples);
let mut adaptation = DiminishingAdaptation::new(0.44, 0.7);
let mut kind_cache: HashMap<Address, SiteProposal> = HashMap::new();
let (mut current_a, mut current_trace) = run(
PriorHandler {
rng,
trace: Trace::default(),
},
model_fn(),
);
let mut current_lw = current_trace.total_log_weight();
let mut sites: Vec<Address> = current_trace.choices.keys().cloned().collect();
for _ in 0..n_warmup {
if let Some((a, t, lw, structure_changed)) = single_site_mh_step(
rng,
&model_fn,
¤t_trace,
current_lw,
&sites,
&mut adaptation,
overrides,
&mut kind_cache,
true, ) {
current_a = a;
current_trace = t;
current_lw = lw;
if structure_changed {
sites = current_trace.choices.keys().cloned().collect();
}
}
}
for _ in 0..n_samples {
if let Some((a, t, lw, structure_changed)) = single_site_mh_step(
rng,
&model_fn,
¤t_trace,
current_lw,
&sites,
&mut adaptation,
overrides,
&mut kind_cache,
false, ) {
current_a = a;
current_trace = t;
current_lw = lw;
if structure_changed {
sites = current_trace.choices.keys().cloned().collect();
}
}
samples.push((current_a.clone(), current_trace.clone()));
}
samples
}
pub fn single_site_random_walk_mh<A, R: Rng>(
rng: &mut R,
_proposal_sigma: f64,
model_fn: impl Fn() -> Model<A>,
current: &Trace,
) -> (A, Trace) {
let mut adaptation = DiminishingAdaptation::new(0.44, 0.7);
adaptive_single_site_mh(rng, model_fn, current, &mut adaptation)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::addr;
use crate::core::distribution::*;
use crate::core::model::{observe, sample, ModelExt};
use crate::runtime::handler::run;
use rand::rngs::StdRng;
use rand::SeedableRng;
#[test]
fn gaussian_walk_proposal_produces_variation() {
let mut rng = StdRng::seed_from_u64(11);
let strat = GaussianWalkProposal;
let x1 = strat.propose(0.0, 1.0, &mut rng);
assert!(x1.is_finite());
}
#[test]
fn log_space_proposal_maintains_positivity() {
let mut rng = StdRng::seed_from_u64(42);
let strat = LogSpaceWalkProposal;
for ¤t in &[0.1, 1.0, 10.0, 100.0] {
for _ in 0..20 {
let proposed = strat.propose(current, 0.5, &mut rng);
assert!(
proposed > 0.0,
"LogSpaceWalk proposed non-positive: {current} -> {proposed}"
);
assert!(
proposed.is_finite(),
"LogSpaceWalk proposed non-finite: {proposed}"
);
}
}
}
#[test]
fn log_space_jacobian_is_correct() {
let s = LogSpaceWalkProposal;
let (x, xp, scale) = (2.0_f64, 3.5_f64, 0.7_f64);
let fwd = s.log_proposal_prob(x, xp, scale);
let rev = s.log_proposal_prob(xp, x, scale);
let net = rev - fwd;
let expected = xp.ln() - x.ln();
assert!(
(net - expected).abs() < 1e-12,
"net correction {net} != {expected}"
);
}
#[test]
fn reflection_proposal_respects_bounds() {
let mut rng = StdRng::seed_from_u64(43);
let strat = ReflectionWalkProposal {
lower_bound: 0.0,
upper_bound: 1.0,
};
for ¤t in &[0.1, 0.5, 0.9] {
for _ in 0..20 {
let proposed = strat.propose(current, 0.3, &mut rng);
assert!(
(0.0..=1.0).contains(&proposed),
"bounds violated: {current} -> {proposed}"
);
}
}
}
#[test]
fn discrete_and_flip_proposals_preserve_types() {
let mut rng = StdRng::seed_from_u64(12);
let u = DiscreteWalkProposal.propose(5u64, 1.0, &mut rng);
let _ = u;
let b = FlipProposal.propose(true, 1.0, &mut rng);
assert!(!b); }
#[test]
fn discrete_walk_is_symmetric_at_boundary() {
let mut rng = StdRng::seed_from_u64(2718);
let s = DiscreteWalkProposal;
let scale = 1.5;
let iters = 400_000;
let estimate = |from: u64, to: u64, rng: &mut StdRng| -> f64 {
let mut hits = 0u64;
for _ in 0..iters {
if s.propose(from, scale, rng) == to {
hits += 1;
}
}
hits as f64 / iters as f64
};
for &(a, b) in &[(0u64, 1u64), (0, 2), (1, 3), (2, 5)] {
let q_ab = estimate(a, b, &mut rng);
let q_ba = estimate(b, a, &mut rng);
let diff = (q_ab - q_ba).abs();
assert!(
diff < 0.004,
"asymmetry at ({a},{b}): q_ab={q_ab:.4}, q_ba={q_ba:.4}, diff={diff:.4}"
);
}
}
#[test]
fn adaptive_chain_runs_and_returns_samples() {
let model_fn = || {
sample(addr!("mu"), Normal::new(0.0, 1.0).unwrap()).and_then(|mu| {
observe(addr!("y"), Normal::new(mu, 1.0).unwrap(), 0.5).map(move |_| mu)
})
};
let mut rng = StdRng::seed_from_u64(13);
let samples = adaptive_mcmc_chain(&mut rng, model_fn, 5, 2);
assert_eq!(samples.len(), 5);
for (_val, t) in &samples {
assert!(t.get_f64(&addr!("mu")).is_some());
}
}
#[test]
fn returned_trace_weight_matches_fresh_rescore() {
let model_fn = || {
sample(addr!("mu"), Normal::new(0.0, 2.0).unwrap()).and_then(|mu| {
observe(addr!("y"), Normal::new(mu, 1.0).unwrap(), 1.3).map(move |_| mu)
})
};
let mut rng = StdRng::seed_from_u64(77);
let samples = adaptive_mcmc_chain(&mut rng, model_fn, 20, 20);
for (_v, t) in &samples {
let (_a, fresh) = run(
ScoreGivenTrace {
base: t.clone(),
trace: Trace::default(),
},
model_fn(),
);
assert!(
(t.total_log_weight() - fresh.total_log_weight()).abs() < 1e-9,
"stale accumulators: {} vs {}",
t.total_log_weight(),
fresh.total_log_weight()
);
}
}
#[test]
fn one_model_run_per_transition() {
use std::cell::Cell;
let count = Cell::new(0usize);
let model_fn = || {
count.set(count.get() + 1);
sample(addr!("mu"), Normal::new(0.0, 1.0).unwrap()).and_then(|mu| {
observe(addr!("y"), Normal::new(mu, 1.0).unwrap(), 0.5).map(move |_| mu)
})
};
let mut rng = StdRng::seed_from_u64(5);
let n_warmup = 30;
let n_samples = 40;
let _ = adaptive_mcmc_chain(&mut rng, model_fn, n_samples, n_warmup);
assert_eq!(count.get(), 1 + n_warmup + n_samples);
}
#[test]
fn adaptation_freezes_after_warmup() {
let model_fn = || {
sample(addr!("mu"), Normal::new(0.0, 1.0).unwrap()).and_then(|mu| {
observe(addr!("y"), Normal::new(mu, 1.0).unwrap(), 0.5).map(move |_| mu)
})
};
let mut rng = StdRng::seed_from_u64(99);
let mut adaptation = DiminishingAdaptation::new(0.44, 0.7);
let overrides: HashMap<Address, SiteProposal> = HashMap::new();
let mut kind_cache: HashMap<Address, SiteProposal> = HashMap::new();
let (_a, mut current) = run(
PriorHandler {
rng: &mut rng,
trace: Trace::default(),
},
model_fn(),
);
let mut current_lw = current.total_log_weight();
let sites: Vec<Address> = current.choices.keys().cloned().collect();
for _ in 0..100 {
if let Some((_a, t, lw, _sc)) = single_site_mh_step(
&mut rng,
&model_fn,
¤t,
current_lw,
&sites,
&mut adaptation,
&overrides,
&mut kind_cache,
true,
) {
current = t;
current_lw = lw;
}
}
let scales_before = adaptation.scales.clone();
for _ in 0..200 {
if let Some((_a, t, lw, _sc)) = single_site_mh_step(
&mut rng,
&model_fn,
¤t,
current_lw,
&sites,
&mut adaptation,
&overrides,
&mut kind_cache,
false,
) {
current = t;
current_lw = lw;
}
}
assert_eq!(
scales_before, adaptation.scales,
"scales changed while adaptation was frozen"
);
let before = adaptation.get_scale(&sites[0]);
for _ in 0..100 {
let _ = single_site_mh_step(
&mut rng,
&model_fn,
¤t,
current_lw,
&sites,
&mut adaptation,
&overrides,
&mut kind_cache,
true,
);
}
let after = adaptation.get_scale(&sites[0]);
assert!(
(before - after).abs() > 0.0,
"adaptation did nothing while enabled"
);
}
}