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.
- binference/__init__.py +85 -0
- binference/_kernels.py +252 -0
- binference/_quadrature.py +202 -0
- binference/_simplex.py +54 -0
- binference/_utils.py +139 -0
- binference/_version.py +1 -0
- binference/basis.py +314 -0
- binference/bins.py +645 -0
- binference/density.py +323 -0
- binference/diagnostics.py +400 -0
- binference/estimator.py +197 -0
- binference/inference.py +368 -0
- binference/io.py +309 -0
- binference/likelihoods.py +1684 -0
- binference/measure.py +246 -0
- binference/model.py +833 -0
- binference/plotting.py +538 -0
- binference/priors/__init__.py +47 -0
- binference/priors/base.py +248 -0
- binference/priors/dirichlet.py +640 -0
- binference/priors/hyper.py +240 -0
- binference/priors/logistic.py +676 -0
- binference/priors/rates.py +139 -0
- binference/response.py +539 -0
- binference/results.py +2271 -0
- binference/samplers/__init__.py +19 -0
- binference/samplers/_curvature.py +67 -0
- binference/samplers/_nuts_core.py +284 -0
- binference/samplers/base.py +118 -0
- binference/samplers/bootstrap.py +136 -0
- binference/samplers/gibbs.py +287 -0
- binference/samplers/laplace.py +200 -0
- binference/samplers/nuts.py +681 -0
- binference/samplers/optimize.py +488 -0
- binference/samplers/vb.py +118 -0
- binference/selection.py +621 -0
- binference/simulate.py +720 -0
- binference/validation.py +477 -0
- binference/warnings.py +48 -0
- binference-0.1.0.dist-info/METADATA +130 -0
- binference-0.1.0.dist-info/RECORD +43 -0
- binference-0.1.0.dist-info/WHEEL +4 -0
- 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
|
+
)
|