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)
}
#[derive(Clone, Debug)]
pub struct LeapfrogPoint {
pub q: Vec<f64>,
pub h: f64,
}
#[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],
mut record: Option<&mut Vec<LeapfrogPoint>>,
) -> (Vec<f64>, Vec<f64>, bool) {
let d = q0.len();
let mut q = q0.to_vec();
let mut p = p0.to_vec();
let record_point = |q: &[f64], p: &[f64], out: &mut Vec<LeapfrogPoint>| {
let k = 0.5 * (0..d).map(|i| p[i] * p[i] * m_inv[i]).sum::<f64>();
let lj = log_joint_at(model_fn, base, sites, q);
out.push(LeapfrogPoint {
q: q.to_vec(),
h: -lj + k,
});
};
if let Some(out) = record.as_deref_mut() {
record_point(&q, &p, out);
}
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];
}
if let Some(out) = record.as_deref_mut() {
record_point(&q, &p, out);
}
}
(q, p, false)
}
type TransitionOut<A> = (bool, Vec<f64>, Option<(A, Trace, f64)>, f64, bool);
#[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],
record: Option<&mut Vec<LeapfrogPoint>>,
) -> 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, record);
if divergent {
return (false, q_cur.to_vec(), None, 0.0, true);
}
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, true);
}
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,
false,
)
} else {
(false, q_cur.to_vec(), None, accept_prob, false)
}
}
#[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, None);
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 session = HmcSession::new(rng, &model_fn, n_warmup, config);
for _ in 0..n_warmup {
session.step(rng, &model_fn);
}
let mut out = Vec::with_capacity(n_samples);
for _ in 0..n_samples {
session.step(rng, &model_fn);
out.push((session.result().clone(), session.trace().clone()));
}
out
}
#[derive(Clone, Debug)]
pub struct HmcStepInfo {
pub accepted: bool,
pub divergent: bool,
pub accept_prob: f64,
pub step_size: f64,
pub trajectory: Vec<LeapfrogPoint>,
}
pub struct HmcSession<A> {
sites: Vec<Address>,
q: Vec<f64>,
cur_a: A,
cur_trace: Trace,
lj_cur: f64,
eps: f64,
frozen_eps: Option<f64>,
da: DualAveraging,
m_inv: Vec<f64>,
mass_sqrt: Vec<f64>,
welford: Welford,
mass_adapt_at: Option<usize>,
n_warmup: usize,
iter: usize,
l: usize,
h: f64,
target_accept: f64,
}
impl<A: Clone> HmcSession<A> {
pub fn new<R: Rng>(
rng: &mut R,
model_fn: &impl Fn() -> Model<A>,
n_warmup: usize,
config: HMCConfig,
) -> Self {
let (cur_a, cur_trace) = run(
PriorHandler {
rng,
trace: Trace::default(),
},
model_fn(),
);
let (sites, q) = positions_from_trace(&cur_trace);
let d = sites.len();
let h = config.finite_diff_eps;
let l = config.n_leapfrog.max(1);
let m_inv = vec![1.0; d];
let mass_sqrt = vec![1.0; d];
let lj_cur = cur_trace.total_log_weight();
let eps0 = if d == 0 {
1.0
} else {
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 da = DualAveraging::new(eps0, config.target_accept);
let welford = Welford::new(d);
let mass_adapt_at = if config.adapt_mass && n_warmup >= 4 {
Some(n_warmup / 2)
} else {
None
};
HmcSession {
sites,
q,
cur_a,
cur_trace,
lj_cur,
eps: eps0,
frozen_eps: None,
da,
m_inv,
mass_sqrt,
welford,
mass_adapt_at,
n_warmup,
iter: 0,
l,
h,
target_accept: config.target_accept,
}
}
pub fn sites(&self) -> &[Address] {
&self.sites
}
pub fn set_step_size(&mut self, eps: f64) {
let eps = eps.max(1e-12);
self.eps = eps;
self.frozen_eps = Some(eps);
self.n_warmup = self.n_warmup.min(self.iter);
}
pub fn set_n_leapfrog(&mut self, l: usize) {
self.l = l.max(1);
}
pub fn position(&self) -> &[f64] {
&self.q
}
pub fn result(&self) -> &A {
&self.cur_a
}
pub fn trace(&self) -> &Trace {
&self.cur_trace
}
pub fn step_size(&self) -> f64 {
if self.iter < self.n_warmup {
self.eps
} else {
self.frozen_or_current()
}
}
pub fn is_warming_up(&self) -> bool {
self.iter < self.n_warmup
}
pub fn iterations(&self) -> usize {
self.iter
}
fn frozen_or_current(&self) -> f64 {
match self.frozen_eps {
Some(e) => e,
None if self.n_warmup > 0 => self.da.frozen_step(),
None => self.eps,
}
}
pub fn step<R: Rng>(&mut self, rng: &mut R, model_fn: &impl Fn() -> Model<A>) -> HmcStepInfo {
self.advance(rng, model_fn, false)
}
pub fn step_recorded<R: Rng>(
&mut self,
rng: &mut R,
model_fn: &impl Fn() -> Model<A>,
) -> HmcStepInfo {
self.advance(rng, model_fn, true)
}
fn advance<R: Rng>(
&mut self,
rng: &mut R,
model_fn: &impl Fn() -> Model<A>,
record: bool,
) -> HmcStepInfo {
if self.sites.is_empty() {
let (a, t) = run(
PriorHandler {
rng,
trace: Trace::default(),
},
model_fn(),
);
self.cur_a = a;
self.lj_cur = t.total_log_weight();
self.cur_trace = t;
self.iter += 1;
return HmcStepInfo {
accepted: true,
divergent: false,
accept_prob: 1.0,
step_size: self.eps,
trajectory: Vec::new(),
};
}
let warming = self.iter < self.n_warmup;
let eps = if warming {
self.eps
} else {
let e = self.frozen_or_current();
self.frozen_eps = Some(e);
e
};
let mut trajectory = Vec::new();
let recorder = if record { Some(&mut trajectory) } else { None };
let (accepted, q_new, endpoint, alpha, divergent) = hmc_transition(
rng,
model_fn,
&self.cur_trace,
&self.sites,
&self.q,
self.lj_cur,
eps,
self.l,
self.h,
&self.m_inv,
&self.mass_sqrt,
recorder,
);
if accepted {
let (a, t, lj) = endpoint.unwrap();
self.cur_a = a;
self.cur_trace = t;
self.q = q_new;
self.lj_cur = lj;
}
if warming {
self.eps = self.da.update(alpha);
if self.mass_adapt_at.is_some() {
self.welford.push(&self.q);
}
if Some(self.iter + 1) == self.mass_adapt_at {
let vars = self.welford.variances();
for (i, &v) in vars.iter().enumerate() {
self.m_inv[i] = v;
self.mass_sqrt[i] = (1.0 / v).sqrt();
}
let eps_reset = find_reasonable_epsilon(
rng,
model_fn,
&self.cur_trace,
&self.sites,
&self.q,
self.lj_cur,
self.h,
&self.m_inv,
&self.mass_sqrt,
);
self.da = DualAveraging::new(eps_reset, self.target_accept);
self.eps = eps_reset;
}
}
self.iter += 1;
HmcStepInfo {
accepted,
divergent,
accept_prob: alpha,
step_size: eps,
trajectory,
}
}
}
#[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}");
}
#[test]
fn session_matches_batch_chain_exactly() {
let model_fn = || {
sample(addr!("mu"), Normal::new(0.0, 1.0).unwrap())
.bind(|mu| observe(addr!("y"), Normal::new(mu, 1.0).unwrap(), 2.0).map(move |_| mu))
};
let cfg = HMCConfig::default();
let (n_warmup, n_samples) = (50, 40);
let mut rng1 = StdRng::seed_from_u64(7);
let batch = hmc_chain(&mut rng1, model_fn, n_samples, n_warmup, cfg);
let mut rng2 = StdRng::seed_from_u64(7);
let mut session = HmcSession::new(&mut rng2, &model_fn, n_warmup, cfg);
for _ in 0..n_warmup {
assert!(session.is_warming_up());
session.step(&mut rng2, &model_fn);
}
for (batch_mu, batch_trace) in &batch {
assert!(!session.is_warming_up());
session.step(&mut rng2, &model_fn);
assert_eq!(*session.result(), *batch_mu);
assert_eq!(
session.trace().total_log_weight(),
batch_trace.total_log_weight()
);
}
}
#[test]
fn recorded_trajectory_shape_and_rng_neutrality() {
let model_fn = || {
sample(addr!("mu"), Normal::new(0.0, 1.0).unwrap())
.bind(|mu| observe(addr!("y"), Normal::new(mu, 1.0).unwrap(), 0.5).map(move |_| mu))
};
let cfg = HMCConfig {
n_leapfrog: 12,
init_step_size: Some(0.2),
..HMCConfig::default()
};
let mut rng1 = StdRng::seed_from_u64(3);
let mut recorded = HmcSession::new(&mut rng1, &model_fn, 0, cfg);
let mut rng2 = StdRng::seed_from_u64(3);
let mut plain = HmcSession::new(&mut rng2, &model_fn, 0, cfg);
for _ in 0..10 {
let info = recorded.step_recorded(&mut rng1, &model_fn);
plain.step(&mut rng2, &model_fn);
if !info.divergent {
assert_eq!(info.trajectory.len(), cfg.n_leapfrog + 1);
}
for pt in &info.trajectory {
assert_eq!(pt.q.len(), 1);
assert!(pt.h.is_finite());
}
assert_eq!(recorded.position(), plain.position());
}
}
}