use crate::core::address::Address;
use crate::core::model::Model;
use crate::runtime::handler::run;
use crate::runtime::interpreters::{PriorHandler, ScoreGivenTrace};
use crate::runtime::trace::{ChoiceValue, Trace};
use rand::Rng;
use rand_distr::StandardNormal;
#[derive(Clone, Copy, Debug)]
pub struct HMCConfig {
pub n_leapfrog: usize,
pub target_accept: f64,
pub init_step_size: Option<f64>,
pub finite_diff_eps: f64,
pub adapt_mass: bool,
}
impl Default for HMCConfig {
fn default() -> Self {
HMCConfig {
n_leapfrog: 16,
target_accept: 0.8,
init_step_size: None,
finite_diff_eps: 1e-5,
adapt_mass: false,
}
}
}
#[derive(Clone, Debug)]
struct DualAveraging {
mu: f64,
log_eps_bar: f64,
h_bar: f64,
m: u64,
gamma: f64,
t0: f64,
kappa: f64,
target: f64,
}
impl DualAveraging {
fn new(eps0: f64, target: f64) -> Self {
DualAveraging {
mu: (10.0 * eps0).ln(),
log_eps_bar: 0.0,
h_bar: 0.0,
m: 0,
gamma: 0.05,
t0: 10.0,
kappa: 0.75,
target,
}
}
fn update(&mut self, alpha: f64) -> f64 {
self.m += 1;
let m = self.m as f64;
let a = alpha.clamp(0.0, 1.0);
let frac = 1.0 / (m + self.t0);
self.h_bar = (1.0 - frac) * self.h_bar + frac * (self.target - a);
let log_eps = self.mu - (m.sqrt() / self.gamma) * self.h_bar;
let w = m.powf(-self.kappa);
self.log_eps_bar = w * log_eps + (1.0 - w) * self.log_eps_bar;
log_eps.exp()
}
fn frozen_step(&self) -> f64 {
self.log_eps_bar.exp()
}
}
struct Welford {
n: u64,
mean: Vec<f64>,
m2: Vec<f64>,
}
impl Welford {
fn new(d: usize) -> Self {
Welford {
n: 0,
mean: vec![0.0; d],
m2: vec![0.0; d],
}
}
fn push(&mut self, x: &[f64]) {
self.n += 1;
let n = self.n as f64;
for ((xi, mean), m2) in x.iter().zip(self.mean.iter_mut()).zip(self.m2.iter_mut()) {
let delta = xi - *mean;
*mean += delta / n;
let delta2 = xi - *mean;
*m2 += delta * delta2;
}
}
fn variances(&self) -> Vec<f64> {
if self.n < 2 {
return vec![1.0; self.mean.len()];
}
let denom = (self.n - 1) as f64;
self.m2
.iter()
.map(|&s| {
let v = s / denom;
if v.is_finite() && v > 1e-8 {
v
} else {
1.0
}
})
.collect()
}
}
fn positions_from_trace(trace: &Trace) -> (Vec<Address>, Vec<f64>) {
let mut sites = Vec::new();
let mut q = Vec::new();
for (addr, choice) in &trace.choices {
if let ChoiceValue::F64(v) = choice.value {
sites.push(addr.clone());
q.push(v);
}
}
(sites, q)
}
fn trace_with_positions(base: &Trace, sites: &[Address], q: &[f64]) -> Trace {
let mut t = base.clone();
for (addr, &val) in sites.iter().zip(q.iter()) {
if let Some(choice) = t.choices.get_mut(addr) {
choice.value = ChoiceValue::F64(val);
}
}
t
}
fn log_joint_at<A>(
model_fn: &impl Fn() -> Model<A>,
base: &Trace,
sites: &[Address],
q: &[f64],
) -> f64 {
let candidate = trace_with_positions(base, sites, q);
let (_a, scored) = run(
ScoreGivenTrace {
base: candidate,
trace: Trace::default(),
},
model_fn(),
);
scored.total_log_weight()
}
fn score_full<A>(
model_fn: &impl Fn() -> Model<A>,
base: &Trace,
sites: &[Address],
q: &[f64],
) -> (A, Trace, f64) {
let candidate = trace_with_positions(base, sites, q);
let (a, scored) = run(
ScoreGivenTrace {
base: candidate,
trace: Trace::default(),
},
model_fn(),
);
let lw = scored.total_log_weight();
(a, scored, lw)
}
fn grad_log_joint<A>(
model_fn: &impl Fn() -> Model<A>,
base: &Trace,
sites: &[Address],
q: &[f64],
h: f64,
) -> (Vec<f64>, bool) {
let d = q.len();
let mut g = vec![0.0; d];
let mut ok = true;
let mut qq = q.to_vec();
for i in 0..d {
let orig = qq[i];
qq[i] = orig + h;
let lp = log_joint_at(model_fn, base, sites, &qq);
qq[i] = orig - h;
let lm = log_joint_at(model_fn, base, sites, &qq);
qq[i] = orig;
let gi = (lp - lm) / (2.0 * h);
if !gi.is_finite() {
ok = false;
}
g[i] = gi;
}
(g, ok)
}
#[allow(clippy::too_many_arguments)]
fn leapfrog<A>(
model_fn: &impl Fn() -> Model<A>,
base: &Trace,
sites: &[Address],
q0: &[f64],
p0: &[f64],
eps: f64,
l: usize,
h: f64,
m_inv: &[f64],
) -> (Vec<f64>, Vec<f64>, bool) {
let d = q0.len();
let mut q = q0.to_vec();
let mut p = p0.to_vec();
let (mut grad, ok) = grad_log_joint(model_fn, base, sites, &q, h);
if !ok {
return (q, p, true);
}
for _ in 0..l {
for i in 0..d {
p[i] += 0.5 * eps * grad[i];
}
for i in 0..d {
q[i] += eps * m_inv[i] * p[i];
}
let (g2, ok2) = grad_log_joint(model_fn, base, sites, &q, h);
grad = g2;
if !ok2 {
return (q, p, true);
}
for i in 0..d {
p[i] += 0.5 * eps * grad[i];
}
}
(q, p, false)
}
type TransitionOut<A> = (bool, Vec<f64>, Option<(A, Trace, f64)>, f64);
#[allow(clippy::too_many_arguments)]
fn hmc_transition<A: Clone, R: Rng>(
rng: &mut R,
model_fn: &impl Fn() -> Model<A>,
base: &Trace,
sites: &[Address],
q_cur: &[f64],
lj_cur: f64,
eps: f64,
l: usize,
h: f64,
m_inv: &[f64],
mass_sqrt: &[f64],
) -> TransitionOut<A> {
let d = q_cur.len();
let p0: Vec<f64> = (0..d)
.map(|i| {
let z: f64 = rng.sample(StandardNormal);
z * mass_sqrt[i]
})
.collect();
let k0 = 0.5 * (0..d).map(|i| p0[i] * p0[i] * m_inv[i]).sum::<f64>();
let h0 = -lj_cur + k0;
let (q_new, p_new, divergent) = leapfrog(model_fn, base, sites, q_cur, &p0, eps, l, h, m_inv);
if divergent {
return (false, q_cur.to_vec(), None, 0.0);
}
let (a_new, t_new, lj_new) = score_full(model_fn, base, sites, &q_new);
if !lj_new.is_finite() {
return (false, q_cur.to_vec(), None, 0.0);
}
let k_new = 0.5 * (0..d).map(|i| p_new[i] * p_new[i] * m_inv[i]).sum::<f64>();
let h_new = -lj_new + k_new;
let accept_prob = (h0 - h_new).exp().min(1.0);
let accept = rng.gen::<f64>() < accept_prob;
if accept {
(true, q_new, Some((a_new, t_new, lj_new)), accept_prob)
} else {
(false, q_cur.to_vec(), None, accept_prob)
}
}
#[allow(clippy::too_many_arguments)]
fn find_reasonable_epsilon<A, R: Rng>(
rng: &mut R,
model_fn: &impl Fn() -> Model<A>,
base: &Trace,
sites: &[Address],
q: &[f64],
lj_q: f64,
h: f64,
m_inv: &[f64],
mass_sqrt: &[f64],
) -> f64 {
let d = q.len();
let p0: Vec<f64> = (0..d)
.map(|i| {
let z: f64 = rng.sample(StandardNormal);
z * mass_sqrt[i]
})
.collect();
let k0 = 0.5 * (0..d).map(|i| p0[i] * p0[i] * m_inv[i]).sum::<f64>();
let h0 = -lj_q + k0;
let log_ratio_at = |eps: f64| -> f64 {
let (q1, p1, divergent) = leapfrog(model_fn, base, sites, q, &p0, eps, 1, h, m_inv);
if divergent {
return f64::NEG_INFINITY;
}
let lj1 = log_joint_at(model_fn, base, sites, &q1);
if !lj1.is_finite() {
return f64::NEG_INFINITY;
}
let k1 = 0.5 * (0..d).map(|i| p1[i] * p1[i] * m_inv[i]).sum::<f64>();
h0 - (-lj1 + k1)
};
let mut eps = 1.0_f64;
let mut lr = log_ratio_at(eps);
let ln_half = 0.5_f64.ln();
let ln2 = 2.0_f64.ln();
let a = if lr > ln_half { 1.0 } else { -1.0 };
let mut iters = 0u32;
while a * lr > -a * ln2 {
eps *= 2.0_f64.powf(a);
lr = log_ratio_at(eps);
iters += 1;
if iters > 100 || !(1e-12..=1e12).contains(&eps) {
break;
}
if a > 0.0 && lr == f64::NEG_INFINITY {
eps /= 2.0;
break;
}
}
eps.clamp(1e-6, 1e3)
}
pub fn hmc_chain<A: Clone, R: Rng>(
rng: &mut R,
model_fn: impl Fn() -> Model<A>,
n_samples: usize,
n_warmup: usize,
config: HMCConfig,
) -> Vec<(A, Trace)> {
let (mut cur_a, mut cur_trace) = run(
PriorHandler {
rng,
trace: Trace::default(),
},
model_fn(),
);
let (sites, mut q) = positions_from_trace(&cur_trace);
let d = sites.len();
if d == 0 {
let mut out = Vec::with_capacity(n_samples);
for _ in 0..n_samples {
let (a, t) = run(
PriorHandler {
rng,
trace: Trace::default(),
},
model_fn(),
);
out.push((a, t));
}
return out;
}
let h = config.finite_diff_eps;
let l = config.n_leapfrog.max(1);
let mut m_inv = vec![1.0; d];
let mut mass_sqrt = vec![1.0; d];
let mut lj_cur = cur_trace.total_log_weight();
let eps0 = match config.init_step_size {
Some(e) => e,
None => find_reasonable_epsilon(
rng, &model_fn, &cur_trace, &sites, &q, lj_cur, h, &m_inv, &mass_sqrt,
),
};
let mut da = DualAveraging::new(eps0, config.target_accept);
let mut eps = eps0;
let mut welford = Welford::new(d);
let mass_adapt_at = if config.adapt_mass && n_warmup >= 4 {
Some(n_warmup / 2)
} else {
None
};
for iter in 0..n_warmup {
let (accepted, q_new, endpoint, alpha) = hmc_transition(
rng, &model_fn, &cur_trace, &sites, &q, lj_cur, eps, l, h, &m_inv, &mass_sqrt,
);
if accepted {
let (a, t, lj) = endpoint.unwrap();
cur_a = a;
cur_trace = t;
q = q_new;
lj_cur = lj;
}
eps = da.update(alpha);
if mass_adapt_at.is_some() {
welford.push(&q);
}
if Some(iter + 1) == mass_adapt_at {
let vars = welford.variances();
for i in 0..d {
m_inv[i] = vars[i];
mass_sqrt[i] = (1.0 / vars[i]).sqrt();
}
let eps_reset = find_reasonable_epsilon(
rng, &model_fn, &cur_trace, &sites, &q, lj_cur, h, &m_inv, &mass_sqrt,
);
da = DualAveraging::new(eps_reset, config.target_accept);
eps = eps_reset;
}
}
let final_eps = if n_warmup > 0 { da.frozen_step() } else { eps };
let mut out = Vec::with_capacity(n_samples);
for _ in 0..n_samples {
let (accepted, q_new, endpoint, _alpha) = hmc_transition(
rng, &model_fn, &cur_trace, &sites, &q, lj_cur, final_eps, l, h, &m_inv, &mass_sqrt,
);
if accepted {
let (a, t, lj) = endpoint.unwrap();
cur_a = a;
cur_trace = t;
q = q_new;
lj_cur = lj;
}
out.push((cur_a.clone(), cur_trace.clone()));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::addr;
use crate::core::distribution::{Distribution, Normal};
use crate::core::model::{observe, sample, ModelExt};
use rand::rngs::StdRng;
use rand::SeedableRng;
#[test]
fn fg31_dual_averaging_moves_step_size_toward_target() {
let mut da = DualAveraging::new(1.0, 0.8);
for _ in 0..200 {
let _ = da.update(0.1);
}
assert!(
da.frozen_step() < 1.0,
"low acceptance should shrink eps, got {}",
da.frozen_step()
);
let mut da = DualAveraging::new(1.0, 0.8);
for _ in 0..200 {
let _ = da.update(1.0);
}
assert!(
da.frozen_step() > 1.0,
"high acceptance should grow eps, got {}",
da.frozen_step()
);
}
#[test]
fn fg31_hmc_standard_normal_marginal() {
let model_fn = || sample(addr!("x"), Normal::new(0.0, 1.0).unwrap());
let mut rng = StdRng::seed_from_u64(7);
let samples = hmc_chain(&mut rng, model_fn, 2000, 500, HMCConfig::default());
let xs: Vec<f64> = samples.iter().map(|(x, _)| *x).collect();
let mean = xs.iter().sum::<f64>() / xs.len() as f64;
let var = xs.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / xs.len() as f64;
assert!(mean.abs() < 0.1, "mean {}", mean);
assert!((var - 1.0).abs() < 0.15, "var {}", var);
}
#[test]
fn fg31_returned_traces_have_fresh_weights() {
let model_fn = || {
sample(addr!("mu"), Normal::new(0.0, 1.0).unwrap())
.bind(|mu| observe(addr!("y"), Normal::new(mu, 1.0).unwrap(), 1.0).map(move |_| mu))
};
let mut rng = StdRng::seed_from_u64(11);
let samples = hmc_chain(&mut rng, model_fn, 50, 50, HMCConfig::default());
for (mu, t) in &samples {
let expected = Normal::new(0.0, 1.0).unwrap().log_prob(mu)
+ Normal::new(*mu, 1.0).unwrap().log_prob(&1.0);
assert!((t.total_log_weight() - expected).abs() < 1e-9);
}
}
#[test]
fn fg31_hmc_diagonal_mass_adaptation_axis_scaled() {
let model_fn = || {
sample(addr!("x"), Normal::new(0.0, 1.0).unwrap())
.bind(|x| sample(addr!("y"), Normal::new(0.0, 10.0).unwrap()).map(move |y| (x, y)))
};
let cfg = HMCConfig {
adapt_mass: true,
..HMCConfig::default()
};
let mut rng = StdRng::seed_from_u64(2024);
let samples = hmc_chain(&mut rng, model_fn, 3000, 1500, cfg);
let xs: Vec<f64> = samples.iter().map(|((x, _), _)| *x).collect();
let ys: Vec<f64> = samples.iter().map(|((_, y), _)| *y).collect();
let mx = xs.iter().sum::<f64>() / xs.len() as f64;
let my = ys.iter().sum::<f64>() / ys.len() as f64;
let vx = xs.iter().map(|x| (x - mx).powi(2)).sum::<f64>() / xs.len() as f64;
let vy = ys.iter().map(|y| (y - my).powi(2)).sum::<f64>() / ys.len() as f64;
assert!(mx.abs() < 0.2, "x mean {mx}");
assert!(my.abs() < 2.0, "y mean {my}");
assert!((vx - 1.0).abs() < 0.25, "var(x) {vx}");
assert!((vy - 100.0).abs() < 25.0, "var(y) {vy}");
}
}