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/inference.py
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
"""The one-call interface: :func:`infer`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
import warnings
|
|
7
|
+
from math import ceil
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
from ._utils import RNGLike, check_positive, warn
|
|
13
|
+
from .bins import Bins
|
|
14
|
+
from .model import PopulationModel
|
|
15
|
+
from .priors import PopulationPrior
|
|
16
|
+
from .results import PointEstimate, PopulationResult
|
|
17
|
+
from .samplers import MAP, NUTS, Bootstrap, Gibbs, Laplace, MeanFieldVB
|
|
18
|
+
from .samplers.base import Engine
|
|
19
|
+
from .samplers.laplace import PROPOSAL_FACTOR
|
|
20
|
+
from .warnings import ApproximationWarning, BinferenceWarning
|
|
21
|
+
|
|
22
|
+
__all__ = ["METHODS", "Auto", "infer"]
|
|
23
|
+
|
|
24
|
+
#: Registry of inference methods accepted by :func:`infer`.
|
|
25
|
+
METHODS = {
|
|
26
|
+
"nuts": NUTS,
|
|
27
|
+
"gibbs": Gibbs,
|
|
28
|
+
"map": MAP,
|
|
29
|
+
"em": MAP,
|
|
30
|
+
"laplace": Laplace,
|
|
31
|
+
"vb": MeanFieldVB,
|
|
32
|
+
"bootstrap": Bootstrap,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
#: An extension of a Gibbs run aims for this many times ``target_ess``.
|
|
36
|
+
EXTEND_TARGET = 1.2
|
|
37
|
+
#: One extension of a Gibbs run adds at most this many times the draws so far.
|
|
38
|
+
EXTEND_MAX_FACTOR = 3
|
|
39
|
+
#: One extension of a Gibbs run adds at least this many times the draws so far.
|
|
40
|
+
EXTEND_MIN_FACTOR = 0.5
|
|
41
|
+
#: Proposals of the first pass that can stop a Laplace trial early.
|
|
42
|
+
PROBE_PROPOSALS = 1000
|
|
43
|
+
#: Draws of the first pass that can stop a Laplace trial early.
|
|
44
|
+
PROBE_DRAWS = 500
|
|
45
|
+
#: A Laplace trial stops when its first pass projects an importance ESS below this share of
|
|
46
|
+
#: ``target_ess``.
|
|
47
|
+
PROBE_ESS_SHARE = 0.5
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _options(engine: type) -> dict[str, inspect.Parameter]:
|
|
51
|
+
return dict(inspect.signature(engine.__init__).parameters)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _default_draws(engine: type) -> int:
|
|
55
|
+
"""The engine's own default ``n_samples``."""
|
|
56
|
+
return inspect.signature(engine.run).parameters["n_samples"].default
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class Auto(Engine):
|
|
60
|
+
r"""Picks Gibbs, Laplace or NUTS from rules set by a benchmark of the engines.
|
|
61
|
+
|
|
62
|
+
It tries Gibbs where it mixes well, then importance-corrected Laplace where its
|
|
63
|
+
check passes, then NUTS, keeping only engines that take every option in
|
|
64
|
+
``engine_kwargs``. :doc:`/how-it-works/auto` states the rules and the benchmark.
|
|
65
|
+
The result records the choice and the reason in ``info["auto_method"]`` and
|
|
66
|
+
``info["auto_reason"]``.
|
|
67
|
+
|
|
68
|
+
Parameters
|
|
69
|
+
----------
|
|
70
|
+
model : PopulationModel
|
|
71
|
+
target_ess : float
|
|
72
|
+
Smallest bulk ESS that Gibbs's run length aims for and that Laplace's
|
|
73
|
+
importance ESS must reach.
|
|
74
|
+
gibbs_max_missing : float
|
|
75
|
+
Largest missing-information fraction for which Gibbs is chosen (0.98: an
|
|
76
|
+
integrated autocorrelation time of 99 iterations).
|
|
77
|
+
nnz_limit : float
|
|
78
|
+
Above this many stored likelihood entries Laplace is kept even when its check
|
|
79
|
+
fails, rather than running NUTS.
|
|
80
|
+
max_gibbs_draws : int
|
|
81
|
+
Most draws per chain a Gibbs run may be extended to before it gives way to the
|
|
82
|
+
next candidate.
|
|
83
|
+
rng, progress
|
|
84
|
+
As for every engine.
|
|
85
|
+
**engine_kwargs
|
|
86
|
+
Options for the chosen engine.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
name = "auto"
|
|
90
|
+
joint = True
|
|
91
|
+
|
|
92
|
+
def __init__(
|
|
93
|
+
self,
|
|
94
|
+
model: PopulationModel,
|
|
95
|
+
*,
|
|
96
|
+
target_ess: float = 400.0,
|
|
97
|
+
gibbs_max_missing: float = 0.98,
|
|
98
|
+
nnz_limit: float = 2e7,
|
|
99
|
+
max_gibbs_draws: int = 25_000,
|
|
100
|
+
rng: RNGLike = None,
|
|
101
|
+
progress: bool = False,
|
|
102
|
+
**engine_kwargs: Any,
|
|
103
|
+
) -> None:
|
|
104
|
+
super().__init__(model, rng=rng, progress=progress)
|
|
105
|
+
self.target_ess = float(check_positive(target_ess, "target_ess"))
|
|
106
|
+
if not 0.0 <= gibbs_max_missing < 1.0:
|
|
107
|
+
raise ValueError(f"gibbs_max_missing must be in [0, 1); got {gibbs_max_missing}.")
|
|
108
|
+
self.gibbs_max_missing = float(gibbs_max_missing)
|
|
109
|
+
self.nnz_limit = float(check_positive(nnz_limit, "nnz_limit"))
|
|
110
|
+
self.max_gibbs_draws = int(check_positive(max_gibbs_draws, "max_gibbs_draws"))
|
|
111
|
+
self.engine_kwargs = engine_kwargs
|
|
112
|
+
self._rng_arg = rng
|
|
113
|
+
self._map: tuple[Any, float] | None = None # the MAP and its missing information
|
|
114
|
+
|
|
115
|
+
def candidates(self, n_samples: int | None = None) -> list[tuple[str, str, int | None]]:
|
|
116
|
+
"""(engine, reason, Gibbs draws per chain) in the order they are tried.
|
|
117
|
+
|
|
118
|
+
Parameters
|
|
119
|
+
----------
|
|
120
|
+
n_samples : int, optional
|
|
121
|
+
Draws per chain of the runs; ``None`` uses the engines' own lengths.
|
|
122
|
+
"""
|
|
123
|
+
m, kw = self.model, self.engine_kwargs
|
|
124
|
+
out: list[tuple[str, str, int | None]] = []
|
|
125
|
+
if Gibbs.unsupported(m) is None and set(kw) <= set(_options(Gibbs)):
|
|
126
|
+
if m.joint: # exact and fast (DECISIONS #80, #81)
|
|
127
|
+
out.append(("gibbs", "a joint model with a conjugate prior", None))
|
|
128
|
+
else:
|
|
129
|
+
if self._map is None:
|
|
130
|
+
with warnings.catch_warnings(record=True) as caught:
|
|
131
|
+
warnings.simplefilter("always", BinferenceWarning)
|
|
132
|
+
est = MAP(m).run()
|
|
133
|
+
# kept as best only when it raised no warning (best would show them)
|
|
134
|
+
keep = est if not caught else None
|
|
135
|
+
self._map = keep, m.missing_information(est.value) # component weights
|
|
136
|
+
f = self._map[1]
|
|
137
|
+
if f <= self.gibbs_max_missing:
|
|
138
|
+
chains = kw.get("n_chains", _options(Gibbs)["n_chains"].default)
|
|
139
|
+
n = max(
|
|
140
|
+
_default_draws(Gibbs),
|
|
141
|
+
ceil(self.target_ess * (1 + f) / (1 - f) / chains),
|
|
142
|
+
)
|
|
143
|
+
if n_samples is not None or n <= self.max_gibbs_draws:
|
|
144
|
+
out.append(("gibbs", f"conjugate prior, missing information {f:.2f}", n))
|
|
145
|
+
out += [
|
|
146
|
+
(key, "", None)
|
|
147
|
+
for key, engine in (("laplace", Laplace), ("nuts", NUTS))
|
|
148
|
+
if set(kw) <= set(_options(engine))
|
|
149
|
+
]
|
|
150
|
+
if not out:
|
|
151
|
+
raise ValueError(
|
|
152
|
+
f"None of the engines method='auto' can use for this model (Laplace, NUTS, "
|
|
153
|
+
f"and Gibbs where it applies) takes all of {sorted(kw)}; pass method= "
|
|
154
|
+
"explicitly."
|
|
155
|
+
)
|
|
156
|
+
return out
|
|
157
|
+
|
|
158
|
+
def run(self, n_samples: int | None = None) -> PopulationResult:
|
|
159
|
+
"""Run the first candidate that applies (see the class docstring).
|
|
160
|
+
|
|
161
|
+
Parameters
|
|
162
|
+
----------
|
|
163
|
+
n_samples : int, optional
|
|
164
|
+
Draws per chain; ``None`` lets auto set the run length. Laplace returns as
|
|
165
|
+
many in total as NUTS's default four chains would (4000 by default).
|
|
166
|
+
"""
|
|
167
|
+
plan = self.candidates(n_samples)
|
|
168
|
+
rejected: list[str] = [] # why earlier candidates were not kept
|
|
169
|
+
for i, (key, why, n_gibbs) in enumerate(plan):
|
|
170
|
+
last = i == len(plan) - 1
|
|
171
|
+
engine = METHODS[key](
|
|
172
|
+
self.model, rng=self._rng_arg, progress=self.progress, **self.engine_kwargs
|
|
173
|
+
)
|
|
174
|
+
if key == "laplace":
|
|
175
|
+
n = _options(NUTS)["n_chains"].default * (n_samples or _default_draws(NUTS))
|
|
176
|
+
result, why = (engine.run(n), "") if last else self._try(engine, n)
|
|
177
|
+
if result is None:
|
|
178
|
+
rejected.append(why)
|
|
179
|
+
continue
|
|
180
|
+
if not (result.approximate or self._passed(result)):
|
|
181
|
+
# k-hat passed (else Laplace flagged and warned) but the ESS fell short
|
|
182
|
+
result.approximate = True
|
|
183
|
+
short = f"its importance ESS is below target_ess={self.target_ess:g}"
|
|
184
|
+
why = "; ".join(filter(None, [why, short]))
|
|
185
|
+
warn(
|
|
186
|
+
f"auto kept Laplace draws although {short}; results are approximate.",
|
|
187
|
+
ApproximationWarning,
|
|
188
|
+
)
|
|
189
|
+
else:
|
|
190
|
+
n = n_samples or n_gibbs
|
|
191
|
+
result = engine.run() if n is None else engine.run(n)
|
|
192
|
+
# stored labels grow with the run: their length stays as asked
|
|
193
|
+
extend = key == "gibbs" and n_samples is None and engine.label_index is None
|
|
194
|
+
if extend and not self._extend(engine, result):
|
|
195
|
+
if not last:
|
|
196
|
+
rejected.append("Gibbs mixed too slowly")
|
|
197
|
+
continue
|
|
198
|
+
why += f"; it did not mix within max_gibbs_draws={self.max_gibbs_draws}"
|
|
199
|
+
if extend:
|
|
200
|
+
result = engine.result
|
|
201
|
+
reasons = [*rejected, why] if why else rejected
|
|
202
|
+
result.info["auto_method"] = key
|
|
203
|
+
result.info["auto_reason"] = (
|
|
204
|
+
"; ".join(reasons) or "the only engine that takes these options"
|
|
205
|
+
)
|
|
206
|
+
if self._map is not None and self._map[0] is not None: # not solved again
|
|
207
|
+
result._cache["best"] = self._map[0]
|
|
208
|
+
return result
|
|
209
|
+
raise AssertionError("unreachable: the last candidate always returns") # pragma: no cover
|
|
210
|
+
|
|
211
|
+
def _extend(self, engine: Gibbs, result: PopulationResult) -> bool:
|
|
212
|
+
"""Extend the Gibbs run until its bulk ESS reaches ``target_ess`` and it passes the
|
|
213
|
+
limits of ``check()``; False once that is projected to need more than
|
|
214
|
+
``max_gibbs_draws`` draws per chain."""
|
|
215
|
+
while True:
|
|
216
|
+
d = result.diagnostics
|
|
217
|
+
ess = np.concatenate([np.ravel(v) for k, v in d.items() if k.startswith("ess_bulk")])
|
|
218
|
+
ess = float(np.min(ess[np.isfinite(ess)], initial=np.inf)) # constant: no ESS
|
|
219
|
+
if ess >= self.target_ess and not result._mixing_problems():
|
|
220
|
+
return True
|
|
221
|
+
done = result.chains.shape[1]
|
|
222
|
+
need = ceil(
|
|
223
|
+
done
|
|
224
|
+
* max(EXTEND_TARGET * self.target_ess / max(ess, 1.0) - 1.0, EXTEND_MIN_FACTOR)
|
|
225
|
+
)
|
|
226
|
+
if done + need > self.max_gibbs_draws:
|
|
227
|
+
return False
|
|
228
|
+
# bounded, so a poor first projection costs less
|
|
229
|
+
result = engine.run(min(need, EXTEND_MAX_FACTOR * done))
|
|
230
|
+
|
|
231
|
+
def _passed(self, res: PopulationResult) -> bool:
|
|
232
|
+
"""Whether Laplace draws pass auto's check: Pareto k-hat and importance ESS."""
|
|
233
|
+
return not res.approximate and res.info.get("ess_importance", 0.0) >= self.target_ess
|
|
234
|
+
|
|
235
|
+
def _try(self, engine: Laplace, n: int) -> tuple[PopulationResult | None, str]:
|
|
236
|
+
"""Laplace's result when it is kept (its warnings re-emitted), else None.
|
|
237
|
+
|
|
238
|
+
Laplace is only tried with no engine options, so it has its default
|
|
239
|
+
``PROPOSAL_FACTOR * n`` proposals. A first pass of ``PROBE_PROPOSALS`` of them
|
|
240
|
+
stops the trial early when the importance ESS it projects for the full run is
|
|
241
|
+
below ``PROBE_ESS_SHARE`` of ``target_ess``.
|
|
242
|
+
"""
|
|
243
|
+
failed = None, "the Laplace importance check failed"
|
|
244
|
+
big = self.model.likelihoods.nnz > self.nnz_limit # kept whatever its check
|
|
245
|
+
with warnings.catch_warnings(record=True) as caught:
|
|
246
|
+
warnings.simplefilter("always")
|
|
247
|
+
try:
|
|
248
|
+
proposals = PROPOSAL_FACTOR * n
|
|
249
|
+
if not big and proposals > PROBE_PROPOSALS:
|
|
250
|
+
with warnings.catch_warnings():
|
|
251
|
+
warnings.simplefilter("ignore")
|
|
252
|
+
engine.n_proposals = PROBE_PROPOSALS
|
|
253
|
+
probe = engine.run(PROBE_DRAWS)
|
|
254
|
+
engine.n_proposals = None
|
|
255
|
+
projected = probe.info["ess_importance"] * proposals / PROBE_PROPOSALS
|
|
256
|
+
if projected < PROBE_ESS_SHARE * self.target_ess:
|
|
257
|
+
return failed
|
|
258
|
+
res = engine.run(n)
|
|
259
|
+
except (ValueError, FloatingPointError, np.linalg.LinAlgError):
|
|
260
|
+
return None, "the Laplace approximation failed"
|
|
261
|
+
if self._passed(res):
|
|
262
|
+
reason = "the Laplace importance check passed"
|
|
263
|
+
elif big:
|
|
264
|
+
reason = f"more than {self.nnz_limit:.0e} likelihood entries"
|
|
265
|
+
else:
|
|
266
|
+
return failed
|
|
267
|
+
for w in caught:
|
|
268
|
+
warnings.warn_explicit(w.message, w.category, w.filename, w.lineno)
|
|
269
|
+
return res, reason
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
METHODS["auto"] = Auto
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def infer(
|
|
276
|
+
likelihoods: Any,
|
|
277
|
+
prior: PopulationPrior | None = None,
|
|
278
|
+
*,
|
|
279
|
+
bins: Bins | None = None,
|
|
280
|
+
selection: Any = None,
|
|
281
|
+
selection_mode: str | None = None,
|
|
282
|
+
method: str = "auto",
|
|
283
|
+
n_samples: int | None = None,
|
|
284
|
+
rng: RNGLike = None,
|
|
285
|
+
progress: bool = False,
|
|
286
|
+
check: bool = True,
|
|
287
|
+
**engine_kwargs: Any,
|
|
288
|
+
) -> PopulationResult | PointEstimate:
|
|
289
|
+
r"""Infer a population distribution from per-object likelihoods in one call.
|
|
290
|
+
|
|
291
|
+
Parameters
|
|
292
|
+
----------
|
|
293
|
+
likelihoods : Likelihoods, PopulationModel, or array_like (N, K)
|
|
294
|
+
Per-object likelihoods over bins (see :class:`~binference.Likelihoods` for
|
|
295
|
+
converting posteriors, samples, Gaussian measurements, response
|
|
296
|
+
matrices or classifier outputs). A raw array is treated as
|
|
297
|
+
likelihoods over categorical bins (or ``bins``).
|
|
298
|
+
prior : PopulationPrior, optional
|
|
299
|
+
Population prior (default ``Dirichlet(1.0)``). Use
|
|
300
|
+
:class:`~binference.priors.GammaRates` to infer rates.
|
|
301
|
+
bins : Bins, optional
|
|
302
|
+
Bins for a raw array input.
|
|
303
|
+
selection : array_like (K,), callable or Selection, optional
|
|
304
|
+
Detection probability (fractions) or exposure (rates) per bin.
|
|
305
|
+
selection_mode : {"on_measurement", "on_truth"}, optional
|
|
306
|
+
Required with an array or callable ``selection`` (see
|
|
307
|
+
:class:`~binference.selection.Selection`).
|
|
308
|
+
method : {"auto", "nuts", "gibbs", "map", "em", "laplace", "vb", "bootstrap"}
|
|
309
|
+
Inference engine. ``"auto"`` (default) picks Gibbs, Laplace or NUTS from
|
|
310
|
+
rules set by a benchmark of the engines (:class:`Auto`); its
|
|
311
|
+
options ``target_ess``, ``gibbs_max_missing``, ``max_gibbs_draws`` and
|
|
312
|
+
``nnz_limit`` go in ``engine_kwargs``, and the choice and its reason are in
|
|
313
|
+
``result.info["auto_method"]`` and ``result.info["auto_reason"]``.
|
|
314
|
+
``"nuts"`` samples the exact posterior with any prior; ``"em"`` is MAP by
|
|
315
|
+
EM on the simplex (``algorithm="em"``) rather than mix-SQP; it needs a
|
|
316
|
+
conjugate prior, and a jointly inferred selection or response runs L-BFGS.
|
|
317
|
+
n_samples : int, optional
|
|
318
|
+
Draws per chain (NUTS, Gibbs, and ``"auto"``) or in total (Laplace, VB,
|
|
319
|
+
bootstrap). Under ``"auto"`` it fixes the run length (see :class:`Auto`).
|
|
320
|
+
rng : int, numpy.random.Generator, or None
|
|
321
|
+
Random number generator or seed (SPEC 7).
|
|
322
|
+
progress : bool
|
|
323
|
+
Show progress bars.
|
|
324
|
+
check : bool
|
|
325
|
+
Warn about convergence problems after sampling (NUTS, Gibbs).
|
|
326
|
+
**engine_kwargs
|
|
327
|
+
Passed to the engine (e.g. ``n_chains``, ``n_warmup``, ``target_accept``
|
|
328
|
+
for NUTS; see :mod:`binference.samplers`).
|
|
329
|
+
|
|
330
|
+
Returns
|
|
331
|
+
-------
|
|
332
|
+
PopulationResult or PointEstimate
|
|
333
|
+
Posterior draws, or the MAP estimate for ``method="map"`` and ``"em"``.
|
|
334
|
+
|
|
335
|
+
Examples
|
|
336
|
+
--------
|
|
337
|
+
>>> import numpy as np, binference
|
|
338
|
+
>>> rng = np.random.default_rng(1)
|
|
339
|
+
>>> bins = binference.Bins.linear(0.0, 1.0, 5)
|
|
340
|
+
>>> z = rng.uniform(0, 1, 300)
|
|
341
|
+
>>> lk = binference.Likelihoods.from_gaussian(z + rng.normal(0, 0.05, 300), 0.05, bins)
|
|
342
|
+
>>> res = binference.infer(lk, rng=0)
|
|
343
|
+
>>> res.mean.shape
|
|
344
|
+
(5,)
|
|
345
|
+
"""
|
|
346
|
+
if isinstance(likelihoods, PopulationModel):
|
|
347
|
+
if any(v is not None for v in (prior, selection, selection_mode, bins)):
|
|
348
|
+
raise ValueError("Pass prior/selection/bins when building the model, not to infer().")
|
|
349
|
+
model = likelihoods
|
|
350
|
+
else:
|
|
351
|
+
model = PopulationModel(
|
|
352
|
+
likelihoods, prior, selection=selection, selection_mode=selection_mode, bins=bins
|
|
353
|
+
)
|
|
354
|
+
key = str(method).lower()
|
|
355
|
+
if key not in METHODS:
|
|
356
|
+
raise ValueError(f"Unknown method {method!r}; choose from {sorted(METHODS)}.")
|
|
357
|
+
if key == "em" and not model.joint: # MAP by EM itself, not mix-SQP (audit O-17)
|
|
358
|
+
engine_kwargs.setdefault("algorithm", "em")
|
|
359
|
+
engine = METHODS[key](model, rng=rng, progress=progress, **engine_kwargs)
|
|
360
|
+
if n_samples is None:
|
|
361
|
+
result = engine.run()
|
|
362
|
+
elif isinstance(engine, MAP):
|
|
363
|
+
raise ValueError(f"n_samples does not apply to method={key!r}.")
|
|
364
|
+
else:
|
|
365
|
+
result = engine.run(int(n_samples))
|
|
366
|
+
if check and METHODS[result.method].markov:
|
|
367
|
+
result.check()
|
|
368
|
+
return result
|
binference/io.py
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"""Saving, loading and converting results."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
from ._version import __version__
|
|
12
|
+
from .basis import Basis
|
|
13
|
+
from .bins import Bins
|
|
14
|
+
from .measure import Measure
|
|
15
|
+
from .selection import Selection
|
|
16
|
+
from .selection import _rebuild as _rebuild_selection
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
19
|
+
from .results import PopulationResult
|
|
20
|
+
|
|
21
|
+
__all__ = ["load_result", "result_to_dict", "save_result", "to_datatree"]
|
|
22
|
+
|
|
23
|
+
SCHEMA_VERSION = 1
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _bins_to_parts(bins: Bins) -> tuple[dict[str, Any], dict[str, np.ndarray]]:
|
|
27
|
+
if bins.is_categorical:
|
|
28
|
+
# JSON keeps integer, float and string labels as they are (audit O-10)
|
|
29
|
+
labels = [lab.item() if isinstance(lab, np.generic) else lab for lab in bins.labels]
|
|
30
|
+
if not all(isinstance(lab, int | float | str) for lab in labels):
|
|
31
|
+
raise ValueError("Category labels must be integers, floats or strings to be saved.")
|
|
32
|
+
return {"bins": "categories", "labels": labels}, {}
|
|
33
|
+
arrays = {f"edges_{d}": np.asarray(e) for d, e in enumerate(bins.edges)}
|
|
34
|
+
names = []
|
|
35
|
+
for d, (m, e) in enumerate(zip(bins.measures, bins.edges, strict=True)):
|
|
36
|
+
names.append(m.name)
|
|
37
|
+
if not m.is_named: # other measures are saved as tables
|
|
38
|
+
arrays[f"measure_z_{d}"], arrays[f"measure_u_{d}"] = m._tabulate(e)
|
|
39
|
+
if bins.is_masked:
|
|
40
|
+
arrays["mask"] = bins.mask
|
|
41
|
+
return {"bins": "grid", "ndim": bins.ndim, "measures": names}, arrays
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _bins_from_parts(meta: dict[str, Any], arrays: Any) -> Bins:
|
|
45
|
+
if meta["bins"] == "categories":
|
|
46
|
+
labels = meta["labels"]
|
|
47
|
+
if all(isinstance(lab, int) for lab in labels) and labels == list(range(len(labels))):
|
|
48
|
+
return Bins.categories(len(labels))
|
|
49
|
+
return Bins.categories(labels)
|
|
50
|
+
nd = int(meta["ndim"])
|
|
51
|
+
measures = []
|
|
52
|
+
for d, name in enumerate(meta.get("measures", ["linear"] * nd)):
|
|
53
|
+
if f"measure_z_{d}" not in arrays:
|
|
54
|
+
measures.append(name)
|
|
55
|
+
else:
|
|
56
|
+
z, u = arrays[f"measure_z_{d}"], arrays[f"measure_u_{d}"]
|
|
57
|
+
measures.append(Measure.tabulated(z, u, name=name))
|
|
58
|
+
mask = np.asarray(arrays["mask"]) if "mask" in arrays else None
|
|
59
|
+
edges = [np.asarray(arrays[f"edges_{d}"]) for d in range(nd)]
|
|
60
|
+
return Bins.grid(*edges, measure=measures, mask=mask)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _basis_meta(basis: Basis) -> dict[str, Any]:
|
|
64
|
+
return {
|
|
65
|
+
"order": basis.order,
|
|
66
|
+
"resolution": list(basis.resolution),
|
|
67
|
+
"oversample": basis.oversample,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _selection_parts(sel: Selection) -> tuple[dict[str, Any], dict[str, np.ndarray]]:
|
|
72
|
+
values, row, scale, _, injections = sel._parts()
|
|
73
|
+
meta: dict[str, Any] = {
|
|
74
|
+
"scale": scale,
|
|
75
|
+
"row": row if row is None or isinstance(row, str) else "array",
|
|
76
|
+
}
|
|
77
|
+
arrays = {"selection_values": np.asarray(values)}
|
|
78
|
+
if meta["row"] == "array":
|
|
79
|
+
arrays["selection_row"] = np.asarray(row)
|
|
80
|
+
if injections is not None:
|
|
81
|
+
meta["injections"] = {"prior": list(injections.prior), "smooth": injections.smooth}
|
|
82
|
+
arrays["selection_injected"] = np.asarray(injections.injected)
|
|
83
|
+
arrays["selection_recovered"] = np.asarray(injections.recovered)
|
|
84
|
+
return meta, arrays
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _selection_from_parts(meta: dict[str, Any], arrays: Any, basis: Basis | None) -> Selection:
|
|
88
|
+
row = np.asarray(arrays["selection_row"]) if meta["row"] == "array" else meta["row"]
|
|
89
|
+
inj = meta.get("injections")
|
|
90
|
+
if inj is not None:
|
|
91
|
+
inj = (
|
|
92
|
+
np.asarray(arrays["selection_injected"]),
|
|
93
|
+
np.asarray(arrays["selection_recovered"]),
|
|
94
|
+
tuple(inj["prior"]),
|
|
95
|
+
inj["smooth"],
|
|
96
|
+
)
|
|
97
|
+
return _rebuild_selection(
|
|
98
|
+
np.asarray(arrays["selection_values"]), row, meta["scale"], basis, inj
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def result_to_dict(result: PopulationResult) -> dict[str, Any]:
|
|
103
|
+
"""Arrays and metadata of a result as a plain dictionary: what :func:`save_result`
|
|
104
|
+
writes, with ``"meta"`` the JSON-compatible metadata and every other entry an array.
|
|
105
|
+
|
|
106
|
+
Engine statistics that are neither numbers, strings nor numeric arrays (e.g. lists
|
|
107
|
+
of NUTS metrics) are left out and named in ``meta["info_dropped"]``.
|
|
108
|
+
|
|
109
|
+
Parameters
|
|
110
|
+
----------
|
|
111
|
+
result : PopulationResult
|
|
112
|
+
The result.
|
|
113
|
+
"""
|
|
114
|
+
meta, arrays = _bins_to_parts(result.bins)
|
|
115
|
+
meta.update(
|
|
116
|
+
schema=SCHEMA_VERSION,
|
|
117
|
+
binference_version=__version__,
|
|
118
|
+
method=result.method,
|
|
119
|
+
kind=result.kind,
|
|
120
|
+
approximate=bool(result.approximate),
|
|
121
|
+
prior=result.prior_repr,
|
|
122
|
+
prior_key=result.prior_key,
|
|
123
|
+
)
|
|
124
|
+
scalars, dropped = {}, []
|
|
125
|
+
for k, v in result.info.items():
|
|
126
|
+
if isinstance(v, int | float | bool | str | np.integer | np.floating | np.bool_):
|
|
127
|
+
scalars[k] = v.item() if hasattr(v, "item") else v
|
|
128
|
+
elif isinstance(v, np.ndarray) and v.dtype.kind in "biuf":
|
|
129
|
+
arrays[f"info_{k}"] = v
|
|
130
|
+
elif k == "hyperparameters" and isinstance(v, dict):
|
|
131
|
+
for name, draws in v.items():
|
|
132
|
+
arrays[f"hyper_{name}"] = np.asarray(draws, dtype=float)
|
|
133
|
+
else:
|
|
134
|
+
dropped.append(k)
|
|
135
|
+
meta["info"] = scalars
|
|
136
|
+
if dropped:
|
|
137
|
+
meta["info_dropped"] = dropped
|
|
138
|
+
out: dict[str, Any] = {"draws": result._draws}
|
|
139
|
+
if result.basis is not None: # the draws per bin follow from the component draws
|
|
140
|
+
meta["basis"] = _basis_meta(result.basis)
|
|
141
|
+
out["component_draws"] = result.component_draws
|
|
142
|
+
if result.selection is not None: # per-object methods need it again (audit O-1)
|
|
143
|
+
meta["selection"], sel_arrays = _selection_parts(result.selection)
|
|
144
|
+
arrays.update(sel_arrays)
|
|
145
|
+
total = result.total if result.kind == "fractions" else None
|
|
146
|
+
if total is not None: # kept so a result loaded without its likelihoods has rates
|
|
147
|
+
out["total"] = total.reshape(result._draws.shape[:2])
|
|
148
|
+
if result.log_density_unconstrained is not None:
|
|
149
|
+
out["log_density_unconstrained"] = np.asarray(result.log_density_unconstrained)
|
|
150
|
+
if result.x is not None: # where prior_information_ratio evaluates, as before saving
|
|
151
|
+
out["x"] = np.asarray(result.x)
|
|
152
|
+
if result.labels is not None: # draws of the objects' bins (or basis components)
|
|
153
|
+
out["label_draws"] = np.asarray(result.labels)
|
|
154
|
+
if result.label_index is not None:
|
|
155
|
+
out["label_index"] = np.asarray(result.label_index)
|
|
156
|
+
out.update(arrays)
|
|
157
|
+
out["meta"] = meta
|
|
158
|
+
return out
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def save_result(result: PopulationResult, path: str) -> None:
|
|
162
|
+
"""Save a result to a compressed ``.npz`` file (versioned schema;
|
|
163
|
+
:func:`result_to_dict`).
|
|
164
|
+
|
|
165
|
+
A :meth:`Measure.transform <binference.Measure.transform>` axis is saved as
|
|
166
|
+
a table of points, so it loads as a tabulated measure: the loaded bins
|
|
167
|
+
compare unequal to the original ones.
|
|
168
|
+
|
|
169
|
+
Parameters
|
|
170
|
+
----------
|
|
171
|
+
result : PopulationResult
|
|
172
|
+
The result.
|
|
173
|
+
path : str
|
|
174
|
+
File to write; ``.npz`` is appended when missing.
|
|
175
|
+
"""
|
|
176
|
+
payload = result_to_dict(result)
|
|
177
|
+
payload["meta"] = np.array(json.dumps(payload["meta"]))
|
|
178
|
+
np.savez_compressed(path, **payload)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def load_result(path: str) -> PopulationResult:
|
|
182
|
+
"""Load a result saved with :func:`save_result` (``.npz`` may be left off ``path``).
|
|
183
|
+
|
|
184
|
+
The returned result is detached from its likelihoods; call
|
|
185
|
+
``result.attach(likelihoods)`` to use per-object methods.
|
|
186
|
+
|
|
187
|
+
Parameters
|
|
188
|
+
----------
|
|
189
|
+
path : str
|
|
190
|
+
File written by :func:`save_result`.
|
|
191
|
+
"""
|
|
192
|
+
from .results import PopulationResult
|
|
193
|
+
|
|
194
|
+
if isinstance(path, str | os.PathLike): # np.savez_compressed adds .npz (audit O-10)
|
|
195
|
+
name = os.fspath(path)
|
|
196
|
+
if not os.path.exists(name) and os.path.exists(name + ".npz"):
|
|
197
|
+
path = name + ".npz"
|
|
198
|
+
with np.load(path, allow_pickle=False) as data:
|
|
199
|
+
meta = json.loads(str(data["meta"]))
|
|
200
|
+
if meta.get("schema", 0) > SCHEMA_VERSION:
|
|
201
|
+
raise ValueError("This file was written by a newer version of binference.")
|
|
202
|
+
bins = _bins_from_parts(meta, data)
|
|
203
|
+
info: dict[str, Any] = dict(meta.get("info", {}))
|
|
204
|
+
hyper = {}
|
|
205
|
+
for key in data.files:
|
|
206
|
+
if key.startswith("info_"):
|
|
207
|
+
info[key[5:]] = np.array(data[key])
|
|
208
|
+
elif key.startswith("hyper_"):
|
|
209
|
+
hyper[key[6:]] = np.array(data[key])
|
|
210
|
+
if hyper:
|
|
211
|
+
info["hyperparameters"] = hyper
|
|
212
|
+
basis = Basis(bins, **meta["basis"]) if "basis" in meta else None
|
|
213
|
+
selection = (
|
|
214
|
+
_selection_from_parts(meta["selection"], data, basis) if "selection" in meta else None
|
|
215
|
+
)
|
|
216
|
+
return PopulationResult(
|
|
217
|
+
None,
|
|
218
|
+
np.array(data["component_draws" if basis is not None else "draws"]),
|
|
219
|
+
method=meta["method"],
|
|
220
|
+
kind=meta["kind"],
|
|
221
|
+
bins=bins,
|
|
222
|
+
x=np.array(data["x"]) if "x" in data.files else None,
|
|
223
|
+
log_density_unconstrained=(
|
|
224
|
+
np.array(data["log_density_unconstrained"])
|
|
225
|
+
if "log_density_unconstrained" in data.files
|
|
226
|
+
else None
|
|
227
|
+
),
|
|
228
|
+
approximate=meta["approximate"],
|
|
229
|
+
info=info,
|
|
230
|
+
labels=np.array(data["label_draws"]) if "label_draws" in data.files else None,
|
|
231
|
+
label_index=np.array(data["label_index"]) if "label_index" in data.files else None,
|
|
232
|
+
prior_repr=meta.get("prior"),
|
|
233
|
+
prior_key=meta.get("prior_key"),
|
|
234
|
+
basis=basis,
|
|
235
|
+
total=np.array(data["total"]) if "total" in data.files else None,
|
|
236
|
+
selection=selection,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def to_datatree(result: PopulationResult):
|
|
241
|
+
"""Convert to an :class:`xarray.DataTree` with ``posterior`` and ``sample_stats`` groups.
|
|
242
|
+
|
|
243
|
+
Follows the ArviZ (>= 1.0) layout: dimensions ``chain``, ``draw`` and
|
|
244
|
+
``bin``, with the bins' centres as numeric coordinates (``bin_center``, or
|
|
245
|
+
``bin_center_<d>`` per axis of a grid), hyperparameter draws in the posterior,
|
|
246
|
+
and the NUTS statistics under ArviZ's names (``acceptance_rate``, ``n_steps``,
|
|
247
|
+
boolean ``diverging``). Requires xarray >= 2024.10.
|
|
248
|
+
|
|
249
|
+
Parameters
|
|
250
|
+
----------
|
|
251
|
+
result : PopulationResult
|
|
252
|
+
The result.
|
|
253
|
+
"""
|
|
254
|
+
try:
|
|
255
|
+
import xarray as xr
|
|
256
|
+
except ImportError as err: # pragma: no cover - depends on environment
|
|
257
|
+
raise ImportError("to_datatree needs xarray: pip install 'binference[xarray]'.") from err
|
|
258
|
+
C = result.chains
|
|
259
|
+
n_chains, n_draws, _ = C.shape
|
|
260
|
+
from .results import _bin_labels
|
|
261
|
+
|
|
262
|
+
coords: dict[str, Any] = {
|
|
263
|
+
"chain": np.arange(n_chains),
|
|
264
|
+
"draw": np.arange(n_draws),
|
|
265
|
+
"bin": np.asarray(_bin_labels(result.bins), dtype=str),
|
|
266
|
+
}
|
|
267
|
+
if not result.bins.is_categorical:
|
|
268
|
+
centers = np.asarray(result.bins.centers).reshape(result.bins.K, -1)
|
|
269
|
+
for d in range(centers.shape[1]):
|
|
270
|
+
name = "bin_center" if centers.shape[1] == 1 else f"bin_center_{d}"
|
|
271
|
+
coords[name] = ("bin", centers[:, d])
|
|
272
|
+
data_vars = {"rho": (("chain", "draw", "bin"), C)}
|
|
273
|
+
rates = result.rates
|
|
274
|
+
if rates is not None:
|
|
275
|
+
data_vars["rate"] = (("chain", "draw", "bin"), rates.reshape(C.shape))
|
|
276
|
+
data_vars["total"] = (("chain", "draw"), result.total.reshape(C.shape[:2]))
|
|
277
|
+
if "selection_draws" in result.info: # an uncertain selection, per draw
|
|
278
|
+
data_vars["selection"] = (("chain", "draw", "bin"), result.info["selection_draws"])
|
|
279
|
+
if "response_draws" in result.info: # an inferred response, per draw
|
|
280
|
+
dims = ("chain", "draw", "measured_bin", "bin")
|
|
281
|
+
data_vars["response"] = (dims, result.info["response_draws"])
|
|
282
|
+
for name, draws in result.info.get("hyperparameters", {}).items():
|
|
283
|
+
data_vars[name] = (("chain", "draw"), np.asarray(draws, dtype=float))
|
|
284
|
+
attrs = {
|
|
285
|
+
"method": result.method,
|
|
286
|
+
"kind": result.kind,
|
|
287
|
+
"approximate": int(result.approximate),
|
|
288
|
+
"prior": str(result.prior_repr),
|
|
289
|
+
"binference_version": __version__,
|
|
290
|
+
}
|
|
291
|
+
post = xr.Dataset(data_vars, coords=coords, attrs=attrs)
|
|
292
|
+
groups = {"posterior": post}
|
|
293
|
+
arviz_names = {"accept_stat": "acceptance_rate", "n_leapfrog": "n_steps"}
|
|
294
|
+
stats = {
|
|
295
|
+
arviz_names.get(k, k): (
|
|
296
|
+
("chain", "draw"),
|
|
297
|
+
np.asarray(v, dtype=bool if k == "diverging" else None),
|
|
298
|
+
)
|
|
299
|
+
for k, v in result.info.items()
|
|
300
|
+
if isinstance(v, np.ndarray) and v.shape == (n_chains, n_draws)
|
|
301
|
+
}
|
|
302
|
+
lp = result.log_density_unconstrained
|
|
303
|
+
if lp is not None and np.shape(lp) == (n_chains, n_draws):
|
|
304
|
+
stats["lp"] = (("chain", "draw"), np.asarray(lp))
|
|
305
|
+
if stats:
|
|
306
|
+
groups["sample_stats"] = xr.Dataset(
|
|
307
|
+
stats, coords={k: coords[k] for k in ("chain", "draw")}
|
|
308
|
+
)
|
|
309
|
+
return xr.DataTree.from_dict(groups)
|