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,19 @@
|
|
|
1
|
+
"""Inference engines.
|
|
2
|
+
|
|
3
|
+
* :class:`NUTS` -- No-U-Turn Sampler on the exact posterior (any prior).
|
|
4
|
+
* :class:`Gibbs` -- data-augmentation Gibbs sampler (conjugate priors; joint labels).
|
|
5
|
+
* :class:`MAP` -- posterior mode / maximum likelihood (mix-SQP, SQUAREM-EM or L-BFGS).
|
|
6
|
+
* :class:`Laplace` -- Gaussian approximation with PSIS importance correction.
|
|
7
|
+
* :class:`MeanFieldVB` -- mean-field variational Bayes (approximate).
|
|
8
|
+
* :class:`Bootstrap` -- bootstrap of the MAP estimator (approximate).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from .base import Engine
|
|
12
|
+
from .bootstrap import Bootstrap
|
|
13
|
+
from .gibbs import Gibbs
|
|
14
|
+
from .laplace import Laplace
|
|
15
|
+
from .nuts import NUTS
|
|
16
|
+
from .optimize import MAP
|
|
17
|
+
from .vb import MeanFieldVB
|
|
18
|
+
|
|
19
|
+
__all__ = ["MAP", "NUTS", "Bootstrap", "Engine", "Gibbs", "Laplace", "MeanFieldVB"]
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Finite-difference curvature of a log density, shared by NUTS and Laplace."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Sequence
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
LogpGrad = Callable[[np.ndarray], tuple[float, np.ndarray]]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _step(x: np.ndarray, j: int, h: float) -> np.ndarray:
|
|
13
|
+
e = np.zeros(x.size)
|
|
14
|
+
e[j] = h * max(1.0, abs(x[j]))
|
|
15
|
+
return e
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def fd_hessian(fn: LogpGrad, x: np.ndarray, h: float = 1e-4, cols=None) -> np.ndarray:
|
|
19
|
+
"""Symmetrised central-difference Hessian of a log density from its gradient.
|
|
20
|
+
|
|
21
|
+
``cols`` restricts the computation to some columns (returns ``(dim, len(cols))``).
|
|
22
|
+
"""
|
|
23
|
+
x = np.asarray(x, dtype=float)
|
|
24
|
+
idx = range(x.size) if cols is None else cols
|
|
25
|
+
H = np.empty((x.size, len(idx)))
|
|
26
|
+
for c, j in enumerate(idx):
|
|
27
|
+
e = _step(x, j, h)
|
|
28
|
+
H[:, c] = (fn(x + e)[1] - fn(x - e)[1]) / (2.0 * e[j])
|
|
29
|
+
if cols is None:
|
|
30
|
+
H = 0.5 * (H + H.T)
|
|
31
|
+
return H
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def fd_hessian_diag(
|
|
35
|
+
fn: LogpGrad, x: np.ndarray, idx: Sequence[int], h: float = 1e-4
|
|
36
|
+
) -> np.ndarray:
|
|
37
|
+
"""The diagonal entries ``idx`` of the Hessian, without storing its columns."""
|
|
38
|
+
x = np.asarray(x, dtype=float)
|
|
39
|
+
out = np.empty(len(idx))
|
|
40
|
+
for c, j in enumerate(idx):
|
|
41
|
+
e = _step(x, j, h)
|
|
42
|
+
out[c] = (fn(x + e)[1][j] - fn(x - e)[1][j]) / (2.0 * e[j])
|
|
43
|
+
return out
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def neg_hessian_eigen(H: np.ndarray) -> tuple[np.ndarray, np.ndarray] | None:
|
|
47
|
+
"""Eigenvalues and vectors of ``-H`` (symmetrised); ``None`` if ``H`` is not finite."""
|
|
48
|
+
A = -0.5 * (H + H.T)
|
|
49
|
+
if not np.all(np.isfinite(A)):
|
|
50
|
+
return None
|
|
51
|
+
return np.linalg.eigh(A)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def covariance_from_hessian(H: np.ndarray, floor: float = 1e-6) -> np.ndarray | None:
|
|
55
|
+
"""A positive-definite covariance from a (possibly indefinite) Hessian of log p.
|
|
56
|
+
|
|
57
|
+
Eigenvalues of ``-H`` are clipped below at ``floor`` times the largest, so
|
|
58
|
+
directions with no or negative curvature get the largest variance seen.
|
|
59
|
+
Returns ``None`` if the Hessian is unusable.
|
|
60
|
+
"""
|
|
61
|
+
eig = neg_hessian_eigen(H)
|
|
62
|
+
if eig is None or not eig[0].max() > 0:
|
|
63
|
+
return None
|
|
64
|
+
lam, U = eig
|
|
65
|
+
lam = np.maximum(lam, floor * lam.max())
|
|
66
|
+
cov = (U / lam) @ U.T
|
|
67
|
+
return 0.5 * (cov + cov.T)
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""Core of the No-U-Turn Sampler (pure functions).
|
|
2
|
+
|
|
3
|
+
Multinomial NUTS with the generalised U-turn criterion and Stan's additional
|
|
4
|
+
checks across merged subtrees; diagonal or dense Euclidean metrics.
|
|
5
|
+
References: Hoffman & Gelman (2014); Betancourt (2017, arXiv:1701.02434).
|
|
6
|
+
|
|
7
|
+
The tree stores, at each end, the position, momentum, velocity
|
|
8
|
+
(``inverse metric @ momentum``) and gradient, so that the U-turn checks need no
|
|
9
|
+
extra metric products. Scalar arithmetic uses :mod:`math`, which is much
|
|
10
|
+
faster than NumPy scalars in this hot loop.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import math
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
from scipy import linalg
|
|
20
|
+
|
|
21
|
+
LogpGrad = Callable[[np.ndarray], tuple[float, np.ndarray]]
|
|
22
|
+
|
|
23
|
+
_NEG_INF = -math.inf
|
|
24
|
+
|
|
25
|
+
#: Iterations of the first fast phase of Stan-style warm-up.
|
|
26
|
+
_INIT_BUFFER = 75
|
|
27
|
+
#: Iterations of the first slow window of Stan-style warm-up; each next one is twice as long.
|
|
28
|
+
_BASE_WINDOW = 25
|
|
29
|
+
#: Iterations of the last fast phase of Stan-style warm-up.
|
|
30
|
+
_TERM_BUFFER = 50
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Metric:
|
|
34
|
+
"""Euclidean metric: inverse mass matrix ``inv_m`` (diagonal vector or dense matrix)."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, inv_m: np.ndarray) -> None:
|
|
37
|
+
self.inv_m = np.asarray(inv_m, dtype=float)
|
|
38
|
+
self.dense = self.inv_m.ndim == 2
|
|
39
|
+
if self.dense:
|
|
40
|
+
# p ~ N(0, inv_m^{-1}) with inv_m = C C^T is p = C^{-T} z; C^{-T} once (audit N-9)
|
|
41
|
+
chol = np.linalg.cholesky(self.inv_m)
|
|
42
|
+
self._draw = linalg.solve_triangular(chol, np.eye(chol.shape[0]), lower=True).T
|
|
43
|
+
else:
|
|
44
|
+
self._draw = 1.0 / np.sqrt(self.inv_m)
|
|
45
|
+
|
|
46
|
+
def mv(self, p: np.ndarray) -> np.ndarray:
|
|
47
|
+
return self.inv_m @ p if self.dense else self.inv_m * p
|
|
48
|
+
|
|
49
|
+
def kinetic(self, p: np.ndarray) -> float:
|
|
50
|
+
return 0.5 * float(p @ self.mv(p))
|
|
51
|
+
|
|
52
|
+
def draw_momentum(self, rng: np.random.Generator) -> np.ndarray:
|
|
53
|
+
z = rng.standard_normal(self.inv_m.shape[0])
|
|
54
|
+
return self._draw @ z if self.dense else self._draw * z
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _logaddexp(a: float, b: float) -> float:
|
|
58
|
+
if a == _NEG_INF:
|
|
59
|
+
return b
|
|
60
|
+
if b == _NEG_INF:
|
|
61
|
+
return a
|
|
62
|
+
if a > b:
|
|
63
|
+
return a + math.log1p(math.exp(b - a))
|
|
64
|
+
return b + math.log1p(math.exp(a - b))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class _Tree:
|
|
68
|
+
__slots__ = (
|
|
69
|
+
"diverging",
|
|
70
|
+
"g_m",
|
|
71
|
+
"g_p",
|
|
72
|
+
"g_prop",
|
|
73
|
+
"log_w",
|
|
74
|
+
"lp_prop",
|
|
75
|
+
"n_leaf",
|
|
76
|
+
"p_m",
|
|
77
|
+
"p_p",
|
|
78
|
+
"p_prop",
|
|
79
|
+
"rho",
|
|
80
|
+
"sum_acc",
|
|
81
|
+
"turning",
|
|
82
|
+
"v_m",
|
|
83
|
+
"v_p",
|
|
84
|
+
"x_m",
|
|
85
|
+
"x_p",
|
|
86
|
+
"x_prop",
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def leapfrog(x, p, g, eps, metric: Metric, fn: LogpGrad):
|
|
91
|
+
p_half = p + (0.5 * eps) * g
|
|
92
|
+
x_new = x + eps * metric.mv(p_half)
|
|
93
|
+
lp_new, g_new = fn(x_new)
|
|
94
|
+
p_new = p_half + (0.5 * eps) * g_new
|
|
95
|
+
return x_new, p_new, g_new, lp_new
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _no_uturn(va, vb, rho) -> bool:
|
|
99
|
+
"""Generalised criterion with velocities ``va``, ``vb`` at the two ends."""
|
|
100
|
+
return float(va @ rho) > 0.0 and float(vb @ rho) > 0.0
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _accept(rng, log_ratio: float) -> bool:
|
|
104
|
+
# u < exp(log_ratio) with u in (0, 1]: log1p(-u) never takes log(0) (audit N-8)
|
|
105
|
+
return math.log1p(-rng.random()) < log_ratio
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _merge(t1: _Tree, t2: _Tree, v: int, rng, biased: bool) -> bool:
|
|
109
|
+
"""Append ``t2`` to ``t1`` in direction ``v``; returns whether the merged tree is valid.
|
|
110
|
+
|
|
111
|
+
``t2``'s proposal replaces ``t1``'s with probability ``exp(t2.log_w - w)``: ``w`` is
|
|
112
|
+
the merged weight within subtrees (multinomial) and the old tree's weight at the top
|
|
113
|
+
level (``biased``, progressive sampling). Then Stan's U-turn checks across the
|
|
114
|
+
merged tree and the two extra sub-checks.
|
|
115
|
+
"""
|
|
116
|
+
merged = _logaddexp(t1.log_w, t2.log_w)
|
|
117
|
+
if _accept(rng, t2.log_w - (t1.log_w if biased else merged)):
|
|
118
|
+
t1.x_prop, t1.p_prop, t1.lp_prop, t1.g_prop = t2.x_prop, t2.p_prop, t2.lp_prop, t2.g_prop
|
|
119
|
+
t1.log_w = merged
|
|
120
|
+
rho = t1.rho + t2.rho
|
|
121
|
+
if v > 0:
|
|
122
|
+
p_ie, p_fb = t1.p_p, t2.p_m
|
|
123
|
+
v_ib, v_ie, v_fb, v_fe = t1.v_m, t1.v_p, t2.v_m, t2.v_p
|
|
124
|
+
else:
|
|
125
|
+
p_ie, p_fb = t1.p_m, t2.p_p
|
|
126
|
+
v_ib, v_ie, v_fb, v_fe = t1.v_p, t1.v_m, t2.v_p, t2.v_m
|
|
127
|
+
ok = (
|
|
128
|
+
_no_uturn(v_ib, v_fe, rho)
|
|
129
|
+
and _no_uturn(v_ib, v_fb, t1.rho + p_fb)
|
|
130
|
+
and _no_uturn(v_ie, v_fe, t2.rho + p_ie)
|
|
131
|
+
)
|
|
132
|
+
t1.rho = rho
|
|
133
|
+
if v > 0:
|
|
134
|
+
t1.x_p, t1.p_p, t1.v_p, t1.g_p = t2.x_p, t2.p_p, t2.v_p, t2.g_p
|
|
135
|
+
else:
|
|
136
|
+
t1.x_m, t1.p_m, t1.v_m, t1.g_m = t2.x_m, t2.p_m, t2.v_m, t2.g_m
|
|
137
|
+
return ok
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _leaf(x, p, g, v, eps, H0, metric, fn, max_dE):
|
|
141
|
+
x1, p1, g1, lp1 = leapfrog(x, p, g, v * eps, metric, fn)
|
|
142
|
+
with np.errstate(over="ignore", invalid="ignore"): # a blown-up step diverges below
|
|
143
|
+
vel = metric.mv(p1)
|
|
144
|
+
H1 = float(lp1) - 0.5 * float(p1 @ vel) # NaN in g1 propagates into p1 and H1
|
|
145
|
+
finite = math.isfinite(H1)
|
|
146
|
+
t = _Tree()
|
|
147
|
+
t.x_m = t.x_p = t.x_prop = x1
|
|
148
|
+
t.p_m = t.p_p = t.p_prop = p1
|
|
149
|
+
t.v_m = t.v_p = vel
|
|
150
|
+
t.g_m = t.g_p = t.g_prop = g1
|
|
151
|
+
t.lp_prop = lp1
|
|
152
|
+
t.rho = p1
|
|
153
|
+
t.turning = False
|
|
154
|
+
t.n_leaf = 1
|
|
155
|
+
if finite:
|
|
156
|
+
dH = H1 - H0
|
|
157
|
+
t.log_w = dH
|
|
158
|
+
t.diverging = -dH > max_dE
|
|
159
|
+
t.sum_acc = math.exp(dH) if dH < 0.0 else 1.0
|
|
160
|
+
else:
|
|
161
|
+
t.log_w = _NEG_INF
|
|
162
|
+
t.diverging = True
|
|
163
|
+
t.sum_acc = 0.0
|
|
164
|
+
return t
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _build(x, p, g, v, depth, eps, H0, metric, fn, rng, max_dE):
|
|
168
|
+
if depth == 0:
|
|
169
|
+
return _leaf(x, p, g, v, eps, H0, metric, fn, max_dE)
|
|
170
|
+
t1 = _build(x, p, g, v, depth - 1, eps, H0, metric, fn, rng, max_dE)
|
|
171
|
+
if t1.turning or t1.diverging:
|
|
172
|
+
return t1
|
|
173
|
+
if v > 0:
|
|
174
|
+
t2 = _build(t1.x_p, t1.p_p, t1.g_p, v, depth - 1, eps, H0, metric, fn, rng, max_dE)
|
|
175
|
+
else:
|
|
176
|
+
t2 = _build(t1.x_m, t1.p_m, t1.g_m, v, depth - 1, eps, H0, metric, fn, rng, max_dE)
|
|
177
|
+
t1.sum_acc += t2.sum_acc
|
|
178
|
+
t1.n_leaf += t2.n_leaf
|
|
179
|
+
if t2.turning or t2.diverging:
|
|
180
|
+
t1.turning = t1.turning or t2.turning
|
|
181
|
+
t1.diverging = t1.diverging or t2.diverging
|
|
182
|
+
return t1
|
|
183
|
+
t1.turning = not _merge(t1, t2, v, rng, biased=False)
|
|
184
|
+
return t1
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def nuts_step(x, lp, g, eps, metric: Metric, fn: LogpGrad, rng, max_depth=10, max_dE=1000.0):
|
|
188
|
+
"""One NUTS transition.
|
|
189
|
+
|
|
190
|
+
Returns ``(x, lp, g, accept_stat, depth, diverging, n_leapfrog, energy)``.
|
|
191
|
+
"""
|
|
192
|
+
p0 = metric.draw_momentum(rng)
|
|
193
|
+
v0 = metric.mv(p0)
|
|
194
|
+
H0 = float(lp) - 0.5 * float(p0 @ v0)
|
|
195
|
+
tree = _Tree()
|
|
196
|
+
tree.x_m = tree.x_p = x
|
|
197
|
+
tree.p_m = tree.p_p = p0
|
|
198
|
+
tree.v_m = tree.v_p = v0
|
|
199
|
+
tree.g_m = tree.g_p = g
|
|
200
|
+
tree.log_w = 0.0
|
|
201
|
+
tree.rho = p0
|
|
202
|
+
tree.x_prop, tree.lp_prop, tree.g_prop, tree.p_prop = x, lp, g, p0
|
|
203
|
+
sum_acc, n_leaf = 0.0, 0
|
|
204
|
+
diverging = False
|
|
205
|
+
n_doublings = 0
|
|
206
|
+
for depth in range(max_depth):
|
|
207
|
+
v = 1 if rng.random() < 0.5 else -1
|
|
208
|
+
if v > 0:
|
|
209
|
+
t = _build(tree.x_p, tree.p_p, tree.g_p, v, depth, eps, H0, metric, fn, rng, max_dE)
|
|
210
|
+
else:
|
|
211
|
+
t = _build(tree.x_m, tree.p_m, tree.g_m, v, depth, eps, H0, metric, fn, rng, max_dE)
|
|
212
|
+
sum_acc += t.sum_acc
|
|
213
|
+
n_leaf += t.n_leaf
|
|
214
|
+
if t.diverging:
|
|
215
|
+
diverging = True
|
|
216
|
+
break
|
|
217
|
+
if t.turning:
|
|
218
|
+
break
|
|
219
|
+
ok = _merge(tree, t, v, rng, biased=True)
|
|
220
|
+
n_doublings = depth + 1
|
|
221
|
+
if not ok:
|
|
222
|
+
break
|
|
223
|
+
accept = sum_acc / max(n_leaf, 1)
|
|
224
|
+
energy = -float(tree.lp_prop) + metric.kinetic(tree.p_prop)
|
|
225
|
+
return tree.x_prop, tree.lp_prop, tree.g_prop, accept, n_doublings, diverging, n_leaf, energy
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def find_reasonable_eps(x, lp, g, metric: Metric, fn: LogpGrad, rng) -> float:
|
|
229
|
+
"""Heuristic initial step size (Hoffman & Gelman 2014, Algorithm 4).
|
|
230
|
+
|
|
231
|
+
Starts from 1 with one momentum draw and an acceptance threshold of 0.5,
|
|
232
|
+
unlike Stan's ``init_stepsize`` (current step size, threshold 0.8).
|
|
233
|
+
"""
|
|
234
|
+
eps = 1.0
|
|
235
|
+
p = metric.draw_momentum(rng)
|
|
236
|
+
H0 = lp - metric.kinetic(p)
|
|
237
|
+
|
|
238
|
+
def dH(e):
|
|
239
|
+
_, p1, _, lp1 = leapfrog(x, p, g, e, metric, fn)
|
|
240
|
+
if not np.isfinite(lp1):
|
|
241
|
+
return -np.inf
|
|
242
|
+
with np.errstate(over="ignore", invalid="ignore"): # trial steps may blow up
|
|
243
|
+
h = lp1 - metric.kinetic(p1) - H0
|
|
244
|
+
return h if math.isfinite(h) else -np.inf
|
|
245
|
+
|
|
246
|
+
h = dH(eps)
|
|
247
|
+
a = 1.0 if h > np.log(0.5) else -1.0
|
|
248
|
+
for _ in range(100):
|
|
249
|
+
if not (a * h > -a * np.log(2.0)):
|
|
250
|
+
break
|
|
251
|
+
eps *= 2.0**a
|
|
252
|
+
h = dH(eps)
|
|
253
|
+
return float(eps)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def adaptation_windows(
|
|
257
|
+
n_warmup: int,
|
|
258
|
+
init_buffer: int = _INIT_BUFFER,
|
|
259
|
+
term_buffer: int = _TERM_BUFFER,
|
|
260
|
+
base_window: int = _BASE_WINDOW,
|
|
261
|
+
) -> list[tuple[int, int]]:
|
|
262
|
+
"""Stan-style slow adaptation windows ``[(start, end), ...]``.
|
|
263
|
+
|
|
264
|
+
As Stan's, except that the first window also extends to the end of the slow
|
|
265
|
+
phase when the next would not fit, where Stan adds a short last window (49
|
|
266
|
+
warm-up lengths, all from 151 to 199 iterations, differ). None below 20.
|
|
267
|
+
"""
|
|
268
|
+
if n_warmup < 20:
|
|
269
|
+
return []
|
|
270
|
+
if init_buffer + base_window + term_buffer > n_warmup:
|
|
271
|
+
init_buffer = int(0.15 * n_warmup)
|
|
272
|
+
term_buffer = int(0.1 * n_warmup)
|
|
273
|
+
base_window = n_warmup - init_buffer - term_buffer
|
|
274
|
+
wins = []
|
|
275
|
+
start, size = init_buffer, base_window
|
|
276
|
+
end_slow = n_warmup - term_buffer
|
|
277
|
+
while start < end_slow:
|
|
278
|
+
end = start + size
|
|
279
|
+
if end + 2 * size > end_slow:
|
|
280
|
+
end = end_slow
|
|
281
|
+
wins.append((start, end))
|
|
282
|
+
start = end
|
|
283
|
+
size *= 2
|
|
284
|
+
return wins
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Base class and shared helpers for inference engines."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from .. import _kernels as kern
|
|
10
|
+
from .._utils import RNGLike, as_generator
|
|
11
|
+
from ..model import PopulationModel
|
|
12
|
+
|
|
13
|
+
__all__ = ["Engine"]
|
|
14
|
+
|
|
15
|
+
#: Most regularised EM steps of the start of Gibbs and of MAP's EM and L-BFGS paths when
|
|
16
|
+
#: ``init`` is ``None``.
|
|
17
|
+
INTERIOR_START_ITER = 50
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Engine:
|
|
21
|
+
"""Base class of all inference engines.
|
|
22
|
+
|
|
23
|
+
Engines are configured at construction and executed with :meth:`run`,
|
|
24
|
+
which returns a :class:`~binference.PopulationResult` (or a
|
|
25
|
+
:class:`~binference.PointEstimate` for :class:`~binference.samplers.MAP`).
|
|
26
|
+
|
|
27
|
+
Parameters
|
|
28
|
+
----------
|
|
29
|
+
model : PopulationModel
|
|
30
|
+
The model to infer.
|
|
31
|
+
rng : int, numpy.random.Generator, or None
|
|
32
|
+
Random number generator.
|
|
33
|
+
progress : bool
|
|
34
|
+
Show a progress bar.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
name: str = "engine"
|
|
38
|
+
approximate: bool = False
|
|
39
|
+
#: whether the engine infers a selection from injections or a tallied response jointly
|
|
40
|
+
#: (DECISIONS #30, #80, #81)
|
|
41
|
+
joint: bool = False
|
|
42
|
+
#: whether its draws are Markov chains, described by R-hat and ESS
|
|
43
|
+
markov: bool = False
|
|
44
|
+
|
|
45
|
+
def __init__(self, model: PopulationModel, *, rng: RNGLike = None, progress: bool = False):
|
|
46
|
+
if not isinstance(model, PopulationModel):
|
|
47
|
+
raise TypeError("Engines operate on a binference.PopulationModel.")
|
|
48
|
+
reason = self.unsupported(model)
|
|
49
|
+
if reason is not None:
|
|
50
|
+
raise ValueError(reason)
|
|
51
|
+
self.model = model
|
|
52
|
+
self.rng = as_generator(rng)
|
|
53
|
+
self.progress = bool(progress)
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def unsupported(cls, model: PopulationModel) -> str | None:
|
|
57
|
+
"""Why the engine cannot run on ``model``, or ``None`` when it can.
|
|
58
|
+
|
|
59
|
+
Parameters
|
|
60
|
+
----------
|
|
61
|
+
model : PopulationModel
|
|
62
|
+
The model to run on.
|
|
63
|
+
"""
|
|
64
|
+
if model.joint and not cls.joint:
|
|
65
|
+
from ..inference import METHODS # the registry imports the engines
|
|
66
|
+
|
|
67
|
+
*names, last = (repr(k) for k, e in METHODS.items() if e.joint)
|
|
68
|
+
fixed = "selection.at_mean()" if model.selection_uncertain else "response.fixed()"
|
|
69
|
+
return (
|
|
70
|
+
f"{cls.__name__} cannot infer a selection from injections or a tallied "
|
|
71
|
+
f"response; use method={', '.join(names)} or {last}, or pass {fixed} for a "
|
|
72
|
+
"fixed one that ignores its uncertainty."
|
|
73
|
+
)
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
def run(self, *args: Any, **kwargs: Any): # pragma: no cover - abstract
|
|
77
|
+
raise NotImplementedError
|
|
78
|
+
|
|
79
|
+
def __repr__(self) -> str:
|
|
80
|
+
return f"{type(self).__name__}({self.model!r})"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def em_update(
|
|
84
|
+
model: PopulationModel, value: np.ndarray, s: np.ndarray | None = None
|
|
85
|
+
) -> np.ndarray | None:
|
|
86
|
+
"""One EM step for conjugate priors (fractions or rates, with selection), given the
|
|
87
|
+
evidences ``s = L @ value`` when they are already computed.
|
|
88
|
+
|
|
89
|
+
Returns ``None`` where an object has zero likelihood, rather than taking zero
|
|
90
|
+
counts that would reset the step to the prior's mode (audit M-1).
|
|
91
|
+
"""
|
|
92
|
+
s = kern.matvec(model._L, value) if s is None else s
|
|
93
|
+
if not kern.possible(s):
|
|
94
|
+
return None
|
|
95
|
+
nhat = value * kern.rmatvec(model._L, model._w / s)
|
|
96
|
+
counts = model._complete_counts(value, nhat)
|
|
97
|
+
return model.prior.map_from_counts(counts, exposure=model._exposure)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def interior_start(model: PopulationModel, n_iter: int = INTERIOR_START_ITER) -> np.ndarray:
|
|
101
|
+
"""A robust interior starting value: up to ``n_iter`` regularised EM steps from uniform.
|
|
102
|
+
|
|
103
|
+
Returns fractions (or rates for rate models) bounded away from zero.
|
|
104
|
+
"""
|
|
105
|
+
K = model.K
|
|
106
|
+
rho = np.full(K, 1.0 / K)
|
|
107
|
+
for _ in range(n_iter):
|
|
108
|
+
nhat = model._complete_counts(rho, model.expected_counts(rho))
|
|
109
|
+
new = (nhat + 0.5) / (nhat.sum() + 0.5 * K)
|
|
110
|
+
converged = np.max(np.abs(new - rho)) < 1e-6
|
|
111
|
+
rho = new
|
|
112
|
+
if converged:
|
|
113
|
+
break
|
|
114
|
+
if model.kind == "rates":
|
|
115
|
+
# rho is the composition of the observed objects, n_k proportional to S_k lambda_k
|
|
116
|
+
# (rates impute nothing), so the MLE-like start is W rho_k / S_k (audit M-7)
|
|
117
|
+
return np.maximum(model.W * rho / np.maximum(model._S, 1e-12), 1e-12)
|
|
118
|
+
return rho
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Bootstrap distribution of the MAP / maximum-likelihood estimator."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import warnings
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from .._utils import RNGLike, is_integer_array, progress, warn
|
|
10
|
+
from ..model import PopulationModel
|
|
11
|
+
from ..results import PopulationResult
|
|
12
|
+
from ..warnings import ApproximationWarning, ConvergenceWarning
|
|
13
|
+
from .base import Engine
|
|
14
|
+
from .optimize import MAP
|
|
15
|
+
|
|
16
|
+
__all__ = ["Bootstrap"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Bootstrap(Engine):
|
|
20
|
+
"""Bootstrap over objects of the MAP / maximum-likelihood estimator.
|
|
21
|
+
|
|
22
|
+
``scheme="nonparametric"`` resamples objects with replacement;
|
|
23
|
+
``scheme="bayesian"`` reweights them with Dirichlet weights (Rubin 1981).
|
|
24
|
+
Integer weights are multiplicities, so a row of weight ``w`` stands for
|
|
25
|
+
``w`` objects that are resampled individually. For rate models the number
|
|
26
|
+
of objects is itself random: objects get Poisson(1) counts (or Gamma(1)
|
|
27
|
+
weights), so the total rate varies between replicates. Every replicate is
|
|
28
|
+
fitted from an interior starting point; ``info["n_not_converged"]`` counts
|
|
29
|
+
the replicates whose fit did not converge (a warning names it).
|
|
30
|
+
|
|
31
|
+
The result is the sampling distribution of an estimator, not a posterior:
|
|
32
|
+
it is flagged ``approximate=True``. Useful for frequentist intervals and
|
|
33
|
+
comparisons with unfolding / empirical-Bayes practice.
|
|
34
|
+
|
|
35
|
+
Parameters
|
|
36
|
+
----------
|
|
37
|
+
model : PopulationModel
|
|
38
|
+
scheme : {"nonparametric", "bayesian"}, optional
|
|
39
|
+
Resample objects (``"nonparametric"``) or reweight them with Dirichlet weights
|
|
40
|
+
(``"bayesian"``).
|
|
41
|
+
tol : float, optional
|
|
42
|
+
KKT tolerance of each replicate's MAP fit.
|
|
43
|
+
max_iter : int, optional
|
|
44
|
+
Most iterations of each replicate's MAP fit.
|
|
45
|
+
rng : int, Generator or None
|
|
46
|
+
Random number generator.
|
|
47
|
+
progress : bool, optional
|
|
48
|
+
Show a progress bar.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
name = "bootstrap"
|
|
52
|
+
approximate = True
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
model: PopulationModel,
|
|
57
|
+
*,
|
|
58
|
+
scheme: str = "nonparametric",
|
|
59
|
+
tol: float = 1e-8,
|
|
60
|
+
max_iter: int = 5000,
|
|
61
|
+
rng: RNGLike = None,
|
|
62
|
+
progress: bool = False,
|
|
63
|
+
) -> None:
|
|
64
|
+
super().__init__(model, rng=rng, progress=progress)
|
|
65
|
+
if scheme not in ("nonparametric", "bayesian"):
|
|
66
|
+
raise ValueError("scheme must be 'nonparametric' or 'bayesian'.")
|
|
67
|
+
self.scheme = scheme
|
|
68
|
+
self.tol = float(tol)
|
|
69
|
+
self.max_iter = int(max_iter)
|
|
70
|
+
|
|
71
|
+
def _replicate_weights(self, w: np.ndarray, integer: bool, rates: bool) -> np.ndarray:
|
|
72
|
+
rng = self.rng
|
|
73
|
+
N = w.size
|
|
74
|
+
if self.scheme == "nonparametric":
|
|
75
|
+
if integer: # resample the underlying objects, not the rows
|
|
76
|
+
if rates:
|
|
77
|
+
return rng.poisson(w).astype(float)
|
|
78
|
+
W = round(float(w.sum()))
|
|
79
|
+
return rng.multinomial(W, w / w.sum()).astype(float)
|
|
80
|
+
counts = rng.poisson(1.0, size=N) if rates else rng.multinomial(N, np.full(N, 1 / N))
|
|
81
|
+
return w * counts
|
|
82
|
+
# Bayesian bootstrap: a row of multiplicity w gets a Gamma(w, 1) weight (a sum of
|
|
83
|
+
# w Exp(1) weights); fractions renormalise to the original total
|
|
84
|
+
g = rng.gamma(w, 1.0) if integer else w * rng.gamma(1.0, 1.0, size=N)
|
|
85
|
+
return g if rates else g * (w.sum() / g.sum())
|
|
86
|
+
|
|
87
|
+
def run(self, n_samples: int = 200) -> PopulationResult:
|
|
88
|
+
"""Return ``n_samples`` bootstrap replicates of the estimate.
|
|
89
|
+
|
|
90
|
+
Parameters
|
|
91
|
+
----------
|
|
92
|
+
n_samples : int, optional
|
|
93
|
+
Number of bootstrap replicates.
|
|
94
|
+
"""
|
|
95
|
+
m = self.model
|
|
96
|
+
w = m.likelihoods.weights
|
|
97
|
+
integer = is_integer_array(w)
|
|
98
|
+
rates = m.kind == "rates"
|
|
99
|
+
base = MAP(m, tol=self.tol, max_iter=self.max_iter).run() # warns if it fails
|
|
100
|
+
# EM cannot revive components that are exactly (or nearly) zero, and maximum
|
|
101
|
+
# likelihood estimates on grids are often sparse: start replicates inside
|
|
102
|
+
start = 0.5 * base.value + 0.5 * base.value.mean()
|
|
103
|
+
out = np.empty((int(n_samples), m.K))
|
|
104
|
+
n_not_converged = 0
|
|
105
|
+
for b in progress(range(int(n_samples)), n_samples, "Bootstrap", self.progress):
|
|
106
|
+
wb = self._replicate_weights(w, integer, rates)
|
|
107
|
+
with warnings.catch_warnings():
|
|
108
|
+
warnings.simplefilter("ignore", ConvergenceWarning)
|
|
109
|
+
mw = m._with_weights(wb) # zero-weight objects dropped (audit M-1)
|
|
110
|
+
engine = MAP(mw, tol=self.tol, max_iter=self.max_iter, init=start)
|
|
111
|
+
engine._diagnostics = False # only the value is used
|
|
112
|
+
est = engine.run()
|
|
113
|
+
out[b] = est.value
|
|
114
|
+
n_not_converged += not est.converged
|
|
115
|
+
if n_not_converged: # their warnings were silenced one by one (audit G-8)
|
|
116
|
+
warn(
|
|
117
|
+
f"{n_not_converged} of {int(n_samples)} bootstrap fits did not converge; raise "
|
|
118
|
+
"max_iter.",
|
|
119
|
+
ConvergenceWarning,
|
|
120
|
+
)
|
|
121
|
+
warn(
|
|
122
|
+
"Bootstrap replicates describe the sampling distribution of the MAP estimator, not "
|
|
123
|
+
"the posterior.",
|
|
124
|
+
ApproximationWarning,
|
|
125
|
+
)
|
|
126
|
+
return PopulationResult(
|
|
127
|
+
m,
|
|
128
|
+
out[None],
|
|
129
|
+
method="bootstrap",
|
|
130
|
+
approximate=True,
|
|
131
|
+
info={
|
|
132
|
+
"scheme": self.scheme,
|
|
133
|
+
"estimate": base.value,
|
|
134
|
+
"n_not_converged": n_not_converged,
|
|
135
|
+
},
|
|
136
|
+
)
|