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.#
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()