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,287 @@
|
|
|
1
|
+
"""Data-augmentation Gibbs sampler for conjugate priors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from .. import _kernels as kern
|
|
11
|
+
from .._utils import RNGLike, check_positive, is_integer_array, progress, spawn
|
|
12
|
+
from ..model import PopulationModel
|
|
13
|
+
from ..priors.dirichlet import _dirichlet_draw
|
|
14
|
+
from ..results import PopulationResult
|
|
15
|
+
from ..selection import _beta_posterior, _draw_beta
|
|
16
|
+
from .base import Engine, interior_start
|
|
17
|
+
|
|
18
|
+
__all__ = ["Gibbs"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Gibbs(Engine):
|
|
22
|
+
r"""Data-augmentation Gibbs sampler (the "hierarchical" sampler).
|
|
23
|
+
|
|
24
|
+
Alternates (i) drawing every object's bin label
|
|
25
|
+
:math:`z_i \sim \mathrm{Cat}(r_i(\rho))` and (ii) drawing the population from
|
|
26
|
+
its conjugate conditional given the counts (Dirichlet family:
|
|
27
|
+
:math:`\rho \mid n \sim \mathrm{Dir}(\alpha + n)`; rates:
|
|
28
|
+
:math:`\lambda_k \mid n \sim \mathrm{Gamma}(a_k + n_k, b_k + S_k)`). With a
|
|
29
|
+
selection function on fractions, the number of undetected objects and their
|
|
30
|
+
bins are imputed as well (negative-binomial augmentation), keeping every
|
|
31
|
+
step conjugate.
|
|
32
|
+
|
|
33
|
+
The marginal posterior of the population is the same as NUTS's; Gibbs
|
|
34
|
+
additionally yields joint draws of the labels. It mixes slowly when
|
|
35
|
+
per-object PDFs are broad compared with the bins (a large fraction of
|
|
36
|
+
missing information; see :meth:`PopulationModel.missing_information`), so
|
|
37
|
+
check the effective sample sizes.
|
|
38
|
+
|
|
39
|
+
Parameters
|
|
40
|
+
----------
|
|
41
|
+
model : PopulationModel
|
|
42
|
+
Must use a conjugate prior and integer object weights.
|
|
43
|
+
n_chains : int
|
|
44
|
+
Independent chains (default 4).
|
|
45
|
+
n_warmup : int
|
|
46
|
+
Burn-in iterations per chain.
|
|
47
|
+
thin : int
|
|
48
|
+
Keep every ``thin``-th draw.
|
|
49
|
+
store_labels : bool or array_like of int
|
|
50
|
+
Store joint draws of the objects' bins. ``True`` stores them for all
|
|
51
|
+
objects (unit weights only), an array for the given object indices
|
|
52
|
+
(sorted and deduplicated, as ``result.label_index`` records).
|
|
53
|
+
init : array_like (K,), optional
|
|
54
|
+
Positive fractions or rates around which the chains start. Each chain
|
|
55
|
+
starts from its own draw of the population's conditional given the
|
|
56
|
+
expected counts there, as by default around a few EM steps.
|
|
57
|
+
chunk : int or None
|
|
58
|
+
Objects processed per block when drawing labels from dense likelihoods, which
|
|
59
|
+
bounds memory; ``None`` processes all at once. Sparse likelihoods are processed at once,
|
|
60
|
+
in memory proportional to their nonzeros.
|
|
61
|
+
rng : int, numpy.random.Generator, or None
|
|
62
|
+
Random number generator.
|
|
63
|
+
progress : bool
|
|
64
|
+
Show a progress bar.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
name = "gibbs"
|
|
68
|
+
joint = True
|
|
69
|
+
markov = True
|
|
70
|
+
|
|
71
|
+
def __init__(
|
|
72
|
+
self,
|
|
73
|
+
model: PopulationModel,
|
|
74
|
+
*,
|
|
75
|
+
n_chains: int = 4,
|
|
76
|
+
n_warmup: int = 500,
|
|
77
|
+
thin: int = 1,
|
|
78
|
+
store_labels: bool | Any = False,
|
|
79
|
+
init: Any = None,
|
|
80
|
+
chunk: int | None = kern.DEFAULT_CHUNK,
|
|
81
|
+
rng: RNGLike = None,
|
|
82
|
+
progress: bool = False,
|
|
83
|
+
) -> None:
|
|
84
|
+
super().__init__(model, rng=rng, progress=progress)
|
|
85
|
+
self._w_int = np.round(model.likelihoods.weights)
|
|
86
|
+
self._unit = bool(np.all(self._w_int == 1))
|
|
87
|
+
if store_labels is True:
|
|
88
|
+
if not self._unit:
|
|
89
|
+
raise ValueError("store_labels=True needs unit weights; pass object indices.")
|
|
90
|
+
self.label_index = np.arange(model.N)
|
|
91
|
+
elif store_labels is False or store_labels is None:
|
|
92
|
+
self.label_index = None
|
|
93
|
+
else:
|
|
94
|
+
self.label_index = np.unique(np.atleast_1d(np.asarray(store_labels, dtype=int)))
|
|
95
|
+
if not self._unit:
|
|
96
|
+
raise ValueError("Storing labels needs unit object weights.")
|
|
97
|
+
if self.label_index.size and not (
|
|
98
|
+
0 <= self.label_index[0] and self.label_index[-1] < model.N
|
|
99
|
+
):
|
|
100
|
+
raise ValueError(f"store_labels must be object indices in 0..{model.N - 1}.")
|
|
101
|
+
if self.label_index is not None and model.response is not None:
|
|
102
|
+
raise ValueError(
|
|
103
|
+
"store_labels is not available with an inferred response (objects are counts "
|
|
104
|
+
"per measured bin); use result.sample_assignments()."
|
|
105
|
+
)
|
|
106
|
+
if init is not None:
|
|
107
|
+
init = check_positive(init, "init")
|
|
108
|
+
if init.shape != (model.K,):
|
|
109
|
+
raise ValueError(f"init must have shape ({model.K},); got {init.shape}.")
|
|
110
|
+
if n_chains < 1 or thin < 1:
|
|
111
|
+
raise ValueError("n_chains and thin must be positive.")
|
|
112
|
+
self.n_chains = int(n_chains)
|
|
113
|
+
self.n_warmup = int(n_warmup)
|
|
114
|
+
self.thin = int(thin)
|
|
115
|
+
self.chunk = None if chunk is None else int(chunk)
|
|
116
|
+
self.init = init
|
|
117
|
+
self._state: list[np.ndarray] | None = None
|
|
118
|
+
self._rngs: list[np.random.Generator] | None = None
|
|
119
|
+
self._warm = False
|
|
120
|
+
self._draws: list[np.ndarray] = []
|
|
121
|
+
self._labels: list[np.ndarray] = []
|
|
122
|
+
# a selection or response inferred with the population: its current value per chain
|
|
123
|
+
self._extra: list[np.ndarray] | None = None
|
|
124
|
+
self._extra_draws: list[np.ndarray] = []
|
|
125
|
+
|
|
126
|
+
@classmethod
|
|
127
|
+
def unsupported(cls, model: PopulationModel) -> str | None:
|
|
128
|
+
if not model.prior.conjugate:
|
|
129
|
+
return (
|
|
130
|
+
f"Gibbs sampling needs a conjugate prior; {model.prior_spec!r} is not. "
|
|
131
|
+
"Use NUTS instead."
|
|
132
|
+
)
|
|
133
|
+
if model.selection_uncertain and model.selection_spec.injections.smooth is not None:
|
|
134
|
+
return (
|
|
135
|
+
"Gibbs sampling infers independent Betas only; a smooth injection model is not "
|
|
136
|
+
"conjugate. Use NUTS instead."
|
|
137
|
+
)
|
|
138
|
+
if not is_integer_array(model.likelihoods.weights):
|
|
139
|
+
return (
|
|
140
|
+
"Gibbs sampling needs integer object weights (multiplicities); "
|
|
141
|
+
"use NUTS for non-integer weights."
|
|
142
|
+
)
|
|
143
|
+
return super().unsupported(model)
|
|
144
|
+
|
|
145
|
+
def _setup(self) -> None:
|
|
146
|
+
if self._state is not None:
|
|
147
|
+
return
|
|
148
|
+
m = self.model
|
|
149
|
+
self._rngs = spawn(self.rng, self.n_chains)
|
|
150
|
+
# overdispersed starts: each chain draws from the conditional given expected counts
|
|
151
|
+
base = interior_start(m) if self.init is None else self.init
|
|
152
|
+
nhat = m._complete_counts(base, m.expected_counts(base))
|
|
153
|
+
self._state = [m.prior.posterior_sample(nhat, r, exposure=m._exposure) for r in self._rngs]
|
|
154
|
+
# each chain starts from a draw given the injections or the tallies
|
|
155
|
+
if m.selection_uncertain:
|
|
156
|
+
self._extra = [m.selection_spec.draw(1, r)[0] for r in self._rngs] # floored
|
|
157
|
+
elif m.response is not None:
|
|
158
|
+
self._extra = [m.response.draw(1, r)[0] for r in self._rngs]
|
|
159
|
+
|
|
160
|
+
def _iterate(self, c: int) -> tuple[np.ndarray, np.ndarray | None]:
|
|
161
|
+
m = self.model
|
|
162
|
+
rng = self._rngs[c]
|
|
163
|
+
value = self._state[c]
|
|
164
|
+
if m.selection_uncertain:
|
|
165
|
+
return self._iterate_joint(c, m, rng, value)
|
|
166
|
+
if m.response is not None:
|
|
167
|
+
return self._iterate_response(c, m, rng, value)
|
|
168
|
+
counts, labels = kern.sample_counts(
|
|
169
|
+
m._L, self._w_int, value, rng, chunk=self.chunk, unit_weights=self._unit
|
|
170
|
+
)
|
|
171
|
+
counts = m._draw_missed(value, counts, rng)
|
|
172
|
+
self._state[c] = m.prior.posterior_sample(counts, rng, exposure=m._exposure)
|
|
173
|
+
return self._state[c], labels
|
|
174
|
+
|
|
175
|
+
def _iterate_joint(self, c, m, rng, value) -> tuple[np.ndarray, np.ndarray | None]:
|
|
176
|
+
# labels with the current S, the missed objects with the absolute S, then rho and
|
|
177
|
+
# S from their conjugate conditionals (DECISIONS #79)
|
|
178
|
+
S = self._extra[c] # on the truth: rows of L_i scaled by S, detections are successes
|
|
179
|
+
counts, labels = kern.sample_counts(
|
|
180
|
+
m.likelihoods.values,
|
|
181
|
+
self._w_int,
|
|
182
|
+
value * S,
|
|
183
|
+
rng,
|
|
184
|
+
chunk=self.chunk,
|
|
185
|
+
unit_weights=self._unit,
|
|
186
|
+
)
|
|
187
|
+
missed = m._draw_missed(value, counts, rng, S=S) - counts # with the absolute S
|
|
188
|
+
s, f = _beta_posterior(m.selection_spec.injections)
|
|
189
|
+
self._extra[c] = _draw_beta(s + counts, f + missed, rng) # floored
|
|
190
|
+
self._state[c] = m.prior.posterior_sample(counts + missed, rng)
|
|
191
|
+
return self._state[c], labels
|
|
192
|
+
|
|
193
|
+
def _iterate_response(self, c, m, rng, value) -> tuple[np.ndarray, None]:
|
|
194
|
+
# each measured bin's objects split over the truth bins, the missed objects with the
|
|
195
|
+
# current efficiency, then rho and the columns of R (with their lost shares) from
|
|
196
|
+
# their Dirichlet conditionals (DECISIONS #81)
|
|
197
|
+
R = self._extra[c]
|
|
198
|
+
P = R[m._mb] * value # one row per measured bin
|
|
199
|
+
split = rng.multinomial(self._w_int.astype(np.int64), P / P.sum(axis=1)[:, None])
|
|
200
|
+
counts = split.sum(axis=0)
|
|
201
|
+
J = R.shape[0]
|
|
202
|
+
A = m._alpha.copy() # (J + 1, K) with the lost share last, or (J, K) without one
|
|
203
|
+
np.add.at(A, m._mb, split)
|
|
204
|
+
missed = np.zeros_like(counts)
|
|
205
|
+
if A.shape[0] > J:
|
|
206
|
+
missed = m._draw_missed(value, counts, rng, S=R.sum(axis=0)) - counts
|
|
207
|
+
A[-1] += missed
|
|
208
|
+
self._extra[c] = _dirichlet_draw(A.T, rng).T[:J]
|
|
209
|
+
self._state[c] = m.prior.posterior_sample(counts + missed, rng)
|
|
210
|
+
return self._state[c], None
|
|
211
|
+
|
|
212
|
+
def warmup(self) -> None:
|
|
213
|
+
"""Run burn-in if it has not been run yet."""
|
|
214
|
+
self._setup()
|
|
215
|
+
if self._warm:
|
|
216
|
+
return
|
|
217
|
+
for _ in progress(range(self.n_warmup), self.n_warmup, "Gibbs burn-in", self.progress):
|
|
218
|
+
for c in range(self.n_chains):
|
|
219
|
+
self._iterate(c)
|
|
220
|
+
self._warm = True
|
|
221
|
+
|
|
222
|
+
def sample(self, n_samples: int) -> Iterator[np.ndarray]:
|
|
223
|
+
"""Generator over kept iterations, yielding ``(n_chains, K)`` values.
|
|
224
|
+
|
|
225
|
+
Parameters
|
|
226
|
+
----------
|
|
227
|
+
n_samples : int
|
|
228
|
+
Kept iterations per chain.
|
|
229
|
+
"""
|
|
230
|
+
self.warmup()
|
|
231
|
+
C, K = self.n_chains, self.model.K
|
|
232
|
+
draws = np.empty((C, n_samples, K))
|
|
233
|
+
joint = self._extra is not None
|
|
234
|
+
extra = np.empty((C, n_samples, *self._extra[0].shape)) if joint else None
|
|
235
|
+
store = self.label_index is not None
|
|
236
|
+
labs = np.empty((C, n_samples, len(self.label_index)), dtype=np.int32) if store else None
|
|
237
|
+
n_done = 0
|
|
238
|
+
try:
|
|
239
|
+
for i in progress(range(n_samples), n_samples, "Gibbs sampling", self.progress):
|
|
240
|
+
for c in range(C):
|
|
241
|
+
for _ in range(self.thin):
|
|
242
|
+
value, labels = self._iterate(c)
|
|
243
|
+
draws[c, i] = value
|
|
244
|
+
if joint:
|
|
245
|
+
extra[c, i] = self._extra[c]
|
|
246
|
+
if store:
|
|
247
|
+
labs[c, i] = labels[self.label_index]
|
|
248
|
+
n_done = i + 1
|
|
249
|
+
yield draws[:, i]
|
|
250
|
+
finally:
|
|
251
|
+
if n_done:
|
|
252
|
+
self._draws.append(draws[:, :n_done])
|
|
253
|
+
if joint:
|
|
254
|
+
self._extra_draws.append(extra[:, :n_done])
|
|
255
|
+
if store:
|
|
256
|
+
self._labels.append(labs[:, :n_done])
|
|
257
|
+
|
|
258
|
+
def run(self, n_samples: int = 1000) -> PopulationResult:
|
|
259
|
+
"""Burn in (first call only), draw ``n_samples`` per chain, and return all draws so far.
|
|
260
|
+
|
|
261
|
+
Parameters
|
|
262
|
+
----------
|
|
263
|
+
n_samples : int, optional
|
|
264
|
+
Draws per chain after burn-in.
|
|
265
|
+
"""
|
|
266
|
+
for _ in self.sample(int(n_samples)):
|
|
267
|
+
pass
|
|
268
|
+
return self.result
|
|
269
|
+
|
|
270
|
+
@property
|
|
271
|
+
def result(self) -> PopulationResult:
|
|
272
|
+
if not self._draws:
|
|
273
|
+
raise RuntimeError("No draws yet; call run().")
|
|
274
|
+
D = np.concatenate(self._draws, axis=1)
|
|
275
|
+
labels = np.concatenate(self._labels, axis=1) if self._labels else None
|
|
276
|
+
info = {"thin": self.thin, "n_warmup": self.n_warmup}
|
|
277
|
+
if self._extra_draws: # inferred with the population (DECISIONS #79, #81)
|
|
278
|
+
key = "selection_draws" if self.model.selection_uncertain else "response_draws"
|
|
279
|
+
info[key] = np.concatenate(self._extra_draws, axis=1)
|
|
280
|
+
return PopulationResult(
|
|
281
|
+
self.model,
|
|
282
|
+
D,
|
|
283
|
+
method="gibbs",
|
|
284
|
+
labels=labels,
|
|
285
|
+
label_index=self.label_index,
|
|
286
|
+
info=info,
|
|
287
|
+
)
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Laplace approximation with optional Pareto-smoothed importance resampling."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import warnings
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from .. import diagnostics as diag
|
|
11
|
+
from .._utils import RNGLike, check_positive, warn
|
|
12
|
+
from ..model import PopulationModel
|
|
13
|
+
from ..results import PopulationResult
|
|
14
|
+
from ..warnings import ApproximationWarning, ConvergenceWarning
|
|
15
|
+
from ._curvature import fd_hessian, neg_hessian_eigen
|
|
16
|
+
from .base import Engine
|
|
17
|
+
from .optimize import MAP
|
|
18
|
+
|
|
19
|
+
__all__ = ["Laplace"]
|
|
20
|
+
|
|
21
|
+
#: Importance proposals per draw when ``n_proposals`` is ``None``.
|
|
22
|
+
PROPOSAL_FACTOR = 2
|
|
23
|
+
#: Eigenvalues of a Hessian that is not positive definite are raised to this times the
|
|
24
|
+
#: largest (at least one).
|
|
25
|
+
EIGEN_FLOOR = 1e-8
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _systematic_resample(w: np.ndarray, n: int, rng: np.random.Generator) -> np.ndarray:
|
|
29
|
+
c = np.cumsum(w)
|
|
30
|
+
c /= c[-1]
|
|
31
|
+
u = (rng.random() + np.arange(n)) / n
|
|
32
|
+
return np.minimum(np.searchsorted(c, u), w.size - 1)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Laplace(Engine):
|
|
36
|
+
"""Gaussian (Laplace) approximation in unconstrained coordinates.
|
|
37
|
+
|
|
38
|
+
The posterior mode in unconstrained coordinates is found with L-BFGS, the
|
|
39
|
+
Hessian is computed from central differences of the exact gradient, and
|
|
40
|
+
draws from the Gaussian are mapped to the simplex (or rates). With
|
|
41
|
+
``importance=True`` (default) the draws are corrected towards the exact
|
|
42
|
+
posterior by Pareto-smoothed importance resampling; the Pareto shape
|
|
43
|
+
``k_hat`` (in ``info``) diagnoses whether the correction is reliable:
|
|
44
|
+
below ``min(1 - 1 / log10(S), 0.7)`` for ``S`` proposals (Vehtari et al.
|
|
45
|
+
2024), in ``info["k_hat_threshold"]``. Without a reliable correction the
|
|
46
|
+
result is flagged ``approximate=True``.
|
|
47
|
+
|
|
48
|
+
The approximation is excellent for well-populated bins with many objects
|
|
49
|
+
and poorer for nearly empty bins, whose log-fractions have long tails.
|
|
50
|
+
|
|
51
|
+
Parameters
|
|
52
|
+
----------
|
|
53
|
+
model : PopulationModel
|
|
54
|
+
importance : bool
|
|
55
|
+
Apply PSIS importance resampling.
|
|
56
|
+
n_proposals : int, optional
|
|
57
|
+
Number of proposals for importance resampling (default
|
|
58
|
+
``2 * n_samples``); without it the draws are the proposals themselves.
|
|
59
|
+
df : float or None
|
|
60
|
+
Degrees of freedom of the multivariate Student-t importance proposal
|
|
61
|
+
(default 5); ``None`` uses the Gaussian itself. Heavier tails make the
|
|
62
|
+
importance correction more robust.
|
|
63
|
+
inflate : float
|
|
64
|
+
Scale applied to the proposal's standard deviations.
|
|
65
|
+
step : float
|
|
66
|
+
Relative step for the finite-difference Hessian.
|
|
67
|
+
rng : int, numpy.random.Generator, or None
|
|
68
|
+
For the proposals and the resampling.
|
|
69
|
+
progress : bool
|
|
70
|
+
Accepted for a common signature; unused.
|
|
71
|
+
|
|
72
|
+
Notes
|
|
73
|
+
-----
|
|
74
|
+
In ILR coordinates the posterior of nearly empty bins has a long one-sided
|
|
75
|
+
tail, so for more than a handful of bins the importance correction often
|
|
76
|
+
fails (``k_hat`` above the threshold) and the result is flagged approximate: the Laplace
|
|
77
|
+
approximation is then a fast summary of well-populated bins, not a
|
|
78
|
+
replacement for NUTS.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
name = "laplace"
|
|
82
|
+
joint = True
|
|
83
|
+
|
|
84
|
+
def __init__(
|
|
85
|
+
self,
|
|
86
|
+
model: PopulationModel,
|
|
87
|
+
*,
|
|
88
|
+
importance: bool = True,
|
|
89
|
+
n_proposals: int | None = None,
|
|
90
|
+
df: float | None = 5.0,
|
|
91
|
+
inflate: float = 1.0,
|
|
92
|
+
step: float = 1e-5,
|
|
93
|
+
rng: RNGLike = None,
|
|
94
|
+
progress: bool = False,
|
|
95
|
+
) -> None:
|
|
96
|
+
super().__init__(model, rng=rng, progress=progress)
|
|
97
|
+
if n_proposals is not None and n_proposals < 5:
|
|
98
|
+
raise ValueError("n_proposals must be at least 5 (for Pareto smoothing).")
|
|
99
|
+
self.importance = bool(importance)
|
|
100
|
+
self.n_proposals = n_proposals
|
|
101
|
+
if df is not None and df <= 0:
|
|
102
|
+
raise ValueError("df must be positive (or None for a Gaussian proposal).")
|
|
103
|
+
self.df = None if df is None else float(df)
|
|
104
|
+
self.inflate = float(check_positive(inflate, "inflate"))
|
|
105
|
+
self.step = float(check_positive(step, "step"))
|
|
106
|
+
self._eigen: tuple | None = None # the mode and curvature, found once per engine
|
|
107
|
+
|
|
108
|
+
def _mode_and_eigen(self):
|
|
109
|
+
# the mode, the Hessian there, the eigen-decomposition of -H and the covariance
|
|
110
|
+
# (N-11): found once per engine (and step), with the warnings that raised, which
|
|
111
|
+
# every run repeats
|
|
112
|
+
if self._eigen is None or self._eigen[0] != self.step:
|
|
113
|
+
with warnings.catch_warnings(record=True) as caught:
|
|
114
|
+
warnings.simplefilter("always")
|
|
115
|
+
found = self._find_mode_and_eigen()
|
|
116
|
+
self._eigen = (self.step, found, caught)
|
|
117
|
+
_, found, caught = self._eigen
|
|
118
|
+
for w in caught:
|
|
119
|
+
warnings.warn_explicit(w.message, w.category, w.filename, w.lineno)
|
|
120
|
+
return tuple(a.copy() for a in found) # callers may modify what they get
|
|
121
|
+
|
|
122
|
+
def _find_mode_and_eigen(self):
|
|
123
|
+
m = self.model
|
|
124
|
+
with warnings.catch_warnings():
|
|
125
|
+
warnings.simplefilter("ignore", ConvergenceWarning)
|
|
126
|
+
x0 = MAP(m, space="unconstrained").run().x
|
|
127
|
+
H = fd_hessian(m.log_density_unconstrained_and_grad, x0, h=self.step)
|
|
128
|
+
eig = neg_hessian_eigen(H)
|
|
129
|
+
if eig is None:
|
|
130
|
+
raise ValueError("The Hessian at the mode is not finite (a non-finite gradient).")
|
|
131
|
+
lam, U = eig
|
|
132
|
+
if np.any(lam <= 0):
|
|
133
|
+
warn(
|
|
134
|
+
"The Hessian at the mode is not positive definite; clipping eigenvalues.",
|
|
135
|
+
ApproximationWarning,
|
|
136
|
+
)
|
|
137
|
+
lam = np.maximum(lam, EIGEN_FLOOR * max(1.0, lam.max()))
|
|
138
|
+
return x0, H, lam, U, (U / lam) @ U.T
|
|
139
|
+
|
|
140
|
+
def mode_and_covariance(self) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
141
|
+
"""Mode, covariance and Hessian in unconstrained coordinates."""
|
|
142
|
+
x0, H, _, _, cov = self._mode_and_eigen()
|
|
143
|
+
return x0, cov, H
|
|
144
|
+
|
|
145
|
+
def run(self, n_samples: int = 4000) -> PopulationResult:
|
|
146
|
+
"""Draw ``n_samples`` (approximately) from the posterior.
|
|
147
|
+
|
|
148
|
+
Parameters
|
|
149
|
+
----------
|
|
150
|
+
n_samples : int, optional
|
|
151
|
+
Draws returned in total.
|
|
152
|
+
"""
|
|
153
|
+
m = self.model
|
|
154
|
+
x0, _, lam, U, cov = self._mode_and_eigen()
|
|
155
|
+
d = x0.size
|
|
156
|
+
sd_mat = (U / np.sqrt(lam)) * self.inflate
|
|
157
|
+
# without importance resampling the proposals are the draws (audit N-4)
|
|
158
|
+
M = (self.n_proposals or PROPOSAL_FACTOR * n_samples) if self.importance else n_samples
|
|
159
|
+
Z = self.rng.standard_normal((M, d))
|
|
160
|
+
if self.importance and self.df is not None:
|
|
161
|
+
Z = Z / np.sqrt(self.rng.chisquare(self.df, size=M) / self.df)[:, None]
|
|
162
|
+
X = x0 + Z @ sd_mat.T
|
|
163
|
+
info: dict[str, Any] = {"mode": x0, "cov": cov}
|
|
164
|
+
approximate = True
|
|
165
|
+
if self.importance:
|
|
166
|
+
# log q up to a constant shared by all proposals
|
|
167
|
+
if self.df is None:
|
|
168
|
+
log_q = -0.5 * np.sum(Z**2, axis=1)
|
|
169
|
+
else:
|
|
170
|
+
log_q = -0.5 * (self.df + d) * np.log1p(np.sum(Z**2, axis=1) / self.df)
|
|
171
|
+
log_p = m._log_density_many(X)
|
|
172
|
+
lw, k_hat = diag.psis(log_p - log_q)
|
|
173
|
+
w = np.exp(lw)
|
|
174
|
+
threshold = diag.psis_threshold(M) # depends on the number of proposals (N-6)
|
|
175
|
+
info["k_hat"] = float(k_hat)
|
|
176
|
+
info["k_hat_threshold"] = float(threshold)
|
|
177
|
+
info["ess_importance"] = diag.importance_ess(w)
|
|
178
|
+
idx = _systematic_resample(w, n_samples, self.rng)
|
|
179
|
+
X = X[idx]
|
|
180
|
+
approximate = not (k_hat < threshold)
|
|
181
|
+
if approximate:
|
|
182
|
+
warn(
|
|
183
|
+
f"Pareto k-hat = {k_hat:.2f} >= {threshold:.2f}: the Laplace approximation "
|
|
184
|
+
"is not a good importance proposal; results are approximate (consider NUTS).",
|
|
185
|
+
ApproximationWarning,
|
|
186
|
+
)
|
|
187
|
+
else:
|
|
188
|
+
warn(
|
|
189
|
+
"Uncorrected Laplace approximation: results are approximate.", ApproximationWarning
|
|
190
|
+
)
|
|
191
|
+
vals = m.transform(X)
|
|
192
|
+
info.update(m._joint_info(X[None]))
|
|
193
|
+
return PopulationResult(
|
|
194
|
+
m,
|
|
195
|
+
vals[None],
|
|
196
|
+
method="laplace",
|
|
197
|
+
x=X[None],
|
|
198
|
+
approximate=approximate,
|
|
199
|
+
info=info,
|
|
200
|
+
)
|