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,1684 @@
1
+ """Per-object likelihoods over bins: the main input to binference."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ import inspect
7
+ import pickle
8
+ from collections.abc import Callable, Sequence
9
+ from functools import partial
10
+ from typing import Any, Literal
11
+
12
+ import numpy as np
13
+ import scipy.sparse as sp
14
+ from scipy import special
15
+
16
+ from . import _kernels as kern
17
+ from ._quadrature import bin_log_average
18
+ from ._utils import check_nonnegative, check_positive_weights, frozen, is_integer_array, warn
19
+ from .basis import Basis
20
+ from .bins import Bins
21
+ from .density import (
22
+ Density,
23
+ Representation,
24
+ _dist_log_masses,
25
+ _dist_logpdf,
26
+ _equal_volumes,
27
+ _has_cdf,
28
+ _has_pdf,
29
+ _is_distribution,
30
+ _log_nonneg,
31
+ log_interval_masses,
32
+ )
33
+ from .measure import Measure
34
+ from .response import Response
35
+ from .selection import Selection, _on_basis, as_selection
36
+ from .warnings import ApproximationWarning, InputWarning
37
+
38
+ __all__ = ["Likelihoods"]
39
+
40
+ InterimPrior = Any # anything Density.coerce accepts
41
+
42
+
43
+ # ----------------------------------------------------------------------------
44
+ # helpers
45
+ # ----------------------------------------------------------------------------
46
+ def _csr_row_max(L: sp.csr_array) -> np.ndarray:
47
+ n = L.shape[0]
48
+ out = np.zeros(n)
49
+ counts = np.diff(L.indptr)
50
+ nz = counts > 0
51
+ if L.data.size:
52
+ starts = L.indptr[:-1][nz]
53
+ out[nz] = np.maximum.reduceat(L.data, starts)
54
+ return out
55
+
56
+
57
+ def _scale_columns(values, factors: np.ndarray):
58
+ if sp.issparse(values):
59
+ values = sp.csr_array(values).copy()
60
+ values.data = values.data * factors[values.indices]
61
+ return values
62
+ return np.asarray(values, dtype=float) * factors
63
+
64
+
65
+ def _interim_masses(
66
+ interim: InterimPrior,
67
+ bins: Bins,
68
+ representation: Representation | None = None,
69
+ tol: float = 1e-3,
70
+ max_depth: int = 12,
71
+ n_sub: int | None = None,
72
+ ) -> np.ndarray:
73
+ """Normalised interim-prior mass per bin."""
74
+ return Density.coerce(interim, representation).masses(
75
+ bins, tol=tol, max_depth=max_depth, n_sub=n_sub
76
+ )
77
+
78
+
79
+ def _check_kind(kind: Any, interim: Any) -> bool:
80
+ """Whether the input is a posterior; exactly one of ``kind`` and ``interim`` must be given."""
81
+ if kind not in (None, "likelihood"):
82
+ raise ValueError(
83
+ f"kind must be 'likelihood' (pass interim= for posteriors); got {kind!r}."
84
+ )
85
+ if (kind is None) == (interim is None):
86
+ raise ValueError(
87
+ "Say what the input is: kind='likelihood' for likelihoods, or interim=<prior> for "
88
+ "posteriors computed under that prior (not both)."
89
+ )
90
+ return interim is not None
91
+
92
+
93
+ def _check_weights(weights: Any, n: int, fractional_weights: bool) -> np.ndarray:
94
+ """Positive integer multiplicities, or any positive weights if ``fractional_weights`` (#1)."""
95
+ w = check_positive_weights(weights, n)
96
+ if not is_integer_array(w):
97
+ if not fractional_weights:
98
+ raise ValueError(
99
+ "weights are integer multiplicities; pass fractional_weights=True "
100
+ "for a weighted pseudo-likelihood."
101
+ )
102
+ warn(
103
+ "Non-integer weights define a pseudo-likelihood: the posterior is not a proper "
104
+ "posterior and its uncertainties are not calibrated.",
105
+ InputWarning,
106
+ )
107
+ return w
108
+
109
+
110
+ def _divide_interim(P: Any, Pi: np.ndarray, what: str) -> Any:
111
+ """Columns of ``P`` divided by the interim masses ``Pi``; refuses mass where ``Pi`` is zero."""
112
+ zero = Pi <= 0
113
+ if np.any(zero) and np.any(np.asarray(P.sum(axis=0)).ravel()[zero] > 0):
114
+ raise ValueError(
115
+ f"{what} have mass in bins where the interim prior is zero; "
116
+ "the interim prior must be positive wherever they are."
117
+ )
118
+ return _scale_columns(P, np.where(zero, 0.0, 1.0 / np.where(zero, 1.0, Pi)))
119
+
120
+
121
+ def _group_rows(keys: list) -> tuple[np.ndarray, np.ndarray]:
122
+ # (first row of each distinct key, group of every row), groups in order of first appearance
123
+ ids: dict = {}
124
+ first: list[int] = []
125
+ group = np.empty(len(keys), dtype=np.intp)
126
+ for i, k in enumerate(keys):
127
+ group[i] = g = ids.setdefault(k, len(ids))
128
+ if g == len(first):
129
+ first.append(i)
130
+ return np.array(first, dtype=np.intp), group
131
+
132
+
133
+ def _read_only(a: Any) -> np.ndarray:
134
+ # a float array that cannot be changed in place: reused only when it is already
135
+ # read-only and owns its data (a read-only view could still change through its base)
136
+ a = np.asarray(a, dtype=float)
137
+ return a if not a.flags.writeable and a.base is None else frozen(a.copy())
138
+
139
+
140
+ def _common(weights, selection, selection_mode, drop_empty, fractional_weights) -> dict[str, Any]:
141
+ # the keyword arguments every constructor passes on to Likelihoods
142
+ return {
143
+ "weights": weights,
144
+ "selection": selection,
145
+ "selection_mode": selection_mode,
146
+ "drop_empty": drop_empty,
147
+ "fractional_weights": fractional_weights,
148
+ }
149
+
150
+
151
+ def _rows_logpdf(dists: list[Any], ndim: int) -> Callable[[np.ndarray, np.ndarray], np.ndarray]:
152
+ """``f(z, r)``: log density of distribution ``r[p]`` at the points ``z[p]``."""
153
+ family = getattr(dists[0], "dist", None)
154
+ keys = sorted(getattr(dists[0], "kwds", {}))
155
+ same = (
156
+ ndim == 1
157
+ and family is not None
158
+ and all(
159
+ getattr(d, "dist", None) is family
160
+ and len(d.args) == len(dists[0].args)
161
+ and sorted(d.kwds) == keys
162
+ for d in dists
163
+ )
164
+ )
165
+ if same: # frozen scipy distributions of one family: one call
166
+ args = [np.array(a, dtype=float) for a in zip(*(d.args for d in dists), strict=True)]
167
+ kwds = {k: np.array([d.kwds[k] for d in dists], dtype=float) for k in keys}
168
+ return lambda z, r: family.logpdf(
169
+ z, *(a[r, None] for a in args), **{k: v[r, None] for k, v in kwds.items()}
170
+ )
171
+
172
+ def logpdf(z, r):
173
+ # each distribution receives its points as (n,) or (n, ndim), as everywhere else
174
+ out = np.empty(z.shape[:2])
175
+ for i in np.unique(r):
176
+ sel = r == i
177
+ pts = z[sel].reshape(-1, ndim) if ndim > 1 else z[sel].ravel()
178
+ out[sel] = _dist_logpdf(dists[i], pts).reshape(out[sel].shape)
179
+ return out
180
+
181
+ return logpdf
182
+
183
+
184
+ def _correlated_gaussian(mu, C, bins, tol, max_depth) -> tuple[np.ndarray, np.ndarray]:
185
+ """Log bin averages (in u) and log bin masses (in z) of multivariate normal likelihoods."""
186
+ try:
187
+ chol = np.linalg.cholesky(C)
188
+ except np.linalg.LinAlgError:
189
+ raise ValueError("cov must be symmetric positive definite for every object.") from None
190
+ inv_chol = np.linalg.inv(chol) # whitening: y = L^-1 (z - mu)
191
+ log_norm = -np.log(np.diagonal(chol, axis1=1, axis2=2)).sum(axis=1)
192
+ log_norm -= 0.5 * bins.ndim * np.log(2 * np.pi)
193
+ logf = partial(_log_mvn_rows, mu=mu, inv_chol=inv_chol, log_norm=log_norm)
194
+ # a strongly correlated Gaussian is a ridge narrower than the bins that both
195
+ # quadrature rules can miss alike; cells that may hold mass are split until they
196
+ # are no wider than twice the narrowest principal standard deviation
197
+ lam, vec = np.linalg.eigh(C)
198
+ split = partial(_ridge_split, mu=mu, vec=vec, sd=np.sqrt(lam), floor=np.log(tol) * 2)
199
+ quad = {"rows": mu.shape[0], "tol": tol, "max_depth": max_depth, "must_split": split}
200
+ log_avg = bin_log_average(logf, bins, **quad)
201
+ if all(m == Measure.linear() for m in bins.measures):
202
+ return log_avg, log_avg + np.log(bins.volumes)
203
+ # the masses only feed the outside fraction, so a looser tolerance is enough
204
+ log_z = bin_log_average(logf, bins, coordinate="z", **{**quad, "tol": max(tol, 1e-2)})
205
+ return log_avg, log_z + np.log(bins.volumes)
206
+
207
+
208
+ def _ridge_split(z_lo, z_hi, r, mu, vec, sd, floor) -> np.ndarray:
209
+ # cells wider than 2 narrowest principal sds that may hold more mass than exp(floor).
210
+ # The cell's mass is at most the probability that the Gaussian's projection on each
211
+ # principal axis falls in the cell's projected interval, which the 1-D CDF gives exactly.
212
+ V, s = vec[r], sd[r] # (P, d, d) eigenvectors in columns, (P, d) principal sds
213
+ centre = np.einsum("pdk,pd->pk", V, 0.5 * (z_lo + z_hi) - mu[r]) / s
214
+ half = np.einsum("pdk,pd->pk", np.abs(V), 0.5 * (z_hi - z_lo)) / s
215
+ near, far = np.maximum(np.abs(centre) - half, 0.0), np.abs(centre) + half
216
+ with np.errstate(divide="ignore"):
217
+ log_bound = np.log(special.ndtr(-near) - special.ndtr(-far) + (near == 0) * 0.5)
218
+ wide = (z_hi - z_lo).max(axis=1) > 2 * s[:, 0]
219
+ return wide & (log_bound.min(axis=1) > floor)
220
+
221
+
222
+ def _log_mvn_rows(z, r, mu, inv_chol, log_norm) -> np.ndarray:
223
+ # log N(z[p] | mu[r[p]], C[r[p]]) for points z of shape (P, M, ndim)
224
+ y = (z - mu[r][:, None, :]) @ np.swapaxes(inv_chol[r], 1, 2)
225
+ return log_norm[r][:, None] - 0.5 * (y**2).sum(axis=-1)
226
+
227
+
228
+ def _log_gauss_rows(z: np.ndarray, r: np.ndarray, mu: np.ndarray, sig: np.ndarray) -> np.ndarray:
229
+ m, s = mu[r, None], sig[r, None]
230
+ return -0.5 * ((z - m) / s) ** 2 - np.log(s) - 0.5 * np.log(2 * np.pi)
231
+
232
+
233
+ # DECISIONS #64
234
+ #: An object with more than this share of its likelihood outside the bins is mostly
235
+ #: outside.
236
+ OUTSIDE_THRESHOLD = 0.9
237
+ #: A warning is raised when more than this share of the objects are mostly outside the
238
+ #: bins.
239
+ OUTSIDE_SHARE = 0.1
240
+ #: ``from_samples`` warns when more than this share of the objects have fewer than
241
+ #: ``min_samples`` effective samples.
242
+ FEW_SAMPLES_SHARE = 0.1
243
+ #: A warning is raised when more than this share of posterior rows sum to one only under
244
+ #: the other ``representation``.
245
+ REPRESENTATION_SHARE = 0.5
246
+ #: Relative tolerance within which a posterior row counts as summing to one.
247
+ REPRESENTATION_RTOL = 1e-2
248
+
249
+
250
+ def _record_outside(lk: Likelihoods, outside: np.ndarray, enabled: bool) -> None:
251
+ """Store each object's mass fraction outside the active bins, and warn if many lose most."""
252
+ _attach_per_object(lk, outside_fraction=outside)
253
+ outside, threshold = lk.meta["outside_fraction"], OUTSIDE_THRESHOLD
254
+ many = outside > threshold
255
+ if enabled and outside.size and many.mean() > OUTSIDE_SHARE:
256
+ warn(
257
+ f"{int(many.sum())} of {outside.size} objects have more than {threshold:.0%} of "
258
+ "their likelihood mass outside the bins (see meta['outside_fraction']); "
259
+ "consider wider bins.",
260
+ InputWarning,
261
+ )
262
+
263
+
264
+ def _outside(log_inside: np.ndarray) -> np.ndarray:
265
+ return -np.expm1(np.minimum(log_inside, 0.0))
266
+
267
+
268
+ # ----------------------------------------------------------------------------
269
+ # main class
270
+ # ----------------------------------------------------------------------------
271
+ def _project_values(norm: Any, log_scale: np.ndarray, M: Any) -> tuple[Any, np.ndarray]:
272
+ # likelihoods flat in the cells of a grid (the rows of M) as likelihoods of the
273
+ # basis components: L_ik = sum_q L_iq M_qk (DECISIONS #76)
274
+ norm, extra = _normalise_linear(norm @ M)
275
+ return norm, log_scale + extra
276
+
277
+
278
+ def _accepts_basis(within_bins: bool, exact: Callable[[dict], bool] | None = None) -> Callable:
279
+ """Let a constructor take a :class:`Basis` for ``bins``.
280
+
281
+ The constructor runs on the basis's quadrature cells when the input has
282
+ information within bins, on its reporting bins otherwise, and the result
283
+ is moved onto the components with :meth:`Likelihoods.on_basis`; when
284
+ ``exact(arguments)`` holds, the constructor receives the basis itself and
285
+ returns likelihoods of the components. The selection is attached last.
286
+ """
287
+
288
+ def wrap(f: Callable) -> Callable:
289
+ sig = inspect.signature(f)
290
+
291
+ @functools.wraps(f)
292
+ def constructor(cls, *args: Any, **kwargs: Any) -> Likelihoods:
293
+ bound = sig.bind(cls, *args, **kwargs)
294
+ basis = bound.arguments.get("bins")
295
+ if not isinstance(basis, Basis):
296
+ return f(cls, *args, **kwargs)
297
+ sel = bound.arguments.pop("selection", None)
298
+ mode = bound.arguments.pop("selection_mode", None)
299
+ direct = exact is not None and exact(bound.arguments)
300
+ if not direct:
301
+ bound.arguments["bins"] = basis.grid if within_bins else basis.bins
302
+ interim = bound.arguments.get("interim")
303
+ if within_bins and interim is not None:
304
+ dens = Density.coerce(interim, bound.arguments.pop("interim_representation", None))
305
+ if dens._values is not None: # per reporting bin: flat in u within each
306
+ g, k = basis.grid, basis.grid_to_bins
307
+ share = g.u_volumes / basis.bins.u_volumes[k]
308
+ dens = Density.from_masses(dens.masses(basis.bins)[k] * share)
309
+ bound.arguments["interim"] = dens
310
+ lk = f(*bound.args, **bound.kwargs)
311
+ lk = lk._copy(basis=basis) if direct else lk.on_basis(basis)
312
+ return lk.with_selection(sel, mode)
313
+
314
+ return constructor
315
+
316
+ return wrap
317
+
318
+
319
+ class Likelihoods:
320
+ r"""Bin-averaged per-object likelihoods :math:`L_{ik} \propto p(d_i \mid z_i \in B_k)`.
321
+
322
+ This container holds the ``(N, K)`` matrix of likelihoods of every object
323
+ for every bin, together with the :class:`~binference.Bins`, optional object
324
+ weights, an optional selection function, and provenance metadata. Rows
325
+ are stored normalised to a maximum of one (their scales never affect
326
+ inference on the population); the per-row log-scales are kept so that
327
+ absolute log-likelihoods (evidences, cross-validation scores) can be
328
+ recovered.
329
+
330
+ Use the ``from_*`` constructors to convert posteriors (with their interim
331
+ prior), posterior samples, analytic measurement models, response
332
+ matrices or classifier outputs into likelihoods. See :doc:`/guides/inputs/index`
333
+ for the conventions.
334
+
335
+ Parameters
336
+ ----------
337
+ values : array_like or sparse matrix, shape (N, K)
338
+ Non-negative likelihood values. Rows with all zeros are invalid.
339
+ bins : Bins or Basis, optional
340
+ The support. Defaults to ``Bins.categories(K)``. With a
341
+ :class:`~binference.Basis` the values are per reporting bin (the
342
+ likelihood taken as flat within each) and are projected onto its
343
+ components (DECISIONS #76).
344
+ representation : {"density", "mass"}
345
+ Values per unit volume (``"density"``) or integrated over each bin
346
+ (``"mass"``). ``"density"`` (default): each value is the likelihood evaluated in (or
347
+ averaged over) the bin. ``"mass"``: each value is the likelihood
348
+ *integrated* over the bin (e.g. CDF differences); it is divided by the
349
+ bin volume. The distinction only matters for unequal bin volumes.
350
+ weights : array_like, shape (N,), optional
351
+ Positive integer multiplicities: an object with weight 3 counts as three
352
+ identical objects (as produced by :meth:`compress`).
353
+ fractional_weights : bool
354
+ Allow non-integer weights. They define a weighted pseudo-likelihood,
355
+ not the likelihood of any data set, so posteriors from it are not
356
+ proper posteriors; a warning says so (DECISIONS #1).
357
+ selection : array_like (K,), callable or Selection, optional
358
+ Detection probability (or exposure) per bin, used by models unless
359
+ overridden; see :doc:`/guides/selection`.
360
+ selection_mode : {"on_measurement", "on_truth"}, optional
361
+ Whether the selection acts on the measurement or on the truth; required with
362
+ an array or callable ``selection``. ``"on_measurement"``: objects are
363
+ selected on their measured data (or the likelihoods already include the
364
+ detection probability); ``"on_truth"``: they are detected with a
365
+ probability that depends on their true value. See
366
+ :class:`~binference.selection.Selection`.
367
+ drop_empty : bool
368
+ Drop all-zero rows with a warning instead of raising.
369
+ log : bool
370
+ If True, ``values`` are log-likelihoods (``-inf`` allowed).
371
+
372
+ Examples
373
+ --------
374
+ >>> import numpy as np, binference
375
+ >>> bins = binference.Bins.linear(0, 1, 4)
376
+ >>> lk = binference.Likelihoods(np.array([[1, 2, 1, 0], [0, 1, 3, 1.0]]), bins)
377
+ >>> lk.N, lk.K
378
+ (2, 4)
379
+ """
380
+
381
+ def __init__(
382
+ self,
383
+ values: Any,
384
+ bins: Bins | None = None,
385
+ *,
386
+ representation: Representation = "density",
387
+ weights: Any = None,
388
+ selection: Any = None,
389
+ selection_mode: str | None = None,
390
+ drop_empty: bool = False,
391
+ log: bool = False,
392
+ fractional_weights: bool = False,
393
+ ) -> None:
394
+ if representation not in ("density", "mass"):
395
+ raise ValueError(
396
+ f"representation must be 'density' or 'mass'; got {representation!r}."
397
+ )
398
+ meta: dict[str, Any] = {"source": "values", "representation": representation}
399
+ if log:
400
+ norm, log_scale = _normalise_log(values)
401
+ else:
402
+ norm, log_scale = _normalise_linear(values)
403
+ N, K = norm.shape
404
+ basis = bins if isinstance(bins, Basis) else None
405
+ bins = Bins.categories(K) if bins is None else bins if basis is None else basis.bins
406
+ if not isinstance(bins, Bins):
407
+ raise TypeError("bins must be a binference.Bins or Basis instance.")
408
+ if bins.K != K:
409
+ raise ValueError(f"values have K={K} columns but bins have K={bins.K}.")
410
+ if representation == "mass":
411
+ vol = np.asarray(bins.volumes, dtype=float)
412
+ norm = _scale_columns(norm, 1.0 / vol)
413
+ norm, extra = _normalise_linear(norm)
414
+ log_scale = log_scale + extra
415
+ weights = _check_weights(weights, N, fractional_weights)
416
+ if basis is not None:
417
+ norm, log_scale = _project_values(norm, log_scale, basis.matrix)
418
+ bins = basis.components
419
+ sel = as_selection(selection, selection_mode, bins if basis is None else basis)
420
+ self._init_from_parts(norm, log_scale, bins, weights, sel, meta, drop_empty, basis)
421
+
422
+ # ------------------------------------------------------------- internals
423
+ def _init_from_parts(
424
+ self, norm, log_scale, bins, weights, selection, meta, drop_empty=False, basis=None
425
+ ):
426
+ empty = ~np.isfinite(log_scale)
427
+ if np.any(empty):
428
+ bad = np.flatnonzero(empty)
429
+ if not drop_empty:
430
+ raise ValueError(
431
+ f"{bad.size} object(s) have zero likelihood in every bin (first: {bad[:5]}). "
432
+ "Check that the bins cover the data, or pass drop_empty=True."
433
+ )
434
+ warn(f"Dropping {bad.size} object(s) with zero likelihood in every bin.", InputWarning)
435
+ keep = ~empty
436
+ norm = _take_rows(norm, keep)
437
+ log_scale = log_scale[keep]
438
+ weights = weights[keep]
439
+ meta = _slice_per_object({**meta, "dropped": bad}, keep)
440
+ if norm.shape[0] == 0:
441
+ raise ValueError("Likelihoods need at least one object.")
442
+ self._values = frozen(norm)
443
+ self._log_scale = _read_only(log_scale)
444
+ self._bins = bins
445
+ self._weights = _read_only(weights)
446
+ if selection is not None and not isinstance(selection, Selection):
447
+ raise TypeError("internal error: selection must be a Selection object")
448
+ self._selection = selection
449
+ self._basis = basis
450
+ self.meta = dict(meta)
451
+
452
+ def __setstate__(self, state: dict[str, Any]) -> None:
453
+ # pickling and deepcopy rebuild writable arrays: freeze them again
454
+ self.__dict__.update(state)
455
+ for name in ("_values", "_log_scale", "_weights"):
456
+ frozen(getattr(self, name))
457
+
458
+ def _copy(self, **changes: Any) -> Likelihoods:
459
+ # a new object sharing every part not in ``changes``
460
+ parts = {
461
+ "norm": self._values,
462
+ "log_scale": self._log_scale,
463
+ "bins": self._bins,
464
+ "weights": self._weights,
465
+ "selection": self._selection,
466
+ "meta": self.meta,
467
+ "basis": self._basis,
468
+ }
469
+ obj = Likelihoods.__new__(Likelihoods)
470
+ obj._init_from_parts(**{**parts, **changes})
471
+ return obj
472
+
473
+ # ------------------------------------------------------------ properties
474
+ @property
475
+ def values(self):
476
+ """Row-normalised likelihood matrix (dense ndarray or sparse CSR array)."""
477
+ return self._values
478
+
479
+ @property
480
+ def log_scale(self) -> np.ndarray:
481
+ """Per-object log normalisation: ``log L_ik = log values_ik + log_scale_i``."""
482
+ return self._log_scale
483
+
484
+ @property
485
+ def basis(self) -> Basis | None:
486
+ """The :class:`~binference.Basis` whose components the columns are, or ``None``.
487
+
488
+ With a basis, :attr:`bins` are its components and ``basis.bins`` the
489
+ reporting bins.
490
+ """
491
+ return self._basis
492
+
493
+ @property
494
+ def bins(self) -> Bins:
495
+ """The :class:`~binference.Bins` the likelihoods are defined over."""
496
+ return self._bins
497
+
498
+ @property
499
+ def weights(self) -> np.ndarray:
500
+ """Per-object weights ``(N,)``."""
501
+ return self._weights
502
+
503
+ @property
504
+ def selection(self) -> Selection | None:
505
+ """Selection function attached to the data, if any.
506
+
507
+ See :class:`~binference.Selection`.
508
+ """
509
+ return self._selection
510
+
511
+ @property
512
+ def N(self) -> int:
513
+ """Number of objects (rows)."""
514
+ return int(self._values.shape[0])
515
+
516
+ @property
517
+ def K(self) -> int:
518
+ """Number of bins (columns)."""
519
+ return int(self._values.shape[1])
520
+
521
+ @property
522
+ def W(self) -> float:
523
+ """Total weight ``sum_i w_i`` (the effective number of objects)."""
524
+ return float(self._weights.sum())
525
+
526
+ @property
527
+ def is_sparse(self) -> bool:
528
+ """Whether values are stored as a sparse CSR array."""
529
+ return sp.issparse(self._values)
530
+
531
+ @property
532
+ def nnz(self) -> int:
533
+ """Number of stored (non-zero) entries."""
534
+ if self.is_sparse:
535
+ return int(self._values.nnz)
536
+ return int(np.count_nonzero(self._values))
537
+
538
+ def __len__(self) -> int:
539
+ return self.N
540
+
541
+ @property
542
+ def shape(self) -> tuple[int, int]:
543
+ """``(N, K)``; with ``len`` and row indexing, it lets scikit-learn split objects."""
544
+ return (self.N, self.K)
545
+
546
+ def __repr__(self) -> str:
547
+ store = "sparse" if self.is_sparse else "dense"
548
+ extra = "" if self._selection is None else f", selection={self._selection.mode!r}"
549
+ w = "" if np.all(self._weights == 1) else f", W={self.W:g}"
550
+ where = f"bins={self._bins!r}" if self._basis is None else f"basis={self._basis!r}"
551
+ return f"Likelihoods(N={self.N}, K={self.K}, {store}{w}{extra}, {where})"
552
+
553
+ def __getitem__(self, idx: Any) -> Likelihoods:
554
+ return self.subset(idx)
555
+
556
+ # --------------------------------------------------------------- methods
557
+ def toarray(self) -> np.ndarray:
558
+ """Dense copy of the row-normalised values."""
559
+ if self.is_sparse:
560
+ return self._values.toarray()
561
+ return np.array(self._values)
562
+
563
+ def to_dense(self) -> Likelihoods:
564
+ """Copy with dense storage."""
565
+ return self._copy(norm=self.toarray())
566
+
567
+ def to_sparse(self, rtol: float = 0.0) -> Likelihoods:
568
+ """Copy with sparse CSR storage, dropping entries below ``rtol`` (relative to row max).
569
+
570
+ Parameters
571
+ ----------
572
+ rtol : float, optional
573
+ Entries below this fraction of their row's maximum are dropped; in
574
+ ``[0, 1]``, and the maximum itself is always kept.
575
+ """
576
+ if not 0.0 <= rtol <= 1.0:
577
+ raise ValueError(f"rtol must lie in [0, 1]; got {rtol}.")
578
+ M = sp.csr_array(self._values, copy=True) # never touch this object's storage
579
+ if rtol > 0:
580
+ M.data[M.data < rtol] = 0.0
581
+ M.eliminate_zeros()
582
+ return self._copy(norm=M)
583
+
584
+ def subset(self, idx: Any) -> Likelihoods:
585
+ """Rows ``idx`` (any NumPy index) as a new object.
586
+
587
+ Parameters
588
+ ----------
589
+ idx : int, slice, array_like of int or bool
590
+ Rows to keep.
591
+ """
592
+ rows = np.arange(self.N)[idx]
593
+ rows = np.atleast_1d(rows)
594
+ vals = self._values[rows]
595
+ return self._copy(
596
+ norm=vals,
597
+ log_scale=self._log_scale[rows],
598
+ weights=self._weights[rows],
599
+ meta=_slice_per_object({**self.meta, "subset_of": self.N}, rows),
600
+ )
601
+
602
+ def with_weights(self, weights: Any, fractional_weights: bool = False) -> Likelihoods:
603
+ """Copy with new object weights.
604
+
605
+ Parameters
606
+ ----------
607
+ weights : array_like (N,)
608
+ Integer multiplicity of each object.
609
+ fractional_weights : bool, optional
610
+ Accept non-integer weights, which give a pseudo-likelihood.
611
+ """
612
+ return self._copy(weights=_check_weights(weights, self.N, fractional_weights))
613
+
614
+ def with_selection(self, selection: Any, selection_mode: str | None = None) -> Likelihoods:
615
+ """Copy with a (new) selection function attached.
616
+
617
+ Parameters
618
+ ----------
619
+ selection : array_like (K,), callable, Selection or None
620
+ Detection probability or exposure per bin; ``None`` removes the selection.
621
+ selection_mode : {"on_measurement", "on_truth"}, optional
622
+ Whether the new selection acts on the measurement or on the truth; required
623
+ with an array or callable ``selection``.
624
+ """
625
+ sel = as_selection(selection, selection_mode, self._basis or self._bins)
626
+ return self._copy(selection=sel)
627
+
628
+ def on_basis(self, basis: Basis) -> Likelihoods:
629
+ """These likelihoods, on ``basis.bins`` or its quadrature cells ``basis.grid``,
630
+ as likelihoods of the components of ``basis``.
631
+
632
+ The likelihood is taken as flat within each of its bins, so
633
+ :math:`L_{ik} = \\sum_q L_{iq} M_{qk}` with :math:`M` the components'
634
+ masses in those bins; constructors that know the likelihood within bins
635
+ (:meth:`from_gaussian`, :meth:`from_density`, :meth:`from_samples`) do
636
+ this on the quadrature cells when given the :class:`~binference.Basis`
637
+ directly. A selection is carried over to the components.
638
+
639
+ Parameters
640
+ ----------
641
+ basis : Basis
642
+ Basis whose ``bins`` or ``grid`` these likelihoods are on.
643
+ """
644
+ if not isinstance(basis, Basis):
645
+ raise TypeError("basis must be a binference.Basis instance.")
646
+ if self._basis is not None:
647
+ raise ValueError("These likelihoods are already on a basis.")
648
+ if self._bins == basis.bins:
649
+ M = basis.matrix
650
+ elif self._bins == basis.grid:
651
+ M = basis.grid_masses
652
+ else:
653
+ raise ValueError("The basis must be built on these likelihoods' bins.")
654
+ norm, log_scale = _project_values(self._values, self._log_scale, M)
655
+ sel = None if self._selection is None else _on_basis(self._selection, basis, M)
656
+ meta = {k: v for k, v in self.meta.items() if k != "interim_prior_masses"}
657
+ return self._copy(
658
+ norm=norm,
659
+ log_scale=log_scale,
660
+ bins=basis.components,
661
+ selection=sel,
662
+ meta=meta,
663
+ basis=basis,
664
+ )
665
+
666
+ def compress(self, decimals: int | None = None) -> Likelihoods:
667
+ """Merge identical rows into weighted rows.
668
+
669
+ Rows are compared after row normalisation, optionally rounded to
670
+ ``decimals``. Weights add and log-scales are combined, so total absolute
671
+ log-likelihoods are preserved exactly when ``decimals`` is None; with
672
+ rounding, each group is represented by its first row. Inference on the
673
+ population is unchanged; Gibbs sampling then draws multinomial counts
674
+ per unique row. Sparse storage stays sparse. Always returns a new object.
675
+
676
+ Parameters
677
+ ----------
678
+ decimals : int, optional
679
+ Round rows to this many decimals before merging; ``None`` merges exact
680
+ duplicates.
681
+ """
682
+ V = self._values
683
+ if sp.issparse(V):
684
+ V = sp.csr_array(V)
685
+ K = V
686
+ if decimals is not None: # entries that round to zero leave the key, as when dense
687
+ K = V.copy()
688
+ K.data = np.round(K.data, decimals)
689
+ K.eliminate_zeros()
690
+ keys = [
691
+ (K.indices[a:b].tobytes(), K.data[a:b].tobytes())
692
+ for a, b in zip(K.indptr[:-1], K.indptr[1:], strict=True)
693
+ ]
694
+ else:
695
+ key = V if decimals is None else np.round(V, decimals)
696
+ keys = [row.tobytes() for row in np.ascontiguousarray(key)]
697
+ first, inverse = _group_rows(keys)
698
+ n = first.size
699
+ w = np.bincount(inverse, weights=self._weights, minlength=n)
700
+ ls = np.bincount(inverse, weights=self._weights * self._log_scale, minlength=n)
701
+ meta = {k: v for k, v in self.meta.items() if k not in _PER_OBJECT_KEYS}
702
+ meta["compressed_from"] = self.N
703
+ return self._copy(norm=V[first], log_scale=ls / w, weights=w, meta=meta)
704
+
705
+ def stack(
706
+ self,
707
+ interim: InterimPrior | None = None,
708
+ interim_representation: Representation | None = None,
709
+ ) -> np.ndarray:
710
+ """Naive "stacked" population estimate: the weighted mean of per-object posteriors.
711
+
712
+ Posteriors are formed under ``interim``: by default the interim
713
+ prior the likelihoods were built with (so the stack reproduces the
714
+ input posteriors), else :meth:`Density.flat`. Stacking is exactly one
715
+ EM step from the interim prior, not the maximum-likelihood population;
716
+ it is biased toward the interim prior and broadened by the measurement
717
+ noise, and is provided for comparison only. Its distance from the
718
+ optimum is ``PopulationModel(lk).kkt_gap(lk.stack())``. With a
719
+ :class:`~binference.Basis` it is over the components (map it with
720
+ ``lk.basis.to_bins``), under a flat interim prior unless ``interim``
721
+ is given.
722
+
723
+ Parameters
724
+ ----------
725
+ interim : Density, "flat", callable, distribution or array_like (K,), optional
726
+ Prior the stacked posteriors are formed under; ``None`` uses the input's
727
+ interim prior.
728
+ interim_representation : {"mass", "density"}, optional
729
+ Whether an array ``interim`` holds masses or densities.
730
+ """
731
+ if interim is None:
732
+ Pi = self.meta.get("interim_prior_masses")
733
+ if Pi is None:
734
+ Pi = Density.flat().masses(self._bins)
735
+ else:
736
+ Pi = _interim_masses(interim, self._bins, interim_representation)
737
+ P = _scale_columns(self._values, Pi)
738
+ tot = np.asarray(P.sum(axis=1)).ravel()
739
+ if np.any(tot <= 0):
740
+ rows = np.flatnonzero(tot <= 0)[:5].tolist()
741
+ raise ValueError(
742
+ f"Rows {rows} have no support where the interim prior is positive, so their "
743
+ "posteriors are undefined."
744
+ )
745
+ P = (sp.diags_array(1.0 / tot) @ P) if sp.issparse(P) else P / tot[:, None]
746
+ out = np.asarray(P.T @ self._weights).ravel()
747
+ return out / out.sum()
748
+
749
+ def log_evidence_terms(self, rho: np.ndarray) -> np.ndarray:
750
+ r"""Per-object absolute log marginal likelihoods :math:`\log(L_i\cdot\rho)`, unweighted.
751
+
752
+ Parameters
753
+ ----------
754
+ rho : ndarray (K,)
755
+ Population fractions.
756
+ """
757
+ s = np.asarray(self._values @ np.asarray(rho, dtype=float)).ravel()
758
+ with np.errstate(divide="ignore"):
759
+ return np.log(s) + self._log_scale
760
+
761
+ # ----------------------------------------------------------- constructors
762
+ @classmethod
763
+ def from_log(cls, log_values: Any, bins: Bins | None = None, **kwargs: Any) -> Likelihoods:
764
+ """Build from log-likelihoods (``-inf`` marks zero likelihood).
765
+
766
+ Parameters
767
+ ----------
768
+ log_values : array_like, shape (N, K)
769
+ Log-likelihoods.
770
+ bins : Bins or Basis, optional
771
+ The support; ``None`` gives categories.
772
+ **kwargs
773
+ Further arguments of :class:`Likelihoods`.
774
+ """
775
+ lk = cls(log_values, bins, log=True, **kwargs)
776
+ lk.meta["source"] = "log_values"
777
+ return lk
778
+
779
+ @classmethod
780
+ @_accepts_basis(within_bins=False)
781
+ def from_table(
782
+ cls,
783
+ values: Any,
784
+ bins: Bins,
785
+ *,
786
+ kind: Literal["likelihood"] | None = None,
787
+ interim: InterimPrior | None = None,
788
+ representation: Representation | None = None,
789
+ interim_representation: Representation | None = None,
790
+ log: bool = False,
791
+ weights: Any = None,
792
+ selection: Any = None,
793
+ selection_mode: str | None = None,
794
+ drop_empty: bool = False,
795
+ fractional_weights: bool = False,
796
+ ) -> Likelihoods:
797
+ r"""Likelihoods from a table of per-object values on the bins.
798
+
799
+ Say what the table holds: ``kind="likelihood"`` for likelihoods, or
800
+ ``interim=`` for posteriors computed under that interim prior. Posteriors
801
+ are converted with :math:`L_{ik} \propto P_{ik} / \Pi_k`, where
802
+ :math:`P_{ik}` is the posterior mass of object *i* in bin *k* and
803
+ :math:`\Pi_k` the interim prior's mass; using posteriors as likelihoods
804
+ would count the interim prior twice.
805
+
806
+ Parameters
807
+ ----------
808
+ values : array_like or sparse matrix, shape (N, K)
809
+ Non-negative values (rows need not be normalised).
810
+ bins : Bins or Basis
811
+ The bins the values are tabulated on.
812
+ With a :class:`~binference.Basis`, likelihoods of its components
813
+ (DECISIONS #76).
814
+ kind : {"likelihood"}, optional
815
+ The values are likelihoods. Exactly one of ``kind`` and ``interim``.
816
+ interim : Density, "flat", callable, distribution or array_like (K,), optional
817
+ The prior the posteriors were computed with, read by
818
+ :meth:`Density.coerce`.
819
+ representation : {"mass", "density"}, optional
820
+ Values per bin (``"mass"``) or at or averaged over the bin
821
+ (``"density"``). Defaults to ``"density"`` for likelihoods and
822
+ ``"mass"`` for posteriors, their usual forms.
823
+ interim_representation : {"mass", "density"}, optional
824
+ How an array ``interim`` is read. Required for arrays on bins of
825
+ unequal volume, where the two readings differ.
826
+ log : bool, optional
827
+ The values are logarithms (``-inf`` allowed; dense only).
828
+ weights : array_like (N,), optional
829
+ Integer multiplicity of each object; ``None`` gives every object weight one.
830
+ selection : array_like (K,), callable or Selection, optional
831
+ Detection probability or exposure per bin; ``None`` means no selection.
832
+ selection_mode : {"on_measurement", "on_truth"}, optional
833
+ Whether the selection acts on the measurement or on the truth; required with
834
+ an array or callable ``selection``.
835
+ drop_empty : bool, optional
836
+ Drop objects with zero likelihood in every bin, with a warning, instead of
837
+ raising.
838
+ fractional_weights : bool, optional
839
+ Accept non-integer weights, which give a pseudo-likelihood.
840
+ """
841
+ posterior = _check_kind(kind, interim)
842
+ if representation is None:
843
+ representation = "mass" if posterior else "density"
844
+ if representation not in ("mass", "density"):
845
+ raise ValueError(
846
+ f"representation must be 'mass' or 'density'; got {representation!r}."
847
+ )
848
+ tail = _common(weights, selection, selection_mode, drop_empty, fractional_weights)
849
+ if not posterior:
850
+ lk = cls(values, bins, representation=representation, log=log, **tail)
851
+ lk.meta["source"] = "table"
852
+ return lk
853
+ P, _ = _normalise_log(values) if log else _normalise_linear(values)
854
+ if P.shape[1] != bins.K:
855
+ raise ValueError(f"values have {P.shape[1]} columns but bins have K={bins.K}.")
856
+ vol = np.asarray(bins.volumes, dtype=float)
857
+ if not log: # the check reads row sums, so it needs the values at their own scale
858
+ raw = values if sp.issparse(values) else np.asarray(values, dtype=float)
859
+ _check_posterior_representation(raw, vol, representation)
860
+ if representation == "density":
861
+ P = _scale_columns(P, vol)
862
+ Pi = _interim_masses(interim, bins, interim_representation)
863
+ lk = cls(_divide_interim(P, Pi, "Posteriors"), bins, **tail)
864
+ lk.meta.update(source="posteriors", representation=representation, interim_prior_masses=Pi)
865
+ return lk
866
+
867
+ @classmethod
868
+ @_accepts_basis(within_bins=True)
869
+ def from_samples(
870
+ cls,
871
+ samples: Any,
872
+ bins: Bins,
873
+ interim: InterimPrior,
874
+ *,
875
+ sample_weights: Any = None,
876
+ interim_representation: Representation | None = None,
877
+ weights: Any = None,
878
+ selection: Any = None,
879
+ selection_mode: str | None = None,
880
+ sparse: bool | None = None,
881
+ drop_empty: bool = False,
882
+ fractional_weights: bool = False,
883
+ min_samples: int = 20,
884
+ floor: float = 0.0,
885
+ keep_samples: bool = True,
886
+ warn_outside: bool = True,
887
+ ) -> Likelihoods:
888
+ r"""Build likelihoods from per-object posterior samples (DECISIONS #31).
889
+
890
+ One Monte Carlo estimator, :math:`\hat L_{ik} \propto \frac1S \sum_s
891
+ w_s\,\mathbb 1[z_s \in B_k]\,T'(z_s) / \pi(z_s)`, averaged over each bin
892
+ in the measure coordinate:
893
+
894
+ * when the interim prior is known only per bin (``"flat"``, masses or
895
+ densities) this is the weighted count divided by the interim mass,
896
+ :math:`L_{ik} \propto c_{ik} / \Pi_k`;
897
+ * when it can be evaluated at points (a function or a distribution) each
898
+ sample is weighted by :math:`T'(z_s)/\pi(z_s)`, which is exact when the
899
+ interim prior varies within bins.
900
+
901
+ Samples drawn from a likelihood are samples under ``Density.flat("z")``.
902
+ The per-object effective number of samples inside the bins and the
903
+ fraction outside are stored in ``meta``; the sums behind the estimator
904
+ are kept so that :meth:`sample_variance` can measure its Monte Carlo
905
+ error at a fitted population.
906
+
907
+ Parameters
908
+ ----------
909
+ samples : array_like or sequence of arrays
910
+ For 1-D bins: an ``(N, S)`` array or a list of ``N`` 1-D arrays
911
+ (ragged allowed). For N-D grids: ``(N, S, ndim)`` or a list of
912
+ ``(S_i, ndim)`` arrays.
913
+ bins : Bins or Basis
914
+ Target bins (grids only).
915
+ With a :class:`~binference.Basis`, likelihoods of its components
916
+ (DECISIONS #76).
917
+ interim : Density, "flat", callable, distribution or array_like (K,)
918
+ Prior under which the samples were drawn, read by
919
+ :meth:`Density.coerce`. There is deliberately no default: samples
920
+ drawn under an informative prior must have it divided out.
921
+ interim_representation : {"mass", "density"}, optional
922
+ How an array ``interim`` is read (see :meth:`from_table`).
923
+ sample_weights : array_like or sequence of arrays, optional
924
+ Importance weights for the samples (same layout as ``samples``).
925
+ sparse : bool, optional
926
+ Store the result sparsely. Defaults to sparse when fewer than 10%
927
+ of entries are non-zero (``_kernels.SPARSE_FILL``; DECISIONS #77).
928
+ min_samples : int
929
+ Warn if more than 10% of the objects have fewer effective samples
930
+ inside the bins than this (audit I-23).
931
+ floor : float
932
+ Pseudo-samples added to every object, spread over the bins in
933
+ proportion to the interim prior: a Dirichlet floor that keeps bins no
934
+ sample reached from having zero likelihood. Off by default.
935
+ keep_samples : bool
936
+ Keep the samples inside the bins and their weights, so that
937
+ :meth:`PopulationResult.sample_values` resamples them within the drawn
938
+ bin (DECISIONS #50); ``False`` saves that copy of the samples.
939
+ warn_outside : bool
940
+ Warn when many objects have most of their samples outside the bins
941
+ (see :meth:`from_gaussian`).
942
+ weights : array_like (N,), optional
943
+ Integer multiplicity of each object; ``None`` gives every object weight one.
944
+ selection : array_like (K,), callable or Selection, optional
945
+ Detection probability or exposure per bin; ``None`` means no selection.
946
+ selection_mode : {"on_measurement", "on_truth"}, optional
947
+ Whether the selection acts on the measurement or on the truth; required with
948
+ an array or callable ``selection``.
949
+ drop_empty : bool, optional
950
+ Drop objects with zero likelihood in every bin, with a warning, instead of
951
+ raising.
952
+ fractional_weights : bool, optional
953
+ Accept non-integer weights, which give a pseudo-likelihood.
954
+ """
955
+ if bins.is_categorical:
956
+ raise ValueError("from_samples needs ordered (grid) bins.")
957
+ if not floor >= 0:
958
+ raise ValueError(f"floor must be non-negative; got {floor}.")
959
+ flat, obj_ids, idx, sw, n_obj = _bin_samples(samples, bins, sample_weights)
960
+ density = Density.coerce(interim, interim_representation)
961
+ Pi = density.masses(bins)
962
+ du = bins.u_volumes
963
+ inside = idx >= 0
964
+ rows, cols, sw_in = obj_ids[inside], idx[inside], sw[inside]
965
+ # weight each sample by T'(z) / pi(z); a per-bin prior is flat in u within bins
966
+ log_pi = density._log_pointwise()
967
+ if log_pi is None:
968
+ with np.errstate(divide="ignore"):
969
+ log_pi_u = np.log(Pi[cols] / du[cols])
970
+ else:
971
+ zz = flat[inside].reshape(-1, bins.ndim)
972
+ log_pi_u = np.asarray(log_pi(zz[:, 0] if bins.ndim == 1 else zz), dtype=float).ravel()
973
+ log_pi_u -= sum(m.log_jacobian(zz[:, d]) for d, m in enumerate(bins.measures))
974
+ if not np.all(np.isfinite(log_pi_u)):
975
+ raise ValueError("Samples fall where the interim density is zero or undefined.")
976
+ # rows are rescaled to counts below, so shift each object's log weights to max 0
977
+ shift = np.full(n_obj, -np.inf)
978
+ np.maximum.at(shift, rows, -log_pi_u)
979
+ w = sw_in * np.exp(-log_pi_u - shift[rows])
980
+ # sum the weights and their squares per (object, bin) entry with one sort
981
+ entry, inv = np.unique(rows * bins.K + cols, return_inverse=True)
982
+ s1, s2 = np.bincount(inv, weights=w), np.bincount(inv, weights=w**2)
983
+ er, ec = np.divmod(entry, bins.K)
984
+ shape = (n_obj, bins.K)
985
+ n_samples = np.bincount(obj_ids, weights=sw, minlength=n_obj)
986
+ inside_w = np.bincount(rows, weights=sw_in, minlength=n_obj)
987
+ outside = 1.0 - inside_w / np.where(n_samples > 0, n_samples, 1.0)
988
+ n_eff = _ess(
989
+ np.bincount(er, weights=s1, minlength=n_obj),
990
+ np.bincount(er, weights=s2, minlength=n_obj),
991
+ )
992
+ # scale each row to counts: sum_k S1_k Pi_k = number of samples inside, so the
993
+ # floor is in pseudo-samples whatever the scale of sample_weights
994
+ S1 = sp.csr_array((s1 / du[ec], (er, ec)), shape=shape)
995
+ tot = S1 @ Pi
996
+ scale = np.bincount(rows, minlength=n_obj) / np.where(tot > 0, tot, 1.0)
997
+ S1 = sp.diags_array(scale) @ S1
998
+ S2 = sp.diags_array(scale**2) @ sp.csr_array((s2 / du[ec] ** 2, (er, ec)), shape=shape)
999
+ est = S1 if floor == 0 else S1.toarray() + floor # proportional to the bin averages
1000
+ if sparse is None:
1001
+ sparse = floor == 0 and S1.nnz < kern.SPARSE_FILL * n_obj * bins.K
1002
+ values = sp.csr_array(est) if sparse else (est.toarray() if sp.issparse(est) else est)
1003
+ short = n_eff < min_samples
1004
+ if short.mean() > FEW_SAMPLES_SHARE:
1005
+ warn(
1006
+ f"{int(short.sum())} of {n_obj} objects have fewer than {min_samples} effective "
1007
+ "samples inside the bins; their likelihoods will be noisy (more samples, wider "
1008
+ "bins or floor= help).",
1009
+ InputWarning,
1010
+ )
1011
+ lk = cls(
1012
+ values,
1013
+ bins,
1014
+ weights=weights,
1015
+ selection=selection,
1016
+ selection_mode=selection_mode,
1017
+ drop_empty=drop_empty,
1018
+ fractional_weights=fractional_weights,
1019
+ )
1020
+ lk.meta.update(source="samples", interim_prior_masses=Pi)
1021
+ _attach_per_object(
1022
+ lk,
1023
+ n_eff=n_eff,
1024
+ sample_sum=S1,
1025
+ sample_sq=S2,
1026
+ sample_count=np.bincount(obj_ids, minlength=n_obj),
1027
+ )
1028
+ lk.meta["sample_floor"] = float(floor)
1029
+ if keep_samples: # sorted by object and bin, each object's weights summing to one
1030
+ key = rows.astype(np.int64) * bins.K + cols
1031
+ order = np.argsort(key, kind="stable")
1032
+ tot = np.bincount(rows, weights=w, minlength=n_obj)
1033
+ share = np.divide(w, tot[rows], out=np.zeros_like(w), where=tot[rows] > 0)
1034
+ G = np.cumsum(share[order])
1035
+ zs = flat[inside].reshape(-1, bins.ndim)[order]
1036
+ _keep_within(lk, n_obj, "samples", bins.K, key[order], zs, G)
1037
+ _record_outside(lk, outside, warn_outside)
1038
+ return lk
1039
+
1040
+ def sample_variance(self, rho: Any) -> tuple[np.ndarray, np.ndarray]:
1041
+ r"""Monte Carlo error of likelihoods built by :meth:`from_samples`, at fractions ``rho``.
1042
+
1043
+ Returns the per-object relative variance :math:`V_i(\rho)` of the
1044
+ estimate of :math:`L_i\cdot\rho` and the effective number of samples
1045
+ under ``rho``. Summed over objects, :math:`\sum_i V_i` is about the bias
1046
+ it puts in the total log-likelihood (Jensen); values above about one
1047
+ call for more samples (DECISIONS #31).
1048
+
1049
+ Parameters
1050
+ ----------
1051
+ rho : array_like (K,)
1052
+ Population fractions.
1053
+ """
1054
+ if "sample_sum" not in self.meta:
1055
+ raise ValueError("sample_variance needs likelihoods built by from_samples.")
1056
+ a = np.asarray(rho, dtype=float).ravel()
1057
+ if a.shape != (self.K,):
1058
+ raise ValueError(f"rho must have length K={self.K}.")
1059
+ if self._basis is not None: # back to the cells the samples were binned on
1060
+ b = self._basis
1061
+ on_grid = self.meta["sample_sum"].shape[1] == b.grid.K
1062
+ a = (b.grid_masses if on_grid else b.matrix) @ a
1063
+ m = np.asarray(self.meta["sample_sum"] @ a).ravel()
1064
+ q = np.asarray(self.meta["sample_sq"] @ a**2).ravel()
1065
+ est = (
1066
+ m + self.meta.get("sample_floor", 0.0) * a.sum()
1067
+ ) # the floor carries no sampling error
1068
+ with np.errstate(divide="ignore", invalid="ignore"):
1069
+ var = np.where(est > 0, (q - m**2 / self.meta["sample_count"]) / est**2, np.inf)
1070
+ return np.maximum(var, 0.0), _ess(m, q)
1071
+
1072
+ @classmethod
1073
+ @_accepts_basis(
1074
+ within_bins=True,
1075
+ # independent axes on an unmasked grid: projected one axis at a time
1076
+ exact=lambda a: a.get("cov") is None and not a["bins"].bins.is_masked,
1077
+ )
1078
+ def from_gaussian(
1079
+ cls,
1080
+ mean: Any,
1081
+ sigma: Any = None,
1082
+ bins: Bins | None = None,
1083
+ *,
1084
+ cov: Any = None,
1085
+ weights: Any = None,
1086
+ selection: Any = None,
1087
+ selection_mode: str | None = None,
1088
+ drop_empty: bool = False,
1089
+ fractional_weights: bool = False,
1090
+ tol: float = 1e-3,
1091
+ max_depth: int = 12,
1092
+ warn_outside: bool = True,
1093
+ ) -> Likelihoods:
1094
+ r"""Likelihoods for Gaussian measurements :math:`d_i \sim \mathcal N(z, \sigma_i^2)`.
1095
+
1096
+ Bin averages are exact CDF differences (stable in the far tails) on
1097
+ axes with a linear measure; on other axes the likelihood is averaged
1098
+ uniformly in the measure coordinate by adaptive quadrature to ``tol``
1099
+ with at most ``max_depth`` refinements (see :meth:`from_density`). For
1100
+ N-D grids, measurement errors are independent per axis (``mean`` and
1101
+ ``sigma`` of shape ``(N, ndim)``) unless ``cov`` gives each object's full
1102
+ covariance; correlated Gaussians are averaged over each bin by adaptive
1103
+ cubature in log space, to ``tol``, in any number of dimensions (DECISIONS #25).
1104
+ Each object's fraction of mass outside the active bins is recorded in
1105
+ ``meta["outside_fraction"]``; ``warn_outside`` warns when more than 10% of
1106
+ the objects have more than 90% of their mass outside.
1107
+
1108
+ Parameters
1109
+ ----------
1110
+ mean : array_like, shape (N,) or (N, ndim)
1111
+ Measured values.
1112
+ sigma : float or array_like, shape (N,) or (N, ndim)
1113
+ Measurement uncertainties (strictly positive).
1114
+ bins : Bins or Basis
1115
+ Grid bins.
1116
+ With a :class:`~binference.Basis`, likelihoods of its components
1117
+ (DECISIONS #76).
1118
+ cov : array_like, shape (ndim, ndim) or (N, ndim, ndim), optional
1119
+ Full covariance matrices (symmetric positive definite), in place of
1120
+ ``sigma``.
1121
+ tol : float, optional
1122
+ Relative tolerance of the adaptive quadrature over each bin.
1123
+ max_depth : int, optional
1124
+ Most refinements of the adaptive quadrature.
1125
+ warn_outside : bool, optional
1126
+ Warn when many objects have most of their likelihood outside the bins.
1127
+ weights : array_like (N,), optional
1128
+ Integer multiplicity of each object; ``None`` gives every object weight one.
1129
+ selection : array_like (K,), callable or Selection, optional
1130
+ Detection probability or exposure per bin; ``None`` means no selection.
1131
+ selection_mode : {"on_measurement", "on_truth"}, optional
1132
+ Whether the selection acts on the measurement or on the truth; required with
1133
+ an array or callable ``selection``.
1134
+ drop_empty : bool, optional
1135
+ Drop objects with zero likelihood in every bin, with a warning, instead of
1136
+ raising.
1137
+ fractional_weights : bool, optional
1138
+ Accept non-integer weights, which give a pseudo-likelihood.
1139
+ """
1140
+ basis = bins if isinstance(bins, Basis) else None
1141
+ if basis is not None: # evaluated on its quadrature cells, returned per component
1142
+ bins = basis.components
1143
+ if not isinstance(bins, Bins):
1144
+ raise TypeError("bins must be a binference.Bins instance.")
1145
+ if bins.is_categorical:
1146
+ raise ValueError("from_gaussian needs ordered (grid) bins.")
1147
+ if (sigma is None) == (cov is None):
1148
+ raise ValueError("Pass exactly one of sigma and cov.")
1149
+ mu = np.asarray(mean, dtype=float)
1150
+ if bins.ndim == 1 and (mu.ndim <= 1 or (mu.ndim == 2 and mu.shape[1] == 1)):
1151
+ mu = mu.reshape(-1, 1)
1152
+ if mu.ndim != 2 or mu.shape[1] != bins.ndim:
1153
+ shapes = "(N,) or (N, 1)" if bins.ndim == 1 else f"(N, {bins.ndim})"
1154
+ raise ValueError(f"mean must have shape {shapes}; got {np.shape(mean)}.")
1155
+ if not np.all(np.isfinite(mu)):
1156
+ raise ValueError("mean must be finite.")
1157
+ tail = _common(weights, selection, selection_mode, drop_empty, fractional_weights)
1158
+ if cov is not None:
1159
+ nd, N = bins.ndim, mu.shape[0]
1160
+ C = np.asarray(cov, dtype=float)
1161
+ if C.shape == (nd, nd):
1162
+ C = np.broadcast_to(C, (N, nd, nd))
1163
+ if C.shape != (N, nd, nd):
1164
+ raise ValueError(
1165
+ f"cov must have shape ({nd}, {nd}) or (N, {nd}, {nd}); got {np.shape(cov)}."
1166
+ )
1167
+ var = np.diagonal(C, axis1=1, axis2=2)
1168
+ if not np.all(np.isfinite(C)) or np.any(var <= 0):
1169
+ raise ValueError("cov must be finite with strictly positive variances.")
1170
+ scale = np.sqrt(var[:, :, None] * var[:, None, :])
1171
+ if np.any(np.abs(C - np.swapaxes(C, 1, 2)) > 1e-10 * scale):
1172
+ raise ValueError("cov must be symmetric for every object.")
1173
+ if np.any(np.abs(C) * (1 - np.eye(nd)) > 1e-12 * scale): # correlated: cubature
1174
+ logL, log_mass = _correlated_gaussian(mu, C, bins, tol, max_depth)
1175
+ lk = cls(logL, bins, log=True, **tail)
1176
+ lk.meta.update(source="gaussian")
1177
+ _attach_per_object(lk, gaussian_mean=mu, gaussian_cov=np.array(C))
1178
+ lk.meta["gaussian_options"] = {"tol": tol, "max_depth": max_depth}
1179
+ _record_outside(lk, _outside(special.logsumexp(log_mass, axis=1)), warn_outside)
1180
+ return lk
1181
+ sigma = np.sqrt(var) # independent axes (correlations below 1e-12): exact
1182
+ sig = np.asarray(sigma, dtype=float)
1183
+ if sig.ndim == 1 and bins.ndim > 1:
1184
+ if sig.shape[0] == mu.shape[0] == bins.ndim:
1185
+ raise ValueError(
1186
+ f"sigma of length {bins.ndim} is ambiguous with N = ndim = {bins.ndim}: pass "
1187
+ "shape (N, 1) for one sigma per object or (1, ndim) for one per axis."
1188
+ )
1189
+ if sig.shape[0] == mu.shape[0]:
1190
+ sig = sig[:, None] # one sigma per object (shared across axes)
1191
+ elif sig.shape[0] == bins.ndim:
1192
+ sig = sig[None, :] # one sigma per axis (shared across objects)
1193
+ else:
1194
+ raise ValueError("sigma has an incompatible shape.")
1195
+ elif sig.ndim == 1:
1196
+ sig = sig[:, None]
1197
+ try:
1198
+ sig = np.broadcast_to(sig, mu.shape)
1199
+ except ValueError:
1200
+ raise ValueError(f"sigma cannot be broadcast to shape {mu.shape}.") from None
1201
+ if not np.all(np.isfinite(sig)) or np.any(sig <= 0):
1202
+ raise ValueError("sigma must be finite and strictly positive.")
1203
+ # per axis: exact log masses, and bin averages (in u on non-linear measures),
1204
+ # summed over the axes of each active bin; with a basis the averages on the
1205
+ # quadrature cells are projected onto the axis's component factors
1206
+ logL = log_mass = 0.0
1207
+ grid = bins if basis is None else basis.grid
1208
+ factors = None if basis is None else basis.axis_masses(grid)
1209
+ for d, (e, m, j, mu_d, sig_d) in enumerate(
1210
+ zip(grid.edges, grid.measures, bins.cell_index, mu.T, sig.T, strict=True)
1211
+ ):
1212
+ x = (e[None, :] - mu_d[:, None]) / sig_d[:, None]
1213
+ lm = log_interval_masses(
1214
+ special.log_ndtr(x), special.log_ndtr(-x), 0.5 * special.erf(x / np.sqrt(2))
1215
+ )
1216
+ if m == Measure.linear():
1217
+ avg = lm - np.log(np.diff(e))[None, :]
1218
+ else:
1219
+ avg = bin_log_average(
1220
+ partial(_log_gauss_rows, mu=mu_d, sig=sig_d),
1221
+ Bins(e, measure=m),
1222
+ rows=mu.shape[0],
1223
+ tol=tol,
1224
+ max_depth=max_depth,
1225
+ )
1226
+ if factors is not None:
1227
+ top = np.max(avg, axis=1, keepdims=True)
1228
+ top = np.where(np.isfinite(top), top, 0.0)
1229
+ with np.errstate(divide="ignore"):
1230
+ avg = np.log(np.asarray(np.exp(avg - top) @ factors[d])) + top
1231
+ lm, j_mass = special.logsumexp(lm, axis=1, keepdims=True), slice(None) # total
1232
+ else:
1233
+ j_mass = j
1234
+ logL = logL + avg[:, j]
1235
+ log_mass = log_mass + lm[:, j_mass]
1236
+ lk = cls(logL, bins, log=True, **tail)
1237
+ lk.meta.update(source="gaussian")
1238
+ # kept so that true values can be drawn from the likelihood within bins
1239
+ _attach_per_object(lk, gaussian_mean=mu, gaussian_sigma=np.array(sig))
1240
+ lk.meta["gaussian_options"] = {"tol": tol, "max_depth": max_depth} # for a rebuild
1241
+ _record_outside(lk, _outside(special.logsumexp(log_mass, axis=1)), warn_outside)
1242
+ return lk
1243
+
1244
+ @classmethod
1245
+ @_accepts_basis(within_bins=True)
1246
+ def from_density(
1247
+ cls,
1248
+ density: Callable[[np.ndarray], np.ndarray] | Sequence[Any],
1249
+ bins: Bins,
1250
+ *,
1251
+ kind: Literal["likelihood"] | None = None,
1252
+ interim: InterimPrior | None = None,
1253
+ interim_representation: Representation | None = None,
1254
+ log: bool = False,
1255
+ tol: float = 1e-3,
1256
+ max_depth: int = 12,
1257
+ n_sub: int | None = None,
1258
+ weights: Any = None,
1259
+ selection: Any = None,
1260
+ selection_mode: str | None = None,
1261
+ drop_empty: bool = False,
1262
+ fractional_weights: bool = False,
1263
+ warn_outside: bool = True,
1264
+ ) -> Likelihoods:
1265
+ """Likelihoods from per-object densities over the bin coordinate.
1266
+
1267
+ ``density`` is either a vectorised function, where ``density(points)``
1268
+ returns an ``(N, M)`` array of values (log-values with ``log=True``) at
1269
+ ``M`` points of shape ``(M,)`` (1-D) or ``(M, ndim)``, or a sequence of
1270
+ distribution objects with ``logpdf``, ``pdf``, ``logcdf`` or ``cdf``
1271
+ (e.g. frozen ``scipy.stats``). Say what they are: ``kind="likelihood"``
1272
+ or ``interim=`` for posteriors, as in :meth:`from_table`.
1273
+
1274
+ Likelihoods are averaged over each bin uniformly in the measure
1275
+ coordinate, the within-bin population shape; posteriors are integrated
1276
+ over each bin in ``z`` and divided by the interim prior's bin masses.
1277
+ On 1-D bins, distributions with a ``cdf`` are integrated exactly where
1278
+ the result is a mass (posteriors, or likelihoods on a linear measure),
1279
+ in log space so that far tails keep their precision.
1280
+
1281
+ Other integrals use adaptive quadrature: each bin is refined until the
1282
+ estimated error is below ``tol`` relative to each row's largest bin
1283
+ average, splitting cells at most ``max_depth`` times; ``n_sub`` instead
1284
+ fixes ``n_sub`` Gauss-Legendre points per bin and axis. The fraction of
1285
+ each distribution's mass outside the active bins is recorded in
1286
+ ``meta["outside_fraction"]`` on 1-D bins (see :meth:`from_gaussian` for
1287
+ ``warn_outside``).
1288
+
1289
+ Parameters
1290
+ ----------
1291
+ density : callable or sequence of distributions
1292
+ Per-object densities of ``z``, as above.
1293
+ bins : Bins or Basis
1294
+ Grid bins.
1295
+ With a :class:`~binference.Basis`, likelihoods of its components.
1296
+ kind : {"likelihood"}, optional
1297
+ The densities are likelihoods. Exactly one of ``kind`` and ``interim``.
1298
+ interim : Density, "flat", callable, distribution or array_like (K,), optional
1299
+ The prior the posteriors were computed with, read by
1300
+ :meth:`Density.coerce`.
1301
+ interim_representation : {"mass", "density"}, optional
1302
+ How an array ``interim`` is read (see :meth:`from_table`).
1303
+ log : bool, optional
1304
+ The function returns logarithms.
1305
+ tol : float, optional
1306
+ Relative tolerance of the adaptive quadrature over each bin.
1307
+ max_depth : int, optional
1308
+ Most refinements of the adaptive quadrature.
1309
+ n_sub : int, optional
1310
+ Fixed Gauss-Legendre points per bin and axis in place of adaptive quadrature.
1311
+ weights : array_like (N,), optional
1312
+ Integer multiplicity of each object; ``None`` gives every object weight one.
1313
+ selection : array_like (K,), callable or Selection, optional
1314
+ Detection probability or exposure per bin; ``None`` means no selection.
1315
+ selection_mode : {"on_measurement", "on_truth"}, optional
1316
+ Whether the selection acts on the measurement or on the truth; required with
1317
+ an array or callable ``selection``.
1318
+ drop_empty : bool, optional
1319
+ Drop objects with zero likelihood in every bin, with a warning, instead of
1320
+ raising.
1321
+ fractional_weights : bool, optional
1322
+ Accept non-integer weights, which give a pseudo-likelihood.
1323
+ warn_outside : bool, optional
1324
+ Warn when many objects have most of their likelihood outside the bins.
1325
+ """
1326
+ if bins.is_categorical:
1327
+ raise ValueError("from_density needs grid bins.")
1328
+ posterior = _check_kind(kind, interim)
1329
+ interim_z = None # read before it is reduced to bin masses below
1330
+ if posterior and bins.ndim == 1:
1331
+ interim_z = _interim_at_points(Density.coerce(interim, interim_representation), bins)
1332
+ quad = {
1333
+ "coordinate": "z" if posterior else "measure",
1334
+ "tol": tol,
1335
+ "max_depth": max_depth,
1336
+ "n_sub": n_sub,
1337
+ }
1338
+ log_inside = None
1339
+ if callable(density):
1340
+ logf = density if log else (lambda z: _log_nonneg(density(z)))
1341
+ log_avg = bin_log_average(logf, bins, **quad)
1342
+ else:
1343
+ dists = list(density)
1344
+ if not dists or not all(_is_distribution(d) for d in dists):
1345
+ raise TypeError("density must be a function or a sequence of distributions.")
1346
+ if bins.ndim == 1 and all(_has_cdf(d) for d in dists):
1347
+ log_mass = np.stack([_dist_log_masses(d, bins.edges[0]) for d in dists])
1348
+ log_mass = log_mass[:, bins.cell_index[0]]
1349
+ log_inside = special.logsumexp(log_mass, axis=1)
1350
+ if log_inside is not None and (posterior or bins.measures[0] == Measure.linear()):
1351
+ log_avg = log_mass - np.log(bins.volumes) # exact
1352
+ elif not all(_has_pdf(d) for d in dists):
1353
+ raise TypeError(
1354
+ "Distributions need a logpdf or pdf here: a cdf gives exact bin masses only "
1355
+ "for posteriors or likelihoods on 1-D bins with a linear measure."
1356
+ )
1357
+ else:
1358
+ log_avg = bin_log_average(
1359
+ _rows_logpdf(dists, bins.ndim), bins, rows=len(dists), **quad
1360
+ )
1361
+ if posterior: # the interim prior gets the same quadrature as the values
1362
+ interim = Density.from_masses(
1363
+ _interim_masses(interim, bins, interim_representation, tol, max_depth, n_sub)
1364
+ )
1365
+ lk = cls.from_table(
1366
+ log_avg,
1367
+ bins,
1368
+ kind=kind,
1369
+ interim=interim,
1370
+ representation="density",
1371
+ log=True,
1372
+ **_common(weights, selection, selection_mode, drop_empty, fractional_weights),
1373
+ )
1374
+ lk.meta["source"] = "density"
1375
+ if bins.ndim == 1: # the likelihood at points, for value draws (DECISIONS #50)
1376
+ if callable(density):
1377
+ n_in = log_avg.shape[0]
1378
+ _keep_within(lk, n_in, "rows", n_in, density, log, interim_z)
1379
+ elif all(_has_pdf(d) for d in dists):
1380
+ _keep_within(lk, len(dists), "dists", dists, interim_z)
1381
+ if log_inside is not None:
1382
+ _record_outside(lk, _outside(log_inside), warn_outside)
1383
+ return lk
1384
+
1385
+ @classmethod
1386
+ def from_binned(
1387
+ cls,
1388
+ response: Response,
1389
+ *,
1390
+ counts: Any = None,
1391
+ values: Any = None,
1392
+ fractional_weights: bool = False,
1393
+ ) -> Likelihoods:
1394
+ r"""Binned measurements: counts per measured bin, or measured values, and a response.
1395
+
1396
+ Every object measured in bin :math:`j` has likelihood row
1397
+ :math:`R_{j\cdot}` (:class:`Response`), and rows are weighted by the
1398
+ counts. The truth bins are the response's. The efficiency (the column
1399
+ sums of :math:`R`) becomes the selection function in ``"on_measurement"`` mode,
1400
+ because the rows already include it. For a response tallied from events,
1401
+ which is inferred with the population, the rows and the efficiency are its
1402
+ posterior means, so a measured bin without tallied events still has a row
1403
+ (DECISIONS #81). This covers unfolding (a detector
1404
+ response) and classifier outputs binned by score
1405
+ (:meth:`Response.from_labelled`, DECISIONS #29).
1406
+
1407
+ Parameters
1408
+ ----------
1409
+ response : Response
1410
+ The response of the measurement.
1411
+ counts : array_like (J,), optional
1412
+ Observed objects per measured bin (integers).
1413
+ values : array_like, optional
1414
+ Measured value of each object, binned by :meth:`Response.index`;
1415
+ values outside the measured bins are left out with a warning. Give exactly one of
1416
+ ``counts`` and ``values``.
1417
+ fractional_weights : bool
1418
+ Accept non-integer (for example weighted) counts, which give a
1419
+ pseudo-likelihood.
1420
+ """
1421
+ if not isinstance(response, Response):
1422
+ raise TypeError(
1423
+ f"response must be a binference.Response; got {type(response).__name__}."
1424
+ )
1425
+ if (counts is None) == (values is None):
1426
+ raise ValueError("Give exactly one of counts= and values=.")
1427
+ J = response.measured_bins.K
1428
+ if values is not None:
1429
+ j = response.index(values)
1430
+ if np.any(j < 0):
1431
+ warn(
1432
+ f"{int(np.count_nonzero(j < 0))} values lie outside the measured bins and "
1433
+ "are left out.",
1434
+ InputWarning,
1435
+ )
1436
+ c = np.bincount(j[j >= 0], minlength=J).astype(float)
1437
+ else:
1438
+ c = np.asarray(counts, dtype=float).ravel()
1439
+ if c.shape != (J,):
1440
+ raise ValueError(f"counts must have one entry per measured bin (J={J}).")
1441
+ check_nonnegative(c, "counts")
1442
+ if not fractional_weights and not is_integer_array(c):
1443
+ raise ValueError(
1444
+ "counts must be integers; pass fractional_weights=True for weighted counts, "
1445
+ "which give a pseudo-likelihood."
1446
+ )
1447
+ keep = c > 0
1448
+ if not keep.any():
1449
+ raise ValueError("counts are all zero: there are no observed objects.")
1450
+ lk = cls(
1451
+ response._rows()[keep],
1452
+ response.truth_bins,
1453
+ weights=c[keep],
1454
+ selection=response._selection(),
1455
+ fractional_weights=fractional_weights,
1456
+ )
1457
+ lk.meta.update(source=f"binned, response from {response.origin}", response=response)
1458
+ _attach_per_object(lk, measured_bin=np.flatnonzero(keep))
1459
+ return lk
1460
+
1461
+
1462
+ # ----------------------------------------------------------------------------
1463
+ # module-level helpers used by the class
1464
+ # ----------------------------------------------------------------------------
1465
+ #: ``meta`` entries holding one value per object (row); they follow subsets and
1466
+ #: dropped rows, and are discarded when rows are merged by ``compress``.
1467
+ _PER_OBJECT_KEYS = (
1468
+ "n_eff",
1469
+ "outside_fraction",
1470
+ "gaussian_mean",
1471
+ "gaussian_sigma",
1472
+ "gaussian_cov",
1473
+ "value_row",
1474
+ "sample_sum",
1475
+ "sample_sq",
1476
+ "sample_count",
1477
+ "measured_bin",
1478
+ )
1479
+
1480
+
1481
+ def _take_rows(v: Any, rows: Any) -> Any:
1482
+ # rows of a dense or sparse per-object array, by index or boolean mask
1483
+ return sp.csr_array(v)[np.arange(v.shape[0])[rows]] if sp.issparse(v) else np.asarray(v)[rows]
1484
+
1485
+
1486
+ def _slice_per_object(meta: dict[str, Any], rows: Any) -> dict[str, Any]:
1487
+ out = dict(meta)
1488
+ for key in _PER_OBJECT_KEYS:
1489
+ if key in out:
1490
+ out[key] = _take_rows(out[key], rows)
1491
+ return out
1492
+
1493
+
1494
+ class _WithinBin:
1495
+ """Within-bin data for :meth:`PopulationResult.sample_values` (DECISIONS #50). They
1496
+ pickle when they can; a function that cannot (a lambda density) pickles as empty, with
1497
+ a warning, so that a model can still be sent elsewhere, and its value draws fall back
1498
+ to flat."""
1499
+
1500
+ __slots__ = ("data",)
1501
+
1502
+ def __init__(self, data: Any = None) -> None:
1503
+ self.data = data
1504
+
1505
+ def __reduce__(self):
1506
+ if self.data is not None and self.data[0] != "samples": # arrays always pickle
1507
+ try:
1508
+ pickle.dumps(self.data)
1509
+ except (pickle.PicklingError, AttributeError, TypeError):
1510
+ warn(
1511
+ "The within-bin data cannot be pickled (e.g. a lambda density), so the "
1512
+ "copy's value draws are flat within bins.",
1513
+ ApproximationWarning,
1514
+ )
1515
+ return _WithinBin, ()
1516
+ return _WithinBin, (self.data,)
1517
+
1518
+
1519
+ def _keep_within(lk: Likelihoods, n: int, kind: str, *data: Any) -> None:
1520
+ """Keep the within-bin information of ``n`` input rows, indexed by the input row
1521
+ (``meta["value_row"]`` maps the surviving rows to it; DECISIONS #50)."""
1522
+ lk.meta["within"] = _WithinBin((kind, *data))
1523
+ _attach_per_object(lk, value_row=np.arange(n))
1524
+
1525
+
1526
+ def _interim_at_points(density: Density, bins: Bins) -> tuple:
1527
+ """What :func:`log_interim` reads: the density, and its log in ``u`` per bin of the
1528
+ 1-D ``bins`` when it is known only per bin (flat in ``u`` within each)."""
1529
+ if density._log_pointwise() is not None:
1530
+ return density, None, bins
1531
+ with np.errstate(divide="ignore"):
1532
+ return density, np.log(density.masses(bins) / bins.u_volumes), bins
1533
+
1534
+
1535
+ def log_interim(interim: tuple | None, z: np.ndarray) -> Any:
1536
+ """The log interim density in ``z`` at the points ``z`` (any shape), up to a constant
1537
+ per bin; 0 without one (likelihoods)."""
1538
+ if interim is None:
1539
+ return 0.0
1540
+ density, log_u, bins = interim
1541
+ x = np.ravel(z)
1542
+ if log_u is None:
1543
+ out = density._log_pointwise()(x)
1544
+ else:
1545
+ out = log_u[np.maximum(bins.index(x), 0)] + bins.measures[0].log_jacobian(x)
1546
+ return np.reshape(out, np.shape(z))
1547
+
1548
+
1549
+ def _attach_per_object(lk: Likelihoods, **arrays: Any) -> None:
1550
+ """Store per-object arrays aligned with the rows that survived ``drop_empty``."""
1551
+ dropped = lk.meta.get("dropped")
1552
+ for key, arr in arrays.items():
1553
+ keep = np.ones(arr.shape[0], dtype=bool)
1554
+ if dropped is not None:
1555
+ keep[dropped] = False
1556
+ lk.meta[key] = _take_rows(arr, keep)
1557
+
1558
+
1559
+ def _normalise_linear(values: Any):
1560
+ """Validate non-negative values and normalise rows to max 1."""
1561
+ if sp.issparse(values):
1562
+ L = sp.csr_array(values, dtype=float, copy=True) # do not modify the caller's matrix
1563
+ L.sum_duplicates()
1564
+ check_nonnegative(L.data, "Likelihood values")
1565
+ L.eliminate_zeros()
1566
+ if L.ndim != 2:
1567
+ raise ValueError("values must be 2-D.")
1568
+ if L.shape[1] < 2:
1569
+ raise ValueError("Need at least two bins (K >= 2).")
1570
+ rmax = _csr_row_max(L)
1571
+ with np.errstate(divide="ignore"):
1572
+ log_scale = np.log(rmax)
1573
+ # in place on the copy; rows without entries (rmax = 0) have nothing to scale
1574
+ L.data *= np.repeat(1.0 / np.where(rmax > 0, rmax, 1.0), np.diff(L.indptr))
1575
+ return L, log_scale
1576
+ L = np.array(values, dtype=float)
1577
+ if L.ndim != 2:
1578
+ raise ValueError("values must be a 2-D array of shape (N, K).")
1579
+ if L.shape[1] < 2:
1580
+ raise ValueError("Need at least two bins (K >= 2).")
1581
+ check_nonnegative(L, "Likelihood values")
1582
+ rmax = L.max(axis=1)
1583
+ with np.errstate(divide="ignore"):
1584
+ log_scale = np.log(rmax)
1585
+ safe = np.where(rmax > 0, rmax, 1.0)
1586
+ L /= safe[:, None]
1587
+ return L, log_scale
1588
+
1589
+
1590
+ def _normalise_log(log_values: Any):
1591
+ """Validate log-likelihoods and exponentiate stably (row max -> 1)."""
1592
+ if sp.issparse(log_values):
1593
+ raise TypeError("Log-likelihoods must be dense; pass linear values for sparse input.")
1594
+ lv = np.array(log_values, dtype=float)
1595
+ if lv.ndim != 2:
1596
+ raise ValueError("log values must be a 2-D array of shape (N, K).")
1597
+ if lv.shape[1] < 2:
1598
+ raise ValueError("Need at least two bins (K >= 2).")
1599
+ if np.any(np.isnan(lv)) or np.any(lv == np.inf):
1600
+ raise ValueError("Log-likelihoods must not contain NaN or +inf.")
1601
+ m = lv.max(axis=1) # finite, or -inf for a row of zeros
1602
+ with np.errstate(invalid="ignore"): # -inf - -inf in those rows, zeroed below
1603
+ L = np.exp(np.subtract(lv, m[:, None], out=lv), out=lv)
1604
+ L[np.isinf(m)] = 0.0
1605
+ return L, m
1606
+
1607
+
1608
+ def _check_posterior_representation(P, vol: np.ndarray, representation: str) -> None:
1609
+ """Warn if declared posterior masses look like densities (or vice versa)."""
1610
+ if _equal_volumes(vol):
1611
+ return # representation cannot affect inference with equal volumes
1612
+ # sums of the values read as masses, and as densities integrated over bins
1613
+ if sp.issparse(P):
1614
+ as_mass = np.asarray(P.sum(axis=1)).ravel()
1615
+ as_dens = np.asarray(P @ vol).ravel()
1616
+ else:
1617
+ as_mass = P.sum(axis=1)
1618
+ as_dens = P @ vol
1619
+ if representation == "mass":
1620
+ sums, alt, other = as_mass, as_dens, "density"
1621
+ else:
1622
+ sums, alt, other = as_dens, as_mass, "mass"
1623
+ mismatched = ~np.isclose(sums, 1.0, rtol=REPRESENTATION_RTOL)
1624
+ looks_other = np.isclose(alt, 1.0, rtol=REPRESENTATION_RTOL)
1625
+ if np.mean(mismatched & looks_other) > REPRESENTATION_SHARE:
1626
+ warn(
1627
+ f"Most posterior rows integrate to one only if interpreted as '{other}' values; "
1628
+ f"did you mean representation='{other}'? "
1629
+ "(Bins have unequal volumes, so this matters.)",
1630
+ InputWarning,
1631
+ )
1632
+
1633
+
1634
+ def _ess(tot: np.ndarray, tot2: np.ndarray) -> np.ndarray:
1635
+ # effective number of samples from the sums of the weights and of their squares
1636
+ with np.errstate(invalid="ignore", divide="ignore"):
1637
+ return np.where(tot2 > 0, tot**2 / tot2, 0.0)
1638
+
1639
+
1640
+ def _bin_samples(samples: Any, bins: Bins, sample_weights: Any):
1641
+ """Flatten per-object samples and map them to bin indices."""
1642
+ ndim = bins.ndim
1643
+ if isinstance(samples, np.ndarray) and samples.dtype != object:
1644
+ arr = np.asarray(samples, dtype=float)
1645
+ if ndim == 1:
1646
+ if arr.ndim != 2:
1647
+ raise ValueError("For 1-D bins, samples must have shape (N, S).")
1648
+ n_obj, S = arr.shape
1649
+ flat = arr.reshape(-1)
1650
+ else:
1651
+ if arr.ndim != 3 or arr.shape[2] != ndim:
1652
+ raise ValueError(f"For {ndim}-D grids, samples must have shape (N, S, {ndim}).")
1653
+ n_obj, S = arr.shape[:2]
1654
+ flat = arr.reshape(-1, ndim)
1655
+ obj_ids = np.repeat(np.arange(n_obj), S)
1656
+ if sample_weights is None:
1657
+ sw = np.ones(n_obj * S)
1658
+ else:
1659
+ sw = np.asarray(sample_weights, dtype=float).reshape(-1)
1660
+ if sw.size != n_obj * S:
1661
+ raise ValueError("sample_weights must match the layout of samples.")
1662
+ else:
1663
+ seq = list(samples)
1664
+ n_obj = len(seq)
1665
+ arrays = [
1666
+ np.asarray(s, dtype=float).reshape(-1)
1667
+ if ndim == 1
1668
+ else np.asarray(s, dtype=float).reshape(-1, ndim)
1669
+ for s in seq
1670
+ ]
1671
+ sizes = np.array([a.shape[0] for a in arrays])
1672
+ if np.any(sizes == 0):
1673
+ raise ValueError("Every object needs at least one sample.")
1674
+ flat = np.concatenate(arrays, axis=0)
1675
+ obj_ids = np.repeat(np.arange(n_obj), sizes)
1676
+ if sample_weights is None:
1677
+ sw = np.ones(flat.shape[0])
1678
+ else:
1679
+ sws = [np.asarray(s, dtype=float).reshape(-1) for s in sample_weights]
1680
+ if [len(s) for s in sws] != sizes.tolist():
1681
+ raise ValueError("sample_weights must match the layout of samples.")
1682
+ sw = np.concatenate(sws)
1683
+ check_nonnegative(sw, "sample_weights")
1684
+ return flat, obj_ids, bins.index(flat), sw, n_obj