Waste-free sequential Monte Carlo

Waste-free sequential Monte Carlo#

run_waste_free_smc.py demonstrates waste-free sequential Monte Carlo (SMC), as described by Dau and Chopin (2022) in Waste-free sequential Monte Carlo, alongside a standard resample-move SMC run. Both sample a four-dimensional correlated Gaussian likelihood with a uniform prior on [-10, 10]^4.

Run from this directory using an environment with Sequince, NumPy and Matplotlib installed:

python run_waste_free_smc.py

Wrap a mutation step to retain its full chains:

WasteFreeResampleMove(
    MetropolisHastingsMutation(posterior, GaussianRandomWalk(scale=0.7)),
    chain_length=100,
)

With N = 10,000 input particles and chain length P = 100, the waste-free step resamples M = N / P = 100 starts, runs P - 1 = 99 transitions per chain and retains all 100 samples, including each start and repeats after rejection. The output has 10,000 uniformly weighted particles. chain_length includes the start and must divide N.

For comparison, standard SMC explicitly resamples and mutates:

MultinomialResampling(always_resample=True)
MetropolisHastingsMutation(
    posterior, GaussianRandomWalk(scale=0.7), n_steps=99
)

The standard run maintains 100 particles and keeps each chain’s endpoint. Both runs use the same target, proposal, tempering ESS ratio and 100 chains with 99 transitions each per stage. This matches mutation work per stage; initial population sizes and adaptive temperature schedules differ, so stage counts and total target evaluations can differ. The script logs likelihood evaluation counts alongside posterior moments. Retained waste-free samples are correlated, and their count is not an independent-sample effective sample size. One run of each method illustrates usage, rather than establishing relative statistical efficiency.

LoggingStep reports temperature and log evidence at each stage. The final log summary compares posterior counts, stage counts, likelihood evaluations and evidence estimates, and quotes the retained-count ratio. The single figure, waste_free_history.png, compares the inverse-temperature schedules. Stage numbers match the log messages. The legend and logs quote the final posterior counts: 100 endpoints for standard SMC and 10,000 retained samples for waste-free SMC. Both populations have uniform weights; the larger retained count does not imply 100 times as many independent samples.

The target has zero mean and covariance covariance[i, j] = 0.8 ** abs(i - j). Posterior means should approach zero and marginal variances should approach one. The evidence reference is -4 * log(20) (approximately -11.983), neglecting the tiny Gaussian mass outside the prior box. Both densities include their normalization constants.

Example plots#

Adaptive tempering schedules for standard and waste-free SMC. Retained sample counts do not measure the number of independent samples.

Adaptive tempering schedules for standard and waste-free SMC. Retained sample counts do not measure the number of independent samples.#

Run provenance.

Complete script#

The target densities and complete sampler setup are shown below:

  1"""Waste-free SMC for a correlated Gaussian likelihood and uniform prior."""
  2
  3import json
  4from pathlib import Path
  5
  6import matplotlib.pyplot as plt
  7import numpy as np
  8from sequince import ParticleState, SequentialMonteCarlo, TemperedPosterior
  9from sequince.log import configure_logging
 10from sequince.steps import (
 11    AdaptiveTemperedWeighting,
 12    LoggingStep,
 13    MultinomialResampling,
 14    StepContext,
 15    WasteFreeResampleMove,
 16)
 17from sequince.steps.mutation import (
 18    GaussianRandomWalk,
 19    MetropolisHastingsMutation,
 20)
 21
 22dims = 4
 23bounds = (-10.0, 10.0)
 24correlation = 0.8
 25n_chains = 100
 26chain_length = 100  # Includes the resampled starting state.
 27seed = 123
 28documentation_output: Path | None = None
 29
 30
 31def log_likelihood(particles: np.ndarray, context: StepContext) -> np.ndarray:
 32    """Return normalized zero-mean Gaussian log densities."""
 33    whitened = np.linalg.solve(context.shared["cholesky"], particles.T)
 34    return context.shared["log_norm"] - 0.5 * np.sum(whitened**2, axis=0)
 35
 36
 37def log_prior(particles: np.ndarray, context: StepContext) -> np.ndarray:
 38    """Return normalized uniform log densities inside the prior box."""
 39    lower, upper = context.shared["bounds"]
 40    inside = np.all((particles >= lower) & (particles <= upper), axis=1)
 41    return np.where(
 42        inside, -particles.shape[1] * np.log(upper - lower), -np.inf
 43    )
 44
 45
 46def main() -> None:
 47    logger = configure_logging()
 48    indices = np.arange(dims)
 49    covariance = correlation ** np.abs(indices[:, None] - indices[None, :])
 50    cholesky = np.linalg.cholesky(covariance)
 51    log_norm = (
 52        -0.5 * dims * np.log(2 * np.pi) - np.log(np.diag(cholesky)).sum()
 53    )
 54    posterior = TemperedPosterior(log_prior, log_likelihood)
 55    # Truncation at +/-10 has negligible effect on the evidence reference.
 56    reference_log_evidence = -dims * np.log(bounds[1] - bounds[0])
 57    logger.info("Reference log evidence: %.4f", reference_log_evidence)
 58    fig, ax = plt.subplots(figsize=(7, 4), layout="constrained")
 59    comparison_rows: list[str] = []
 60    posterior_counts: dict[str, int] = {}
 61
 62    summaries: dict[str, dict] = {}
 63
 64    for name in ("Standard", "Waste-free"):
 65        mutation = MetropolisHastingsMutation(
 66            posterior,
 67            GaussianRandomWalk(scale=0.7),
 68            n_steps=chain_length - 1,
 69        )
 70        if name == "Waste-free":
 71            n_particles = n_chains * chain_length
 72            moves = [
 73                WasteFreeResampleMove(mutation, chain_length=chain_length)
 74            ]
 75        else:
 76            n_particles = n_chains
 77            moves = [MultinomialResampling(always_resample=True), mutation]
 78
 79        logger.info("%s SMC: %d particles", name, n_particles)
 80        rng = np.random.default_rng(seed)
 81        initial = ParticleState(rng.uniform(*bounds, size=(n_particles, dims)))
 82        smc = SequentialMonteCarlo(
 83            steps=[
 84                AdaptiveTemperedWeighting(posterior, target_ess_ratio=0.85),
 85                *moves,
 86                LoggingStep(
 87                    str_fn=lambda state, context: (
 88                        f"Stage {context.t + 1}: "
 89                        f"beta={float(context.metadata['beta']):.4f}, "
 90                        f"log evidence={float(state.log_evidence):.4f}"
 91                    )
 92                ),
 93            ]
 94        )
 95        results = smc.run(
 96            initial,
 97            rng=rng,
 98            shared_context={
 99                "bounds": bounds,
100                "cholesky": cholesky,
101                "log_norm": log_norm,
102            },
103        )
104        particles = results.final_state.particles
105        logger.info(
106            "%s posterior means (reference: zero): %s",
107            name,
108            particles.mean(axis=0),
109        )
110        logger.info(
111            "%s posterior variances (reference: one): %s",
112            name,
113            particles.var(axis=0),
114        )
115        if documentation_output is not None:
116            summaries[name] = {
117                "log_evidence": float(results.final_state.log_evidence),
118                "weighted_mean": results.weighted_mean().tolist(),
119                "weighted_population_covariance": results.weighted_covariance().tolist(),
120                "n_particles": len(particles),
121                "likelihood_evaluations": int(
122                    results.context.evaluation_counts.log_likelihood_evaluations
123                ),
124            }
125        posterior_counts[name] = len(particles)
126        comparison_rows.append(
127            f"{name:<12} {len(particles):>10,d} {len(results.outputs):>8d} "
128            f"{results.context.evaluation_counts.log_likelihood_evaluations:>16,d} "
129            f"{float(results.final_state.log_evidence):>12.4f}"
130        )
131        stages = np.arange(1, len(results.outputs) + 1)
132        ax.plot(
133            stages,
134            results.metadata_history("beta"),
135            marker=".",
136            label=f"{name}: {len(particles):,} posterior samples",
137        )
138
139    logger.info(
140        "Final comparison:\n%-12s %10s %8s %16s %12s\n%s",
141        "Method",
142        "Samples",
143        "Stages",
144        "Likelihood evals",
145        "Log evidence",
146        "\n".join(comparison_rows),
147    )
148    logger.info(
149        "Waste-free retains %.0f times as many posterior samples; "
150        "these samples are correlated, including repeats after rejection.",
151        posterior_counts["Waste-free"] / posterior_counts["Standard"],
152    )
153
154    ax.set(
155        title="Adaptive tempering: standard and waste-free SMC",
156        xlabel="Stage",
157        ylabel=r"Inverse temperature, $\beta$",
158        ylim=(0, 1.05),
159    )
160    ax.spines[["top", "right"]].set_visible(False)
161    ax.grid(alpha=0.2)
162    ax.legend()
163    output_directory = documentation_output or Path(".")
164    fig.savefig(output_directory / "waste_free_history.png", dpi=180)
165    if documentation_output is not None:
166        (documentation_output / "summary.json").write_text(
167            json.dumps(summaries, allow_nan=False) + "\n"
168        )
169    plt.close(fig)
170    logger.info("Saved waste_free_history.png")
171
172
173if __name__ == "__main__":
174    main()