binference 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- binference/__init__.py +85 -0
- binference/_kernels.py +252 -0
- binference/_quadrature.py +202 -0
- binference/_simplex.py +54 -0
- binference/_utils.py +139 -0
- binference/_version.py +1 -0
- binference/basis.py +314 -0
- binference/bins.py +645 -0
- binference/density.py +323 -0
- binference/diagnostics.py +400 -0
- binference/estimator.py +197 -0
- binference/inference.py +368 -0
- binference/io.py +309 -0
- binference/likelihoods.py +1684 -0
- binference/measure.py +246 -0
- binference/model.py +833 -0
- binference/plotting.py +538 -0
- binference/priors/__init__.py +47 -0
- binference/priors/base.py +248 -0
- binference/priors/dirichlet.py +640 -0
- binference/priors/hyper.py +240 -0
- binference/priors/logistic.py +676 -0
- binference/priors/rates.py +139 -0
- binference/response.py +539 -0
- binference/results.py +2271 -0
- binference/samplers/__init__.py +19 -0
- binference/samplers/_curvature.py +67 -0
- binference/samplers/_nuts_core.py +284 -0
- binference/samplers/base.py +118 -0
- binference/samplers/bootstrap.py +136 -0
- binference/samplers/gibbs.py +287 -0
- binference/samplers/laplace.py +200 -0
- binference/samplers/nuts.py +681 -0
- binference/samplers/optimize.py +488 -0
- binference/samplers/vb.py +118 -0
- binference/selection.py +621 -0
- binference/simulate.py +720 -0
- binference/validation.py +477 -0
- binference/warnings.py +48 -0
- binference-0.1.0.dist-info/METADATA +130 -0
- binference-0.1.0.dist-info/RECORD +43 -0
- binference-0.1.0.dist-info/WHEEL +4 -0
- binference-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,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
|