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,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
+ )