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
binference/validation.py
ADDED
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
"""Validation tools: exact reference posteriors, simulation-based calibration, coverage.
|
|
2
|
+
|
|
3
|
+
* :func:`exact_posterior` computes the posterior on a fine grid over the
|
|
4
|
+
simplex for ``K = 2`` or ``3`` bins (a reference for testing engines).
|
|
5
|
+
* :func:`sbc` runs simulation-based calibration (Talts et al. 2018): if the
|
|
6
|
+
inference is calibrated, the rank of the true value among posterior draws is
|
|
7
|
+
uniform. Ranks are computed for every bin fraction and for the joint
|
|
8
|
+
log-likelihood (Modrák et al. 2025), and their uniformity is tested by a
|
|
9
|
+
rank-histogram chi-square or the ECDF test of Säilynoja et al. (2022).
|
|
10
|
+
* :func:`coverage` measures the empirical coverage of credible intervals for
|
|
11
|
+
bin fractions and user-defined functionals.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections.abc import Callable, Iterator, Sequence
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
from scipy import special, stats
|
|
22
|
+
|
|
23
|
+
from . import _utils
|
|
24
|
+
from ._quadrature import MAX_ELEMENTS
|
|
25
|
+
from ._utils import RNGLike, as_generator, chunk_slices, spawn, warn
|
|
26
|
+
from .likelihoods import Likelihoods
|
|
27
|
+
from .model import PopulationModel
|
|
28
|
+
from .priors import PopulationPrior
|
|
29
|
+
from .results import _SYMBOL, PopulationResult, _central, _flat_draws
|
|
30
|
+
from .warnings import ConvergenceWarning
|
|
31
|
+
|
|
32
|
+
__all__ = ["CoverageResult", "ExactPosterior", "SBCResult", "coverage", "exact_posterior", "sbc"]
|
|
33
|
+
|
|
34
|
+
#: Most bins of an SBC rank histogram when ``n_bins`` is ``None``.
|
|
35
|
+
SBC_MAX_BINS = 20
|
|
36
|
+
#: Fewest simulations per bin of an SBC rank histogram when ``n_bins`` is ``None``.
|
|
37
|
+
SBC_SIMULATIONS_PER_BIN = 5
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ----------------------------------------------------------------------------
|
|
41
|
+
# exact posterior by quadrature
|
|
42
|
+
# ----------------------------------------------------------------------------
|
|
43
|
+
@dataclass
|
|
44
|
+
class ExactPosterior:
|
|
45
|
+
"""Posterior on quadrature nodes over the simplex (``K <= 3``).
|
|
46
|
+
|
|
47
|
+
Attributes
|
|
48
|
+
----------
|
|
49
|
+
points : (n, K) array
|
|
50
|
+
Quadrature nodes (fractions).
|
|
51
|
+
weights : (n,) array
|
|
52
|
+
Normalised posterior weights (quadrature weight times density), summing to one.
|
|
53
|
+
log_density : (n,) array
|
|
54
|
+
Unnormalised log posterior density of the fractions at each node (its
|
|
55
|
+
maximum locates the mode; the weights also carry the node spacing).
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
points: np.ndarray
|
|
59
|
+
weights: np.ndarray
|
|
60
|
+
log_density: np.ndarray
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def mean(self) -> np.ndarray:
|
|
64
|
+
return self.weights @ self.points
|
|
65
|
+
|
|
66
|
+
def cov(self) -> np.ndarray:
|
|
67
|
+
d = self.points - self.mean
|
|
68
|
+
return (d * self.weights[:, None]).T @ d
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def std(self) -> np.ndarray:
|
|
72
|
+
return np.sqrt(np.diag(self.cov()))
|
|
73
|
+
|
|
74
|
+
def quantile(self, q: Any, k: int) -> np.ndarray:
|
|
75
|
+
"""Quantiles of the marginal posterior of fraction ``k``.
|
|
76
|
+
|
|
77
|
+
Parameters
|
|
78
|
+
----------
|
|
79
|
+
q : float or array_like
|
|
80
|
+
Probabilities in [0, 1].
|
|
81
|
+
k : int
|
|
82
|
+
Bin index.
|
|
83
|
+
"""
|
|
84
|
+
x = self.points[:, k]
|
|
85
|
+
o = np.argsort(x)
|
|
86
|
+
c = np.cumsum(self.weights[o])
|
|
87
|
+
return np.interp(q, c - 0.5 * self.weights[o], x[o])
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def exact_posterior(
|
|
91
|
+
model: PopulationModel | Likelihoods | Any,
|
|
92
|
+
prior: PopulationPrior | None = None,
|
|
93
|
+
n_grid: int = 200,
|
|
94
|
+
max_elements: int = MAX_ELEMENTS,
|
|
95
|
+
) -> ExactPosterior:
|
|
96
|
+
r"""Quadrature posterior of the fractions for ``K = 2`` or ``K = 3`` bins.
|
|
97
|
+
|
|
98
|
+
Works for priors with a closed-form simplex density (Dirichlet family).
|
|
99
|
+
Selection effects are included. The fractions are stick-broken,
|
|
100
|
+
:math:`\rho = (x, (1-x)y, (1-x)(1-y))`, each stick coordinate substituted
|
|
101
|
+
as :math:`(1-\cos\pi s)/2` and integrated by Gauss-Legendre with
|
|
102
|
+
``n_grid`` nodes, which places nodes densely at the boundary and handles
|
|
103
|
+
the integrable singularities of concentrations below one (audit G-3).
|
|
104
|
+
With the default ``n_grid``, means of a known Dirichlet posterior are
|
|
105
|
+
exact to 0.01 posterior SD for concentrations down to 0.2, and tail
|
|
106
|
+
probabilities to 0.005 for concentrations of 0.5 and above (at 0.2 the
|
|
107
|
+
mass below a 6e-9 quantile is 22% off). ``max_elements`` bounds the size of
|
|
108
|
+
the (objects x nodes) blocks evaluated at once (audit G-5).
|
|
109
|
+
|
|
110
|
+
Parameters
|
|
111
|
+
----------
|
|
112
|
+
model : PopulationModel, Likelihoods or array_like (N, K)
|
|
113
|
+
The model, or likelihoods to build one from with ``prior``.
|
|
114
|
+
prior : PopulationPrior, optional
|
|
115
|
+
Population prior; ``None`` gives ``Dirichlet(1.0)``.
|
|
116
|
+
n_grid : int, optional
|
|
117
|
+
Gauss-Legendre nodes per stick coordinate.
|
|
118
|
+
max_elements : int, optional
|
|
119
|
+
Largest block of objects times nodes evaluated at once.
|
|
120
|
+
"""
|
|
121
|
+
if not isinstance(model, PopulationModel):
|
|
122
|
+
model = PopulationModel(model, prior)
|
|
123
|
+
if model.kind != "fractions" or not model.prior.has_density:
|
|
124
|
+
raise ValueError(
|
|
125
|
+
"exact_posterior needs a fractions model with a closed-form prior density."
|
|
126
|
+
)
|
|
127
|
+
if model.K not in (2, 3):
|
|
128
|
+
raise ValueError("exact_posterior supports K = 2 or 3 bins.")
|
|
129
|
+
if model.joint:
|
|
130
|
+
raise ValueError(
|
|
131
|
+
"exact_posterior integrates the population only; a selection from injections or "
|
|
132
|
+
"a tallied response would be fixed. Pass selection.at_mean() or response.fixed() "
|
|
133
|
+
"to compare with that."
|
|
134
|
+
)
|
|
135
|
+
s, ws = np.polynomial.legendre.leggauss(int(n_grid))
|
|
136
|
+
s, ws = 0.5 * (s + 1.0), 0.5 * ws
|
|
137
|
+
x = 0.5 * (1.0 - np.cos(np.pi * s))
|
|
138
|
+
log_dx = np.log(0.5 * np.pi * np.sin(np.pi * s) * ws) # quadrature weight in x
|
|
139
|
+
if model.K == 2:
|
|
140
|
+
pts = np.stack([x, 1.0 - x], axis=1)
|
|
141
|
+
log_q = log_dx
|
|
142
|
+
else:
|
|
143
|
+
X, Y = np.meshgrid(x, x, indexing="ij")
|
|
144
|
+
pts = np.stack([X.ravel(), ((1 - X) * Y).ravel(), ((1 - X) * (1 - Y)).ravel()], axis=1)
|
|
145
|
+
# d(rho_1, rho_2) = (1 - x) dx dy
|
|
146
|
+
log_q = (log_dx[:, None] + log_dx[None, :] + np.log1p(-X)).ravel()
|
|
147
|
+
L, w = model._L, model._w
|
|
148
|
+
logp = np.empty(pts.shape[0])
|
|
149
|
+
for sl in chunk_slices(pts.shape[0], max(1, int(max_elements) // max(model.N, 1))):
|
|
150
|
+
P = pts[sl]
|
|
151
|
+
with np.errstate(divide="ignore"):
|
|
152
|
+
ll = w @ np.log(np.asarray(L @ P.T))
|
|
153
|
+
if model.selection is not None:
|
|
154
|
+
ll = ll - model.W * np.log(P @ model.selection)
|
|
155
|
+
logp[sl] = ll
|
|
156
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
157
|
+
logp = logp + model.prior.log_density(pts)
|
|
158
|
+
logp = np.where(np.isfinite(logp), logp, -np.inf)
|
|
159
|
+
logw = logp + log_q
|
|
160
|
+
wts = np.exp(logw - special.logsumexp(logw))
|
|
161
|
+
return ExactPosterior(points=pts, weights=wts, log_density=logp)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# ----------------------------------------------------------------------------
|
|
165
|
+
# SBC
|
|
166
|
+
# ----------------------------------------------------------------------------
|
|
167
|
+
Simulator = Callable[[np.random.Generator], tuple[Likelihoods, np.ndarray]]
|
|
168
|
+
Fitter = Callable[[Likelihoods, np.random.Generator], PopulationResult]
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@dataclass
|
|
172
|
+
class SBCResult:
|
|
173
|
+
"""Ranks from simulation-based calibration.
|
|
174
|
+
|
|
175
|
+
Attributes
|
|
176
|
+
----------
|
|
177
|
+
ranks : (n_simulations, n_quantities) int array
|
|
178
|
+
Number of posterior draws below the true value.
|
|
179
|
+
n_draws : int
|
|
180
|
+
Draws per simulation used for ranking (ranks lie in ``0..n_draws``).
|
|
181
|
+
names : list of str
|
|
182
|
+
Quantity names (``"rho[k]"``, ``"lambda[k]"`` for rates or ``"w[k]"`` for the
|
|
183
|
+
component weights of a :class:`~binference.Basis`, and ``"loglike"``).
|
|
184
|
+
min_ess : (n_simulations,) array
|
|
185
|
+
Smallest bulk effective sample size of each fit (NaN without chains);
|
|
186
|
+
:func:`sbc` warns when it falls below ``n_draws``.
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
ranks: np.ndarray
|
|
190
|
+
n_draws: int
|
|
191
|
+
names: list[str]
|
|
192
|
+
min_ess: np.ndarray = field(default_factory=lambda: np.empty(0))
|
|
193
|
+
|
|
194
|
+
def _edges(self, n_bins: int | None) -> np.ndarray:
|
|
195
|
+
n_bins = n_bins or self._default_bins()
|
|
196
|
+
if not 1 <= n_bins <= self.n_draws + 1:
|
|
197
|
+
raise ValueError(f"n_bins must be between 1 and n_draws + 1 = {self.n_draws + 1}.")
|
|
198
|
+
return np.linspace(0, self.n_draws + 1, n_bins + 1)
|
|
199
|
+
|
|
200
|
+
def histogram(self, n_bins: int | None = None) -> np.ndarray:
|
|
201
|
+
"""Rank histograms, shape ``(n_quantities, n_bins)``.
|
|
202
|
+
|
|
203
|
+
Parameters
|
|
204
|
+
----------
|
|
205
|
+
n_bins : int, optional
|
|
206
|
+
Histogram bins; ``None`` takes the smallest of ``SBC_MAX_BINS``,
|
|
207
|
+
``n_simulations // SBC_SIMULATIONS_PER_BIN`` and ``n_draws + 1``, at least 2.
|
|
208
|
+
"""
|
|
209
|
+
edges = self._edges(n_bins)
|
|
210
|
+
return np.stack([np.histogram(r, bins=edges)[0] for r in self.ranks.T])
|
|
211
|
+
|
|
212
|
+
def expected(self, n_bins: int | None = None) -> np.ndarray:
|
|
213
|
+
"""Expected count per histogram bin under uniform ranks, ``(n_bins,)``.
|
|
214
|
+
|
|
215
|
+
Bins hold different numbers of the ``n_draws + 1`` possible ranks unless
|
|
216
|
+
``n_bins`` divides ``n_draws + 1`` (audit G-1).
|
|
217
|
+
|
|
218
|
+
Parameters
|
|
219
|
+
----------
|
|
220
|
+
n_bins : int, optional
|
|
221
|
+
Histogram bins; ``None`` takes the smallest of ``SBC_MAX_BINS``,
|
|
222
|
+
``n_simulations // SBC_SIMULATIONS_PER_BIN`` and ``n_draws + 1``, at least 2.
|
|
223
|
+
"""
|
|
224
|
+
per_bin = np.histogram(np.arange(self.n_draws + 1), bins=self._edges(n_bins))[0]
|
|
225
|
+
return per_bin * self.ranks.shape[0] / (self.n_draws + 1)
|
|
226
|
+
|
|
227
|
+
def _default_bins(self) -> int:
|
|
228
|
+
n = self.ranks.shape[0]
|
|
229
|
+
return int(max(2, min(SBC_MAX_BINS, n // SBC_SIMULATIONS_PER_BIN, self.n_draws + 1)))
|
|
230
|
+
|
|
231
|
+
def pvalues(self, n_bins: int | None = None, *, method: str = "chisquare") -> np.ndarray:
|
|
232
|
+
r"""P-values of rank uniformity, one per quantity.
|
|
233
|
+
|
|
234
|
+
Parameters
|
|
235
|
+
----------
|
|
236
|
+
n_bins : int, optional
|
|
237
|
+
Histogram bins for ``method="chisquare"``; ``None`` as for :meth:`histogram`.
|
|
238
|
+
method : {"chisquare", "ecdf"}, optional
|
|
239
|
+
Chi-square test of the rank histogram or ECDF test of the ranks.
|
|
240
|
+
``"chisquare"``: Pearson's chi-square test of the rank histogram.
|
|
241
|
+
``"ecdf"``: the ECDF test of Säilynoja, Bürkner & Vehtari (2022).
|
|
242
|
+
With :math:`S` simulations and :math:`M` = ``n_draws``, the count
|
|
243
|
+
:math:`C_j` of ranks at most :math:`j` is
|
|
244
|
+
:math:`\mathrm{Binomial}(S, (j+1)/(M+1))` under uniformity. The
|
|
245
|
+
statistic is the smallest two-sided binomial tail probability
|
|
246
|
+
|
|
247
|
+
.. math:: \hat\gamma = \min_j 2\min\{P(C \le C_j), P(C \ge C_j)\}
|
|
248
|
+
|
|
249
|
+
over :math:`j = 0, \dots, M-1`, and the p-value is
|
|
250
|
+
:math:`P(\gamma \le \hat\gamma)` for uniform ranks, computed
|
|
251
|
+
exactly from the joint (multinomial) law of the counts: the smallest
|
|
252
|
+
:math:`\alpha` for which the rank ECDF leaves the simultaneous
|
|
253
|
+
:math:`1 - \alpha` band. It takes no ``n_bins``.
|
|
254
|
+
|
|
255
|
+
Returns
|
|
256
|
+
-------
|
|
257
|
+
(n_quantities,) array
|
|
258
|
+
"""
|
|
259
|
+
if method == "ecdf":
|
|
260
|
+
if n_bins is not None:
|
|
261
|
+
raise ValueError('n_bins applies to method="chisquare" only.')
|
|
262
|
+
return np.array([_ecdf_pvalue(r, self.n_draws) for r in self.ranks.T])
|
|
263
|
+
if method != "chisquare":
|
|
264
|
+
raise ValueError(f'method must be "chisquare" or "ecdf"; got {method!r}.')
|
|
265
|
+
H = self.histogram(n_bins)
|
|
266
|
+
e = self.expected(n_bins)
|
|
267
|
+
return np.array([stats.chisquare(h, f_exp=e).pvalue for h in H])
|
|
268
|
+
|
|
269
|
+
def passed(
|
|
270
|
+
self, alpha: float = 0.01, n_bins: int | None = None, *, method: str = "chisquare"
|
|
271
|
+
) -> bool:
|
|
272
|
+
"""True if no quantity rejects uniformity at Bonferroni-corrected level ``alpha``.
|
|
273
|
+
|
|
274
|
+
Each p-value from :meth:`pvalues` must lie above ``alpha / n_quantities``.
|
|
275
|
+
|
|
276
|
+
Parameters
|
|
277
|
+
----------
|
|
278
|
+
alpha : float, optional
|
|
279
|
+
Significance level, Bonferroni-corrected over the quantities.
|
|
280
|
+
n_bins : int, optional
|
|
281
|
+
Histogram bins for ``method="chisquare"``, as for :meth:`pvalues`.
|
|
282
|
+
method : {"chisquare", "ecdf"}, optional
|
|
283
|
+
Chi-square test of the rank histogram or ECDF test of the ranks.
|
|
284
|
+
"""
|
|
285
|
+
p = self.pvalues(n_bins, method=method)
|
|
286
|
+
return bool(np.all(p > alpha / p.size))
|
|
287
|
+
|
|
288
|
+
def plot(self, **kwargs: Any):
|
|
289
|
+
from .plotting import plot_sbc
|
|
290
|
+
|
|
291
|
+
return plot_sbc(self, **kwargs)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _ecdf_pvalue(ranks: np.ndarray, n_draws: int) -> float:
|
|
295
|
+
"""P-value of the ECDF test of :meth:`SBCResult.pvalues` for one quantity."""
|
|
296
|
+
S, L = ranks.size, n_draws + 1
|
|
297
|
+
F = np.arange(1, L) / L
|
|
298
|
+
c = np.arange(S + 1)
|
|
299
|
+
# two-sided binomial tail probability of every count c at every rank j < n_draws
|
|
300
|
+
tail = 2 * np.minimum(stats.binom.cdf(c, S, F[:, None]), stats.binom.sf(c - 1, S, F[:, None]))
|
|
301
|
+
C = np.cumsum(np.bincount(ranks, minlength=L))[:-1]
|
|
302
|
+
g = tail[np.arange(L - 1), C].min()
|
|
303
|
+
if g >= 1.0:
|
|
304
|
+
return 1.0
|
|
305
|
+
# each tail is a p-value, so p <= (L - 1) g; below 1e-12 that bound is returned, which
|
|
306
|
+
# spares the recursion its widest bands (S x S work per step for badly skewed ranks)
|
|
307
|
+
if (L - 1) * g < 1e-12:
|
|
308
|
+
return float((L - 1) * g)
|
|
309
|
+
# P(every C_j has tail > g): C_j - C_{j-1} | C_{j-1} ~ Binomial(S - C_{j-1}, 1/(L - j))
|
|
310
|
+
lo_prev, prob = 0, np.ones(1)
|
|
311
|
+
for j in range(L - 1):
|
|
312
|
+
inside = np.flatnonzero(tail[j] > g) # an interval: the binomial is unimodal
|
|
313
|
+
if inside.size == 0:
|
|
314
|
+
return 1.0
|
|
315
|
+
prev = lo_prev + np.arange(prob.size)
|
|
316
|
+
new = np.arange(inside[0], inside[-1] + 1)
|
|
317
|
+
prob = prob @ stats.binom.pmf(new - prev[:, None], S - prev[:, None], 1.0 / (L - j))
|
|
318
|
+
lo_prev = inside[0]
|
|
319
|
+
return float(min(1.0, max(0.0, 1.0 - prob.sum())))
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _fits(
|
|
323
|
+
simulate: Simulator, fit: Fitter, n_simulations: int, rng: RNGLike, what: str, show: bool
|
|
324
|
+
) -> Iterator[tuple[PopulationResult, np.ndarray]]:
|
|
325
|
+
# each fit to a simulated data set, and its true value
|
|
326
|
+
gen = as_generator(rng)
|
|
327
|
+
for _ in _utils.progress(range(n_simulations), n_simulations, what, show):
|
|
328
|
+
r_sim, r_fit = spawn(gen, 2)
|
|
329
|
+
lk, truth = simulate(r_sim)
|
|
330
|
+
yield fit(lk, r_fit), truth
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _names(res: PopulationResult) -> list[str]:
|
|
334
|
+
"""Names of the model's parameters: the component weights ``w`` with a
|
|
335
|
+
:class:`~binference.Basis` (as the prior draws them), else fractions or rates."""
|
|
336
|
+
symbol = "w" if res.basis is not None else _SYMBOL[res.kind]
|
|
337
|
+
return [f"{symbol}[{k}]" for k in range(res.component_draws.shape[-1])]
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def sbc(
|
|
341
|
+
simulate: Simulator,
|
|
342
|
+
fit: Fitter,
|
|
343
|
+
n_simulations: int = 100,
|
|
344
|
+
*,
|
|
345
|
+
n_draws: int = 99,
|
|
346
|
+
log_likelihood: bool = True,
|
|
347
|
+
rng: RNGLike = None,
|
|
348
|
+
progress: bool = False,
|
|
349
|
+
) -> SBCResult:
|
|
350
|
+
"""Simulation-based calibration.
|
|
351
|
+
|
|
352
|
+
Parameters
|
|
353
|
+
----------
|
|
354
|
+
simulate : callable
|
|
355
|
+
``simulate(rng) -> (likelihoods, true_value)`` drawing a population from
|
|
356
|
+
the prior and a catalogue from it (see
|
|
357
|
+
:func:`binference.simulate.prior_predictive`). The true value is the
|
|
358
|
+
fractions, or the rates for rate models.
|
|
359
|
+
fit : callable
|
|
360
|
+
``fit(likelihoods, rng) -> PopulationResult`` running the inference to
|
|
361
|
+
be validated (with the same prior as the simulator).
|
|
362
|
+
n_simulations : int
|
|
363
|
+
Number of simulated data sets.
|
|
364
|
+
n_draws : int
|
|
365
|
+
Posterior draws ranked per fit, thinned evenly from its draws. They
|
|
366
|
+
should be roughly independent, i.e. fewer than the ESS: a warning
|
|
367
|
+
names how many fits have a smallest bulk ESS below it, since
|
|
368
|
+
autocorrelated draws make the rank histograms look over-dispersed.
|
|
369
|
+
log_likelihood : bool
|
|
370
|
+
Also rank the joint log-likelihood of the true population.
|
|
371
|
+
rng : int, Generator or None
|
|
372
|
+
Seed or generator of the simulations and fits.
|
|
373
|
+
progress : bool
|
|
374
|
+
Show a progress bar over the simulations.
|
|
375
|
+
"""
|
|
376
|
+
ranks, ess_min = [], []
|
|
377
|
+
names: list[str] = []
|
|
378
|
+
for res, truth in _fits(simulate, fit, n_simulations, rng, "SBC", progress):
|
|
379
|
+
if res.n_draws < n_draws:
|
|
380
|
+
raise ValueError(
|
|
381
|
+
f"Need at least {n_draws} posterior draws per fit; got {res.n_draws}."
|
|
382
|
+
)
|
|
383
|
+
names = _names(res) + (["loglike"] if log_likelihood else [])
|
|
384
|
+
D = _flat_draws(res.component_draws, 1, n_draws)
|
|
385
|
+
row = list((D < truth[None, :]).sum(axis=0))
|
|
386
|
+
if log_likelihood:
|
|
387
|
+
m = res.model
|
|
388
|
+
ll_true = m.log_likelihood(truth, absolute=False)
|
|
389
|
+
ll_draws = m._log_likelihood_many(D)
|
|
390
|
+
row.append(int((ll_draws < ll_true).sum()))
|
|
391
|
+
ranks.append(row)
|
|
392
|
+
d = res.diagnostics
|
|
393
|
+
ess_min.append(np.nanmin(d["ess_bulk"]) if "ess_bulk" in d else np.nan)
|
|
394
|
+
ess_min = np.asarray(ess_min)
|
|
395
|
+
low = int(np.sum(ess_min < n_draws)) # NaN (no chains) compares False
|
|
396
|
+
if low:
|
|
397
|
+
warn(
|
|
398
|
+
f"In {low} of {ess_min.size} fits the smallest bulk ESS is below n_draws={n_draws}; "
|
|
399
|
+
"autocorrelated draws make the ranks look over-dispersed.",
|
|
400
|
+
ConvergenceWarning,
|
|
401
|
+
)
|
|
402
|
+
return SBCResult(np.asarray(ranks, dtype=int), n_draws, names, ess_min)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
# ----------------------------------------------------------------------------
|
|
406
|
+
# coverage
|
|
407
|
+
# ----------------------------------------------------------------------------
|
|
408
|
+
@dataclass
|
|
409
|
+
class CoverageResult:
|
|
410
|
+
"""Empirical coverage of central credible intervals."""
|
|
411
|
+
|
|
412
|
+
levels: np.ndarray
|
|
413
|
+
covered: np.ndarray # (n_simulations, n_levels, n_quantities) bool
|
|
414
|
+
names: list[str]
|
|
415
|
+
|
|
416
|
+
def coverage_rates(self) -> np.ndarray:
|
|
417
|
+
"""Coverage rate per level and quantity, ``(n_levels, n_quantities)``."""
|
|
418
|
+
return self.covered.mean(axis=0)
|
|
419
|
+
|
|
420
|
+
def stderr(self) -> np.ndarray:
|
|
421
|
+
"""Binomial standard error of the nominal coverage, ``(n_levels, 1)``."""
|
|
422
|
+
n = self.covered.shape[0]
|
|
423
|
+
return np.sqrt(self.levels * (1 - self.levels) / n)[:, None]
|
|
424
|
+
|
|
425
|
+
def average(self) -> np.ndarray:
|
|
426
|
+
"""Coverage per level averaged over quantities, ``(n_levels,)``."""
|
|
427
|
+
return self.coverage_rates().mean(axis=1)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def coverage(
|
|
431
|
+
simulate: Simulator,
|
|
432
|
+
fit: Fitter,
|
|
433
|
+
n_simulations: int = 100,
|
|
434
|
+
*,
|
|
435
|
+
levels: Sequence[float] = (0.5, 0.68, 0.9, 0.95),
|
|
436
|
+
functionals: dict[str, Callable[[np.ndarray], float]] | None = None,
|
|
437
|
+
rng: RNGLike = None,
|
|
438
|
+
progress: bool = False,
|
|
439
|
+
) -> CoverageResult:
|
|
440
|
+
"""Empirical coverage of central credible intervals over simulated data sets.
|
|
441
|
+
|
|
442
|
+
``functionals`` maps names to functions of the fractions, or of the rates
|
|
443
|
+
for rate models (e.g. a mean
|
|
444
|
+
redshift); their intervals are checked alongside every bin fraction.
|
|
445
|
+
|
|
446
|
+
Parameters
|
|
447
|
+
----------
|
|
448
|
+
simulate : callable
|
|
449
|
+
``simulate(rng) -> (likelihoods, truth)``, as in :func:`sbc`.
|
|
450
|
+
fit : callable
|
|
451
|
+
``fit(likelihoods, rng) -> result``, as in :func:`sbc`.
|
|
452
|
+
n_simulations : int, optional
|
|
453
|
+
Number of simulated data sets.
|
|
454
|
+
levels : sequence of float, optional
|
|
455
|
+
Credible levels whose coverage is measured.
|
|
456
|
+
functionals : dict, optional
|
|
457
|
+
Named functions of the fractions (or rates) whose intervals are also checked.
|
|
458
|
+
rng : int, Generator or None
|
|
459
|
+
Random number generator.
|
|
460
|
+
progress : bool, optional
|
|
461
|
+
Show a progress bar.
|
|
462
|
+
"""
|
|
463
|
+
levels_arr = np.asarray(levels, dtype=float)
|
|
464
|
+
functionals = functionals or {}
|
|
465
|
+
out = []
|
|
466
|
+
names: list[str] = []
|
|
467
|
+
for res, truth in _fits(simulate, fit, n_simulations, rng, "coverage", progress):
|
|
468
|
+
names = _names(res) + list(functionals)
|
|
469
|
+
X = _flat_draws(res.component_draws, 1, None)
|
|
470
|
+
Q = np.column_stack([X, *(np.array([f(r) for r in X]) for f in functionals.values())])
|
|
471
|
+
tq = np.array([*truth, *(f(truth) for f in functionals.values())])
|
|
472
|
+
cov = np.empty((levels_arr.size, Q.shape[1]), dtype=bool)
|
|
473
|
+
for j, lev in enumerate(levels_arr):
|
|
474
|
+
lo, hi = _central(Q, lev)
|
|
475
|
+
cov[j] = (tq >= lo) & (tq <= hi)
|
|
476
|
+
out.append(cov)
|
|
477
|
+
return CoverageResult(levels_arr, np.asarray(out), names)
|
binference/warnings.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Warning classes emitted by binference.
|
|
2
|
+
|
|
3
|
+
All warnings derive from :class:`BinferenceWarning`, so they can be filtered in one
|
|
4
|
+
place, e.g. ``warnings.simplefilter("ignore", binference.BinferenceWarning)``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"ApproximationWarning",
|
|
11
|
+
"BinferenceWarning",
|
|
12
|
+
"ConvergenceWarning",
|
|
13
|
+
"DiagnosticsError",
|
|
14
|
+
"InputWarning",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class BinferenceWarning(UserWarning):
|
|
19
|
+
"""Base class for all binference warnings."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class InputWarning(BinferenceWarning):
|
|
23
|
+
"""Inputs look inconsistent with the declared conventions.
|
|
24
|
+
|
|
25
|
+
Examples: posterior masses whose rows do not sum to one, values that look
|
|
26
|
+
like densities when masses were declared, or objects with too few samples.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ConvergenceWarning(BinferenceWarning):
|
|
31
|
+
"""A sampler or optimiser shows signs of non-convergence.
|
|
32
|
+
|
|
33
|
+
Examples: large R-hat, low effective sample size, divergent transitions, or
|
|
34
|
+
an optimiser that stopped before meeting its tolerance.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ApproximationWarning(BinferenceWarning):
|
|
39
|
+
"""A result comes from an approximation with known failure modes.
|
|
40
|
+
|
|
41
|
+
Mean-field variational Bayes, Laplace approximations without importance
|
|
42
|
+
correction (or with an unreliable correction), and bootstrap intervals are
|
|
43
|
+
flagged with this warning.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class DiagnosticsError(RuntimeError):
|
|
48
|
+
"""``result.check(strict=True)`` found problems (the messages its warnings carry)."""
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: binference
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Population and hierarchical inference with PDFs: honest population distributions from noisy per-object PDFs over bins.
|
|
5
|
+
Project-URL: Homepage, https://github.com/joshspeagle/binference
|
|
6
|
+
Project-URL: Source, https://github.com/joshspeagle/binference
|
|
7
|
+
Project-URL: Documentation, https://joshspeagle.github.io/binference/
|
|
8
|
+
Project-URL: Issues, https://github.com/joshspeagle/binference/issues
|
|
9
|
+
Author: Joshua S. Speagle
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: bayesian,deconvolution,dirichlet,hierarchical,mcmc,photometric redshifts,population inference,quantification,unfolding
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Science/Research
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Topic :: Scientific/Engineering :: Astronomy
|
|
21
|
+
Classifier: Topic :: Scientific/Engineering :: Mathematics
|
|
22
|
+
Classifier: Topic :: Scientific/Engineering :: Physics
|
|
23
|
+
Requires-Python: >=3.13
|
|
24
|
+
Requires-Dist: numpy>=2.1
|
|
25
|
+
Requires-Dist: scipy>=1.14.1
|
|
26
|
+
Requires-Dist: threadpoolctl>=3.1
|
|
27
|
+
Provides-Extra: all
|
|
28
|
+
Requires-Dist: matplotlib>=3.9.2; extra == 'all'
|
|
29
|
+
Requires-Dist: xarray>=2024.10; extra == 'all'
|
|
30
|
+
Provides-Extra: dev
|
|
31
|
+
Requires-Dist: fonttools>=4.47; extra == 'dev'
|
|
32
|
+
Requires-Dist: ipykernel>=6; extra == 'dev'
|
|
33
|
+
Requires-Dist: matplotlib>=3.9.2; extra == 'dev'
|
|
34
|
+
Requires-Dist: myst-nb>=1.1; extra == 'dev'
|
|
35
|
+
Requires-Dist: numpydoc>=1.6; extra == 'dev'
|
|
36
|
+
Requires-Dist: pre-commit>=3; extra == 'dev'
|
|
37
|
+
Requires-Dist: pytest-cov>=5; extra == 'dev'
|
|
38
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
39
|
+
Requires-Dist: sphinx-copybutton>=0.5; extra == 'dev'
|
|
40
|
+
Requires-Dist: sphinx>=7.2; extra == 'dev'
|
|
41
|
+
Requires-Dist: sphinxcontrib-bibtex>=2.6; extra == 'dev'
|
|
42
|
+
Requires-Dist: xarray>=2024.10; extra == 'dev'
|
|
43
|
+
Provides-Extra: docs
|
|
44
|
+
Requires-Dist: fonttools>=4.47; extra == 'docs'
|
|
45
|
+
Requires-Dist: ipykernel>=6; extra == 'docs'
|
|
46
|
+
Requires-Dist: matplotlib>=3.9.2; extra == 'docs'
|
|
47
|
+
Requires-Dist: myst-nb>=1.1; extra == 'docs'
|
|
48
|
+
Requires-Dist: numpydoc>=1.6; extra == 'docs'
|
|
49
|
+
Requires-Dist: sphinx-copybutton>=0.5; extra == 'docs'
|
|
50
|
+
Requires-Dist: sphinx>=7.2; extra == 'docs'
|
|
51
|
+
Requires-Dist: sphinxcontrib-bibtex>=2.6; extra == 'docs'
|
|
52
|
+
Provides-Extra: plot
|
|
53
|
+
Requires-Dist: matplotlib>=3.9.2; extra == 'plot'
|
|
54
|
+
Provides-Extra: test
|
|
55
|
+
Requires-Dist: matplotlib>=3.9.2; extra == 'test'
|
|
56
|
+
Requires-Dist: pytest-cov>=5; extra == 'test'
|
|
57
|
+
Requires-Dist: pytest>=8; extra == 'test'
|
|
58
|
+
Provides-Extra: xarray
|
|
59
|
+
Requires-Dist: xarray>=2024.10; extra == 'xarray'
|
|
60
|
+
Description-Content-Type: text/markdown
|
|
61
|
+
|
|
62
|
+
# binference
|
|
63
|
+
|
|
64
|
+
**Population and hierarchical inference with PDFs.**
|
|
65
|
+
|
|
66
|
+
Many objects are each measured with noise: galaxies, merger events, patients,
|
|
67
|
+
classified documents. binference reads each object's likelihood, posterior, samples,
|
|
68
|
+
binned measurement or classifier output. From these it infers the population the objects
|
|
69
|
+
come from, with calibrated uncertainty, and each object's posterior under that
|
|
70
|
+
population.
|
|
71
|
+
|
|
72
|
+
![Population posteriors for a mock catalogue][figure]
|
|
73
|
+
|
|
74
|
+
[figure]: https://raw.githubusercontent.com/joshspeagle/binference/main/docs/_static/quickstart.png
|
|
75
|
+
|
|
76
|
+
*The population inferred from 2000 simulated objects, under the default Dirichlet prior
|
|
77
|
+
(top) and a Gaussian-process prior (bottom). Each panel shows the posterior median, its
|
|
78
|
+
68% and 95% intervals, the true density (dashed) and the stacked estimate (dotted).*
|
|
79
|
+
|
|
80
|
+
## Install
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
pip install "binference[plot]"
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The [installation page](https://joshspeagle.github.io/binference/start/install.html)
|
|
87
|
+
lists the extras and the supported Python versions.
|
|
88
|
+
|
|
89
|
+
## Example
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
import binference
|
|
93
|
+
|
|
94
|
+
bins = binference.Bins.linear(0.0, 3.0, 15)
|
|
95
|
+
|
|
96
|
+
# a simulated stand-in for your arrays: measured values and their Gaussian errors
|
|
97
|
+
mock = binference.simulate.mock_1d(2000, bins, rng=42)
|
|
98
|
+
z_obs, z_err = mock.measurements["mean"], mock.measurements["sigma"]
|
|
99
|
+
|
|
100
|
+
lk = binference.Likelihoods.from_gaussian(z_obs, z_err, bins)
|
|
101
|
+
result = binference.infer(lk, rng=1) # method="auto" chooses the engine
|
|
102
|
+
print(result.summary())
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Documentation
|
|
106
|
+
|
|
107
|
+
- [Quick start][quickstart]
|
|
108
|
+
- [Is binference right for my data?][is-it-right]
|
|
109
|
+
- [Tutorials][tutorials]
|
|
110
|
+
- [Full documentation][docs]
|
|
111
|
+
|
|
112
|
+
[quickstart]: https://joshspeagle.github.io/binference/start/quickstart.html
|
|
113
|
+
[is-it-right]: https://joshspeagle.github.io/binference/start/is-it-right.html
|
|
114
|
+
[tutorials]: https://joshspeagle.github.io/binference/tutorials/index.html
|
|
115
|
+
[docs]: https://joshspeagle.github.io/binference/
|
|
116
|
+
|
|
117
|
+
To contribute, see [`CONTRIBUTING.md`][contributing].
|
|
118
|
+
|
|
119
|
+
[contributing]: https://github.com/joshspeagle/binference/blob/main/CONTRIBUTING.md
|
|
120
|
+
|
|
121
|
+
## Status and citing
|
|
122
|
+
|
|
123
|
+
binference is alpha software, and its API may change before 1.0. If you use it, please
|
|
124
|
+
cite it as described on the [citing page][citing].
|
|
125
|
+
|
|
126
|
+
[citing]: https://joshspeagle.github.io/binference/reference/citing.html
|
|
127
|
+
|
|
128
|
+
## License
|
|
129
|
+
|
|
130
|
+
MIT; see [LICENSE](https://github.com/joshspeagle/binference/blob/main/LICENSE).
|