binference 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. binference/__init__.py +85 -0
  2. binference/_kernels.py +252 -0
  3. binference/_quadrature.py +202 -0
  4. binference/_simplex.py +54 -0
  5. binference/_utils.py +139 -0
  6. binference/_version.py +1 -0
  7. binference/basis.py +314 -0
  8. binference/bins.py +645 -0
  9. binference/density.py +323 -0
  10. binference/diagnostics.py +400 -0
  11. binference/estimator.py +197 -0
  12. binference/inference.py +368 -0
  13. binference/io.py +309 -0
  14. binference/likelihoods.py +1684 -0
  15. binference/measure.py +246 -0
  16. binference/model.py +833 -0
  17. binference/plotting.py +538 -0
  18. binference/priors/__init__.py +47 -0
  19. binference/priors/base.py +248 -0
  20. binference/priors/dirichlet.py +640 -0
  21. binference/priors/hyper.py +240 -0
  22. binference/priors/logistic.py +676 -0
  23. binference/priors/rates.py +139 -0
  24. binference/response.py +539 -0
  25. binference/results.py +2271 -0
  26. binference/samplers/__init__.py +19 -0
  27. binference/samplers/_curvature.py +67 -0
  28. binference/samplers/_nuts_core.py +284 -0
  29. binference/samplers/base.py +118 -0
  30. binference/samplers/bootstrap.py +136 -0
  31. binference/samplers/gibbs.py +287 -0
  32. binference/samplers/laplace.py +200 -0
  33. binference/samplers/nuts.py +681 -0
  34. binference/samplers/optimize.py +488 -0
  35. binference/samplers/vb.py +118 -0
  36. binference/selection.py +621 -0
  37. binference/simulate.py +720 -0
  38. binference/validation.py +477 -0
  39. binference/warnings.py +48 -0
  40. binference-0.1.0.dist-info/METADATA +130 -0
  41. binference-0.1.0.dist-info/RECORD +43 -0
  42. binference-0.1.0.dist-info/WHEEL +4 -0
  43. binference-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,681 @@
1
+ """The No-U-Turn Sampler engine (exact for every prior)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import os
7
+ import pickle
8
+ import warnings
9
+ from collections.abc import Iterator
10
+ from typing import Any
11
+
12
+ import numpy as np
13
+
14
+ from .._utils import RNGLike, progress, spawn, warn
15
+ from ..model import PopulationModel
16
+ from ..results import PopulationResult
17
+ from ..warnings import ConvergenceWarning
18
+ from ._curvature import covariance_from_hessian, fd_hessian, fd_hessian_diag
19
+ from ._nuts_core import Metric, adaptation_windows, find_reasonable_eps, nuts_step
20
+ from .base import Engine
21
+
22
+ __all__ = ["NUTS"]
23
+
24
+ #: ``target_accept`` when it is ``None``, except for logistic priors.
25
+ _TARGET_ACCEPT = 0.8
26
+ #: ``target_accept`` for logistic priors when it is ``None``.
27
+ _TARGET_ACCEPT_LOGISTIC = 0.9
28
+ #: ``metric="auto"`` is dense for logistic priors up to this many dimensions, and up to
29
+ #: this many the warm-up curvature is averaged over every chain's point, not taken at one.
30
+ _DENSE_MAX_DIM = 200
31
+ #: Up to this many dimensions, warm-up starts the metric from the curvature at the mode.
32
+ _HESSIAN_MAX_DIM = 500
33
+ # Partial centring re-creates a funnel when the field itself pins the scale
34
+ # hyperparameters down poorly (few modes per hyperparameter). In validation runs 0.28
35
+ # separated K <= 6, N <= 40 (0.33-0.62; divergences when centred) from K >= 10
36
+ # (0.11-0.25; 3-20x more ESS/s when centred).
37
+ #: Logistic priors stay non-centred when the median spread of the modes' log-scales given
38
+ #: the field exceeds this.
39
+ _CENTRING_MAX_SD = 0.28
40
+ #: Warm-up iteration of the partial centring of logistic priors, or the first slow
41
+ #: window's start if that is earlier.
42
+ _GEOMETRY_ITER = 25
43
+ #: ``t0``, ``gamma`` and ``kappa`` of the dual-averaging step-size adaptation.
44
+ _DUAL_AVERAGING = (10.0, 0.05, 0.75)
45
+
46
+
47
+ def _centred(model: PopulationModel, lam: np.ndarray) -> PopulationModel:
48
+ """The model with its logistic prior partially centred by weights ``lam``."""
49
+ return model._with_bound_prior(model.prior.with_centering(lam))
50
+
51
+
52
+ def _recentre(prior, x: np.ndarray, lam: np.ndarray) -> np.ndarray:
53
+ """``prior.recentre`` on the prior's block of ``x``; an inferred selection's
54
+ logit S after it is unchanged (DECISIONS #80)."""
55
+ d = prior.dim
56
+ return np.concatenate([prior.recentre(x[:d], lam), x[d:]])
57
+
58
+
59
+ def _conditional_log_scale_sd(model, prior, pts: list[np.ndarray]) -> np.ndarray:
60
+ """Spread of each mode's log-scale given the field, from the hyperparameter curvature.
61
+
62
+ In the centred form the field stays put while the hyperparameters move, so the
63
+ curvature of the hyperparameter block alone says how well the field pins them.
64
+ (The marginal spread from the full Hessian is unreliable: weakly identified
65
+ length scales can make it arbitrarily large.) Modes with fixed scales get 0.
66
+ """
67
+ n_act = prior.n_active
68
+ if prior.n_free == 0:
69
+ return np.zeros(n_act)
70
+ zeros = np.zeros(n_act)
71
+ fn = _centred(model, zeros).log_density_unconstrained_and_grad
72
+ hyper = range(n_act, prior.dim) # the hyperparameters, before any logit S
73
+ H = np.mean(
74
+ [fd_hessian(fn, _recentre(prior, x, zeros), cols=hyper)[hyper] for x in pts], axis=0
75
+ )
76
+ cov = covariance_from_hessian(H)
77
+ if cov is None:
78
+ return np.full(n_act, np.inf)
79
+ _, dlogs = prior.log_scales(prior.theta_from_x(np.mean(pts, axis=0)[: prior.dim]))
80
+ G = dlogs[prior.free] # (n_free, n_active): d log s_j / d log theta
81
+ return np.sqrt(np.maximum(np.einsum("aj,ab,bj->j", G, cov, G), 0.0))
82
+
83
+
84
+ class _Chain:
85
+ """State of one NUTS chain, including its step-size and metric adaptation."""
86
+
87
+ def __init__(self, x0, fn, rng, n_warmup, target_accept, dense):
88
+ self.fn = fn
89
+ self.rng = rng
90
+ self.x = np.asarray(x0, dtype=float)
91
+ self.lp, self.g = fn(self.x)
92
+ d = self.x.size
93
+ self.metric = Metric(np.eye(d) if dense else np.ones(d))
94
+ self.informed = False # metric initialised from curvature rather than the identity
95
+ self.dense = dense
96
+ self.eps = find_reasonable_eps(self.x, self.lp, self.g, self.metric, fn, rng)
97
+ self.target = target_accept
98
+ self._restart_dual_averaging()
99
+ self.windows = adaptation_windows(n_warmup)
100
+ self.win_ends = {e for _, e in self.windows}
101
+ self.buf: list[np.ndarray] = []
102
+ self.n_warmup = n_warmup
103
+ self.it = 0
104
+
105
+ def reset_geometry(self, fn, x, inv_m) -> None:
106
+ """Move to a new parametrisation and/or metric mid warm-up."""
107
+ self.fn = fn
108
+ self.x = np.asarray(x, dtype=float)
109
+ self.lp, self.g = fn(self.x)
110
+ if inv_m is not None:
111
+ self.metric = Metric(inv_m)
112
+ self.informed = True
113
+ self.buf = []
114
+ self.eps = find_reasonable_eps(self.x, self.lp, self.g, self.metric, self.fn, self.rng)
115
+ self._restart_dual_averaging()
116
+
117
+ def _restart_dual_averaging(self):
118
+ self.mu = np.log(10.0 * self.eps)
119
+ self.Hbar = 0.0
120
+ self.log_eps_bar = 0.0
121
+ self.m = 0
122
+
123
+ def step(self, max_depth, adapt):
124
+ x, lp, g, acc, depth, div, nl, energy = nuts_step(
125
+ self.x, self.lp, self.g, self.eps, self.metric, self.fn, self.rng, max_depth
126
+ )
127
+ self.x, self.lp, self.g = x, lp, g
128
+ if adapt:
129
+ self._adapt(acc)
130
+ self.it += 1
131
+ return acc, depth, div, nl, energy
132
+
133
+ def _adapt(self, acc):
134
+ t0, gamma, kappa = _DUAL_AVERAGING
135
+ self.m += 1
136
+ m = self.m
137
+ self.Hbar = (1.0 - 1.0 / (m + t0)) * self.Hbar + (self.target - acc) / (m + t0)
138
+ log_eps = self.mu - np.sqrt(m) / gamma * self.Hbar
139
+ eta = m ** (-kappa)
140
+ self.log_eps_bar = eta * log_eps + (1.0 - eta) * self.log_eps_bar
141
+ self.eps = float(np.exp(log_eps))
142
+ it = self.it
143
+ if any(s <= it < e for s, e in self.windows):
144
+ self.buf.append(self.x.copy())
145
+ if (it + 1) in self.win_ends:
146
+ arr = np.asarray(self.buf)
147
+ n = len(arr)
148
+ d = self.x.size
149
+ prev = self.metric.inv_m
150
+ if self.dense:
151
+ cov = np.atleast_2d(np.cov(arr.T)) if n > 1 else prev
152
+ if self.informed: # shrink toward the curvature-based metric
153
+ k = d + 5.0
154
+ inv_m = (n / (n + k)) * cov + (k / (n + k)) * prev
155
+ else: # Stan's regularisation
156
+ inv_m = (n / (n + 5.0)) * cov + 1e-3 * (5.0 / (n + 5.0)) * np.eye(d)
157
+ else:
158
+ var = arr.var(axis=0, ddof=1) if n > 1 else prev
159
+ if self.informed:
160
+ inv_m = (n / (n + 5.0)) * var + (5.0 / (n + 5.0)) * prev
161
+ else:
162
+ inv_m = (n / (n + 5.0)) * var + 1e-3 * (5.0 / (n + 5.0))
163
+ self.metric = Metric(inv_m)
164
+ self.buf = []
165
+ self.eps = find_reasonable_eps(self.x, self.lp, self.g, self.metric, self.fn, self.rng)
166
+ self._restart_dual_averaging()
167
+ if it == self.n_warmup - 1 and self.m > 0:
168
+ self.eps = float(np.exp(self.log_eps_bar))
169
+
170
+
171
+ _STAT_KEYS = ("accept_stat", "tree_depth", "diverging", "n_leapfrog", "energy")
172
+
173
+
174
+ def _advance_chain(chain: _Chain, n_iter: int, adapt: bool, max_depth: int, record: bool):
175
+ """Advance one chain; returns ``(draws, log_probs, stats, n_divergent)``."""
176
+ d = chain.x.size
177
+ xs = np.empty((n_iter, d)) if record else None
178
+ lps = np.empty(n_iter) if record else None
179
+ st = np.empty((len(_STAT_KEYS), n_iter)) if record else None
180
+ n_div = 0
181
+ for i in range(n_iter):
182
+ acc, depth, div, nl, energy = chain.step(max_depth, adapt)
183
+ n_div += int(div)
184
+ if record:
185
+ xs[i] = chain.x
186
+ lps[i] = chain.lp
187
+ st[:, i] = (acc, depth, div, nl, energy)
188
+ return xs, lps, st, n_div
189
+
190
+
191
+ def _cpu_count() -> int:
192
+ # the cores this process may use (CPU affinity, e.g. under a batch scheduler)
193
+ return os.process_cpu_count() or 1
194
+
195
+
196
+ def _worker_threads(n_workers: int) -> int:
197
+ """BLAS threads per worker: the cores shared between workers, never above the
198
+ limit already in force in this process (e.g. ``OMP_NUM_THREADS=1``; audit N-3)."""
199
+ from threadpoolctl import threadpool_info
200
+
201
+ share = max(1, _cpu_count() // n_workers)
202
+ info = threadpool_info()
203
+ # the BLAS pools' limit (another runtime, e.g. a single-threaded OpenMP, does not cap
204
+ # it); where no BLAS pool is visible (e.g. Accelerate), every reported limit counts
205
+ blas = [lib["num_threads"] for lib in info if lib.get("user_api") == "blas"]
206
+ limits = [max(blas)] if blas else [lib["num_threads"] for lib in info]
207
+ return min([share, *limits])
208
+
209
+
210
+ _WORKER_MODEL: PopulationModel | None = None # the base model, sent once per worker
211
+
212
+
213
+ def _worker_model(model: PopulationModel) -> PopulationModel:
214
+ """The model without the data kept for value draws (DECISIONS #84), which workers
215
+ never use: kept samples need not be copied to each of them."""
216
+ lk = model.likelihoods
217
+ if "within" not in lk.meta:
218
+ return model
219
+ out = copy.copy(model)
220
+ out.likelihoods = lk._copy(meta={k: v for k, v in lk.meta.items() if k != "within"})
221
+ return out
222
+
223
+
224
+ def _init_worker(model: PopulationModel, n_threads: int) -> None:
225
+ global _WORKER_MODEL
226
+ from threadpoolctl import threadpool_limits
227
+
228
+ _WORKER_MODEL = model
229
+ threadpool_limits(limits=n_threads)
230
+
231
+
232
+ def _chain_worker(payload):
233
+ """Run in a worker process: advance one chain and send it back without the model.
234
+
235
+ An error from the model comes back as a value, so the parent can tell it from a
236
+ failure of the pool itself (audit N-7).
237
+ """
238
+ chain, lam, n_iter, adapt, max_depth, record = payload
239
+ try:
240
+ model = _WORKER_MODEL if lam is None else _centred(_WORKER_MODEL, lam)
241
+ chain.fn = model.log_density_unconstrained_and_grad
242
+ out = _advance_chain(chain, n_iter, adapt, max_depth, record)
243
+ except Exception as err:
244
+ return err
245
+ chain.fn = None
246
+ return chain, out
247
+
248
+
249
+ class NUTS(Engine):
250
+ """No-U-Turn Sampler on the exact posterior, for every prior.
251
+
252
+ Samples the unconstrained coordinates of the model (isometric log-ratios
253
+ for simplex priors, whitened fields and log-hyperparameters for smooth
254
+ priors, log-rates for rates) with multinomial NUTS, dual-averaging step
255
+ size adaptation and Stan-style windowed metric adaptation. Chains run in
256
+ lock-step, so the engine supports progress reporting, a generator
257
+ interface (:meth:`sample`) and resuming (call :meth:`run` again to draw
258
+ more samples without re-running warm-up).
259
+
260
+ Parameters
261
+ ----------
262
+ model : PopulationModel
263
+ n_chains : int
264
+ Number of independent chains (default 4; needed for R-hat).
265
+ n_warmup : int
266
+ Warm-up (adaptation) iterations per chain.
267
+ target_accept : float, optional
268
+ Target mean acceptance statistic for step-size adaptation. Defaults to
269
+ 0.8, or 0.9 for logistic (smoothness) priors, whose hyperparameters
270
+ make the geometry harder.
271
+ max_depth : int
272
+ Maximum tree depth (at least 1; at most ``2**max_depth`` gradient
273
+ evaluations per draw).
274
+ metric : {"auto", "diag", "dense"}
275
+ Adapted Euclidean metric. ``"auto"`` uses a dense metric for logistic
276
+ (smoothness) priors with at most 200 unconstrained dimensions, whose
277
+ hyperparameters correlate with the field, and a diagonal one otherwise.
278
+ reparametrize : bool
279
+ Partially centre the basis coefficients of logistic priors during warm-up.
280
+ For logistic priors, switch each basis coefficient between the
281
+ non-centred and centred forms according to how strongly the data
282
+ constrain it (partial non-centring with weights ``1 / -H_jj`` from the
283
+ curvature at the chains' positions early in warm-up). If the field
284
+ pins the hyperparameters down poorly (few basis coefficients per
285
+ hyperparameter, e.g. a handful of bins), the coefficients stay
286
+ non-centred, which avoids funnels. This changes the sampling
287
+ coordinates, not the posterior.
288
+ init : array_like (dim,) or (n_chains, dim), optional
289
+ Starting points in the model's unconstrained coordinates; a single
290
+ point gets ``jitter`` added for each chain. Every start must have a
291
+ finite log density. By default chains start at the posterior mode (in
292
+ unconstrained coordinates) plus ``jitter``.
293
+ jitter : float
294
+ Standard deviation of the Gaussian jitter around the starting point.
295
+ n_jobs : int
296
+ Number of worker processes for the chains (``-1``: one per CPU this
297
+ process may use). The default ``1`` runs the chains in this process.
298
+ The workers share the cores' BLAS threads instead of oversubscribing
299
+ them, never exceeding a thread limit already set. Parallel and serial
300
+ runs use the same random streams, so their draws agree up to rounding
301
+ differences in multithreaded linear algebra. Parallel runs show no
302
+ progress bar.
303
+ rng : int, Generator or None
304
+ Random number generator (SPEC 7); each chain gets an independent child.
305
+ progress : bool
306
+ Show a progress bar.
307
+
308
+ Notes
309
+ -----
310
+ For logistic (smoothness) priors, after ``min(25, first window start)``
311
+ warm-up iterations (none when ``n_warmup < 20``, which also skips metric
312
+ adaptation) the engine reparametrises the prior (``reparametrize``) and,
313
+ for at most 500 dimensions, initialises the metric from the
314
+ finite-difference curvature of the log posterior at the chains'
315
+ positions. Each later adaptation window shrinks its estimate toward the
316
+ previous window's metric, starting from that curvature-based one.
317
+ Simplex and rate priors use Stan's windowed adaptation unchanged.
318
+
319
+ Results (``x``, ``log_density_unconstrained``, ``model``) are in the
320
+ prior's non-centred coordinates whatever the sampling coordinates;
321
+ ``info["centring"]`` holds the partial-centring weights used, and
322
+ ``info["inv_metric"]`` is in those sampling coordinates.
323
+ """
324
+
325
+ name = "nuts"
326
+ joint = True
327
+ markov = True
328
+
329
+ def __init__(
330
+ self,
331
+ model: PopulationModel,
332
+ *,
333
+ n_chains: int = 4,
334
+ n_warmup: int = 1000,
335
+ target_accept: float | None = None,
336
+ max_depth: int = 10,
337
+ metric: str = "auto",
338
+ reparametrize: bool = True,
339
+ init: Any = None,
340
+ jitter: float = 0.1,
341
+ n_jobs: int = 1,
342
+ rng: RNGLike = None,
343
+ progress: bool = False,
344
+ ) -> None:
345
+ logistic = model.prior.has_centering
346
+ # results are in the non-centred form, so only engines ever hold a centred prior
347
+ if logistic and np.any(model.prior.lam != 1.0):
348
+ raise ValueError(
349
+ "model: the logistic prior must be in its non-centred form (prior.lam all 1), "
350
+ "as the model is built; NUTS chooses the centring itself."
351
+ )
352
+ super().__init__(model, rng=rng, progress=progress)
353
+ if n_chains < 1:
354
+ raise ValueError("n_chains must be at least 1.")
355
+ if max_depth < 1:
356
+ raise ValueError("max_depth must be at least 1.")
357
+ if n_warmup < 0 or jitter < 0:
358
+ raise ValueError("n_warmup and jitter must be non-negative.")
359
+ if n_jobs == -1:
360
+ n_jobs = _cpu_count()
361
+ if n_jobs < 1:
362
+ raise ValueError("n_jobs must be a positive integer or -1.")
363
+ self.n_jobs = int(n_jobs)
364
+ if metric not in ("auto", "diag", "dense"):
365
+ raise ValueError("metric must be 'auto', 'diag' or 'dense'.")
366
+ if target_accept is None:
367
+ target_accept = _TARGET_ACCEPT_LOGISTIC if logistic else _TARGET_ACCEPT
368
+ if not 0 < target_accept < 1:
369
+ raise ValueError("target_accept must be in (0, 1).")
370
+ self.n_chains = int(n_chains)
371
+ self.n_warmup = int(n_warmup)
372
+ self.target_accept = float(target_accept)
373
+ self.max_depth = int(max_depth)
374
+ if metric == "auto":
375
+ metric = "dense" if logistic and model.dim <= _DENSE_MAX_DIM else "diag"
376
+ self.metric = metric
377
+ self.reparametrize = bool(reparametrize)
378
+ self.init = init
379
+ self.jitter = float(jitter)
380
+ self._chains: list[_Chain] | None = None
381
+ self._base_model = model
382
+ self._lam = None # partial-centring weights of the sampling coordinates, once chosen
383
+ self._pool = None # worker processes, kept for one sample() (audit N-13)
384
+ self._warm = False
385
+ self._x: list[np.ndarray] = []
386
+ self._lp: list[np.ndarray] = []
387
+ self._stats: dict[str, list[np.ndarray]] = {k: [] for k in _STAT_KEYS}
388
+ self.warmup_divergences = 0
389
+
390
+ # ---------------------------------------------------------- set-up
391
+ def _initial_points(self) -> np.ndarray:
392
+ d = self.model.dim
393
+ child = spawn(self.rng, 1)[0]
394
+ if self.init is not None:
395
+ x0 = np.asarray(self.init, dtype=float)
396
+ if x0.shape == (d,):
397
+ x0 = np.tile(x0, (self.n_chains, 1))
398
+ x0 = x0 + self.jitter * child.standard_normal(x0.shape)
399
+ if x0.shape != (self.n_chains, d):
400
+ raise ValueError(f"init must have shape ({d},) or ({self.n_chains}, {d}).")
401
+ if not all(np.isfinite(self.model.log_density_unconstrained(x)) for x in x0):
402
+ raise ValueError("init has a starting point with zero posterior density.")
403
+ return x0
404
+ from .optimize import MAP
405
+
406
+ with warnings.catch_warnings():
407
+ warnings.simplefilter("ignore", ConvergenceWarning)
408
+ mode = MAP(self.model, space="unconstrained").run()
409
+ x_mode = mode.x
410
+ pts = []
411
+ for _ in range(self.n_chains):
412
+ for scale in (self.jitter, self.jitter / 10, 0.0):
413
+ x = x_mode + scale * child.standard_normal(d)
414
+ if np.isfinite(self.model.log_density_unconstrained(x)):
415
+ break
416
+ pts.append(x)
417
+ return np.asarray(pts)
418
+
419
+ def _setup(self) -> None:
420
+ if self._chains is not None:
421
+ return
422
+ x0 = self._initial_points()
423
+ rngs = spawn(self.rng, self.n_chains)
424
+ fn = self.model.log_density_unconstrained_and_grad
425
+ self._chains = [
426
+ _Chain(x0[c], fn, rngs[c], self.n_warmup, self.target_accept, self.metric == "dense")
427
+ for c in range(self.n_chains)
428
+ ]
429
+
430
+ def _adapt_geometry(self) -> None:
431
+ """Reparametrise a logistic prior and initialise the metric from curvature.
432
+
433
+ Only for priors with a ``with_centering`` method (the logistic family):
434
+ for simplex priors the curvature at single points is a poor guide to
435
+ the posterior scales (sparse bins), so they keep Stan's adaptation.
436
+ """
437
+ assert self._chains is not None
438
+ base = self._base_model # non-centred (see __init__)
439
+ prior0 = base.prior
440
+ if not prior0.has_centering:
441
+ return
442
+ chains = self._chains
443
+ d = base.dim
444
+ pts = [ch.x for ch in chains]
445
+ # curvature at every chain's position for small problems, one chain otherwise
446
+ n_pts = len(chains) if d <= _DENSE_MAX_DIM else 1
447
+ model = base
448
+ if self.reparametrize and prior0.n_active > 0:
449
+ fn0 = base.log_density_unconstrained_and_grad
450
+ n_act = prior0.n_active
451
+ # only the field's diagonal curvature is needed here (audit N-10)
452
+ diag = np.mean([fd_hessian_diag(fn0, x, range(n_act)) for x in pts[:n_pts]], axis=0)
453
+ lam = np.clip(1.0 / np.maximum(-diag, 1.0), 0.0, 1.0)
454
+ # one decision for all modes that share the hyperparameters: mixing centred
455
+ # and non-centred modes under one length scale mixed worst in validation
456
+ sd_c = _conditional_log_scale_sd(base, prior0, pts)
457
+ scaled = sd_c > 0.0
458
+ if np.any(scaled) and np.median(sd_c[scaled]) > _CENTRING_MAX_SD:
459
+ lam[scaled] = 1.0
460
+ model = _centred(base, lam)
461
+ pts = [_recentre(prior0, x, lam) for x in pts]
462
+ self._lam = lam
463
+ self.model = model
464
+ fn = model.log_density_unconstrained_and_grad
465
+ inv_m = None
466
+ if d <= _HESSIAN_MAX_DIM:
467
+ H = np.mean([fd_hessian(fn, x) for x in pts[:n_pts]], axis=0)
468
+ cov = covariance_from_hessian(H)
469
+ if cov is not None:
470
+ inv_m = cov if self.metric == "dense" else np.diag(cov).copy()
471
+ for ch, x in zip(chains, pts, strict=True):
472
+ ch.reset_geometry(fn, x, inv_m)
473
+
474
+ # ---------------------------------------------------------- running
475
+ def _parallel(self, n_iter: int, adapt: bool, record: bool):
476
+ """Advance every chain ``n_iter`` iterations in worker processes.
477
+
478
+ Returns per-chain outputs, or ``None`` if no worker pool could be used
479
+ (the caller then runs serially; the chains are untouched). An error raised
480
+ by the model in a worker comes back as a value and is raised here.
481
+ """
482
+ from concurrent.futures import ProcessPoolExecutor
483
+ from concurrent.futures.process import BrokenProcessPool
484
+
485
+ assert self._chains is not None
486
+ chains = self._chains
487
+ fn = self.model.log_density_unconstrained_and_grad
488
+ for ch in chains:
489
+ ch.fn = None # the model goes to each worker once, not with every chain
490
+ payloads = [(ch, self._lam, n_iter, adapt, self.max_depth, record) for ch in chains]
491
+ try:
492
+ if self._pool is None:
493
+ model = _worker_model(self._base_model)
494
+ try: # e.g. a lambda in a prior cannot be sent to workers
495
+ pickle.dumps((model, payloads[0]))
496
+ except (pickle.PicklingError, AttributeError, TypeError) as err:
497
+ return self._serial_from_now(err)
498
+ n_workers = min(self.n_jobs, len(chains))
499
+ self._pool = ProcessPoolExecutor(
500
+ max_workers=n_workers,
501
+ initializer=_init_worker,
502
+ initargs=(model, _worker_threads(n_workers)),
503
+ )
504
+ # errors from the model come back as values (see _chain_worker), so only a
505
+ # failure of the pool itself falls back to serial (audit N-7)
506
+ results = list(self._pool.map(_chain_worker, payloads))
507
+ except (OSError, BrokenProcessPool, NotImplementedError) as err:
508
+ return self._serial_from_now(err)
509
+ finally:
510
+ for ch in chains:
511
+ ch.fn = fn
512
+ for r in results:
513
+ if isinstance(r, BaseException):
514
+ raise r
515
+ self._chains = []
516
+ outs = []
517
+ for ch, out in results:
518
+ ch.fn = fn
519
+ self._chains.append(ch)
520
+ outs.append(out)
521
+ return outs
522
+
523
+ def _serial_from_now(self, err: BaseException) -> None:
524
+ warn(
525
+ f"Could not run chains in parallel ({type(err).__name__}: {err}); "
526
+ "running them serially.",
527
+ RuntimeWarning,
528
+ )
529
+ self.n_jobs = 1 # do not try again for this engine
530
+ self._close_pool()
531
+
532
+ def _close_pool(self) -> None:
533
+ if self._pool is not None:
534
+ self._pool.shutdown()
535
+ self._pool = None
536
+
537
+ def _advance(self, n_iter: int, adapt: bool, desc: str) -> None:
538
+ """Warm-up iterations for every chain (parallel or interleaved serial)."""
539
+ if n_iter <= 0:
540
+ return
541
+ assert self._chains is not None
542
+ if self.n_jobs > 1 and len(self._chains) > 1:
543
+ outs = self._parallel(n_iter, adapt, record=False)
544
+ if outs is not None:
545
+ self.warmup_divergences += sum(o[3] for o in outs)
546
+ return
547
+ for _ in progress(range(n_iter), n_iter, desc, self.progress):
548
+ for ch in self._chains:
549
+ _, _, div, _, _ = ch.step(self.max_depth, adapt=adapt)
550
+ self.warmup_divergences += int(div)
551
+
552
+ def warmup(self) -> None:
553
+ """Run warm-up (adaptation) if it has not been run yet."""
554
+ self._setup()
555
+ if self._warm:
556
+ return
557
+ assert self._chains is not None
558
+ windows = self._chains[0].windows
559
+ logistic = self._base_model.prior.has_centering
560
+ # adapt the geometry once the chains have left the mode for the typical set
561
+ switch = min(_GEOMETRY_ITER, windows[0][0]) if (windows and logistic) else 0
562
+ try:
563
+ self._advance(switch, adapt=True, desc="NUTS warm-up")
564
+ if switch > 0:
565
+ self._adapt_geometry()
566
+ self._advance(self.n_warmup - switch, adapt=True, desc="NUTS warm-up")
567
+ finally:
568
+ self._close_pool() # warm-up phases share one pool; sampling starts its own
569
+ self._warm = True
570
+
571
+ def _record(self, xs, lps, st, n_done: int) -> None:
572
+ if n_done:
573
+ self._x.append(xs[:, :n_done])
574
+ self._lp.append(lps[:, :n_done])
575
+ for j, k in enumerate(_STAT_KEYS):
576
+ self._stats[k].append(st[:, j, :n_done])
577
+
578
+ def _report(self, X: np.ndarray, LP: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
579
+ """Draws and log densities from the sampling coordinates to the model's (audit N-1)."""
580
+ if self._lam is None:
581
+ return X, LP
582
+ d = self.model.prior.dim # an inferred selection's logit S follows unchanged
583
+ x, lp = self.model.prior.to_noncentred(X[..., :d].reshape(-1, d), LP.reshape(-1))
584
+ x = np.concatenate([x.reshape(*X.shape[:-1], d), X[..., d:]], axis=-1)
585
+ return x, lp.reshape(LP.shape)
586
+
587
+ def sample(self, n_samples: int) -> Iterator[np.ndarray]:
588
+ """Generator over post-warm-up iterations, yielding ``(n_chains, dim)`` positions.
589
+
590
+ Positions are in the model's unconstrained coordinates (the prior's
591
+ non-centred form). Draws are also recorded and included in the next
592
+ :meth:`run` / :attr:`result`. With ``n_jobs > 1`` the chains advance in
593
+ worker processes and the positions are yielded once all have finished.
594
+
595
+ Parameters
596
+ ----------
597
+ n_samples : int
598
+ Iterations per chain.
599
+ """
600
+ try:
601
+ self.warmup()
602
+ assert self._chains is not None
603
+ C, d = self.n_chains, self.model.dim
604
+ n_samples = int(n_samples)
605
+ if self.n_jobs > 1 and C > 1 and n_samples > 0:
606
+ outs = self._parallel(n_samples, adapt=False, record=True)
607
+ if outs is not None:
608
+ xs, lps = self._report(
609
+ np.stack([o[0] for o in outs]), np.stack([o[1] for o in outs])
610
+ )
611
+ self._record(xs, lps, np.stack([o[2] for o in outs]), n_samples)
612
+ yield from (xs[:, i] for i in range(n_samples))
613
+ return
614
+ xs = np.empty((C, n_samples, d))
615
+ lps = np.empty((C, n_samples))
616
+ st = np.empty((C, len(_STAT_KEYS), n_samples))
617
+ n_done = 0
618
+ try:
619
+ for i in progress(range(n_samples), n_samples, "NUTS sampling", self.progress):
620
+ for c, ch in enumerate(self._chains):
621
+ acc, depth, div, nl, energy = ch.step(self.max_depth, adapt=False)
622
+ xs[c, i] = ch.x
623
+ lps[c, i] = ch.lp
624
+ st[c, :, i] = (acc, depth, div, nl, energy)
625
+ xs[:, i], lps[:, i] = self._report(xs[:, i], lps[:, i])
626
+ n_done = i + 1
627
+ yield xs[:, i]
628
+ finally:
629
+ self._record(xs, lps, st, n_done)
630
+ finally:
631
+ self._close_pool()
632
+
633
+ def run(self, n_samples: int = 1000) -> PopulationResult:
634
+ """Warm up (first call only), draw ``n_samples`` per chain, and return all draws so far.
635
+
636
+ Parameters
637
+ ----------
638
+ n_samples : int, optional
639
+ Draws per chain after warm-up.
640
+ """
641
+ for _ in self.sample(int(n_samples)):
642
+ pass
643
+ return self.result
644
+
645
+ @property
646
+ def result(self) -> PopulationResult:
647
+ """A :class:`~binference.PopulationResult` with every draw recorded so far."""
648
+ if not self._x:
649
+ raise RuntimeError("No draws yet; call run().")
650
+ X = np.concatenate(self._x, axis=1)
651
+ LP = np.concatenate(self._lp, axis=1)
652
+ C, n, d = X.shape
653
+ base = self._base_model
654
+ info: dict[str, Any] = {} if self._lam is None else {"centring": self._lam}
655
+ vals = base.transform(X.reshape(-1, d)).reshape(C, n, -1)
656
+ stats = {k: np.concatenate(v, axis=1) for k, v in self._stats.items()}
657
+ info.update(stats)
658
+ info["n_divergent"] = int(stats["diverging"].sum())
659
+ info["warmup_divergences"] = self.warmup_divergences
660
+ info["max_depth_fraction"] = float(np.mean(stats["tree_depth"] >= self.max_depth))
661
+ info["step_size"] = np.array([ch.eps for ch in self._chains])
662
+ info["inv_metric"] = [ch.metric.inv_m for ch in self._chains]
663
+ E = stats["energy"]
664
+ with np.errstate(invalid="ignore", divide="ignore"):
665
+ ebfmi = np.sum(np.diff(E, axis=1) ** 2, axis=1) / np.sum(
666
+ (E - E.mean(axis=1, keepdims=True)) ** 2, axis=1
667
+ )
668
+ info["ebfmi"] = ebfmi
669
+ info.update(base._joint_info(X))
670
+ hyper = getattr(base.prior, "hyper_names", [])
671
+ if hyper:
672
+ u = X[..., base.prior.n_active : base.prior.dim]
673
+ info["hyperparameters"] = {name: np.exp(u[..., j]) for j, name in enumerate(hyper)}
674
+ return PopulationResult(
675
+ base,
676
+ vals,
677
+ method="nuts",
678
+ x=X,
679
+ log_density_unconstrained=LP,
680
+ info=info,
681
+ )