Hamiltonian Monte Carlo from scratch

R
MCMC
Bayesian statistics
ecology tutorial
Build Hamiltonian Monte Carlo in base R: leapfrog integration, energy error, and a measured comparison with random-walk Metropolis on a collinear posterior.
Author

Tidy Ecology

Published

2026-07-28

Modified

2026-10-15

Corrected 15 October 2026: the post said the dimension sweep shows the random walk’s huge-proposal trick dying as the problem gets bigger; that sweep uses targets with no correlation and so has no ridge for the trick to cross, and the post now says the trick is not tested in more dimensions.

Corrected 7 October 2026: the post said a wrong gradient makes a hand-written HMC sample the wrong distribution; with the accept step on the correct potential it only costs acceptance and efficiency, and the post now says so and that the finite-difference check cannot catch a wrong potential. It also said central-difference gradients lose to the random walk from five dimensions upwards and read HMC’s advantage as much the same at both ends of the correlation sweep; at five dimensions the gain and that cost are too close for a single tuning run to order, and both readings are now given as results of a single run.

Corrected 30 September 2026: the post said a finite-difference gradient needs two potential evaluations per dimension and that HMC with numerical gradients always loses, and that the trajectory sweep singled out the leapfrog return time; it now says that cost applies to central differences and that the sweep’s grid cannot tell the leapfrog return time from the exact one. Its effective sample size estimator also paired the autocorrelations out of step with Geyer’s rule, which stopped it from exceeding the chain length and understated HMC; with the pairing corrected, the gains over the random walk (for posterior means) are larger, central-difference gradients lose only at the higher dimensions tested, and forward differences come in under the gain at every dimension tested.

The two numbers came off the same hemispherical photograph. Canopy openness is the fraction of sky visible through a gap fraction analysis; transmitted light is the modelled radiation reaching the forest floor, computed from the same pixels with a solar track laid over them. Across sixty plots in a beech stand they correlate at better than 0.99, because one is essentially a smooth function of the other. Both went into a model of understorey herb biomass, because the field protocol said to record both and nobody revisited that until somebody tried to fit the thing.

That model is an unpleasant target to sample. The posterior is not diffuse, it is a narrow diagonal blade: the sum of the two coefficients is pinned down tightly by the data while their difference is barely constrained at all. A random-walk sampler dropped into that blade has to take short steps, because any step long enough to be useful along the blade is long enough to walk off the edge of it. It shuffles. You come back later to a trace plot that looks like a mountain range and an effective sample size in the dozens.

Stan does not shuffle here, and the reason is that Stan is not proposing at random. It builds a physical trajectory: it treats minus the log posterior as a potential energy landscape, gives the parameter vector a random shove, and rolls it across that landscape for a while under Hamilton’s equations. A ball rolling along the floor of a narrow valley follows the valley. That is the whole idea, and it fits in about forty lines of base R.

This post writes those lines and then measures what they buy: the potential energy, the Gaussian momentum, the leapfrog integrator, and the Metropolis correction that repairs the integrator’s error. Every claim is checked against something. The integrator is checked against exact reversibility and exact volume preservation, the sampler against a posterior whose mean, variance and correlation are known in closed form, and the efficiency against a random-walk sampler with both tuned and both counted in evaluations rather than in iterations. Some of what comes out is not what the folklore says.

If you have not built the random-walk sampler by hand, Metropolis-Hastings from scratch does that, and this post reuses its accept step almost verbatim. MCMC convergence diagnostics from scratch builds the effective sample size estimator that every efficiency number here depends on, and Gibbs sampling for conjugate models covers the case where you can sample the conditionals exactly and need none of this.

library(ggplot2)

te_pal <- list(forest = "#275139", green = "#2f8f63", sage = "#93a87f",
               clay = "#b5534e", gold = "#cda23f", line = "#dad9ca",
               ink = "#16241d", paper = "#f5f4ee")

theme_te <- function() {
  theme_minimal(base_size = 12) +
    theme(panel.grid.minor = element_blank(),
          panel.grid.major = element_line(colour = "#e7e6dc"),
          plot.background = element_rect(fill = "#f5f4ee", colour = NA),
          panel.background = element_rect(fill = "#f5f4ee", colour = NA),
          plot.title = element_text(face = "bold", colour = te_pal$ink),
          axis.title = element_text(colour = "#2c3a31"),
          legend.position = "bottom")
}

The survey, and a posterior we can check against

The model is deliberately one whose answer we already know. Log herb biomass, centred, regressed on two standardised canopy variables with a known residual standard deviation and an independent normal prior on each coefficient. With sigma fixed, that posterior is exactly bivariate normal, and its mean and covariance come out of two lines of matrix algebra. Everything the sampler produces later can be held against those two lines.

The predictors are built to be collinear on purpose, because collinearity is the condition under which gradient information starts to matter.

set.seed(20260728)
n_plot    <- 60
sigma_obs <- 0.35
tau_prior <- 5

open_c <- rnorm(n_plot)
light  <- 0.99 * open_c + sqrt(1 - 0.99^2) * rnorm(n_plot)
open_c <- (open_c - mean(open_c)) / sd(open_c)
light  <- (light  - mean(light))  / sd(light)
Xmat   <- cbind(open_c, light)
b_true <- c(0.45, 0.25)
yobs   <- drop(Xmat %*% b_true) + rnorm(n_plot, 0, sigma_obs)
yobs   <- yobs - mean(yobs)

Prec  <- crossprod(Xmat) / sigma_obs^2 + diag(2) / tau_prior^2
Covp  <- solve(Prec)
mpost <- unname(drop(Covp %*% (crossprod(Xmat, yobs) / sigma_obs^2)))
sd_post  <- unname(sqrt(diag(Covp)))
cor_post <- unname(Covp[1, 2] / prod(sd_post))
axis_sd  <- sqrt(eigen(Covp, symmetric = TRUE)$values)
axis_vec <- eigen(Covp, symmetric = TRUE)$vectors
eps_lim  <- 2 * min(axis_sd)

print(round(c(plots = n_plot, sigma = sigma_obs, prior_sd = tau_prior,
              predictor_r = cor(open_c, light)), 4))
      plots       sigma    prior_sd predictor_r 
    60.0000      0.3500      5.0000      0.9943 
print(round(c(true_open = b_true[1], true_light = b_true[2],
              post_mean_open = mpost[1], post_mean_light = mpost[2],
              post_mean_sum = sum(mpost), true_sum = sum(b_true)), 4))
      true_open      true_light  post_mean_open post_mean_light   post_mean_sum 
         0.4500          0.2500          0.0060          0.7123          0.7183 
       true_sum 
         0.7000 
print(round(c(post_sd_open = sd_post[1], post_sd_light = sd_post[2],
              post_corr = cor_post), 4))
 post_sd_open post_sd_light     post_corr 
       0.4231        0.4231       -0.9942 
print(round(c(along_ridge_sd = max(axis_sd), across_ridge_sd = min(axis_sd),
              axis_ratio = max(axis_sd) / min(axis_sd), eps_limit = eps_lim), 4))
 along_ridge_sd across_ridge_sd      axis_ratio       eps_limit 
         0.5975          0.0323         18.5184          0.0645 

The two predictors correlate at 0.9943 in this sample, and the posterior inherits the mirror image of that: the coefficients correlate at -0.9942. Look at what this does to the individual estimates. The true coefficients were 0.4500 and 0.2500, and the posterior means are 0.0060 and 0.7123, which the data pin down only in combination. The sum is fine: the truth is 0.7000 and the posterior mean of the sum is 0.7183. This is what collinearity does. The data speak clearly about the combined effect of the two canopy variables and say almost nothing about how to split it between them, and the posterior is honest about that by being long in one direction and thin in the other.

The last line puts numbers on the shape. Rotating the posterior onto its own principal axes, the standard deviation along the ridge is 0.5975 and the standard deviation across it is 0.0323, a ratio of 18.5184. Any sampler that takes isotropic steps is caught between those two numbers: the across-ridge width sets how far it can safely step, the along-ridge length sets how far it needs to travel. The quantity called eps_limit, twice the across-ridge standard deviation, will turn out to be exactly where the leapfrog integrator falls apart. Hold onto 0.0645 for two sections.

Potential energy, momentum, and a gradient written by hand

Hamiltonian Monte Carlo starts by renaming things. The negative log posterior becomes a potential energy \(U(q)\), where \(q\) is the parameter vector. Then it invents a momentum vector \(p\) of the same length, gives it an independent standard normal distribution, and calls \(K(p) = \frac{1}{2}p^{\top}p\) the kinetic energy. The joint density of \((q, p)\) is proportional to \(\exp(-U(q) - K(p))\), and because the two blocks are independent, the marginal of \(q\) under that joint density is exactly the posterior we wanted. The momentum is scaffolding. It is thrown away after every iteration and drawn fresh.

For this model the potential is a sum of squares over the residuals plus a ridge term from the prior, and the gradient is one line of matrix algebra: \(\nabla U(b) = -X^{\top}(y - Xb)/\sigma^2 + b/\tau^2\). Writing it out and pre-computing the cross-products means a gradient costs one small matrix product, the same order of work as evaluating the potential itself. That matters later, when we start counting.

XtX <- crossprod(Xmat)
Xty <- unname(drop(crossprod(Xmat, yobs)))
yty <- sum(yobs * yobs)

pot <- function(b) {
  (yty - 2 * sum(b * Xty) + sum(b * (XtX %*% b))) / (2 * sigma_obs^2) +
    sum(b * b) / (2 * tau_prior^2)
}
grad_pot <- function(b) {
  (drop(XtX %*% b) - Xty) / sigma_obs^2 + b / tau_prior^2
}

b_chk  <- c(0.20, 0.70)
step_h <- 1e-6
g_hand <- unname(grad_pot(b_chk))
g_num  <- c((pot(b_chk + c(step_h, 0)) - pot(b_chk - c(step_h, 0))) / (2 * step_h),
            (pot(b_chk + c(0, step_h)) - pot(b_chk - c(0, step_h))) / (2 * step_h))
g_quad <- unname(drop(Prec %*% (b_chk - mpost)))

print(round(c(gradient_1 = g_hand[1], gradient_2 = g_hand[2]), 2))
gradient_1 gradient_2 
     87.57      86.99 
print(signif(c(fd_step = step_h,
               vs_finite_difference = max(abs(g_hand - g_num)),
               vs_quadratic_form = max(abs(g_hand - g_quad))), 4))
             fd_step vs_finite_difference    vs_quadratic_form 
           1.000e-06            1.446e-08            3.894e-12 

Two checks on the same gradient, because a wrong gradient fails quietly. As long as the accept step uses the correct potential, the leapfrog stays reversible and volume preserving whatever gradient it is given, so the chain still samples the right distribution; a wrong gradient shows up only as lower acceptance and fewer effective draws, which is easy to blame on tuning. A wrong potential does sample the wrong distribution, and the finite-difference check cannot catch that, because it compares the gradient with that same potential. At the test point the analytic gradient is 87.57 and 86.99. A central finite difference of the potential with a step of \(1.000 \times 10^{-6}\) agrees to \(1.446 \times 10^{-8}\), which is about what you expect once floating point cancellation is accounted for. The quadratic form \(\Lambda(b - m)\), which is what the gradient of a Gaussian potential must reduce to, agrees to within floating-point rounding (vs_quadratic_form in the output above).

Do this check on your own models: it costs four lines, and if the two numbers disagree, fix the gradient before you tune the sampler or trust any efficiency figure it gives.

The leapfrog, and why it has to be reversible

Hamilton’s equations say that \(\dot{q} = p\) and \(\dot{p} = -\nabla U(q)\). Solving them exactly is not on offer for a real posterior, so we integrate numerically, and the choice of integrator is not free. The Metropolis correction that makes HMC exact requires the proposal map to be reversible and to preserve volume in phase space. Ordinary Runge-Kutta does neither. The leapfrog scheme does both, and it does them exactly rather than approximately.

Its structure is a half step on the momentum, a full step on the position, and a half step on the momentum again. Consecutive half steps in the middle of a trajectory merge, so a trajectory of \(L\) position updates costs \(L + 1\) gradient evaluations, and that is the number this post counts.

leapfrog <- function(q, p, eps, n_step, grad_fun) {
  p <- p - 0.5 * eps * grad_fun(q)
  for (i in seq_len(n_step)) {
    q <- q + eps * p
    if (i < n_step) p <- p - eps * grad_fun(q)
  }
  list(q = q, p = p - 0.5 * eps * grad_fun(q))
}

q_start <- c(0.10, 0.80)
p_start <- c(-0.40, 1.10)
fwd <- leapfrog(q_start, p_start, 0.045, 30, grad_pot)
bck <- leapfrog(fwd$q, -fwd$p, 0.045, 30, grad_pot)
print(signif(c(position_returned = max(abs(bck$q - q_start)),
               momentum_returned = max(abs(-bck$p - p_start))), 3))
position_returned momentum_returned 
         1.11e-15          3.33e-16 
flow <- function(z) {
  r <- leapfrog(z[1:2], z[3:4], 0.045, 30, grad_pot)
  c(r$q, r$p)
}
jac <- matrix(0, 4, 4)
for (j in 1:4) {
  bump <- rep(0, 4); bump[j] <- 1e-5
  jac[, j] <- (flow(c(q_start, p_start) + bump) -
                 flow(c(q_start, p_start) - bump)) / 2e-5
}
print(round(c(jacobian_determinant = det(jac)), 6))
jacobian_determinant 
                   1 
print(signif(c(distance_from_one = abs(det(jac) - 1)), 4))
distance_from_one 
        8.148e-11 

The reversibility test is the honest one: run thirty leapfrog steps forwards, flip the sign of the momentum, run thirty more, and see where you are. If the integrator is reversible you are back at the start. The position and the momentum both return to within floating-point rounding (position_returned and momentum_returned in the output above). What is left is rounding accumulated over sixty steps, not integration error. The map really is its own inverse once you negate the momentum.

Volume preservation is checked by building the Jacobian of the whole four-dimensional map with central differences and taking its determinant. It comes out at 1, away from one by \(8.148 \times 10^{-11}\), which is the accuracy of the finite difference rather than a property of the integrator. A phase-space volume carried along by the leapfrog is squeezed and sheared but never compressed. That is why the accept ratio in a later section is a plain ratio of joint densities with no Jacobian factor hanging off it.

Energy is not conserved, and that is the point

The exact Hamiltonian flow conserves \(H(q, p) = U(q) + K(p)\). The leapfrog does not. What it conserves instead is a nearby modified Hamiltonian, which is why its energy error stays bounded and oscillates rather than drifting away. The cleanest place to see this is a standard normal target, where \(U(q) = q^2/2\) and the exact flow is a circle in the \((q, p)\) plane. The leapfrog traces an ellipse instead, and the ellipse can be written down: starting from \(q = 1.5\) with zero momentum, the orbit reaches a maximum momentum of \(1.5\sqrt{1 - \varepsilon^2/4}\).

grad_std <- function(q) q

orbit <- function(eps, n_step, q0, p0) {
  qs <- numeric(n_step + 1); ps <- numeric(n_step + 1)
  qs[1] <- q0; ps[1] <- p0
  q <- q0; ph <- p0 - 0.5 * eps * grad_std(q0)
  for (i in seq_len(n_step)) {
    q <- q + eps * ph
    gq <- grad_std(q)
    qs[i + 1] <- q; ps[i + 1] <- ph - 0.5 * eps * gq
    ph <- ph - eps * gq
  }
  data.frame(q = qs, p = ps)
}

eps_orb <- c(0.3, 0.9, 1.8)
n_orb   <- 140
q_orb   <- 1.5
orb <- do.call(rbind, lapply(eps_orb, function(e) {
  o <- orbit(e, n_orb, q_orb, 0)
  o$eps <- factor(sprintf("%.1f", e))
  o
}))
shape <- do.call(rbind, lapply(eps_orb, function(e) {
  o <- orbit(e, n_orb, q_orb, 0)
  data.frame(eps = e, q_reach = max(abs(o$q)), p_reach = max(abs(o$p)),
             p_predicted = q_orb * sqrt(1 - e^2 / 4))
}))
print(round(shape, 4))
  eps q_reach p_reach p_predicted
1 0.3     1.5  1.4830      1.4830
2 0.9     1.5  1.3395      1.3395
3 1.8     1.5  0.6538      0.6538
print(round(c(orbit_steps = n_orb, start_position = q_orb,
              step_small = eps_orb[1], step_mid = eps_orb[2],
              step_large = eps_orb[3]), 2))
   orbit_steps start_position     step_small       step_mid     step_large 
         140.0            1.5            0.3            0.9            1.8 

All three orbits reach the same maximum position, 1.5, because that is where they started with no momentum. What differs is how far up the momentum axis they get. At a step size of 0.3 the orbit reaches 1.4830 and the prediction is 1.4830. At 1.8 it reaches only 0.6538 against a prediction of 0.6538. The orbits stay closed, which is the bounded error, but the large-step orbit is a flattened ellipse rather than the circle the exact flow would trace.

ang  <- seq(0, 2 * pi, length.out = 400)
ring <- data.frame(q = q_orb * cos(ang), p = q_orb * sin(ang))

ggplot(orb, aes(q, p, colour = eps)) +
  geom_point(size = 0.9) +
  geom_path(data = ring, aes(q, p), inherit.aes = FALSE,
            colour = te_pal$ink, linetype = "dashed", linewidth = 0.45) +
  scale_colour_manual(values = c("0.3" = te_pal$green, "0.9" = te_pal$gold,
                                 "1.8" = te_pal$clay),
                      name = "leapfrog step size") +
  coord_fixed(xlim = c(-1.75, 1.75), ylim = c(-1.75, 1.75)) +
  labs(x = "position q", y = "momentum p",
       title = "Leapfrog orbits in phase space") +
  theme_te()
A square panel with position on the horizontal axis and momentum on the vertical. Three rings of small dots share the same left and right extremes but differ in height. The green ring sits almost exactly on the dashed reference circle, the gold ring is slightly flattened, and the red ring is a strongly flattened ellipse reaching less than half as far up the momentum axis as the green one.
Figure 1: Leapfrog orbits on a standard normal target, all started at position 1.5 with zero momentum and run for 140 steps. The dashed circle is the exact energy contour the true Hamiltonian flow would follow. Green is a step size of 0.3, gold 0.9 and red 1.8.

The flattening has a limit. When the step size reaches 2 on this target the predicted momentum reach hits zero and the quantity under the square root turns negative, which is the algebra telling you the orbit has stopped being an ellipse and become a hyperbola. Past that point the trajectory never comes back: it runs off to infinity, geometrically.

Sweeping the step size until the integrator breaks

On the collinear posterior the same threshold applies, scaled by the tightest direction of the target. The across-ridge standard deviation is 0.0323, so the prediction is that the leapfrog holds together up to a step size of about 0.0645 and disintegrates after it. The sweep below fixes the trajectory at 12 steps, runs 400 proposals at each of 14 step sizes, and records the acceptance rate and the mean absolute change in the Hamiltonian.

eps_grid <- c(0.010, 0.020, 0.030, 0.040, 0.050, 0.056, 0.060, 0.062,
              0.064, 0.066, 0.068, 0.070, 0.080, 0.100)
n_probe <- 400
sweep_eps <- data.frame(eps = eps_grid, accept = NA_real_, energy_error = NA_real_)

for (i in seq_along(eps_grid)) {
  set.seed(51000000 + i)
  ee <- eps_grid[i]; q <- mpost; taken <- 0L; err <- numeric(n_probe)
  for (r in seq_len(n_probe)) {
    p <- rnorm(2)
    st <- leapfrog(q, p, ee, 12, grad_pot)
    dh <- pot(st$q) + 0.5 * sum(st$p * st$p) - pot(q) - 0.5 * sum(p * p)
    err[r] <- dh
    if (is.finite(dh) && log(runif(1)) < -dh) { q <- st$q; taken <- taken + 1L }
  }
  sweep_eps$accept[i] <- taken / n_probe
  sweep_eps$energy_error[i] <- mean(abs(err))
}
sweep_eps$log10_error <- log10(sweep_eps$energy_error)

print(data.frame(eps = sweep_eps$eps, accept = round(sweep_eps$accept, 3),
                 log10_error = round(sweep_eps$log10_error, 2)))
     eps accept log10_error
1  0.010  0.988       -2.08
2  0.020  0.975       -1.21
3  0.030  0.935       -0.85
4  0.040  0.955       -1.00
5  0.050  0.838       -0.35
6  0.056  0.953       -1.21
7  0.060  0.755       -0.19
8  0.062  0.600        0.18
9  0.064  0.850       -0.52
10 0.066  0.002        4.87
11 0.068  0.000        6.88
12 0.070  0.000        8.44
13 0.080  0.000       13.73
14 0.100  0.000       20.27
print(round(c(probes_per_step_size = n_probe, steps_per_trajectory = 12,
              last_stable_eps = max(sweep_eps$eps[sweep_eps$accept > 0.05]),
              first_broken_eps = min(sweep_eps$eps[sweep_eps$accept <= 0.05]), predicted_limit = eps_lim), 4))
probes_per_step_size steps_per_trajectory      last_stable_eps 
            400.0000              12.0000               0.0640 
    first_broken_eps      predicted_limit 
              0.0660               0.0645 

The collapse is not gradual. At a step size of 0.064 the acceptance rate is 0.850 and the mean energy error is ten to the -0.52. One grid point later, at 0.066, acceptance is 0.002 and the energy error is ten to the 4.87. That is not a sampler mixing badly. That is a sampler whose proposals have almost all been thrown to infinity and rejected. The predicted threshold was 0.0645, and the measured break falls between 0.064 and 0.066, which brackets it.

Below the threshold the acceptance rate does not fall smoothly with the step size, and that is worth pausing on. It goes 0.838 at 0.050, up to 0.953 at 0.056, down to 0.600 at 0.062, and back up to 0.850 at 0.064. The number of steps is held at 12 here, so changing the step size also changes where in its orbit the trajectory stops, and the modified Hamiltonian the leapfrog conserves differs from the true one by an amount that depends on that phase. Tuning a step size by watching acceptance alone, on a short trajectory, can hand you a local optimum that means nothing.

sb <- rbind(
  data.frame(eps = sweep_eps$eps, value = sweep_eps$accept,
             panel = "acceptance rate"),
  data.frame(eps = sweep_eps$eps, value = sweep_eps$log10_error,
             panel = "log10 mean energy error"))

ggplot(sb, aes(eps, value)) +
  geom_vline(xintercept = eps_lim, colour = te_pal$clay, linetype = "dashed",
             linewidth = 0.6) +
  geom_line(colour = te_pal$forest, linewidth = 0.7) +
  geom_point(colour = te_pal$forest, size = 1.8) +
  facet_wrap(~panel, ncol = 1, scales = "free_y") +
  labs(x = "leapfrog step size", y = NULL,
       title = "The integrator breaks at a sharp step size") +
  theme_te()
Two stacked panels sharing a horizontal axis of step size. The top panel shows acceptance rate wobbling high and irregularly up to the dashed line, then dropping vertically to zero and staying there. The bottom panel shows the base ten logarithm of mean energy error, low and ragged up to the same dashed line: eight of its nine points sit below the zero gridline and the one at step size 0.062 pokes just above it. Past the dashed line it climbs steeply and without interruption.
Figure 2: Acceptance rate and energy error against leapfrog step size on the collinear posterior, at 12 steps per trajectory and 400 proposals per point. The dashed red line marks twice the across-ridge posterior standard deviation, the predicted stability threshold.

The lower panel is the more useful diagnostic in practice. Stan reports these overshoots as divergent transitions, and a divergence is exactly this: a trajectory whose energy error was large enough that the integrator is no longer tracking the dynamics. On a Gaussian target you can work the threshold out in advance. On a real hierarchical model you cannot, which is why the warning exists.

The accept step, and a check against the closed form

The integrator’s error is a bias, and the Metropolis correction removes it. After a trajectory from \((q, p)\) to \((q^{*}, p^{*})\), accept the new position with probability \(\min(1, \exp(H(q, p) - H(q^{*}, p^{*})))\). If the integration were exact the exponent would be zero and every proposal would be accepted; because it is not, the accept step converts energy error into rejections and the chain stays exactly on the target. There is no Jacobian term because the earlier section measured that the Jacobian is one.

The sampler and the effective sample size estimator both go in here. The autocovariances come out of an FFT, and the effective sample size uses Geyer’s initial positive sequence: sum the autocorrelations in adjacent pairs, starting with lags zero and one, and stop at the first pair that is not positive. One property of that estimator matters below. HMC on a Gaussian target often lands each draw near the mirror image of the one before, and then the estimate can exceed the chain length. For a posterior mean such draws really are better than independent; for a variance or a tail quantile they do not help. Every effective sample size in this post is the one for the mean of a coordinate. Following Stan, the estimate is not allowed to exceed the chain length times its base-ten logarithm, so where an HMC run reaches that ceiling the figure reported understates it.

autocov <- function(x) {
  n <- length(x); x <- x - mean(x)
  n2 <- 2^ceiling(log2(2 * n))
  fx <- fft(c(x, rep(0, n2 - n)))
  Re(fft(fx * Conj(fx), inverse = TRUE))[seq_len(n)] / (n2 * n)
}

ess_series <- function(x) {
  n <- length(x); gam <- autocov(x)
  if (gam[1] <= 0) return(NA_real_)
  rho <- gam / gam[1]; k <- floor(n / 2)
  paired <- rho[2 * seq_len(k) - 1] + rho[2 * seq_len(k)]
  stop_at <- which(paired <= 0)
  m <- if (length(stop_at)) stop_at[1] - 1L else k
  n / max(-1 + 2 * sum(paired[seq_len(m)]), 1 / log10(n))
}

ess_worst <- function(draws) min(apply(draws, 2, ess_series))

hmc_chain <- function(q0, eps, n_step, n_iter, grad_fun, pot_fun, seed) {
  set.seed(seed)
  d <- length(q0)
  kicks <- matrix(rnorm(n_iter * d), n_iter, d)
  logu <- log(runif(n_iter)); keep <- matrix(0, n_iter, d)
  q <- q0; uq <- pot_fun(q); taken <- 0L
  for (it in seq_len(n_iter)) {
    p <- kicks[it, ]
    st <- leapfrog(q, p, eps, n_step, grad_fun)
    unew <- pot_fun(st$q)
    dh <- unew + 0.5 * sum(st$p * st$p) - uq - 0.5 * sum(p * p)
    if (is.finite(dh) && logu[it] < -dh) { q <- st$q; uq <- unew; taken <- taken + 1L }
    keep[it, ] <- q
  }
  list(draws = keep, accept = taken / n_iter, evals = n_iter * (n_step + 1))
}

rw_chain <- function(q0, scale_p, n_iter, pot_fun, seed) {
  set.seed(seed)
  d <- length(q0)
  jumps <- matrix(rnorm(n_iter * d, 0, scale_p), n_iter, d)
  logu <- log(runif(n_iter)); keep <- matrix(0, n_iter, d)
  q <- q0; uq <- pot_fun(q); taken <- 0L
  for (it in seq_len(n_iter)) {
    prop <- q + jumps[it, ]; unew <- pot_fun(prop)
    if (logu[it] < uq - unew) { q <- prop; uq <- unew; taken <- taken + 1L }
    keep[it, ] <- q
  }
  list(draws = keep, accept = taken / n_iter, evals = n_iter)
}

That is the whole sampler. The leapfrog and the accept loop together, and the only thing inside either that is not arithmetic is the call to the gradient. Now hold it against the closed form.

n_val  <- 4000
step_v <- 0.052
L_val  <- 29
val <- hmc_chain(mpost, step_v, L_val, n_val, grad_pot, pot, 60606060)

print(c(iterations = n_val, leapfrog_steps = L_val,
        gradient_evaluations = val$evals))
          iterations       leapfrog_steps gradient_evaluations 
                4000                   29               120000 
print(round(c(step_size = step_v, accept = val$accept), 4))
step_size    accept 
   0.0520    0.7415 
print(round(c(mean_open_hmc = mean(val$draws[, 1]), mean_open_exact = mpost[1],
              mean_light_hmc = mean(val$draws[, 2]), mean_light_exact = mpost[2]), 4))
   mean_open_hmc  mean_open_exact   mean_light_hmc mean_light_exact 
          0.0122           0.0060           0.7049           0.7123 
print(round(c(sd_open_hmc = sd(val$draws[, 1]), sd_open_exact = sd_post[1],
              sd_light_hmc = sd(val$draws[, 2]), sd_light_exact = sd_post[2],
              corr_hmc = cor(val$draws[, 1], val$draws[, 2]),
              corr_exact = cor_post), 4))
   sd_open_hmc  sd_open_exact   sd_light_hmc sd_light_exact       corr_hmc 
        0.4243         0.4231         0.4232         0.4231        -0.9943 
    corr_exact 
       -0.9942 
val_ess <- apply(val$draws, 2, ess_series)
mcse <- apply(val$draws, 2, sd) / sqrt(val_ess)
print(round(c(ess_open = val_ess[1], ess_light = val_ess[2]), 1))
 ess_open ess_light 
   8307.3    8099.1 
print(round(c(mcse_open = mcse[1], mcse_light = mcse[2],
              error_open = mean(val$draws[, 1]) - mpost[1],
              error_light = mean(val$draws[, 2]) - mpost[2]), 4))
  mcse_open  mcse_light  error_open error_light 
     0.0047      0.0047      0.0062     -0.0074 

4000 iterations at a step size of 0.052 with 29 leapfrog steps, which is 120000 gradient evaluations, accepting 0.7415 of proposals. The posterior mean of the first coefficient is 0.0060 exactly and 0.0122 from the chain. The second is 0.7123 exactly and 0.7049 from the chain. The standard deviations are 0.4231 and 0.4231 exactly against 0.4243 and 0.4232. The correlation is -0.9942 exactly against -0.9943.

The errors on the two means are 0.0062 and -0.0074, and the Monte Carlo standard error on the first of the two is 0.0047. The effective sample size for the first coefficient came back as 8307.3, more than the 4000 draws in the chain: at this trajectory length each draw tends to land on the opposite side of the posterior mean from the one before, and for estimating a mean that is better than independent draws. For the variance the same chain is worth fewer draws than it holds, because mirror-image draws are no help there.

What the gradients buy, counted in evaluations

Here is the comparison that justifies the whole apparatus, and it has to be done carefully or it means nothing. Comparing HMC and random-walk Metropolis per iteration is meaningless, because one HMC iteration costs n_step + 1 gradient evaluations and one random-walk iteration costs one potential evaluation. The currency has to be work. For this model a gradient and a potential evaluation are both one small matrix product, so counting them one for one is close to fair; that assumption is revisited below, and it does not survive intact.

Both samplers are tuned on a fixed budget of 30000 evaluations. The HMC grid crosses four step sizes with three trajectory lengths; the random-walk grid runs seven proposal scales spanning a factor of more than twenty.

budget <- 30000
hmc_tune <- data.frame(eps = rep(c(0.032, 0.045, 0.052, 0.058), each = 3),
                       traj = rep(c(0.95, 1.5, 2.2), times = 4))
hmc_tune$n_step <- pmax(1, round(hmc_tune$traj / hmc_tune$eps))
hmc_tune$iters  <- floor(budget / (hmc_tune$n_step + 1))
hmc_tune$accept <- NA_real_; hmc_tune$ess_per_eval <- NA_real_
for (i in seq_len(nrow(hmc_tune))) {
  r <- hmc_chain(mpost, hmc_tune$eps[i], hmc_tune$n_step[i], hmc_tune$iters[i],
                 grad_pot, pot, 31415926)
  hmc_tune$accept[i] <- r$accept
  hmc_tune$ess_per_eval[i] <- ess_worst(r$draws) / r$evals
}
print(data.frame(eps = hmc_tune$eps, n_step = hmc_tune$n_step,
                 iters = hmc_tune$iters, accept = round(hmc_tune$accept, 3),
                 ess_per_eval = round(hmc_tune$ess_per_eval, 5)))
     eps n_step iters accept ess_per_eval
1  0.032     30   967  0.978      0.02755
2  0.032     47   625  0.923      0.05825
3  0.032     69   428  0.944      0.03759
4  0.045     21  1363  0.814      0.03082
5  0.045     33   882  0.861      0.08208
6  0.045     49   600  0.953      0.05556
7  0.052     18  1578  0.766      0.02986
8  0.052     29  1000  0.740      0.07116
9  0.052     42   697  0.943      0.06612
10 0.058     16  1764  0.552      0.02339
11 0.058     26  1111  0.516      0.03039
12 0.058     38   769  0.978      0.07400
rw_tune <- data.frame(scale_mult = c(1.7, 3.6, 7, 14, 20, 28, 40))
rw_tune$scale_p <- rw_tune$scale_mult * min(axis_sd)
rw_tune$accept <- NA_real_; rw_tune$ess_per_eval <- NA_real_
for (i in seq_len(nrow(rw_tune))) {
  r <- rw_chain(mpost, rw_tune$scale_p[i], budget, pot, 27182818)
  rw_tune$accept[i] <- r$accept
  rw_tune$ess_per_eval[i] <- ess_worst(r$draws) / r$evals
}
print(data.frame(mult = rw_tune$scale_mult, scale = round(rw_tune$scale_p, 4),
                 accept = round(rw_tune$accept, 3),
                 ess_per_eval = round(rw_tune$ess_per_eval, 5)))
  mult  scale accept ess_per_eval
1  1.7 0.0549  0.554      0.00150
2  3.6 0.1162  0.320      0.00425
3  7.0 0.2259  0.169      0.00358
4 14.0 0.4517  0.081      0.01021
5 20.0 0.6453  0.051      0.01045
6 28.0 0.9034  0.033      0.01167
7 40.0 1.2906  0.019      0.01008
ih <- which.max(hmc_tune$ess_per_eval)
ir <- which.max(rw_tune$ess_per_eval)
print(round(c(budget_evaluations = budget,
              hmc_best = hmc_tune$ess_per_eval[ih],
              hmc_accept = hmc_tune$accept[ih],
              rw_best = rw_tune$ess_per_eval[ir],
              rw_accept = rw_tune$accept[ir],
              hmc_over_rw = hmc_tune$ess_per_eval[ih] / rw_tune$ess_per_eval[ir],
              classic_rw_target = 0.234), 4))
budget_evaluations           hmc_best         hmc_accept            rw_best 
        30000.0000             0.0821             0.8605             0.0117 
         rw_accept        hmc_over_rw  classic_rw_target 
            0.0331             7.0322             0.2340 

The best HMC setting delivers 0.0821 effective draws per gradient evaluation, at 33 leapfrog steps and a step size of 0.045. The best random walk delivers 0.0117 per potential evaluation. The ratio is 7.0322.

That is the gain per unit of work for the posterior mean, on a posterior with a correlation of -0.9942 and an axis ratio of 18.5184, and if anything it is understated, because several HMC rows in the table sit at the estimator’s ceiling. For a mean, then, the gain is not far short of the order of magnitude the folklore promises. Part of it comes from the mirror-image draws described above, which do nothing for a variance or a tail quantile, so for those the advantage is much smaller. What keeps the random walk in the race at all is visible in its table. The scale that wins is 0.9034, which is 28 times the across-ridge standard deviation and larger than the along-ridge standard deviation, and it accepts 0.0331 of its proposals. The textbook target acceptance for random-walk Metropolis is 0.234. The best tuning here is nowhere near it: the sampler does better by throwing enormous proposals, having almost all of them rejected, and occasionally landing a jump that crosses the entire ridge in one move. In two dimensions a huge proposal still has a workable chance of landing on the blade. That trick is what keeps the random walk within range here. Whether it survives in more dimensions is not tested below: the dimension sweep in the next section uses targets with no correlation, so there is no ridge for a huge proposal to cross.

cloud_hmc <- hmc_chain(mpost, hmc_tune$eps[ih], hmc_tune$n_step[ih],
                       floor(budget / (hmc_tune$n_step[ih] + 1)),
                       grad_pot, pot, 12211221)
cloud_rw  <- rw_chain(mpost, rw_tune$scale_p[ir], budget, pot, 13311331)
thin_by  <- hmc_tune$n_step[ih] + 1
thin_idx <- seq(1, budget, by = thin_by)

lab_h <- sprintf("HMC: %d draws, worst ESS %.0f", nrow(cloud_hmc$draws), ess_worst(cloud_hmc$draws))
lab_r <- sprintf("Random walk: %d draws, worst ESS %.0f", length(thin_idx), ess_worst(cloud_rw$draws))
cl <- rbind(
  data.frame(b1 = cloud_hmc$draws[, 1], b2 = cloud_hmc$draws[, 2], who = lab_h),
  data.frame(b1 = cloud_rw$draws[thin_idx, 1], b2 = cloud_rw$draws[thin_idx, 2], who = lab_r))
cl$who <- factor(cl$who, levels = c(lab_h, lab_r))

ell <- axis_vec %*% diag(axis_sd * sqrt(qchisq(0.95, 2))) %*% rbind(cos(ang), sin(ang))
ell <- data.frame(b1 = mpost[1] + ell[1, ], b2 = mpost[2] + ell[2, ])

print(round(c(ellipse_percent = 95, cloud_hmc_points = nrow(cloud_hmc$draws),
              cloud_rw_points = length(thin_idx),
              cloud_hmc_ess = ess_worst(cloud_hmc$draws),
              cloud_rw_ess = ess_worst(cloud_rw$draws)), 1))
 ellipse_percent cloud_hmc_points  cloud_rw_points    cloud_hmc_ess 
            95.0            882.0            883.0           2122.1 
    cloud_rw_ess 
           271.1 
ggplot(cl, aes(b1, b2)) +
  geom_point(colour = te_pal$green, alpha = 0.4, size = 0.85) +
  geom_path(data = ell, aes(b1, b2), inherit.aes = FALSE,
            colour = te_pal$clay, linewidth = 0.7) +
  facet_wrap(~who) +
  labs(x = "coefficient on canopy openness", y = "coefficient on light",
       title = "Same evaluation budget, two samplers") +
  theme_te()
Two side-by-side scatter panels of the same long thin diagonal cloud running from upper left to lower right, each hugging a narrow red ellipse. The two clouds look alike in coverage and in spread; the difference is in the panel titles, where the effective sample size for HMC is nearly eight times that for the random walk.
Figure 3: Draws from the two best-tuned samplers on the collinear posterior, each given 30000 evaluations, with the random walk thinned to match the number of points. The red outline is the exact 95 per cent posterior ellipse from the closed form. The two point clouds are built to look the same and they do: the whole difference between the samplers is the worst-coordinate effective sample size printed in the panel titles, which the picture itself has no way of showing.

The picture is worth looking at precisely because the two clouds are so similar. Both cover the ridge, both fill the exact 95 per cent ellipse and spill a little past it, and neither shape tells you which sampler was better. The difference is in the counting: 2122.1 effective draws for HMC against 271.1 for the random walk, from the same 30000 evaluations. Judging a sampler by looking at its scatter plot is not a method.

Where the gain actually comes from

Two dimensions is a small problem. To find out where the gain lives, run the same tuned comparison on plain Gaussian targets: first sweeping the correlation at two dimensions, then sweeping the dimension with no correlation at all. Both samplers get a small tuning grid each time and the best of each is reported.

gauss_target <- function(Sig) {
  Pm <- solve(Sig)
  list(pot = function(q) 0.5 * sum(q * (Pm %*% q)),
       grad = function(q) drop(Pm %*% q),
       dim = nrow(Sig), ax = sqrt(eigen(Sig, symmetric = TRUE)$values))
}
tune_rw <- function(tg, n_iter, seed) {
  best <- c(eff = -1, acc = NA)
  for (f in c(2.4, 6, 14, 30)) {
    r <- rw_chain(rep(0, tg$dim), f * min(tg$ax) / sqrt(tg$dim), n_iter, tg$pot, seed)
    e <- ess_worst(r$draws) / r$evals
    if (!is.na(e) && e > best["eff"]) best <- c(eff = e, acc = r$accept)
  }
  best
}
tune_hmc <- function(tg, n_iter, seed) {
  best <- c(eff = -1, acc = NA, nstep = NA)
  for (ef in c(0.3, 0.55, 0.85)) for (tf in c(0.7, 1.3)) {
    e <- ef * 2 * min(tg$ax)
    L <- max(1, round(tf * (pi / 2) * max(tg$ax) / e))
    r <- hmc_chain(rep(0, tg$dim), e, L, n_iter, tg$grad, tg$pot, seed)
    ee <- ess_worst(r$draws) / r$evals
    if (!is.na(ee) && ee > best["eff"]) best <- c(eff = ee, acc = r$accept, nstep = L)
  }
  best
}

corr_grid <- c(0, 0.9, 0.99)
corr_out <- data.frame(correlation = corr_grid, rw = NA_real_, hmc = NA_real_,
                       steps = NA_real_, gain = NA_real_)
for (i in seq_along(corr_grid)) {
  tg <- gauss_target(matrix(c(1, corr_grid[i], corr_grid[i], 1), 2, 2))
  a <- tune_rw(tg, 15000, 4040404)
  b <- tune_hmc(tg, 2500, 5050505)
  corr_out$rw[i]    <- unname(a["eff"])
  corr_out$hmc[i]   <- unname(b["eff"])
  corr_out$steps[i] <- unname(b["nstep"])
  corr_out$gain[i]  <- unname(b["eff"] / a["eff"])
}
print(data.frame(correlation = corr_out$correlation, rw = round(corr_out$rw, 5),
                 hmc = round(corr_out$hmc, 5), steps = corr_out$steps,
                 gain = round(corr_out$gain, 2)))
  correlation      rw     hmc steps gain
1        0.00 0.12317 1.12388     2 9.12
2        0.90 0.04939 0.31038     8 6.28
3        0.99 0.01631 0.16049    17 9.84
print(round(c(rw_penalty = corr_out$rw[1] / corr_out$rw[3],
              hmc_penalty = corr_out$hmc[1] / corr_out$hmc[3]), 3))
 rw_penalty hmc_penalty 
      7.554       7.003 
dim_grid <- c(1, 2, 5, 10, 25)
dim_out <- data.frame(dimension = dim_grid, rw = NA_real_, hmc = NA_real_,
                      gain = NA_real_)
for (i in seq_along(dim_grid)) {
  tg <- gauss_target(diag(dim_grid[i]))
  a <- tune_rw(tg, 15000, 4040404)
  b <- tune_hmc(tg, 2500, 5050505)
  dim_out$rw[i]   <- unname(a["eff"])
  dim_out$hmc[i]  <- unname(b["eff"])
  dim_out$gain[i] <- unname(b["eff"] / a["eff"])
}
dim_out$fd_cost <- 2 * dim_grid
print(data.frame(dimension = dim_out$dimension, rw = round(dim_out$rw, 5),
                 hmc = round(dim_out$hmc, 5), gain = round(dim_out$gain, 2),
                 fd_cost = dim_out$fd_cost))
  dimension      rw     hmc  gain fd_cost
1         1 0.23889 0.85611  3.58       2
2         2 0.12317 1.12388  9.12       4
3         5 0.05582 0.51009  9.14      10
4        10 0.02310 0.31846 13.79      20
5        25 0.00757 0.21031 27.78      50

Correlation first. Going from an uncorrelated pair to a correlation of 0.99, the random walk loses a factor of 7.554 in effective draws per evaluation and HMC loses a factor of 7.003. In this run HMC’s advantage is similar at both ends, 9.84 at the high correlation against 9.12 at zero, where the HMC figure is close to the estimator’s ceiling; each figure comes from a single tuning run and a single seed, and other seeds could order the two ends differently, so read that comparison as rough. The important thing is that HMC is penalised too. It handles the ridge by taking more leapfrog steps, 17 of them at correlation 0.99 against 2 when there is no correlation, and those extra steps are extra gradients that come straight off the efficiency. HMC does not make a badly conditioned posterior cheap. It makes it cheaper.

Dimension is the real story. At one dimension the gain is 3.58, the smallest in the sweep. At 25 dimensions on a perfectly spherical target, where there is no geometry to exploit at all, the gain is 27.78. HMC’s efficiency per gradient falls from 0.85611 to 0.21031 across that range, while the random walk’s falls from 0.23889 to 0.00757. The random walk degrades roughly in proportion to the dimension because its proposal has to shrink; HMC degrades far more slowly, because a trajectory that follows the gradient does not care how many directions there are to get lost in.

Now the assumption that a gradient costs the same as a potential evaluation. If you compute gradients by central differences you need two potential evaluations per dimension, which is the fd_cost column: 2, 4, 10, 20 and 50. Compare that with the gain column. At 1 and 2 dimensions the gain still exceeds the central-difference cost, and at 10 and 25 the cost exceeds the gain, so there HMC with those numerical gradients loses to a well-tuned random walk. At 5 dimensions the two are close enough that a single tuning run cannot say which is larger. A forward difference needs only one evaluation per dimension plus one, which comes in under the measured gain at every dimension tested in this run, only just at 25, where another run could reverse the order; it is also less accurate, and this post did not measure what that costs the sampler. These gains are also for the posterior mean; for a variance, where HMC’s mirror-image draws do not help, they are smaller and the case for numerical gradients is weaker still. Either way the margin is thin. The case for HMC rests on getting the gradient for roughly the price of the potential, by hand as here or by automatic differentiation as in Stan.

Trajectory length, and the U-turn

One tuning parameter is left and it is the dangerous one. On a spherical Gaussian target the exact Hamiltonian flow rotates the phase point at unit angular speed, so after a trajectory of length \(\pi/2\) the position has been completely replaced by the initial momentum and the draw is independent. After a trajectory of length \(2\pi\) the phase point is back where it started and the draw is the starting point. The leapfrog rotates at a slightly different speed, \(\omega = \frac{2}{\varepsilon}\arcsin(\varepsilon/2)\), so its return time is \(2\pi/\omega\) and not quite \(2\pi\).

The sweep below fixes the step size on a five-dimensional standard normal, runs 700 iterations at each of 30 trajectory lengths, and measures the effective draws per gradient evaluation.

tg5   <- gauss_target(diag(5))
eps_u <- 0.25
n_u   <- 700
steps_u <- c(2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 23, 24, 25, 26, 27, 28,
             30, 32, 36, 40, 44, 46, 47, 48, 49, 50, 51, 52, 54)
uturn <- data.frame(n_step = steps_u, traj = steps_u * eps_u,
                    ess_per_eval = NA_real_, accept = NA_real_)
for (i in seq_along(steps_u)) {
  r <- hmc_chain(rep(0, 5), eps_u, steps_u[i], n_u, tg5$grad, tg5$pot, 70707070)
  uturn$ess_per_eval[i] <- ess_worst(r$draws) / r$evals
  uturn$accept[i] <- r$accept
}
dip_first  <- uturn$traj[which.min(ifelse(uturn$traj < 9, uturn$ess_per_eval, Inf))]
dip_second <- uturn$traj[which.min(ifelse(uturn$traj > 9, uturn$ess_per_eval, Inf))]
omega <- (2 / eps_u) * asin(eps_u / 2)
rw5 <- tune_rw(tg5, 15000, 4040404)

print(round(c(dimension = 5, iterations = n_u, lengths_tried = length(steps_u),
              step_size = eps_u), 2))
    dimension    iterations lengths_tried     step_size 
         5.00        700.00         30.00          0.25 
print(round(c(first_dip = dip_first, second_dip = dip_second,
              dip_spacing = dip_second - dip_first), 2))
  first_dip  second_dip dip_spacing 
       6.25       12.50        6.25 
print(round(c(leapfrog_period = 2 * pi / omega, exact_period = 2 * pi), 4))
leapfrog_period    exact_period 
         6.2667          6.2832 
print(round(c(best_ess_per_eval = max(uturn$ess_per_eval)), 4))
best_ess_per_eval 
           0.2586 
print(signif(c(worst_ess_per_eval = min(uturn$ess_per_eval)), 4))
worst_ess_per_eval 
         8.383e-05 
print(round(c(best_over_worst = max(uturn$ess_per_eval) / min(uturn$ess_per_eval),
              random_walk_over_worst_hmc =
                unname(rw5["eff"]) / min(uturn$ess_per_eval)), 1))
           best_over_worst random_walk_over_worst_hmc 
                    3085.3                      665.8 
print(round(c(random_walk_here = unname(rw5["eff"]),
              lowest_accept = min(uturn$accept)), 4))
random_walk_here    lowest_accept 
          0.0558           0.9871 

The efficiency collapses at trajectory lengths of 6.25 and 12.50, spaced 6.25 apart. The predicted leapfrog return time at this step size is 6.2667, against 6.2832 for the exact flow. The measured dips agree with both to the resolution of the grid, which is 0.25: the grid confirms the return time, but it is far too coarse to tell the leapfrog value from the exact one.

ggplot(uturn, aes(traj, ess_per_eval)) +
  geom_vline(xintercept = c(2 * pi / omega, 4 * pi / omega), colour = te_pal$clay,
             linetype = "dashed", linewidth = 0.6) +
  geom_line(colour = te_pal$forest, linewidth = 0.7) +
  geom_point(colour = te_pal$forest, size = 1.8) +
  scale_y_log10(breaks = 10^(-4:-1), labels = c("0.0001", "0.001", "0.01", "0.1")) +
  labs(x = "trajectory length (step size times number of steps)",
       y = "ESS per gradient evaluation",
       title = "Trajectory length decides everything") +
  theme_te()
A single panel with trajectory length on the horizontal axis and effective sample size per gradient on a logarithmic vertical axis labelled from a ten thousandth to a tenth, the curve spanning more than three orders of magnitude. The curve climbs to a broad peak at about a quarter, above the top label, declines gently, then plunges to a sharp minimum at the first dashed line, recovers to a lower peak, and plunges again at the second dashed line.
Figure 4: Effective sample size per gradient evaluation against trajectory length, for HMC on a five-dimensional standard normal at a fixed step size of 0.25. The dashed red lines mark one and two leapfrog return times. Note the logarithmic vertical axis.

The vertical axis is logarithmic because a linear one would show a flat line with two invisible holes in it. The best trajectory length gives 0.2586 effective draws per gradient and the worst gives \(8.383 \times 10^{-5}\), a factor of 3085.3 between two settings of one number, and the best figure sits at the estimator’s ceiling.

Two things about that collapse deserve emphasis. First, the acceptance rate never warns you: the lowest acceptance anywhere in the sweep is 0.9871, so at the bottom of the dip the sampler is accepting almost every proposal and dutifully returning it to where it started. Any diagnostic built on acceptance is blind to this. Second, a well-tuned random-walk sampler on the same five-dimensional target gets 0.0558 effective draws per evaluation, which beats the badly tuned HMC by a factor of 665.8. A gradient-based sampler with the wrong trajectory length is far worse than no gradients at all.

This is the problem the No-U-Turn sampler solves, by extending each trajectory until it starts to double back on itself and stopping there, so the number of steps is chosen afresh at every iteration and never has to be guessed. That is why Stan does not ask you for a trajectory length.

What to take away

Hamiltonian Monte Carlo is four ideas stacked: minus the log posterior as a potential, a Gaussian momentum to push against it, the leapfrog to integrate the resulting dynamics, and a Metropolis accept step to absorb the integration error. The leapfrog earns its place by being exactly reversible and exactly volume preserving, measured here as a round trip that closes to within floating-point rounding and a Jacobian determinant within \(8.148 \times 10^{-11}\) of one. The accept step earns its place by producing a mean, a standard deviation and a correlation that sit within two Monte Carlo standard errors of the closed form. Nothing in the method is mysterious once the pieces are separated.

What the measurements say about when to use it is more qualified than the usual story. On the two-parameter collinear posterior the gain over a well-tuned random walk was 7.0322 per evaluation for the posterior mean (much less for a variance), and the random walk got there by accepting 0.0331 of proposals rather than the textbook 0.234. The gain grew with dimension, reaching 27.78 at 25 dimensions on a target with no correlation at all, which says the advantage is mostly about dimension rather than about geometry. And the whole case depends on cheap gradients: a central-difference gradient costs 2 potential evaluations per dimension, which is roughly level with the measured gain at 5 dimensions and exceeds it at 10 and 25, so there the random walk wins; a cheaper forward difference comes in under the gain at every dimension tested in this run, only just at the largest, at an accuracy cost not measured here.

The honest limit is that HMC needs a gradient of the log posterior everywhere, so discrete parameters have to be marginalised out by hand before the sampler ever sees the model, and the geometry it exploits belongs to the parameterisation you happened to write down rather than to the model, so the same posterior written two ways can be easy or impossible to sample. Two smaller caveats sit underneath that. Every target here is Gaussian, chosen so that each claim could be checked against a closed form, whereas a real hierarchical posterior has curvature that no single step size fits. And every effective sample size here is for a posterior mean, where HMC’s mirror-image draws help most; where the estimator’s ceiling binds, the HMC figures understate the gain for the mean, and for a variance or a tail quantile the gains are smaller. What to do about the parameterisation is the subject of the next post in this cluster.

References

Duane S, Kennedy AD, Pendleton BJ, Roweth D 1987 Physics Letters B 195(2):216-222 (10.1016/0370-2693(87)91197-X)

Hoffman MD, Gelman A 2014 Journal of Machine Learning Research 15:1593-1623

Betancourt M 2017 arXiv preprint (10.48550/arXiv.1701.02434)

Carpenter B, Gelman A, Hoffman MD, Lee D, Goodrich B, Betancourt M, Brubaker M, Guo J, Li P, Riddell A 2017 Journal of Statistical Software 76(1):1-32 (10.18637/jss.v076.i01)

Monnahan CC, Thorson JT, Branch TA 2017 Methods in Ecology and Evolution 8(3):339-348 (10.1111/2041-210X.12681)

Roberts GO, Gelman A, Gilks WR 1997 Annals of Applied Probability 7(1):110-120 (10.1214/aoap/1034625254)

Geyer CJ 1992 Statistical Science 7(4):473-483 (10.1214/ss/1177011137)

Newsletter

Get updates by email

An occasional email when tutorials are added or substantially corrected. No spam; unsubscribe anytime.

By subscribing you agree to receive these emails and confirm your address once. See the privacy policy.