binference 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. binference/__init__.py +85 -0
  2. binference/_kernels.py +252 -0
  3. binference/_quadrature.py +202 -0
  4. binference/_simplex.py +54 -0
  5. binference/_utils.py +139 -0
  6. binference/_version.py +1 -0
  7. binference/basis.py +314 -0
  8. binference/bins.py +645 -0
  9. binference/density.py +323 -0
  10. binference/diagnostics.py +400 -0
  11. binference/estimator.py +197 -0
  12. binference/inference.py +368 -0
  13. binference/io.py +309 -0
  14. binference/likelihoods.py +1684 -0
  15. binference/measure.py +246 -0
  16. binference/model.py +833 -0
  17. binference/plotting.py +538 -0
  18. binference/priors/__init__.py +47 -0
  19. binference/priors/base.py +248 -0
  20. binference/priors/dirichlet.py +640 -0
  21. binference/priors/hyper.py +240 -0
  22. binference/priors/logistic.py +676 -0
  23. binference/priors/rates.py +139 -0
  24. binference/response.py +539 -0
  25. binference/results.py +2271 -0
  26. binference/samplers/__init__.py +19 -0
  27. binference/samplers/_curvature.py +67 -0
  28. binference/samplers/_nuts_core.py +284 -0
  29. binference/samplers/base.py +118 -0
  30. binference/samplers/bootstrap.py +136 -0
  31. binference/samplers/gibbs.py +287 -0
  32. binference/samplers/laplace.py +200 -0
  33. binference/samplers/nuts.py +681 -0
  34. binference/samplers/optimize.py +488 -0
  35. binference/samplers/vb.py +118 -0
  36. binference/selection.py +621 -0
  37. binference/simulate.py +720 -0
  38. binference/validation.py +477 -0
  39. binference/warnings.py +48 -0
  40. binference-0.1.0.dist-info/METADATA +130 -0
  41. binference-0.1.0.dist-info/RECORD +43 -0
  42. binference-0.1.0.dist-info/WHEEL +4 -0
  43. binference-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,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)