Skip to content

Tutorial notebook. Source .ipynb on GitHub.

Sequential Monte Carlo

SMC moves a population of particles through a sequence of distributions that ends at the target. Every step reweights the particles towards the next distribution, resamples them and moves them. The population copes with several modes, and the weights give an estimate of the normalising constant (the evidence) at no extra cost.

probjax has two SMC kernels, both on the same contract as MCMC, so run, adaptors and jax.vmap work unchanged:

  • smc tempers from the prior to the posterior and moves the particles with any MCMC kernel. The schedule can be fixed or adaptive, and the move adapts to the population as it goes.
  • diffusion_smc follows the reverse SDE of a diffusion model, either a pretrained network (neural.diffusion_guide) or Monte Carlo score estimates of the target (score_guide).

The notebook runs on CPU in a few minutes. Particle counts are modest; raise them for harder problems.

import time

import jax
import jax.numpy as jnp
import matplotlib.pyplot as plt
import numpy as np
from flax import nnx
from jax.scipy.stats import multivariate_normal, norm

jax.config.update("jax_platform_name", "cpu")

from probjax.inference import (
    diffusion_smc, hmc, population_diagnostics, run, score_guide, smc,
    summarize_replicates,
)
from probjax.inference.neural import diffusion_guide
from probjax.nn import VP, TimeMLP
from probjax.nn.generative.diffusion.config import VPNoiseSchedule

key = jax.random.key(42)


def next_key():
    global key
    key, result = jax.random.split(key)
    return result

1. A posterior with a known evidence

A Gaussian prior and a correlated Gaussian likelihood give a Gaussian posterior and a closed-form evidence \(p(y) = \mathcal N(y; 0, I + \Sigma)\), so every estimate below can be checked.

smc(logprior, loglikelihood, move=...) targets \(\pi_t(x) \propto p(x)\, p(y \mid x)^t\) and raises \(t\) from 0 to 1. Each step reweights by the likelihood gained, resamples, and runs a few steps of the move kernel, here HMC. The SMC kernel's params are the move's params, shared by all particles.

observation = jnp.array([1.5, -0.75])
noise_cov = jnp.array([[0.4, 0.15], [0.15, 0.7]])
logprior = lambda x: norm.logpdf(x).sum()
loglikelihood = lambda x: multivariate_normal.logpdf(observation, x, noise_cov)

posterior_cov = jnp.linalg.inv(jnp.eye(2) + jnp.linalg.inv(noise_cov))
posterior_mean = posterior_cov @ jnp.linalg.solve(noise_cov, observation)
exact_log_evidence = multivariate_normal.logpdf(
    observation, jnp.zeros(2), jnp.eye(2) + noise_cov
)

num_particles = 2048
move = hmc(num_integration_steps=4)
fixed = smc(logprior, loglikelihood, move=move, num_mcmc_steps=3)
state = fixed.init(next_key(), jax.random.normal(next_key(), (num_particles, 2)))
params = fixed.init_params(state, step_size=0.3)

schedule = jnp.linspace(0.0, 1.0, 21)[1:]
start = time.perf_counter()
result = run(next_key(), fixed, state, params, 20, args=(schedule,),
             collect=("ess", "acceptance_rate"))
result.state.log_evidence.block_until_ready()
print(f"{time.perf_counter() - start:.1f}s including compilation")
print("log evidence:", float(result.state.log_evidence), " exact:", float(exact_log_evidence))
print("mean:", result.state.particles.mean(0), " exact:", posterior_mean)
0.4s including compilation
log evidence: -3.3175902366638184  exact: -3.316518545150757
mean: [ 1.1304522  -0.52671814]  exact: [ 1.1293744 -0.5408271]

Fixed, adaptive and waste-free schedules

A fixed schedule spends the same effort on every step, however much the likelihood changes there. With target_ess each step instead picks the largest temperature that keeps that fraction of effective particles. Once the temperature reaches 1, further steps change nothing, so the step count passed to run is a cap.

waste_free=p keeps every state of the move chains, not only the last (Dau and Chopin, 2022): it resamples \(N/p\) particles and runs \(p - 1\) moves from each. The kept states are correlated, so \(N\) overstates the information, but nothing the moves computed is thrown away.

kernels = {
    "fixed, 20 steps": (fixed, dict(args=(schedule,)), 20),
    "adaptive": (smc(logprior, loglikelihood, move=move, num_mcmc_steps=3,
                     target_ess=0.7), {}, 30),
    "adaptive, waste-free": (smc(logprior, loglikelihood, move=move,
                                 target_ess=0.7, waste_free=4), {}, 30),
}
fig, axes = plt.subplots(1, 2, figsize=(11, 3.5))
for name, (kernel, options, num_steps) in kernels.items():
    result = run(next_key(), kernel, state, params, num_steps,
                 collect=("temperature", "ess"), **options)
    temperatures = np.asarray(result.trace["temperature"])
    steps = int(np.argmax(temperatures >= 1.0)) + 1
    print(f"{name:22s} steps {steps:2d}  log evidence {float(result.state.log_evidence):.3f}")
    axes[0].plot(temperatures[:steps], "o-", label=name)
    axes[1].plot(result.trace["ess"][:steps], "o-", label=name)
print(f"{'exact':22s}           log evidence {float(exact_log_evidence):.3f}")
axes[0].set(xlabel="SMC step", ylabel="temperature", title="Schedules")
axes[1].set(xlabel="SMC step", ylabel="ESS / N after reweighting", title="Weight degeneracy")
axes[0].legend()
plt.tight_layout()
plt.show()
fixed, 20 steps        steps 20  log evidence -3.320


adaptive               steps  3  log evidence -3.297


adaptive, waste-free   steps  3  log evidence -3.323
exact                            log evidence -3.317

png

The adaptive schedule takes small steps where the likelihood first bites and large ones at the end, and holds the ESS at the target throughout. All three evidence estimates match the exact value to a few hundredths.

The estimate is random, so the honest error bar comes from independent repetitions. The kernel is a pure function, so jax.vmap runs them in one compiled call; summarize_replicates gives the Monte Carlo standard error across runs. Evidence is averaged on its own scale, not in logs.

adaptive = kernels["adaptive"][0]


def one_run(key):
    init_key, run_key = jax.random.split(key)
    state = adaptive.init(init_key, jax.random.normal(init_key, (512, 2)))
    result = run(run_key, adaptive, state, params, 30)
    return jnp.exp(result.state.log_evidence), result.state.particles[:, 0].mean()


evidence, means = jax.jit(jax.vmap(one_run))(jax.random.split(next_key(), 16))
for label, values, exact in (("evidence", evidence, jnp.exp(exact_log_evidence)),
                             ("posterior mean x[0]", means, posterior_mean[0])):
    stats = summarize_replicates(values)
    print(f"{label:20s} {float(stats.mean):.4f} ± {float(stats.standard_error):.4f}"
          f"   exact {float(exact):.4f}")
evidence             0.0363 ± 0.0005   exact 0.0363
posterior mean x[0]  1.1235 ± 0.0058   exact 1.1294

2. Adapting the move to the population

A move that suits the prior can be hopeless for the posterior. Below, the likelihood pins one coordinate to within 0.02 and leaves the other loose. HMC with the prior's unit mass matrix and a fixed step size starts rejecting almost every proposal as the posterior narrows, and the particles stop moving.

The population after each step is a sample from the current target, so it tells us the next target's shape. kernel.default_adaptation() uses it after every step:

  • particle_adaptor sets HMC's inverse mass matrix to the particle variances (the full covariance if you start from a full matrix);
  • acceptance_rate_adaptor scales the step size by \(\exp(\text{acceptance} - 0.65)\).

Both are ordinary adaptors, passed to run like any other.

sharp_noise = jnp.array([0.02, 1.0])
sharp_observation = jnp.array([0.8, -0.4])
sharp_likelihood = lambda x: norm.logpdf(sharp_observation, x, sharp_noise).sum()
sharp_exact = norm.logpdf(sharp_observation, 0.0, jnp.sqrt(1 + sharp_noise**2)).sum()

sharp = smc(logprior, sharp_likelihood, move=move, num_mcmc_steps=3, target_ess=0.7)
sharp_state = sharp.init(next_key(), jax.random.normal(next_key(), (1024, 2)))
sharp_params = sharp.init_params(sharp_state, step_size=0.3)


def sharp_run(key, adapt):
    init_key, run_key = jax.random.split(key)
    state = sharp.init(init_key, jax.random.normal(init_key, (1024, 2)))
    adaptor = sharp.default_adaptation() if adapt else None
    result = run(run_key, sharp, state, sharp_params, 40, adaptor=adaptor,
                 collect=("temperature", "acceptance_rate"))
    return result.state.log_evidence, result.trace, result.params


fig, ax = plt.subplots(figsize=(7, 3.5))
for adapt in (False, True):
    log_evidence, trace, final = jax.jit(jax.vmap(lambda k: sharp_run(k, adapt)))(
        jax.random.split(next_key(), 8)
    )
    label = "adapted" if adapt else "fixed move"
    steps = int(np.argmax(np.asarray(trace["temperature"][0]) >= 1.0)) + 1
    ax.plot(trace["acceptance_rate"][0, :steps], "o-", label=label)
    print(f"{label:11s} log evidence {float(log_evidence.mean()):7.3f} ± {float(log_evidence.std()):.3f}"
          f"  (exact {float(sharp_exact):.3f})")
print("adapted inverse mass matrix:", final.inverse_mass_matrix[0],
      " posterior variances:", 1 / (1 + 1 / sharp_noise**2))
ax.set(xlabel="SMC step", ylabel="mean acceptance rate", title="HMC moves along the path")
ax.legend()
plt.tight_layout()
plt.show()
fixed move  log evidence  -2.473 ± 0.117  (exact -2.545)


adapted     log evidence  -2.527 ± 0.062  (exact -2.545)
adapted inverse mass matrix: [4.236471e-04 5.405653e-01]  posterior variances: [3.9984006e-04 5.0000000e-01]

png

Without adaptation the acceptance rate collapses to zero, the particles stop moving, and resampling only copies them: the evidence estimates scatter twice as much and sit off the exact value. The adapted move keeps accepting, and its mass matrix ends at the posterior variances.

3. Data tempering: one observation at a time

Instead of raising one temperature, observations can enter one by one. Let the log-likelihood return one term per observation and give the kernel a vector temperature: the target becomes \(p(x) \exp(\sum_i t_i \log p(y_i \mid x))\). A schedule of 0/1 masks then adds the data in order, and every intermediate population is the posterior given the data so far.

num_observations = 12
data = jnp.array([1.0, -0.5]) + 0.7 * jax.random.normal(next_key(), (num_observations, 2))
per_observation = lambda x: norm.logpdf(data, x, 0.7).sum(axis=1)

streaming = smc(logprior, per_observation, move=move, num_mcmc_steps=3)
state = streaming.init(next_key(), jax.random.normal(next_key(), (1024, 2)),
                       temperature=jnp.zeros(num_observations))
masks = jnp.tril(jnp.ones((num_observations, num_observations)))
result = run(next_key(), streaming, state, params, num_observations, args=(masks,),
             collect=("particles",))

seen = jnp.arange(1, num_observations + 1)
exact_var = 1 / (1 + seen / 0.7**2)
exact_mean = exact_var * jnp.cumsum(data[:, 0]) / 0.7**2
particles = result.trace["particles"][..., 0]
fig, ax = plt.subplots(figsize=(7, 3.5))
ax.errorbar(seen, particles.mean(1), 2 * particles.std(1), fmt="o", label="SMC mean ± 2 sd")
ax.plot(seen, exact_mean, "k-", label="exact mean")
ax.fill_between(seen, exact_mean - 2 * jnp.sqrt(exact_var), exact_mean + 2 * jnp.sqrt(exact_var),
                color="k", alpha=0.1, label="exact ± 2 sd")
ax.set(xlabel="observations seen", ylabel="x[0]", title="Posterior as the data arrive")
ax.legend()
plt.tight_layout()
plt.show()

png

4. Multimodal and curved targets

Tempering from a broad reference makes SMC robust to several modes: the population spreads over all of them while the target is still flat, and the reweighting keeps the right proportions as they separate. Here the "likelihood" is the target divided by a broad Gaussian reference, so the path runs from that reference to the target.

def funnel(z):
    return norm.logpdf(z[1], 0.0, 3.0) + norm.logpdf(z[0], 0.0, jnp.exp(z[1] / 2))


mixture_means = jnp.array([[6.0, 0.0], [0.0, 6.0], [-6.0, 0.0], [0.0, -6.0], [0.0, 0.0]])


def mixture(z):
    components = jax.vmap(lambda m: norm.logpdf(z, m, 0.7).sum())(mixture_means)
    return jax.scipy.special.logsumexp(components) - jnp.log(5.0)


turns = jnp.linspace(0.0, 6 * jnp.pi, 150)
spiral_means = 0.15 * jnp.stack([turns * jnp.cos(turns), turns * jnp.sin(turns)], -1)


def spiral(z):
    components = jax.vmap(lambda m: norm.logpdf(z, m, 0.15).sum())(spiral_means)
    return jax.scipy.special.logsumexp(components) - jnp.log(150.0)


reference = lambda x: norm.logpdf(x, 0.0, 5.0).sum()
gallery = [("Funnel", funnel, ((-12, 12), (-6, 6))),
           ("Five modes", mixture, ((-9, 9), (-9, 9))),
           ("Spiral", spiral, ((-3.5, 3.5), (-3.5, 3.5)))]
fig, axes = plt.subplots(1, 3, figsize=(15, 4.3))
for ax, (name, target, limits) in zip(axes, gallery):
    kernel = smc(reference, lambda x, target=target: target(x) - reference(x),
                 move=move, num_mcmc_steps=5, target_ess=0.8)
    state = kernel.init(next_key(), 5.0 * jax.random.normal(next_key(), (2048, 2)))
    result = run(next_key(), kernel, state, kernel.init_params(state, step_size=0.1),
                 100, adaptor=kernel.default_adaptation(), collect=("temperature",))
    steps = int(np.argmax(np.asarray(result.trace["temperature"]) >= 1.0)) + 1
    x = result.state.particles
    ax.hist2d(np.asarray(x[:, 0]), np.asarray(x[:, 1]), bins=60, range=limits, cmap="Blues")
    gx, gy = np.meshgrid(np.linspace(*limits[0], 120), np.linspace(*limits[1], 120))
    log_density = np.asarray(jax.vmap(target)(jnp.stack([gx.ravel(), gy.ravel()], -1)))
    levels = log_density.max() - np.array([8.0, 4.0, 1.0])
    ax.contour(gx, gy, log_density.reshape(gx.shape), levels=levels, colors="black",
               linewidths=0.6, linestyles="solid")
    ax.set(title=f"{name}: {steps} steps, log Z = {float(result.state.log_evidence):.2f}")
plt.tight_layout()
plt.show()
print("All three targets are normalised, so log Z should be close to 0.")

png

All three targets are normalised, so log Z should be close to 0.

5. Reverse diffusion SMC: no training needed

diffusion_smc takes a different route to the target. Particles start as Gaussian noise and follow the reverse SDE of a diffusion process down to the data. A twist \(\psi_t(x)\) looks ahead to the target and weights the particles; at the last time it is the target itself, so the sampler is consistent whatever the twist, and a good twist only needs fewer particles.

score_guide builds the reverse SDE from the target alone (reverse diffusion SMC, Wu et al. 2025). At every particle it estimates the score of the noised target by importance sampling, and the same samples give the twist: an estimate of the noised target's density. That costs num_inner target evaluations per particle and step, but needs neither a tempering path nor MCMC moves. Too few inner samples bias the score at small noise levels, which shows up as a low evidence estimate; 256 are enough here.

noise = VPNoiseSchedule(t_min=1e-3, t_max=1.0)
guide = score_guide(mixture, noise, (2,), num_inner=256)
times = jnp.linspace(1.0, 1e-3, 101)
reverse = diffusion_smc(guide, times)

start = time.perf_counter()
state = reverse.init(next_key(), 1024)
result = run(next_key(), reverse, state, None, len(times) - 1, collect=("ess", "resampled"))
weights = np.asarray(jnp.exp(result.state.log_weights))
print(f"{time.perf_counter() - start:.1f}s;  log Z = {float(result.state.log_evidence):.3f} (exact 0)")

nearest = jnp.argmin(jnp.linalg.norm(result.state.particles[:, None] - mixture_means, axis=-1), 1)
mass = np.bincount(np.asarray(nearest), weights=weights, minlength=5)
print("mass per mode:", np.round(mass, 3), " (exact 0.2 each)")
print("resampled at", int(result.trace["resampled"].sum()), "of", len(times) - 1, "steps")

fig, axes = plt.subplots(1, 2, figsize=(11, 4))
x = np.asarray(result.state.particles)
axes[0].scatter(x[:, 0], x[:, 1], s=4 + 400 * weights, alpha=0.5)
axes[0].set(title="Reverse diffusion SMC on the five modes", xlim=(-9, 9), ylim=(-9, 9))
axes[1].plot(times[1:], result.trace["ess"], "o-", ms=3)
axes[1].invert_xaxis()
axes[1].set(xlabel="diffusion time t", ylabel="ESS / N", title="Weights along the reverse path")
plt.tight_layout()
plt.show()
2.0s;  log Z = -0.157 (exact 0)
mass per mode: [0.204 0.178 0.233 0.173 0.212]  (exact 0.2 each)
resampled at 4 of 100 steps

png

6. Steering a pretrained diffusion model

A diffusion model trained on data is a prior you can sample but not evaluate. neural.diffusion_guide(model, V) turns it into a guide for the target \(p_\phi(x)\, e^{V(x)}\): an observation model, a reward or a constraint, with no retraining. The twist is \(V\) at the model's denoised estimate of the clean point, faded in as the noise goes, and its gradient steers each reverse step (twisted diffusion sampler, Wu et al. 2023). The guide's params are the network weights, split out of the model the same way neural.density does for flows.

We train a small VP model on a ring of eight Gaussians, so that the exact posterior is known, and condition it on a noisy observation \(y\) of \(x\).

angles = jnp.linspace(0, 2 * jnp.pi, 8, endpoint=False)
ring_means = 3.0 * jnp.stack([jnp.cos(angles), jnp.sin(angles)], -1)
ring_std = 0.3


def ring_samples(key, n):
    component_key, noise_key = jax.random.split(key)
    component = jax.random.randint(component_key, (n,), 0, 8)
    return ring_means[component] + ring_std * jax.random.normal(noise_key, (n, 2))


start = time.perf_counter()
model = VP(TimeMLP(2, rngs=nnx.Rngs(0)), event_spec=2, num_steps=64)
losses = model.fit(next_key(), ring_samples(next_key(), 8000),
                   num_steps=15_000, batch_size=256,).losses
print(f"trained in {time.perf_counter() - start:.0f}s; loss {float(losses[:200].mean()):.2f} -> {float(losses[-200:].mean()):.2f}")
times = model.solver_cfg.solve_schedule(model.train_cfg.t_min, model.train_cfg.t_max, 64)
trained in 31s; loss 3.07 -> 2.03

The observation \(y = (1.5, 0)\) with noise 0.3 sits between two modes of the ring and far from where the prior puts its mass. We compare the twisted sampler with twist=False, which simulates the prior and weights by the likelihood only at the end, over 16 independent runs of 1000 particles each.

y, obs_noise = jnp.array([1.5, 0.0]), 0.3
potential = lambda x: norm.logpdf(y, x, obs_noise).sum()

# Exact posterior of the true ring prior: a reweighted Gaussian mixture.
component_log_weights = norm.logpdf(y, ring_means, jnp.sqrt(ring_std**2 + obs_noise**2)).sum(-1)
exact_log_evidence = jax.scipy.special.logsumexp(component_log_weights) - jnp.log(8.0)
shrink = ring_std**2 / (ring_std**2 + obs_noise**2)
component_means = ring_means + shrink * (y - ring_means)
component_std = jnp.sqrt(shrink) * obs_noise


def exact_posterior(key, n):
    component_key, noise_key = jax.random.split(key)
    component = jax.random.categorical(component_key, component_log_weights, shape=(n,))
    return component_means[component] + component_std * jax.random.normal(noise_key, (n, 2))


samplers = {}
for twist in (False, True):
    guide, model_params = diffusion_guide(model, potential, twist=twist)
    kernel = diffusion_smc(guide, times)

    def one_run(key, kernel=kernel):
        init_key, run_key = jax.random.split(key)
        state = kernel.init(init_key, 1000, model_params)
        result = run(run_key, kernel, state, model_params, len(times) - 1, collect=("ess",))
        return result.state, result.trace["ess"]

    states, ess = jax.jit(jax.vmap(one_run))(jax.random.split(next_key(), 16))
    samplers["twisted" if twist else "no twist"] = (states, ess)
    print(f"{'twisted' if twist else 'no twist':9s} log Z {float(states.log_evidence.mean()):6.2f}"
          f" ± {float(states.log_evidence.std()):.2f}   lowest ESS {float(ess.min(1).mean()):.3f}")
print(f"exact     log Z {float(exact_log_evidence):6.2f} (for the true ring; the model is close)")
no twist  log Z  -8.49 ± 0.50   lowest ESS 0.005


twisted   log Z  -8.59 ± 0.14   lowest ESS 0.115
exact     log Z  -8.45 (for the true ring; the model is close)
fig, axes = plt.subplots(1, 3, figsize=(15, 4.3))
truth = np.asarray(exact_posterior(next_key(), 2000))
axes[0].scatter(truth[:, 0], truth[:, 1], s=3, alpha=0.4, color="k")
axes[0].scatter(*np.asarray(ring_means).T, marker="x", color="C3", label="prior modes")
axes[0].set(title="Exact posterior", xlim=(-4, 4), ylim=(-4, 4))
axes[0].legend()
states, ess = samplers["twisted"]
x = np.asarray(states.particles[0])
w = np.asarray(jnp.exp(states.log_weights[0]))
axes[1].scatter(x[:, 0], x[:, 1], s=3 + 300 * w, alpha=0.4)
axes[1].set(title="Twisted diffusion SMC (one run)", xlim=(-4, 4), ylim=(-4, 4))
for name, (_, ess) in samplers.items():
    axes[2].plot(times[1:], ess.mean(0), label=name)
axes[2].invert_xaxis()
axes[2].set(xlabel="diffusion time t", ylabel="mean ESS / N", title="Weight degeneracy")
axes[2].legend()
plt.tight_layout()
plt.show()

png

Without a twist the particles follow the prior and only meet the likelihood at the end. The final reweighting then rests on the few particles that happen to land near \(y\). The twisted sampler steers the particles towards the observation while there is still noise to spend, keeps more of them effective, and its evidence estimate varies less between runs.

The twist is a heuristic. For an observation sitting on a mode of the prior, plain prior simulation already does well, and the twist can do slightly worse. Either way the answer is consistent; only the particle budget changes.

7. Diagnostics beyond the weights

The weight ESS says nothing about duplicates: after resampling, many particles can be copies of one parent. population_diagnostics counts distinct particles and distinct ancestors, which exposes that collapse.

state = fixed.init(next_key(), jax.random.normal(next_key(), (num_particles, 2)))
result = run(next_key(), fixed, state, params, 20, args=(schedule,), collect=("ancestors",))
d = population_diagnostics(result.state.particles, jnp.full(num_particles, -jnp.log(num_particles)),
                           result.trace["ancestors"][-1])
print(f"weight ESS {float(d.ess):.0f}, distinct particles {int(d.unique_particles)}, "
      f"parents in the last step {int(d.unique_ancestors)} of {num_particles}")
weight ESS 2048, distinct particles 2048, parents in the last step 2022 of 2048

The MCMC moves make every particle distinct again, even though the last resampling drew from fewer distinct parents.

When to use what

Situation Kernel
Posterior with a prior and a likelihood; you want the evidence smc, target_ess=...
Badly scaled or changing geometry along the path add kernel.default_adaptation()
Data arriving over time, fixed parameters smc with a per-observation likelihood and mask schedule
Expensive moves; reuse every MCMC state waste_free=p
Large populations that do not fit in memory at once batch_size=...
Unnormalised target, no tempering path, cheap evaluations diffusion_smc with score_guide
Condition a trained diffusion model on an observation or reward diffusion_smc with neural.diffusion_guide

For filtering, SMC² and trajectories over physical time see the temporal SMC notebook.