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,400 @@
|
|
|
1
|
+
"""Convergence and quality diagnostics.
|
|
2
|
+
|
|
3
|
+
* :func:`ess` and :func:`rhat` follow Vehtari, Gelman, Simpson, Carpenter &
|
|
4
|
+
Burkner (2021): rank-normalised, split-chain statistics.
|
|
5
|
+
* :func:`autocorr_time` follows the windowed estimator used by emcee
|
|
6
|
+
(Sokal 1997; Goodman & Weare 2010).
|
|
7
|
+
* :func:`psis` implements Pareto-smoothed importance sampling (Vehtari et al.
|
|
8
|
+
2024), returning smoothed log-weights and the shape diagnostic ``k_hat``.
|
|
9
|
+
* :func:`kkt_gap` and :func:`missing_information` are model-specific checks.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
from scipy import special, stats
|
|
18
|
+
|
|
19
|
+
from ._utils import chunk_slices
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"autocorr_function",
|
|
23
|
+
"autocorr_time",
|
|
24
|
+
"ess",
|
|
25
|
+
"importance_ess",
|
|
26
|
+
"kkt_gap",
|
|
27
|
+
"mcse",
|
|
28
|
+
"missing_information",
|
|
29
|
+
"psis",
|
|
30
|
+
"psis_threshold",
|
|
31
|
+
"rhat",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
#: Quantiles whose indicators define the tail ESS.
|
|
35
|
+
TAIL_QUANTILES = (0.05, 0.95)
|
|
36
|
+
#: Pareto k-hat at or above which importance sampling is unreliable, reached from about
|
|
37
|
+
#: 2,000 weights; with n weights the limit is min(1 - 1/log10(n), K_HAT_MAX).
|
|
38
|
+
K_HAT_MAX = 0.7
|
|
39
|
+
#: Pareto smoothing needs at least this many importance weights.
|
|
40
|
+
PSIS_MIN_WEIGHTS = 5
|
|
41
|
+
#: The generalised Pareto is fitted to the ceil(min(a n, b sqrt(n))) largest of n
|
|
42
|
+
#: weights, for (a, b) these.
|
|
43
|
+
PSIS_TAIL = (0.2, 3.0)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ----------------------------------------------------------------------------
|
|
47
|
+
# autocorrelation
|
|
48
|
+
# ----------------------------------------------------------------------------
|
|
49
|
+
def _next_pow_two(n: int) -> int:
|
|
50
|
+
i = 1
|
|
51
|
+
while i < n:
|
|
52
|
+
i <<= 1
|
|
53
|
+
return i
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _autocov(x: np.ndarray) -> np.ndarray:
|
|
57
|
+
"""Biased autocovariance along the last axis (FFT)."""
|
|
58
|
+
x = np.asarray(x, dtype=float)
|
|
59
|
+
n = x.shape[-1]
|
|
60
|
+
m = _next_pow_two(2 * n)
|
|
61
|
+
xc = x - x.mean(axis=-1, keepdims=True)
|
|
62
|
+
f = np.fft.rfft(xc, n=m, axis=-1)
|
|
63
|
+
acov = np.fft.irfft(f * np.conjugate(f), n=m, axis=-1)[..., :n]
|
|
64
|
+
return acov / n
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def autocorr_function(x: Any) -> np.ndarray:
|
|
68
|
+
"""Normalised autocorrelation function of a 1-D chain (or average over chains).
|
|
69
|
+
|
|
70
|
+
Parameters
|
|
71
|
+
----------
|
|
72
|
+
x : array_like, shape (n_draws,) or (n_chains, n_draws)
|
|
73
|
+
Draws of a scalar quantity.
|
|
74
|
+
"""
|
|
75
|
+
x = np.atleast_2d(np.asarray(x, dtype=float))
|
|
76
|
+
acov = _autocov(x)
|
|
77
|
+
with np.errstate(invalid="ignore", divide="ignore"):
|
|
78
|
+
acf = acov / acov[..., :1]
|
|
79
|
+
acf = np.where(np.isfinite(acf), acf, 0.0)
|
|
80
|
+
return acf.mean(axis=0)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def autocorr_time(x: Any, c: float = 5.0) -> float:
|
|
84
|
+
"""Integrated autocorrelation time with Sokal's adaptive window.
|
|
85
|
+
|
|
86
|
+
Parameters
|
|
87
|
+
----------
|
|
88
|
+
x : array_like (n_draws,) or (n_chains, n_draws)
|
|
89
|
+
Draws; autocorrelation functions are averaged over chains, as in emcee.
|
|
90
|
+
c : float, optional
|
|
91
|
+
Window factor: the window is the smallest lag at least ``c`` times the
|
|
92
|
+
running estimate of the time.
|
|
93
|
+
"""
|
|
94
|
+
f = autocorr_function(x)
|
|
95
|
+
taus = 2.0 * np.cumsum(f) - 1.0
|
|
96
|
+
window = np.arange(len(taus)) < c * taus
|
|
97
|
+
m = int(np.argmin(window)) if np.any(~window) else len(taus) - 1
|
|
98
|
+
return float(max(taus[m], 1.0))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ----------------------------------------------------------------------------
|
|
102
|
+
# ESS and R-hat (Vehtari et al. 2021)
|
|
103
|
+
# ----------------------------------------------------------------------------
|
|
104
|
+
def _as_chains(x: Any) -> np.ndarray:
|
|
105
|
+
a = np.asarray(x, dtype=float)
|
|
106
|
+
if a.ndim == 1:
|
|
107
|
+
a = a[None, :]
|
|
108
|
+
if a.ndim != 2:
|
|
109
|
+
raise ValueError("Expected an array of shape (n_draws,) or (n_chains, n_draws).")
|
|
110
|
+
return a
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _split(a: np.ndarray) -> np.ndarray:
|
|
114
|
+
n = a.shape[1]
|
|
115
|
+
if n < 4:
|
|
116
|
+
return a
|
|
117
|
+
half = n // 2
|
|
118
|
+
return np.concatenate([a[:, :half], a[:, n - half :]], axis=0)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _z_scale(a: np.ndarray) -> np.ndarray:
|
|
122
|
+
flat = a.ravel()
|
|
123
|
+
ranks = stats.rankdata(flat, method="average")
|
|
124
|
+
z = special.ndtri((ranks - 3.0 / 8.0) / (flat.size - 2.0 * 3.0 / 8.0 + 1.0))
|
|
125
|
+
return z.reshape(a.shape)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _ess_raw(a: np.ndarray) -> float:
|
|
129
|
+
n_chain, n_draw = a.shape
|
|
130
|
+
if n_draw < 4:
|
|
131
|
+
return float("nan")
|
|
132
|
+
if np.all(a == a.flat[0]):
|
|
133
|
+
return float("nan")
|
|
134
|
+
acov = _autocov(a)
|
|
135
|
+
chain_mean = a.mean(axis=1)
|
|
136
|
+
mean_var = np.mean(acov[:, 0]) * n_draw / (n_draw - 1.0)
|
|
137
|
+
var_plus = mean_var * (n_draw - 1.0) / n_draw
|
|
138
|
+
if n_chain > 1:
|
|
139
|
+
var_plus += np.var(chain_mean, ddof=1)
|
|
140
|
+
if var_plus <= 0:
|
|
141
|
+
return float("nan")
|
|
142
|
+
rho = np.zeros(n_draw)
|
|
143
|
+
rho_even = 1.0
|
|
144
|
+
rho[0] = rho_even
|
|
145
|
+
rho_odd = 1.0 - (mean_var - np.mean(acov[:, 1])) / var_plus
|
|
146
|
+
rho[1] = rho_odd
|
|
147
|
+
t = 1
|
|
148
|
+
while t < n_draw - 3 and (rho_even + rho_odd) > 0.0:
|
|
149
|
+
rho_even = 1.0 - (mean_var - np.mean(acov[:, t + 1])) / var_plus
|
|
150
|
+
rho_odd = 1.0 - (mean_var - np.mean(acov[:, t + 2])) / var_plus
|
|
151
|
+
if (rho_even + rho_odd) >= 0:
|
|
152
|
+
rho[t + 1] = rho_even
|
|
153
|
+
rho[t + 2] = rho_odd
|
|
154
|
+
t += 2
|
|
155
|
+
max_t = t - 2
|
|
156
|
+
if rho_even > 0:
|
|
157
|
+
rho[max_t + 1] = rho_even
|
|
158
|
+
t = 1
|
|
159
|
+
while t <= max_t - 2:
|
|
160
|
+
if (rho[t + 1] + rho[t + 2]) > (rho[t - 1] + rho[t]):
|
|
161
|
+
rho[t + 1] = (rho[t - 1] + rho[t]) / 2.0
|
|
162
|
+
rho[t + 2] = rho[t + 1]
|
|
163
|
+
t += 2
|
|
164
|
+
total = n_chain * n_draw
|
|
165
|
+
tau = -1.0 + 2.0 * np.sum(rho[: max_t + 1]) + np.sum(rho[max_t + 1 : max_t + 2])
|
|
166
|
+
tau = max(tau, 1.0 / np.log10(total))
|
|
167
|
+
return float(total / tau)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def ess(x: Any, kind: str = "bulk") -> float:
|
|
171
|
+
"""Effective sample size of a scalar quantity.
|
|
172
|
+
|
|
173
|
+
Parameters
|
|
174
|
+
----------
|
|
175
|
+
x : array_like, shape (n_draws,) or (n_chains, n_draws)
|
|
176
|
+
kind : {"bulk", "tail", "mean"}
|
|
177
|
+
Bulk, tail or mean ESS. ``"bulk"``: rank-normalised split-chain ESS;
|
|
178
|
+
``"tail"``: minimum of the ESS of the 5% and 95% quantile indicators;
|
|
179
|
+
``"mean"``: ESS of the raw (split) draws, appropriate for the Monte Carlo
|
|
180
|
+
error of the mean.
|
|
181
|
+
"""
|
|
182
|
+
a = _split(_as_chains(x))
|
|
183
|
+
if kind == "bulk":
|
|
184
|
+
return _ess_raw(_z_scale(a))
|
|
185
|
+
if kind == "mean":
|
|
186
|
+
return _ess_raw(a)
|
|
187
|
+
if kind == "tail":
|
|
188
|
+
q05, q95 = np.quantile(a, TAIL_QUANTILES)
|
|
189
|
+
e1 = _ess_raw((a <= q05).astype(float))
|
|
190
|
+
e2 = _ess_raw((a <= q95).astype(float))
|
|
191
|
+
return float(np.nanmin([e1, e2])) if np.isfinite([e1, e2]).any() else float("nan")
|
|
192
|
+
raise ValueError("kind must be 'bulk', 'tail' or 'mean'.")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _rhat_raw(a: np.ndarray) -> float:
|
|
196
|
+
n = a.shape[1]
|
|
197
|
+
if a.shape[0] < 2 or n < 2:
|
|
198
|
+
return float("nan")
|
|
199
|
+
chain_mean = a.mean(axis=1)
|
|
200
|
+
between = n * np.var(chain_mean, ddof=1)
|
|
201
|
+
within = np.mean(np.var(a, axis=1, ddof=1))
|
|
202
|
+
if within <= 0:
|
|
203
|
+
return float("nan")
|
|
204
|
+
return float(np.sqrt((between / within + n - 1.0) / n))
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def rhat(x: Any) -> float:
|
|
208
|
+
"""Rank-normalised split R-hat (maximum of bulk and folded/tail versions).
|
|
209
|
+
|
|
210
|
+
Needs ``x`` of shape ``(n_chains, n_draws)``; a single chain is split in
|
|
211
|
+
two. Values above about 1.01 indicate non-convergence.
|
|
212
|
+
|
|
213
|
+
Parameters
|
|
214
|
+
----------
|
|
215
|
+
x : array_like, shape (n_draws,) or (n_chains, n_draws)
|
|
216
|
+
Draws of a scalar quantity.
|
|
217
|
+
"""
|
|
218
|
+
a = _split(_as_chains(x))
|
|
219
|
+
if a.shape[0] < 2:
|
|
220
|
+
return float("nan")
|
|
221
|
+
bulk = _rhat_raw(_z_scale(a))
|
|
222
|
+
folded = _rhat_raw(_z_scale(np.abs(a - np.median(a))))
|
|
223
|
+
return float(np.nanmax([bulk, folded]))
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def mcse(x: Any) -> float:
|
|
227
|
+
"""Monte Carlo standard error of the mean of a scalar quantity.
|
|
228
|
+
|
|
229
|
+
Parameters
|
|
230
|
+
----------
|
|
231
|
+
x : array_like, shape (n_draws,) or (n_chains, n_draws)
|
|
232
|
+
Draws of a scalar quantity.
|
|
233
|
+
"""
|
|
234
|
+
a = _as_chains(x)
|
|
235
|
+
n_eff = ess(a, kind="mean")
|
|
236
|
+
if not np.isfinite(n_eff) or n_eff <= 0:
|
|
237
|
+
return float("nan")
|
|
238
|
+
return float(np.std(a, ddof=1) / np.sqrt(n_eff))
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# ----------------------------------------------------------------------------
|
|
242
|
+
# Pareto-smoothed importance sampling
|
|
243
|
+
# ----------------------------------------------------------------------------
|
|
244
|
+
def _gpdfit(x: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
|
245
|
+
"""Zhang & Stephens (2009) generalised-Pareto fit with the PSIS prior on k, of each
|
|
246
|
+
row of the sorted exceedances ``x`` ``(..., n)``."""
|
|
247
|
+
prior_bs, prior_k = 3.0, 10.0
|
|
248
|
+
n = x.shape[-1]
|
|
249
|
+
m_est = 30 + int(n**0.5)
|
|
250
|
+
b = 1.0 - np.sqrt(m_est / (np.arange(1, m_est + 1, dtype=float) - 0.5))
|
|
251
|
+
b = b / (prior_bs * x[..., int(n / 4 + 0.5) - 1, None]) + 1.0 / x[..., -1, None]
|
|
252
|
+
k = np.log1p(-b[..., None] * x[..., None, :]).mean(axis=-1)
|
|
253
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
254
|
+
len_scale = n * (np.log(-(b / k)) - k - 1.0)
|
|
255
|
+
weights = 1.0 / np.exp(len_scale[..., None, :] - len_scale[..., None]).sum(axis=-1)
|
|
256
|
+
weights = np.where(weights >= 10 * np.finfo(float).eps, weights, 0.0) # NaN dropped too
|
|
257
|
+
b_post = np.sum(b * weights, axis=-1) / weights.sum(axis=-1)
|
|
258
|
+
k_post = np.log1p(-b_post[..., None] * x).mean(axis=-1)
|
|
259
|
+
sigma = -k_post / b_post
|
|
260
|
+
k_post = (n * k_post + prior_k * 0.5) / (n + prior_k)
|
|
261
|
+
return k_post, sigma
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _gpinv(p: np.ndarray, k: np.ndarray, sigma: np.ndarray) -> np.ndarray:
|
|
265
|
+
# quantiles p of generalised Paretos (k, sigma), broadcast; NaN where sigma <= 0
|
|
266
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
267
|
+
q = np.where(
|
|
268
|
+
np.abs(k) < np.finfo(float).eps,
|
|
269
|
+
-sigma * np.log1p(-p),
|
|
270
|
+
sigma * np.expm1(-k * np.log1p(-p)) / k,
|
|
271
|
+
)
|
|
272
|
+
return np.where(sigma > 0, q, np.nan)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def psis_threshold(n: int) -> float:
|
|
276
|
+
"""The Pareto ``k_hat`` below which PSIS with ``n`` weights is reliable.
|
|
277
|
+
|
|
278
|
+
``min(1 - 1 / log10(n), 0.7)`` (Vehtari et al. 2024, §3), for ``n >= 5``.
|
|
279
|
+
|
|
280
|
+
Parameters
|
|
281
|
+
----------
|
|
282
|
+
n : int
|
|
283
|
+
Number of importance weights.
|
|
284
|
+
"""
|
|
285
|
+
if n < PSIS_MIN_WEIGHTS:
|
|
286
|
+
raise ValueError(f"n must be at least {PSIS_MIN_WEIGHTS} importance weights; got {n}.")
|
|
287
|
+
return min(1.0 - 1.0 / float(np.log10(n)), K_HAT_MAX)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def psis(log_weights: Any) -> tuple[np.ndarray, float]:
|
|
291
|
+
"""Pareto-smoothed importance sampling.
|
|
292
|
+
|
|
293
|
+
Parameters
|
|
294
|
+
----------
|
|
295
|
+
log_weights : array_like (n,)
|
|
296
|
+
Unnormalised log importance ratios.
|
|
297
|
+
|
|
298
|
+
Returns
|
|
299
|
+
-------
|
|
300
|
+
log_weights_smoothed : (n,) array
|
|
301
|
+
Normalised (``logsumexp == 0``) smoothed log-weights.
|
|
302
|
+
k_hat : float
|
|
303
|
+
Estimated Pareto shape: the estimate is reliable below
|
|
304
|
+
:func:`psis_threshold` of the number of weights (0.7 from about 2,000);
|
|
305
|
+
``-inf`` for equal weights, which have no tail.
|
|
306
|
+
"""
|
|
307
|
+
lw = np.array(log_weights, dtype=float).ravel()
|
|
308
|
+
if lw.size < PSIS_MIN_WEIGHTS:
|
|
309
|
+
raise ValueError(f"Need at least {PSIS_MIN_WEIGHTS} importance weights.")
|
|
310
|
+
lw, k = _psis_rows(lw[None]) # lw is a copy
|
|
311
|
+
return lw[0], float(k[0])
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _psis_rows(log_weights: np.ndarray, block: int = 2**22) -> tuple[np.ndarray, np.ndarray]:
|
|
315
|
+
""":func:`psis` of each row of the float array ``log_weights`` ``(r, n)``, ``n >= 5``,
|
|
316
|
+
in place: the smoothed log-weights (that array) and ``k_hat`` ``(r,)``. Rows whose
|
|
317
|
+
tails are equally long are fitted together, about ``block`` values at a time."""
|
|
318
|
+
lw = log_weights
|
|
319
|
+
r, n = lw.shape
|
|
320
|
+
lw[~np.isfinite(lw)] = -np.inf
|
|
321
|
+
lw -= lw.max(axis=1, keepdims=True)
|
|
322
|
+
k = np.full(r, np.inf) # no tail of more than 4 weights to fit
|
|
323
|
+
equal = np.all(lw > -1e-10, axis=1) # equal weights (to round-off): exact, no tail
|
|
324
|
+
k[equal] = -np.inf
|
|
325
|
+
share, factor = PSIS_TAIL
|
|
326
|
+
tail_len = int(np.ceil(min(share * n, factor * np.sqrt(n)))) # rounded up, as ArviZ, loo
|
|
327
|
+
# the tail_len + 1 largest weights of each row, sorted: the cut and the tail above it
|
|
328
|
+
top = np.argpartition(lw, n - tail_len - 1, axis=1)[:, n - tail_len - 1 :]
|
|
329
|
+
srt = np.take_along_axis(lw, top, axis=1)
|
|
330
|
+
order = np.argsort(srt, axis=1, kind="stable")
|
|
331
|
+
top, srt = np.take_along_axis(top, order, axis=1), np.take_along_axis(srt, order, axis=1)
|
|
332
|
+
cut = np.maximum(srt[:, 0], np.log(np.finfo(float).tiny)) # in logs, as ArviZ
|
|
333
|
+
exp_cut = np.exp(cut)
|
|
334
|
+
size = np.sum(srt > cut[:, None], axis=1) # weights tied with the cut stay below it
|
|
335
|
+
fit = ~equal & (size > 4)
|
|
336
|
+
for m in np.unique(size[fit]):
|
|
337
|
+
same = np.flatnonzero(fit & (size == m))
|
|
338
|
+
probs = np.arange(0.5, m) / m
|
|
339
|
+
for sl in chunk_slices(same.size, max(1, block // (m * (30 + int(m**0.5))))):
|
|
340
|
+
rows = same[sl]
|
|
341
|
+
x = np.exp(srt[rows, -m:]) - exp_cut[rows, None]
|
|
342
|
+
k[rows], sigma = _gpdfit(x)
|
|
343
|
+
k[rows] = np.where(np.isnan(k[rows]), np.inf, k[rows]) # a degenerate fit: no tail
|
|
344
|
+
smoothed = np.log(_gpinv(probs, k[rows, None], sigma[:, None]) + exp_cut[rows, None])
|
|
345
|
+
ok = np.isfinite(k[rows])
|
|
346
|
+
lw[rows[ok, None], top[rows[ok], -m:]] = np.minimum(smoothed[ok], 0.0)
|
|
347
|
+
mx = lw.max(axis=1, keepdims=True) # normalised: logsumexp of each row zero
|
|
348
|
+
lse = mx[:, 0] + np.log(np.exp(lw - mx).sum(axis=1))
|
|
349
|
+
lw -= np.where(equal, np.log(n), lse)[:, None]
|
|
350
|
+
return lw, k
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
# ----------------------------------------------------------------------------
|
|
354
|
+
# model-specific diagnostics
|
|
355
|
+
# ----------------------------------------------------------------------------
|
|
356
|
+
def kkt_gap(model: Any, value: Any, include_prior: bool = True) -> float:
|
|
357
|
+
"""First-order optimality violation of a point estimate.
|
|
358
|
+
|
|
359
|
+
See :meth:`binference.PopulationModel.kkt_gap`.
|
|
360
|
+
|
|
361
|
+
Parameters
|
|
362
|
+
----------
|
|
363
|
+
model : PopulationModel
|
|
364
|
+
The model.
|
|
365
|
+
value : array_like (K,)
|
|
366
|
+
Fractions or rates.
|
|
367
|
+
include_prior : bool, optional
|
|
368
|
+
Include the prior's pseudo-counts in the optimality conditions.
|
|
369
|
+
"""
|
|
370
|
+
return model.kkt_gap(np.asarray(value, dtype=float), include_prior=include_prior)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def missing_information(model: Any, value: Any, **kwargs: Any) -> float:
|
|
374
|
+
"""Largest fraction of missing information.
|
|
375
|
+
|
|
376
|
+
See :meth:`binference.PopulationModel.missing_information`.
|
|
377
|
+
|
|
378
|
+
Parameters
|
|
379
|
+
----------
|
|
380
|
+
model : PopulationModel
|
|
381
|
+
The model.
|
|
382
|
+
value : array_like (K,)
|
|
383
|
+
Fractions or rates.
|
|
384
|
+
**kwargs
|
|
385
|
+
Passed to :meth:`binference.PopulationModel.missing_information`.
|
|
386
|
+
"""
|
|
387
|
+
return model.missing_information(np.asarray(value, dtype=float), **kwargs)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def importance_ess(weights: Any) -> float:
|
|
391
|
+
r"""Kish's effective sample size of importance weights,
|
|
392
|
+
:math:`(\sum_s w_s)^2 / \sum_s w_s^2` (normalised or not).
|
|
393
|
+
|
|
394
|
+
Parameters
|
|
395
|
+
----------
|
|
396
|
+
weights : array_like (n,)
|
|
397
|
+
Importance weights.
|
|
398
|
+
"""
|
|
399
|
+
w = np.asarray(weights, dtype=float)
|
|
400
|
+
return float(np.sum(w) ** 2 / np.sum(w**2))
|
binference/estimator.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""A scikit-learn style estimator wrapper around :func:`binference.infer`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from ._utils import RNGLike, as_generator
|
|
11
|
+
from .bins import Bins
|
|
12
|
+
from .inference import infer
|
|
13
|
+
from .likelihoods import Likelihoods
|
|
14
|
+
from .priors import PopulationPrior
|
|
15
|
+
from .results import PointEstimate, PopulationResult
|
|
16
|
+
|
|
17
|
+
__all__ = ["PopulationEstimator"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PopulationEstimator:
|
|
21
|
+
"""Population inference with the scikit-learn estimator conventions.
|
|
22
|
+
|
|
23
|
+
``fit(X)`` infers the population from per-object likelihoods ``X`` (a
|
|
24
|
+
:class:`~binference.Likelihoods` or an ``(N, K)`` array) and stores the result
|
|
25
|
+
in ``result_``. ``predict_proba(X)`` gives per-object posteriors over bins
|
|
26
|
+
for (new) objects under the inferred population, and ``score(X)`` the mean
|
|
27
|
+
log posterior-predictive density per object, which can be used to compare
|
|
28
|
+
priors or binnings by cross-validation. The estimator follows the
|
|
29
|
+
scikit-learn ``get_params``/``set_params`` contract without depending on
|
|
30
|
+
scikit-learn.
|
|
31
|
+
|
|
32
|
+
Parameters
|
|
33
|
+
----------
|
|
34
|
+
prior : PopulationPrior, optional
|
|
35
|
+
Population prior (default ``Dirichlet(1.0)``).
|
|
36
|
+
method : str
|
|
37
|
+
Inference engine (see :func:`binference.infer`).
|
|
38
|
+
selection : array_like (K,), callable or Selection, optional
|
|
39
|
+
Selection function.
|
|
40
|
+
selection_mode : {"on_measurement", "on_truth"}, optional
|
|
41
|
+
Required with an array or callable ``selection``.
|
|
42
|
+
bins : Bins, optional
|
|
43
|
+
Bins for raw array inputs.
|
|
44
|
+
n_samples : int, optional
|
|
45
|
+
Draws per chain / in total.
|
|
46
|
+
rng : int, Generator, or None
|
|
47
|
+
Random number generator.
|
|
48
|
+
engine_kwargs : dict, optional
|
|
49
|
+
Extra keyword arguments for the engine.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
prior: PopulationPrior | None = None,
|
|
55
|
+
*,
|
|
56
|
+
method: str = "auto",
|
|
57
|
+
selection: Any = None,
|
|
58
|
+
selection_mode: str | None = None,
|
|
59
|
+
bins: Bins | None = None,
|
|
60
|
+
n_samples: int | None = None,
|
|
61
|
+
rng: RNGLike = None,
|
|
62
|
+
engine_kwargs: dict[str, Any] | None = None,
|
|
63
|
+
) -> None:
|
|
64
|
+
self.prior = prior
|
|
65
|
+
self.method = method
|
|
66
|
+
self.selection = selection
|
|
67
|
+
self.selection_mode = selection_mode
|
|
68
|
+
self.bins = bins
|
|
69
|
+
self.n_samples = n_samples
|
|
70
|
+
self.rng = rng
|
|
71
|
+
self.engine_kwargs = engine_kwargs
|
|
72
|
+
|
|
73
|
+
# -------------------------------------------------- sklearn contract
|
|
74
|
+
@classmethod
|
|
75
|
+
def _get_param_names(cls) -> list[str]:
|
|
76
|
+
sig = inspect.signature(cls.__init__)
|
|
77
|
+
return [p for p in sig.parameters if p != "self"]
|
|
78
|
+
|
|
79
|
+
def __sklearn_tags__(self):
|
|
80
|
+
# scikit-learn >= 1.6 asks every estimator for its tags; X is all it needs
|
|
81
|
+
from sklearn.utils import Tags, TargetTags
|
|
82
|
+
|
|
83
|
+
return Tags(estimator_type=None, target_tags=TargetTags(required=False))
|
|
84
|
+
|
|
85
|
+
def get_params(self, deep: bool = True) -> dict[str, Any]:
|
|
86
|
+
"""Constructor parameters (scikit-learn convention).
|
|
87
|
+
|
|
88
|
+
Parameters
|
|
89
|
+
----------
|
|
90
|
+
deep : bool, optional
|
|
91
|
+
Accepted for scikit-learn compatibility; has no effect.
|
|
92
|
+
"""
|
|
93
|
+
return {name: getattr(self, name) for name in self._get_param_names()}
|
|
94
|
+
|
|
95
|
+
def set_params(self, **params: Any) -> PopulationEstimator:
|
|
96
|
+
"""Set constructor parameters (scikit-learn convention).
|
|
97
|
+
|
|
98
|
+
Parameters
|
|
99
|
+
----------
|
|
100
|
+
**params
|
|
101
|
+
Constructor parameters and their new values.
|
|
102
|
+
"""
|
|
103
|
+
valid = set(self._get_param_names())
|
|
104
|
+
for k, v in params.items():
|
|
105
|
+
if k not in valid:
|
|
106
|
+
raise ValueError(f"Invalid parameter {k!r} for {type(self).__name__}.")
|
|
107
|
+
setattr(self, k, v)
|
|
108
|
+
return self
|
|
109
|
+
|
|
110
|
+
def __repr__(self) -> str:
|
|
111
|
+
args = ", ".join(f"{k}={v!r}" for k, v in self.get_params().items() if v is not None)
|
|
112
|
+
return f"{type(self).__name__}({args})"
|
|
113
|
+
|
|
114
|
+
# ------------------------------------------------------------- API
|
|
115
|
+
def fit(self, X: Any, y: Any = None) -> PopulationEstimator:
|
|
116
|
+
"""Infer the population from likelihoods ``X``.
|
|
117
|
+
|
|
118
|
+
Parameters
|
|
119
|
+
----------
|
|
120
|
+
X : Likelihoods or array_like (N, K)
|
|
121
|
+
Per-object likelihoods.
|
|
122
|
+
y : None, optional
|
|
123
|
+
Ignored; accepted for scikit-learn compatibility.
|
|
124
|
+
"""
|
|
125
|
+
kwargs = dict(self.engine_kwargs or {})
|
|
126
|
+
self.result_: PopulationResult | PointEstimate = infer(
|
|
127
|
+
X,
|
|
128
|
+
self.prior,
|
|
129
|
+
bins=self.bins if not isinstance(X, Likelihoods) else None,
|
|
130
|
+
selection=self.selection,
|
|
131
|
+
selection_mode=self.selection_mode,
|
|
132
|
+
method=self.method,
|
|
133
|
+
n_samples=self.n_samples,
|
|
134
|
+
rng=self.rng,
|
|
135
|
+
**kwargs,
|
|
136
|
+
)
|
|
137
|
+
res = self.result_
|
|
138
|
+
self.population_ = res.rho if isinstance(res, PointEstimate) else res.mean
|
|
139
|
+
self.n_features_in_ = int(self.population_.size)
|
|
140
|
+
return self
|
|
141
|
+
|
|
142
|
+
def _check_fitted(self) -> None:
|
|
143
|
+
if not hasattr(self, "result_"):
|
|
144
|
+
raise RuntimeError(f"This {type(self).__name__} is not fitted yet; call fit().")
|
|
145
|
+
|
|
146
|
+
def predict_proba(self, X: Any) -> np.ndarray:
|
|
147
|
+
"""Per-object posterior over bins under the inferred population.
|
|
148
|
+
|
|
149
|
+
Parameters
|
|
150
|
+
----------
|
|
151
|
+
X : Likelihoods or array_like (N, K)
|
|
152
|
+
Likelihoods of the objects.
|
|
153
|
+
"""
|
|
154
|
+
self._check_fitted()
|
|
155
|
+
return self.result_.predict_proba(X)
|
|
156
|
+
|
|
157
|
+
def predict(self, X: Any) -> np.ndarray:
|
|
158
|
+
"""Most probable bin of each object under the inferred population.
|
|
159
|
+
|
|
160
|
+
Parameters
|
|
161
|
+
----------
|
|
162
|
+
X : Likelihoods or array_like (N, K)
|
|
163
|
+
Likelihoods of the objects.
|
|
164
|
+
"""
|
|
165
|
+
return np.argmax(self.predict_proba(X), axis=1)
|
|
166
|
+
|
|
167
|
+
def score(self, X: Any, y: Any = None) -> float:
|
|
168
|
+
"""Mean log posterior-predictive density per object (higher is better).
|
|
169
|
+
|
|
170
|
+
Parameters
|
|
171
|
+
----------
|
|
172
|
+
X : Likelihoods or array_like (N, K)
|
|
173
|
+
Per-object likelihoods.
|
|
174
|
+
y : None, optional
|
|
175
|
+
Ignored; accepted for scikit-learn compatibility.
|
|
176
|
+
"""
|
|
177
|
+
self._check_fitted()
|
|
178
|
+
lp = self.result_.log_predictive(X)
|
|
179
|
+
w = X.weights if isinstance(X, Likelihoods) else np.ones(lp.size)
|
|
180
|
+
return float(np.dot(w, lp) / w.sum())
|
|
181
|
+
|
|
182
|
+
def sample(self, n: int = 1, rng: RNGLike = None) -> np.ndarray:
|
|
183
|
+
"""Draws of the population fractions from the fitted posterior.
|
|
184
|
+
|
|
185
|
+
Parameters
|
|
186
|
+
----------
|
|
187
|
+
n : int, optional
|
|
188
|
+
Number of population draws returned.
|
|
189
|
+
rng : int, Generator or None
|
|
190
|
+
Random number generator.
|
|
191
|
+
"""
|
|
192
|
+
self._check_fitted()
|
|
193
|
+
res = self.result_
|
|
194
|
+
if isinstance(res, PointEstimate):
|
|
195
|
+
return np.tile(res.rho, (n, 1))
|
|
196
|
+
idx = as_generator(rng).integers(0, res.n_draws, size=n)
|
|
197
|
+
return res.samples[idx]
|