Flow preconditioning

Flow preconditioning#

sequince supports preconditioning for mutation steps, as described by Karamanis et al. (2022). A normalizing flow can learn a transformation from the current particle population to a simpler latent space, where the mutation kernel can explore more efficiently.

sequince.transforms.FlowTransform adapts a normalizing flow for use as a particle transform. The flow must implement the sequince.flows.NormalizingFlow protocol:

Construct the transform by passing it a compatible flow and, optionally, the keyword arguments used to fit that flow:

from sequince.transforms import FlowTransform

flow = ...
preconditioning_transform = FlowTransform(
    flow=flow,
    fit_kwargs={...},
)

The resulting object can be used anywhere a particle transform is accepted by sequince.

Fitting the flow#

The fitting of the transform is handled by the mutation step. The frequency of fitting can be controlled by the transform_refit argument. Current options are:

  • "always": fit the flow every time the mutation step is called (default)

  • "once": fit the first time the mutation step is called, then reuse

  • "per-context": fit once per unique context, then reuse for that context

When using the prebuilt samplers, the transform_refit argument should be passed in the mutation_kwargs dictionary.

Prebuilt flows#

sequince-density provides various normalizing flows that can be used for preconditioning via the zuko and flowjax packages. See the sequince_density.flows.zuko and sequince_density.flows.flowjax modules for more information.

Continue with Persistent sampling to combine this approach with persistent sampling.

Example#

The following example uses a Zuko masked autoregressive flow to precondition a four-dimensional Rosenbrock target. It requires sequince-density with its Zuko backend and PyTorch support.

Example plots#

Posterior marginals and joint distributions for the four-dimensional Rosenbrock target sampled with flow preconditioning.

Posterior marginals and joint distributions for the four-dimensional Rosenbrock target sampled with flow preconditioning.#

Run provenance.

Download the script.

  1"""Example of using a normalizing flow for preconditioning in sequince.
  2
  3Running this example requires the sequince-density package to be installed with
  4the zuko backend.
  5"""
  6
  7from __future__ import annotations
  8
  9import json
 10import logging
 11from pathlib import Path
 12
 13import numpy as np
 14import torch
 15from orng import RandomGenerator
 16from sequince.array_api import to_numpy
 17from sequince.callbacks import IntermediateParticlesPlot, ParticleTransformPlot
 18from sequince.log import configure_logging
 19from sequince.plot import plot_corner
 20from sequince.prebuilt_samplers import make_standard_bayesian_smc
 21from sequince.result import SMCResult
 22from sequince.state import ParticleState
 23from sequince.transforms import AffineTransform, FlowTransform
 24from sequince_density.flows.zuko import ZukoFlow
 25
 26seed = 1234
 27n_particles = 1000
 28dims = 4
 29rosenbrock_scale = 1.0
 30outdir = Path("./")
 31documentation_output: Path | None = None
 32
 33
 34def rosenbrock(x: torch.Tensor) -> torch.Tensor:
 35    return torch.sum(
 36        100.0 * (x[..., 1:] - x[..., :-1] ** 2.0) ** 2.0
 37        + (1.0 - x[..., :-1]) ** 2.0,
 38        dim=-1,
 39    )
 40
 41
 42def log_prior(particles: torch.Tensor, context) -> torch.Tensor | float:
 43    lower = context.shared["bounds"][:, 0]
 44    upper = context.shared["bounds"][:, 1]
 45    in_bounds = torch.all((particles >= lower) & (particles <= upper), dim=-1)
 46    log_volume = torch.sum(torch.log(upper - lower))
 47    values = torch.where(in_bounds, -log_volume, -torch.inf)
 48    return values
 49
 50
 51def log_likelihood(particles: torch.Tensor, context) -> torch.Tensor | float:
 52    potential = rosenbrock(particles)
 53    values = -potential / context.shared["rosenbrock_scale"]
 54    return values
 55
 56
 57def _documentation_summary(result: SMCResult) -> dict[str, object]:
 58    """Summarize the weighted empirical measure on the host for JSON output."""
 59    state = result.final_state
 60    particles = to_numpy(state.particles)
 61    weights = to_numpy(state.weights)
 62    mean = np.sum(particles * weights[:, None], axis=0) / weights.sum()
 63    centered = particles - mean
 64    # The pinned Sequince revision uses a NumPy-only transpose signature in
 65    # weighted_covariance. Host arrays keep this documentation step portable.
 66    covariance = (centered * weights[:, None]).T @ centered / weights.sum()
 67    return {
 68        "log_evidence": float(state.log_evidence),
 69        "weighted_mean": mean.tolist(),
 70        "weighted_population_covariance": covariance.tolist(),
 71        "n_particles": len(particles),
 72    }
 73
 74
 75def main() -> None:
 76    logger = configure_logging(level=logging.INFO)
 77
 78    rng = RandomGenerator(backend="torch", seed=seed)
 79
 80    bounds = torch.tensor([[-5.0, 5.0]]).repeat(dims, 1)
 81    labels = [f"x{i + 1}" for i in range(dims)]
 82
 83    # Define the normalizing flow to use for preconditioning.
 84    # We use a Masked Autoregressive Flow (MAF) with 2 transforms and 2 hidden
 85    # layers of 16 units each.
 86    flow = ZukoFlow(
 87        dims=bounds.shape[0],
 88        flow_class="MAF",
 89        transforms=2,
 90        hidden_features=[16, 16],
 91        particle_transform=AffineTransform(),
 92        seed=seed,
 93    )
 94
 95    # We then construct a preconditioning transform that uses the flow to
 96    # transform the particles. This transform will be applied to the particles
 97    # before the mutation step in the SMC algorithm.
 98    # We also specify some fitting parameters for the flow.
 99    transform = FlowTransform(
100        flow=flow, fit_kwargs={"n_epochs": 75, "lr": 5e-3, "batch_size": 128}
101    )
102
103    # Construct the SMC sampler with the preconditioning transform.  We use the
104    # default settings for the SMC sampler, except that we specify the
105    # particle transform.
106    # We also add two callbacks to plot the particles and transformed particles
107    # at each iteration.
108    runner, posterior, metadata = make_standard_bayesian_smc(
109        log_prior=log_prior,
110        log_likelihood=log_likelihood,
111        particle_transform=transform,
112        callbacks=[
113            IntermediateParticlesPlot(
114                outdir=outdir / "particles",
115                base_filename="particles",
116            ),
117            ParticleTransformPlot(
118                outdir=outdir / "particles",
119                base_filename="transformed_particles",
120            ),
121        ],
122    )
123
124    # Draw initial particles from a uniform distribution over the bounds.
125    initial_particles = rng.uniform(
126        bounds[:, 0], bounds[:, 1], size=(n_particles, bounds.shape[0])
127    )
128    initial_state = ParticleState(initial_particles)
129
130    # Define shared context to pass to the log_prior and log_likelihood
131    # functions.
132    shared_context = {
133        "bounds": bounds,
134        "rosenbrock_scale": rosenbrock_scale,
135    }
136
137    # Run the SMC sampler
138    result = runner.run(
139        initial_state,
140        rng=rng,
141        metadata=metadata,
142        shared_context=shared_context,
143    )
144
145    if documentation_output is not None:
146        summary = _documentation_summary(result)
147        (documentation_output / "summary.json").write_text(
148            json.dumps(summary, allow_nan=False) + "\n"
149        )
150
151    # Compute the log evidence and its standard deviation from the SMC result.
152    log_evidence = result.log_evidence()
153    log_evidence_std = result.log_evidence_error()
154    logger.info(f"Log evidence: {log_evidence:.4f} ± {log_evidence_std:.4f}")
155
156    # Get posterior samples and weights for plotting
157    # When using rejection sampling, the weights will be uniform for accepted
158    # samples.
159    posterior_samples, weights = result.posterior_samples(method="rejection")
160
161    # Plot the posterior samples
162    plot_corner(
163        posterior_samples,
164        weights=weights,
165        parameter_labels=labels,
166        filename=outdir / "rosenbrock_corner.png",
167    )
168
169
170if __name__ == "__main__":
171    main()