Persistent sampling#
This example demonstrates how to use persistent sampling in sequince to
sample an n-dimensional Rosenbrock target.
Persistent sampling is a variation of SMC proposed by Karamanis and Seljak (2025) in Persistent sampling: enhancing the efficiency of sequential Monte Carlo. It retains and reweights populations from previous stages.
Run the example from the repository root:
python persistent_sampling/rosenbrock.py
For a larger run:
python persistent_sampling/rosenbrock.py \
--dimensions 16 \
--n-particles 1024 \
--target-ess 2048 \
--target-total-ess 8192 \
--mutation-steps 32
The script prints diagnostics from the final weighted persistent state,
including its archive size, effective sample size, weighted mean, best
Rosenbrock potential, and log evidence. Unless --no-plot is supplied, it also
saves a weighted corner plot as examples/persistent_sampling/rosenbrock_<D>d.png.
Use --output PATH to choose a different location.
Run the following for all available options:
python persistent_sampling/rosenbrock.py --help
Preconditioning#
First read Flow preconditioning for an explicit flow-transform setup. This example also shows how to use the normalizing-flow-based preconditioning described in Karamanis et al. (2022), Accelerating astronomical and cosmological inference with Preconditioned Monte Carlo, combined with persistent sampling from Karamanis and Seljak (2025).
The --flow option enables learned normalizing-flow preconditioning:
python persistent_sampling/rosenbrock.py --dimensions 6 --flow
The flow option requires sequince-density and its flow dependencies.
Example plots#
Posterior marginals and joint distributions from the persistent Rosenbrock run, using the script defaults.#
Complete script#
1"""Sample a configurable-dimensional Rosenbrock target persistently.
2
3For example, run a six-dimensional problem with::
4
5 python persistent_sampling/rosenbrock.py --dimensions 6
6
7The target is the bounded density ``exp(-Rosenbrock(x) / scale)`` under a
8uniform prior. A corner plot of the final weighted persistent population is
9saved unless ``--no-plot`` is supplied. Pass ``--flow`` to use optional learned
10normalizing-flow preconditioning instead of the default bounded transform.
11"""
12
13from __future__ import annotations
14
15import argparse
16import json
17from pathlib import Path
18
19import numpy as np
20from sequince import (
21 ParticleState,
22 PersistentParticleState,
23 make_persistent_bayesian_smc,
24)
25from sequince.log import configure_logging
26from sequince.plot import plot_corner
27from sequince.steps import StepContext
28from sequince.transforms import BoundedToUnboundedTransform
29
30documentation_output: Path | None = None
31
32
33def rosenbrock(particles: np.ndarray) -> np.ndarray:
34 """Return the Rosenbrock potential for each particle."""
35 values = np.asarray(particles, dtype=float)
36 return np.sum(
37 100.0 * (values[:, 1:] - values[:, :-1] ** 2.0) ** 2.0
38 + (1.0 - values[:, :-1]) ** 2.0,
39 axis=1,
40 )
41
42
43def make_likelihood_and_prior(
44 dimensions: int,
45 *,
46 bound: float,
47 scale: float,
48) -> tuple[
49 callable[[np.ndarray, StepContext], np.ndarray],
50 callable[[np.ndarray, StepContext], np.ndarray],
51]:
52 """Construct a bounded Rosenbrock target."""
53 log_prior_volume = dimensions * np.log(2.0 * bound)
54
55 def log_prior(particles: np.ndarray, context=None) -> np.ndarray:
56 del context
57 values = np.asarray(particles, dtype=float)
58 inside = np.all(np.abs(values) <= bound, axis=1)
59 return np.where(inside, -log_prior_volume, -np.inf)
60
61 def log_likelihood(particles: np.ndarray, context=None) -> np.ndarray:
62 del context
63 return -rosenbrock(particles) / scale
64
65 return (
66 log_prior,
67 log_likelihood,
68 )
69
70
71def build_parser() -> argparse.ArgumentParser:
72 """Build the command-line parser."""
73 parser = argparse.ArgumentParser(description=__doc__)
74 parser.add_argument("--dimensions", type=int, default=4)
75 parser.add_argument("--n-particles", type=int, default=256)
76 parser.add_argument(
77 "--target-ess",
78 type=float,
79 default=None,
80 help="Absolute persistent ESS (default: twice n-particles).",
81 )
82 parser.add_argument(
83 "--target-total-ess",
84 type=float,
85 default=None,
86 help="Optional final persistent ESS accumulated at beta=1.",
87 )
88 parser.add_argument("--mutation-steps", type=int, default=8)
89 parser.add_argument(
90 "--flow",
91 action="store_true",
92 help="Use optional learned normalizing-flow preconditioning.",
93 )
94 parser.add_argument("--bound", type=float, default=5.0)
95 parser.add_argument(
96 "--rosenbrock-scale",
97 type=float,
98 default=1.0,
99 help="Divide the Rosenbrock potential by this positive value.",
100 )
101 parser.add_argument("--seed", type=int, default=20260820)
102 parser.add_argument(
103 "--output",
104 type=Path,
105 default=None,
106 help="Plot path (default: persistent_sampling/rosenbrock_<D>d.png).",
107 )
108 parser.add_argument("--no-plot", action="store_true")
109 return parser
110
111
112def main() -> None:
113 """Run persistent SMC and report archive diagnostics."""
114 parser = build_parser()
115 args = parser.parse_args()
116 if args.dimensions < 2:
117 parser.error("--dimensions must be at least 2")
118 if args.n_particles < 1:
119 parser.error("--n-particles must be positive")
120 if args.bound <= 0.0:
121 parser.error("--bound must be positive")
122 if args.rosenbrock_scale <= 0.0:
123 parser.error("--rosenbrock-scale must be positive")
124 if args.mutation_steps < 1:
125 parser.error("--mutation-steps must be positive")
126 target_ess = (
127 float(args.target_ess)
128 if args.target_ess is not None
129 else float(2 * args.n_particles)
130 )
131
132 configure_logging()
133
134 rng = np.random.default_rng(args.seed)
135 log_prior, log_likelihood = make_likelihood_and_prior(
136 args.dimensions,
137 bound=args.bound,
138 scale=args.rosenbrock_scale,
139 )
140 initial_particles = rng.uniform(
141 -args.bound,
142 args.bound,
143 size=(args.n_particles, args.dimensions),
144 )
145 initial_state = ParticleState(initial_particles)
146
147 bounds = {
148 index: (-args.bound, args.bound) for index in range(args.dimensions)
149 }
150 bounded_transform = BoundedToUnboundedTransform(bounds=bounds)
151 if args.flow:
152 try:
153 from sequince.transforms import FlowTransform
154 from sequince_density.flows import ZukoFlow
155 except ImportError as exc:
156 parser.error(
157 "--flow requires the optional sequince-density dependency "
158 f"({exc})"
159 )
160 flow = ZukoFlow(
161 dims=args.dimensions,
162 flow_class="MAF",
163 hidden_features=[64, 64],
164 particle_transform=bounded_transform,
165 )
166 particle_transform = FlowTransform(
167 flow,
168 fit_kwargs={
169 "n_epochs": 100,
170 "lr": 1e-3,
171 "batch_size": 500,
172 },
173 )
174 transform_refit = "per-context"
175 else:
176 particle_transform = bounded_transform
177 transform_refit = "once"
178
179 sampler, posterior, metadata = make_persistent_bayesian_smc(
180 log_prior=log_prior,
181 log_likelihood=log_likelihood,
182 n_particles=args.n_particles,
183 target_ess=target_ess,
184 target_total_ess=args.target_total_ess,
185 mutation_n_steps=args.mutation_steps,
186 mutation_kwargs={"transform_refit": transform_refit},
187 particle_transform=particle_transform,
188 )
189 result = sampler.run(
190 initial_state,
191 rng=rng,
192 metadata=metadata,
193 )
194
195 final_state = result.final_state
196 if not isinstance(final_state, PersistentParticleState):
197 raise TypeError("persistent SMC must return a PersistentParticleState")
198 particles = np.asarray(final_state.particles)
199 weights = np.asarray(final_state.weights)
200 potentials = rosenbrock(particles)
201 best = particles[int(np.argmin(potentials))]
202 weighted_mean = np.average(particles, axis=0, weights=weights)
203 weighted_mean_potential = np.average(potentials, weights=weights)
204 print()
205 print(f"dimensions: {args.dimensions}")
206 print(f"iterations: {len(result)}")
207 print(f"final beta: {float(result.context.metadata['beta']):.6f}")
208 print(f"final archive size: {final_state.n_particles}")
209 print(f"final persistent ESS: {final_state.effective_sample_size():.1f}")
210 print(
211 "weighted mean: "
212 f"{np.array2string(weighted_mean, precision=3, threshold=12)}"
213 )
214 print(f"weighted mean Rosenbrock potential: {weighted_mean_potential:.6g}")
215 print(f"best Rosenbrock potential: {float(np.min(potentials)):.6g}")
216 print(f"best particle: {np.array2string(best, precision=3)}")
217 print(f"final log evidence: {float(final_state.log_evidence):+.6f}")
218
219 if documentation_output is not None:
220 summary = {
221 "log_evidence": float(final_state.log_evidence),
222 "weighted_mean": weighted_mean.tolist(),
223 "archive_size": int(final_state.n_particles),
224 "effective_sample_size": float(
225 final_state.effective_sample_size()
226 ),
227 "weighted_mean_potential": float(weighted_mean_potential),
228 }
229 (documentation_output / "summary.json").write_text(
230 json.dumps(summary, allow_nan=False) + "\n"
231 )
232 if args.output is None:
233 args.output = documentation_output / "corner.png"
234
235 if not args.no_plot:
236 output = args.output or Path(__file__).with_name(
237 f"rosenbrock_{args.dimensions}d.png"
238 )
239 output.parent.mkdir(parents=True, exist_ok=True)
240 posterior_samples, weights = result.posterior_samples(
241 method="rejection", rng=rng
242 )
243 plot_corner(
244 posterior_samples,
245 weights=weights,
246 parameter_labels=[
247 rf"$x_{{{index + 1}}}$" for index in range(args.dimensions)
248 ],
249 filename=output,
250 truths=np.ones(args.dimensions),
251 )
252 print(f"saved plot: {output}")
253
254
255if __name__ == "__main__":
256 main()