Tutorial notebook. Source
.ipynb on GitHub.
Exact sampling: adaptive rejection and learned proposals¶
MCMC returns a correlated chain that is only asymptotically exact. Rejection sampling returns independent, exact draws, at the price of needing an envelope: a proposal \(q\) and a constant \(M\) with \(p(x) \le M q(x)\). Draw \(x \sim q\) and accept with probability \(p(x) / (M q(x))\); the accepted draws are distributed exactly as \(p\).
In probjax the exact samplers are kernels like any other, so run, warmup,
adaptors and jax.vmap work unchanged. Every step returns one exact draw:
arsbuilds its envelope from tangents of a log-concave density and tightens it inside the state as it samples.rejectiontakes anyDensityas \(q\). The bound \(\log M\) is a parameter, learned bybound_adaptor, and a flow proposal can be trained on the accepted draws withfit_adaptor.
This notebook runs on CPU in well under a minute.
import time
import jax
import jax.numpy as jnp
import matplotlib.pyplot as plt
import optax
from flax import nnx
jax.config.update("jax_platform_name", "cpu")
from probjax.inference import (
Density, ars, bound_adaptor, compose_adaptors, fit_adaptor, maximum_likelihood,
rejection, run, warmup,
)
from probjax.inference.neural import density
from probjax.inference.rejection import univariate
from probjax.nn import maf
1. Adaptive rejection sampling¶
The posterior of a Poisson log-rate \(\theta\) with a \(\mathcal N(0, 2^2)\) prior is log-concave: \(\log p(\theta \mid y) = \theta \sum_i y_i - n e^{\theta} - \theta^2 / 8 + c\). ARS needs only the log density and a starting point. The envelope starts from three tangents; whenever a draw falls between the squeeze (the chords) and the envelope, the target is evaluated there and a tangent is added.
counts = jnp.array([3, 7, 4, 6, 5, 2, 8, 5])
def log_rate_posterior(theta):
return theta * counts.sum() - counts.size * jnp.exp(theta) - theta**2 / 8
kernel = ars(log_rate_posterior)
state = kernel.init(jax.random.key(0), 0.0)
initial_envelope = state.envelope
start = time.perf_counter()
result = run(jax.random.key(1), kernel, state, None, 5000,
collect=("position", "num_proposals", "num_evals"))
print(f"5000 exact draws in {time.perf_counter() - start:.2f}s")
print(f"target evaluations: {int(result.trace['num_evals'].sum())}, "
f"tangent points: {int(result.state.envelope.num_points)}")
print(f"envelope draws per accepted draw: {result.trace['num_proposals'].mean():.3f}")
5000 exact draws in 0.21s
target evaluations: 45, tangent points: 48
envelope draws per accepted draw: 1.004
grid = jnp.linspace(0.8, 2.5, 400)
log_target = log_rate_posterior(grid)
shift = log_target.max()
fig, axes = plt.subplots(1, 3, figsize=(15, 3.8))
for ax, env, title in [
(axes[0], initial_envelope, "Initial envelope (3 tangents)"),
(axes[1], result.state.envelope, f"After 5000 draws ({int(result.state.envelope.num_points)} tangents)"),
]:
upper = jax.vmap(lambda x: univariate.upper_hull(env, x))(grid)
lower = jax.vmap(lambda x: univariate.squeeze(env, x))(grid)
ax.plot(grid, jnp.exp(log_target - shift), "k", lw=2, label="target")
ax.plot(grid, jnp.exp(upper - shift), color="tab:red", label="envelope")
ax.plot(grid, jnp.exp(lower - shift), color="tab:blue", ls="--", label="squeeze")
points = env.x[: int(env.num_points)]
ax.plot(points, jnp.exp(log_rate_posterior(points) - shift), "o", color="tab:red", ms=4)
ax.set(xlim=(grid[0], grid[-1]), ylim=(0, 1.6), xlabel=r"$\theta$", title=title)
axes[0].legend(frameon=False)
density_on_grid = jnp.exp(log_target - jax.scipy.special.logsumexp(log_target)) / (grid[1] - grid[0])
axes[2].hist(result.trace["position"], bins=60, density=True, color="tab:red", alpha=0.5, label="ARS draws")
axes[2].plot(grid, density_on_grid, "k", lw=1.5, label="exact")
axes[2].set(xlabel=r"$\theta$", title="Draws against the exact posterior")
axes[2].legend(frameon=False)
plt.tight_layout()
plt.show()

The envelope starts loose, so early draws are often rejected and each rejection teaches it a tangent. After a few dozen evaluations it hugs the target, the squeeze accepts almost every draw without touching the target, and the remaining thousands of draws cost essentially nothing. Refining the envelope never affects exactness: every accepted draw is exact whatever the envelope looks like at the time.
2. Rejection sampling with a learned bound¶
For a multivariate target, rejection takes any proposal with heavier
tails. The target here is a banana,
\(x_1 \sim \mathcal N(0, 2^2)\), \(x_2 \mid x_1 \sim \mathcal N(x_1^2/2 - 2, 0.5^2)\),
and the proposal a broad Gaussian. \(\log M\) is unknown, so the kernel starts
with \(\log M = -\infty\) and its default adaptation, bound_adaptor, raises it
to the largest \(\log p - \log q\) seen during warmup. Each step's info reports
the largest ratio among its proposals, so a bound that is still too low shows
up as max_log_ratio > log_bound.
def banana(x):
return -0.5 * (x[0] ** 2 / 4 + (x[1] - 0.5 * x[0] ** 2 + 2) ** 2 / 0.25)
broad = Density(
sample=lambda key, scale: scale * jax.random.normal(key, (2,)),
logpdf=lambda x, scale: jax.scipy.stats.norm.logpdf(x, 0.0, scale).sum(),
)
kernel = rejection(banana, proposal=broad)
state = kernel.init(jax.random.key(0), jnp.zeros(2))
params = kernel.init_params(state, 4.0)
tuned = warmup(jax.random.key(1), kernel, state, params, 2000)
gaussian_draws = run(jax.random.key(2), kernel, tuned.state, tuned.params, 5000,
collect=("position", "num_proposals", "max_log_ratio")).trace
def report(name, trace, log_bound):
violations = int((trace["max_log_ratio"] > log_bound).sum())
lag_one = jnp.corrcoef(trace["position"][:-1, 0], trace["position"][1:, 0])[0, 1]
print(f"{name:>16}: log M = {float(log_bound):5.2f}, proposals per draw = "
f"{trace['num_proposals'].mean():5.1f}, steps exceeding the bound = {violations}, "
f"lag-1 autocorrelation = {lag_one:+.3f}")
gaussian_bound = tuned.params.log_bound
report("Gaussian q", gaussian_draws, gaussian_bound)
Gaussian q: log M = 7.11, proposals per draw = 197.3, steps exceeding the bound = 2, lag-1 autocorrelation = +0.021
The draws are independent: the lag-1 autocorrelation is zero up to noise,
where an MCMC chain would show strong correlation. The cost moves into the
number of proposals per draw, which is \(M\) times the ratio of normalising
constants: a broad Gaussian wastes most proposals in the empty space around
the banana. A small number of steps exceeding the bound means warmup saw
nearly, but not exactly, the largest ratio. Those draws are slightly
over-represented, so warm up longer or pass bound_adaptor(margin=...) when
that matters.
3. A learned flow proposal¶
A proposal that matches the target needs far fewer proposals per draw. The
accepted draws are exact target samples, so they are exactly the right data
for training a flow by maximum likelihood. fit_adaptor(field="proposal")
does that inside the scan, composed with bound_adaptor so the bound keeps
up. The flow starts broad (standardization scale 4).
flow = maf(2, 3, rngs=nnx.Rngs(0))
flow.set_standardization(jnp.zeros(2), 4.0 * jnp.ones(2))
proposal, proposal_params = density(flow)
kernel = rejection(banana, proposal=proposal)
state = kernel.init(jax.random.key(0), jnp.zeros(2))
policy = compose_adaptors(
proposal=fit_adaptor(maximum_likelihood(proposal), optax.adam(3e-3), field="proposal"),
bound=bound_adaptor(),
)
start = time.perf_counter()
trained = run(jax.random.key(1), kernel, state, kernel.init_params(state, proposal_params), 3000,
adaptor=policy, collect=("num_proposals",))
print(f"training: {time.perf_counter() - start:.1f}s, log M after training = {float(trained.params.log_bound):.2f}")
training: 0.5s, log M after training = 6.13
The bound only ever grows, so after training it still reflects the poor early proposals and is far too loose for the trained flow: the number of proposals per draw stays high (left panel below). The remedy is a second, short phase: keep the trained flow fixed and learn the bound afresh.
fresh = trained.params._replace(log_bound=jnp.asarray(-jnp.inf))
tuned = warmup(jax.random.key(2), kernel, trained.state, fresh, 2000)
flow_draws = run(jax.random.key(3), kernel, tuned.state, tuned.params, 5000,
collect=("position", "num_proposals", "max_log_ratio")).trace
report("Gaussian q", gaussian_draws, gaussian_bound)
report("trained flow q", flow_draws, tuned.params.log_bound)
Gaussian q: log M = 7.11, proposals per draw = 197.3, steps exceeding the bound = 2, lag-1 autocorrelation = +0.021
trained flow q: log M = 3.30, proposals per draw = 4.4, steps exceeding the bound = 8, lag-1 autocorrelation = -0.026
xs, ys = jnp.meshgrid(jnp.linspace(-7, 7, 200), jnp.linspace(-4, 12, 200))
log_banana = jax.vmap(jax.vmap(lambda a, b: banana(jnp.array([a, b]))))(xs, ys)
fig, axes = plt.subplots(1, 3, figsize=(15, 3.8))
window = 100
axes[0].plot(jnp.convolve(trained.trace["num_proposals"], jnp.ones(window) / window, mode="valid"),
color="tab:green")
axes[0].axhline(flow_draws["num_proposals"].mean(), color="tab:green", ls="--", label="after re-learning the bound")
axes[0].axhline(gaussian_draws["num_proposals"].mean(), color="tab:orange", ls="--", label="Gaussian q")
axes[0].set(xlabel="training step", ylabel="proposals per draw (running mean)", yscale="log",
title="Training with a growing bound")
axes[0].legend(frameon=False, fontsize=8)
flow_samples = jax.vmap(proposal.sample, (0, None))(jax.random.split(jax.random.key(4), 3000), tuned.params.proposal)
for ax, points, color, title in [
(axes[1], flow_samples, "tab:green", "Trained flow $q$ (approximate)"),
(axes[2], flow_draws["position"][:3000], "tab:red", "Rejection draws (exact, iid)"),
]:
ax.contour(xs, ys, log_banana, levels=[-8, -4.5, -2, -0.5], colors="0.6", linewidths=0.8)
ax.scatter(*points.T, s=1, alpha=0.4, color=color, rasterized=True)
ax.set(xlim=(-7, 7), ylim=(-4, 12), title=title)
plt.tight_layout()
plt.show()

With the bound re-learned, the trained flow needs a fraction of the proposals the broad Gaussian needs, and every accepted draw is still exact: the flow's own mistakes (its samples are visibly off in the tips of the banana) are corrected by the accept step, not inherited.
When to use what¶
| Situation | Use |
|---|---|
| Univariate, log-concave (conditionals in a Gibbs sampler, GLM posteriors per coordinate) | ars: exact, and nearly free once the envelope is tight |
| Univariate, not log-concave | arms (an MCMC kernel on the same envelope) |
| Low dimension, a proposal with heavier tails is available | rejection with bound_adaptor warmup |
| The proposal is poor | Train a flow with fit_adaptor(field="proposal"), then re-learn the bound with the flow fixed |
| High dimension | MCMC: the proposals needed per draw grow exponentially with dimension |
Caveats:
- The bound has to be a bound. Draws are exact only where
\(\log p - \log q \le \log M\).
bound_adaptorlearns \(\log M\) from what it sees, so checkmax_log_ratioagainstlog_boundon the draws you keep, and give the proposal heavier tails than the target, or no finite bound exists at all. - Freeze adaptation for the draws you report, as with MCMC: a bound that is still growing over-represents the regions where it was too low.