binference 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- binference/__init__.py +85 -0
- binference/_kernels.py +252 -0
- binference/_quadrature.py +202 -0
- binference/_simplex.py +54 -0
- binference/_utils.py +139 -0
- binference/_version.py +1 -0
- binference/basis.py +314 -0
- binference/bins.py +645 -0
- binference/density.py +323 -0
- binference/diagnostics.py +400 -0
- binference/estimator.py +197 -0
- binference/inference.py +368 -0
- binference/io.py +309 -0
- binference/likelihoods.py +1684 -0
- binference/measure.py +246 -0
- binference/model.py +833 -0
- binference/plotting.py +538 -0
- binference/priors/__init__.py +47 -0
- binference/priors/base.py +248 -0
- binference/priors/dirichlet.py +640 -0
- binference/priors/hyper.py +240 -0
- binference/priors/logistic.py +676 -0
- binference/priors/rates.py +139 -0
- binference/response.py +539 -0
- binference/results.py +2271 -0
- binference/samplers/__init__.py +19 -0
- binference/samplers/_curvature.py +67 -0
- binference/samplers/_nuts_core.py +284 -0
- binference/samplers/base.py +118 -0
- binference/samplers/bootstrap.py +136 -0
- binference/samplers/gibbs.py +287 -0
- binference/samplers/laplace.py +200 -0
- binference/samplers/nuts.py +681 -0
- binference/samplers/optimize.py +488 -0
- binference/samplers/vb.py +118 -0
- binference/selection.py +621 -0
- binference/simulate.py +720 -0
- binference/validation.py +477 -0
- binference/warnings.py +48 -0
- binference-0.1.0.dist-info/METADATA +130 -0
- binference-0.1.0.dist-info/RECORD +43 -0
- binference-0.1.0.dist-info/WHEEL +4 -0
- binference-0.1.0.dist-info/licenses/LICENSE +21 -0
binference/model.py
ADDED
|
@@ -0,0 +1,833 @@
|
|
|
1
|
+
r"""The hierarchical population model.
|
|
2
|
+
|
|
3
|
+
For fractions :math:`\rho` (simplex priors and logistic priors):
|
|
4
|
+
|
|
5
|
+
.. math:: \log p(\rho \mid D) = \log p(\rho) + \sum_i w_i \log(L_i\cdot\rho)
|
|
6
|
+
- W \log(S\cdot\rho) + \text{const},
|
|
7
|
+
|
|
8
|
+
where the selection term is present only when a selection function
|
|
9
|
+
:math:`S` is given. For rates :math:`\lambda` (:class:`~binference.priors.GammaRates`):
|
|
10
|
+
|
|
11
|
+
.. math:: \log p(\lambda \mid D) = \log p(\lambda) + \sum_i w_i \log(L_i\cdot\lambda)
|
|
12
|
+
- S\cdot\lambda + \text{const}.
|
|
13
|
+
|
|
14
|
+
Gradient-based engines work in the unconstrained coordinates of the prior
|
|
15
|
+
(isometric log-ratios for simplex priors, whitened fields for logistic priors,
|
|
16
|
+
log-rates for rates); the log-likelihood gradient with respect to the softmax
|
|
17
|
+
logits (or log-rates) is
|
|
18
|
+
|
|
19
|
+
.. math:: \partial\ell/\partial\eta_k = \hat n_k - W\rho_k S_k/(S\cdot\rho)
|
|
20
|
+
\quad\text{or}\quad \hat n_k - S_k\lambda_k,
|
|
21
|
+
|
|
22
|
+
with expected counts :math:`\hat n_k = \rho_k \sum_i w_i L_{ik}/(L_i\cdot\rho)`.
|
|
23
|
+
|
|
24
|
+
A selection on the truth measured by injections is inferred with the population: the
|
|
25
|
+
unconstrained coordinates gain :math:`y = \mathrm{logit}\,S` after the prior's, the
|
|
26
|
+
likelihood uses that :math:`S`, and :math:`\partial\ell/\partial y_k =
|
|
27
|
+
(1 - S_k)\,\partial\ell/\partial\eta_k` (DECISIONS #80). A response tallied from events
|
|
28
|
+
(binned measurements) is inferred the same way: the coordinates gain the isometric
|
|
29
|
+
log-ratios of each column of :math:`R` with its lost share, under their Dirichlet
|
|
30
|
+
posterior given the tallies, and the likelihood is
|
|
31
|
+
:math:`\sum_j c_j\log(R_{j\cdot}\cdot\rho) - W\log(\epsilon\cdot\rho)` with
|
|
32
|
+
:math:`\epsilon` the column sums (DECISIONS #28, #81).
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import copy
|
|
38
|
+
from typing import Any
|
|
39
|
+
|
|
40
|
+
import numpy as np
|
|
41
|
+
from scipy import linalg, special
|
|
42
|
+
|
|
43
|
+
from . import _kernels as kern
|
|
44
|
+
from ._simplex import helmert_basis, log_softmax, softmax
|
|
45
|
+
from ._utils import RNGLike, as_generator, check_nonnegative, chunk_slices
|
|
46
|
+
from .bins import Bins
|
|
47
|
+
from .likelihoods import Likelihoods, _scale_columns
|
|
48
|
+
from .priors import Dirichlet, PopulationPrior
|
|
49
|
+
from .priors.base import _TINY, BoundPrior
|
|
50
|
+
from .priors.rates import _gamma_draw
|
|
51
|
+
from .selection import Selection, _logit_density, as_selection
|
|
52
|
+
|
|
53
|
+
__all__ = ["PopulationModel"]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class PopulationModel:
|
|
57
|
+
"""Likelihoods + prior (+ selection): the object every inference engine works on.
|
|
58
|
+
|
|
59
|
+
Parameters
|
|
60
|
+
----------
|
|
61
|
+
likelihoods : Likelihoods or array_like (N, K)
|
|
62
|
+
Per-object bin-averaged likelihoods. A raw array is wrapped with
|
|
63
|
+
``Likelihoods(array, bins)``.
|
|
64
|
+
prior : PopulationPrior, optional
|
|
65
|
+
Population prior; defaults to ``Dirichlet(1.0)``. Use
|
|
66
|
+
:class:`~binference.priors.GammaRates` to infer rates instead of fractions.
|
|
67
|
+
selection : array_like (K,), callable or Selection, optional
|
|
68
|
+
Per-bin detection probability (fractions) or exposure (rates), or a
|
|
69
|
+
function of the true value averaged over each bin. Overrides any
|
|
70
|
+
selection attached to ``likelihoods``.
|
|
71
|
+
selection_mode : {"on_measurement", "on_truth"}, optional
|
|
72
|
+
Required with an array or callable ``selection``. ``"on_measurement"``:
|
|
73
|
+
objects are selected on their measured data (or the likelihoods already
|
|
74
|
+
include the detection probability); only the normalisation term is
|
|
75
|
+
added. ``"on_truth"``: objects are detected with probability ``S_k``
|
|
76
|
+
depending on their true bin; likelihood rows are multiplied by ``S_k``
|
|
77
|
+
as well. :meth:`Selection.combined` gives both at once.
|
|
78
|
+
bins : Bins, optional
|
|
79
|
+
Bins for a raw array of likelihoods; ignored otherwise.
|
|
80
|
+
|
|
81
|
+
Notes
|
|
82
|
+
-----
|
|
83
|
+
``likelihoods`` must be ordinary likelihoods :math:`p(d_i \\mid z)` evaluated at
|
|
84
|
+
the observed data, *not* renormalised over the detectable data space (see
|
|
85
|
+
:doc:`/guides/selection`).
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
def __init__(
|
|
89
|
+
self,
|
|
90
|
+
likelihoods: Likelihoods | Any,
|
|
91
|
+
prior: PopulationPrior | None = None,
|
|
92
|
+
*,
|
|
93
|
+
selection: Any = None,
|
|
94
|
+
selection_mode: str | None = None,
|
|
95
|
+
bins: Bins | None = None,
|
|
96
|
+
) -> None:
|
|
97
|
+
if not isinstance(likelihoods, Likelihoods):
|
|
98
|
+
likelihoods = Likelihoods(likelihoods, bins)
|
|
99
|
+
elif bins is not None and bins != likelihoods.bins:
|
|
100
|
+
raise ValueError("bins differ from the bins attached to the likelihoods.")
|
|
101
|
+
self.likelihoods = likelihoods
|
|
102
|
+
self.prior_spec = Dirichlet(1.0) if prior is None else prior
|
|
103
|
+
if not isinstance(self.prior_spec, PopulationPrior):
|
|
104
|
+
raise TypeError("prior must be a binference.priors.PopulationPrior instance.")
|
|
105
|
+
self.prior: BoundPrior = self.prior_spec.bind(likelihoods.bins)
|
|
106
|
+
if selection is None and selection_mode is None:
|
|
107
|
+
sel = likelihoods.selection
|
|
108
|
+
else:
|
|
109
|
+
sel = as_selection(selection, selection_mode, likelihoods.basis or likelihoods.bins)
|
|
110
|
+
if sel is not None and sel.injections is not None:
|
|
111
|
+
if sel.mode != "on_truth":
|
|
112
|
+
raise ValueError(
|
|
113
|
+
"Only a selection on the truth is inferred from injections: on the "
|
|
114
|
+
"measurement the likelihoods' cut already fixes S, and with S free the "
|
|
115
|
+
"catalogue would add only missed objects, driving S to zero. Pass "
|
|
116
|
+
"selection.at_mean() as a fixed selection, or mode='on_truth' for a "
|
|
117
|
+
"completeness."
|
|
118
|
+
)
|
|
119
|
+
if self.prior.kind == "rates":
|
|
120
|
+
raise ValueError(
|
|
121
|
+
"A selection from injections is inferred jointly for fractions only; for "
|
|
122
|
+
"rates pass selection.at_mean() or a fixed exposure."
|
|
123
|
+
)
|
|
124
|
+
if sel is not None and sel.scale == "relative" and self.prior.kind == "rates":
|
|
125
|
+
raise ValueError(
|
|
126
|
+
"Rates need an absolute selection (exposures); this one is relative "
|
|
127
|
+
"(DECISIONS #46)."
|
|
128
|
+
)
|
|
129
|
+
resp = likelihoods.meta.get("response")
|
|
130
|
+
#: a response tallied from events, inferred with the population (or ``None``)
|
|
131
|
+
self.response = resp if resp is not None and resp.uncertain else None
|
|
132
|
+
if self.response is not None:
|
|
133
|
+
self._check_response(likelihoods, selection is None and selection_mode is None)
|
|
134
|
+
#: the columns' Dirichlet parameters (J + 1, K)
|
|
135
|
+
self._alpha = self.response._dirichlet()
|
|
136
|
+
self.selection_spec: Selection | None = sel
|
|
137
|
+
#: per-bin selection values (or ``None``)
|
|
138
|
+
self.selection = None if sel is None else sel.values
|
|
139
|
+
self.selection_mode = None if sel is None else sel.mode
|
|
140
|
+
#: factor on the likelihood columns (selection on the truth), or ``None``
|
|
141
|
+
self.selection_row = None if sel is None else sel.row
|
|
142
|
+
# For fractions only ratios of S matter. Data-augmentation steps (Gibbs, EM,
|
|
143
|
+
# VB) impute missed objects with probability 1 - S, so they use S rescaled
|
|
144
|
+
# to a maximum of one, which leaves the posterior unchanged.
|
|
145
|
+
self._detect = None if sel is None else sel.values / sel.values.max()
|
|
146
|
+
self._S = np.ones(self.K) if sel is None else sel.values # exposure, for rates
|
|
147
|
+
self._set_likelihoods(likelihoods)
|
|
148
|
+
|
|
149
|
+
def _check_response(self, likelihoods: Likelihoods, no_override: bool) -> None:
|
|
150
|
+
# an inferred response needs the rows from_binned built, with its own selection
|
|
151
|
+
if not (no_override and likelihoods.selection == self.response._selection()):
|
|
152
|
+
raise ValueError(
|
|
153
|
+
"The efficiency of a response inferred from its tallies is the selection; "
|
|
154
|
+
"to set another, build the likelihoods from response.fixed()."
|
|
155
|
+
)
|
|
156
|
+
if "measured_bin" not in likelihoods.meta or likelihoods.basis is not None:
|
|
157
|
+
raise ValueError(
|
|
158
|
+
"An inferred response needs the rows Likelihoods.from_binned builds, one per "
|
|
159
|
+
"measured bin (not compressed or moved onto a Basis); or use response.fixed()."
|
|
160
|
+
)
|
|
161
|
+
if self.prior.kind == "rates":
|
|
162
|
+
raise ValueError(
|
|
163
|
+
"A response is inferred jointly for fractions only; for rates build the "
|
|
164
|
+
"likelihoods from response.fixed()."
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
def _set_likelihoods(self, likelihoods: Likelihoods) -> None:
|
|
168
|
+
# the data and what is derived from them, in one place
|
|
169
|
+
self.likelihoods = likelihoods
|
|
170
|
+
# effective per-object likelihoods: columns scaled by the selection on the truth
|
|
171
|
+
self._L = self.effective_likelihoods(likelihoods)
|
|
172
|
+
self._w = likelihoods.weights
|
|
173
|
+
self.W = float(self._w.sum())
|
|
174
|
+
self._ll_const = float(np.dot(self._w, likelihoods.log_scale))
|
|
175
|
+
if self.response is not None: # each row's measured bin
|
|
176
|
+
self._mb = likelihoods.meta["measured_bin"]
|
|
177
|
+
|
|
178
|
+
# ----------------------------------------------------------- properties
|
|
179
|
+
@property
|
|
180
|
+
def total_exposure(self) -> np.ndarray | None:
|
|
181
|
+
r""":math:`S` of the Poisson law of the number of objects, :math:`W \sim
|
|
182
|
+
\mathrm{Poisson}(\Lambda\,S\cdot\rho)`: detection probabilities or exposures
|
|
183
|
+
(ones without a selection); ``None`` for a relative selection, which
|
|
184
|
+
leaves the total unidentified (DECISIONS #46)."""
|
|
185
|
+
sel = self.selection_spec
|
|
186
|
+
return None if sel is not None and sel.scale == "relative" else self._S
|
|
187
|
+
|
|
188
|
+
@property
|
|
189
|
+
def _exposure(self) -> np.ndarray | None:
|
|
190
|
+
"""The exposure of a rate model's conjugate updates (``None`` for fractions)."""
|
|
191
|
+
return self._S if self.kind == "rates" else None
|
|
192
|
+
|
|
193
|
+
@property
|
|
194
|
+
def joint(self) -> bool:
|
|
195
|
+
"""Whether a selection from injections or a tallied response is inferred with the
|
|
196
|
+
population, as extra unconstrained coordinates (DECISIONS #80, #81)."""
|
|
197
|
+
return self.selection_uncertain or self.response is not None
|
|
198
|
+
|
|
199
|
+
@property
|
|
200
|
+
def selection_uncertain(self) -> bool:
|
|
201
|
+
"""Whether the selection comes from injections and is inferred with the population.
|
|
202
|
+
|
|
203
|
+
Methods that take a population value (``log_likelihood``, ``kkt_gap``, ...)
|
|
204
|
+
then use its :attr:`~Selection.values`.
|
|
205
|
+
"""
|
|
206
|
+
return self.selection_spec is not None and self.selection_spec.injections is not None
|
|
207
|
+
|
|
208
|
+
def draw_total(
|
|
209
|
+
self, value: np.ndarray, rng: np.random.Generator, S: np.ndarray | None = None
|
|
210
|
+
) -> np.ndarray | None:
|
|
211
|
+
r"""Draws of the total :math:`\Lambda \mid \rho \sim \mathrm{Gamma}(W, S\cdot\rho)`.
|
|
212
|
+
|
|
213
|
+
One draw per row of fractions ``value`` ``(..., K)``: its exact posterior under
|
|
214
|
+
:math:`p(\Lambda) \propto 1/\Lambda`. Returns ``None``
|
|
215
|
+
without an exposure.
|
|
216
|
+
|
|
217
|
+
Parameters
|
|
218
|
+
----------
|
|
219
|
+
value : ndarray (..., K)
|
|
220
|
+
Fractions.
|
|
221
|
+
rng : Generator
|
|
222
|
+
Random number generator.
|
|
223
|
+
S : ndarray (..., K), optional
|
|
224
|
+
Selection per row (draws of an uncertain one); ``None`` uses
|
|
225
|
+
:attr:`total_exposure`.
|
|
226
|
+
"""
|
|
227
|
+
S = self.total_exposure if S is None else np.asarray(S)
|
|
228
|
+
if S is None:
|
|
229
|
+
return None
|
|
230
|
+
return _gamma_draw(self.W, np.sum(np.asarray(value) * S, axis=-1), rng)
|
|
231
|
+
|
|
232
|
+
@property
|
|
233
|
+
def bins(self) -> Bins:
|
|
234
|
+
return self.likelihoods.bins
|
|
235
|
+
|
|
236
|
+
@property
|
|
237
|
+
def K(self) -> int:
|
|
238
|
+
return self.likelihoods.K
|
|
239
|
+
|
|
240
|
+
@property
|
|
241
|
+
def N(self) -> int:
|
|
242
|
+
return self.likelihoods.N
|
|
243
|
+
|
|
244
|
+
@property
|
|
245
|
+
def kind(self) -> str:
|
|
246
|
+
"""``"fractions"`` or ``"rates"`` (set by the prior)."""
|
|
247
|
+
return self.prior.kind
|
|
248
|
+
|
|
249
|
+
@property
|
|
250
|
+
def dim(self) -> int:
|
|
251
|
+
"""Dimension of the unconstrained parameter vector: the prior's, plus ``K`` for
|
|
252
|
+
:math:`\\mathrm{logit}\\,S` when the selection is inferred, or the log-ratios of the
|
|
253
|
+
response's columns (``J - 1`` or ``J`` each, ``J`` with a lost share) when the
|
|
254
|
+
response is inferred."""
|
|
255
|
+
extra = self.K if self.selection_uncertain else 0
|
|
256
|
+
if self.response is not None:
|
|
257
|
+
extra = self.K * (self._alpha.shape[0] - 1)
|
|
258
|
+
return self.prior.dim + extra
|
|
259
|
+
|
|
260
|
+
def selection_at(self, X: np.ndarray) -> np.ndarray | None:
|
|
261
|
+
"""The selection :math:`S` at unconstrained points ``X`` ``(..., dim)``, or ``None``
|
|
262
|
+
when it is fixed.
|
|
263
|
+
|
|
264
|
+
Parameters
|
|
265
|
+
----------
|
|
266
|
+
X : ndarray (..., dim)
|
|
267
|
+
Unconstrained points.
|
|
268
|
+
"""
|
|
269
|
+
if not self.selection_uncertain:
|
|
270
|
+
return None
|
|
271
|
+
return special.expit(np.asarray(X, dtype=float)[..., self.prior.dim :])
|
|
272
|
+
|
|
273
|
+
def _response_logits(self, X: np.ndarray) -> np.ndarray:
|
|
274
|
+
# log compositions (..., K, J + 1) of the response's columns from their log-ratios
|
|
275
|
+
C = self._alpha.shape[0] # J measured bins, and the lost share if it is modelled
|
|
276
|
+
Y = np.asarray(X, dtype=float)[..., self.prior.dim :].reshape(*np.shape(X)[:-1], -1, C - 1)
|
|
277
|
+
return log_softmax(Y @ helmert_basis(C).T)
|
|
278
|
+
|
|
279
|
+
def response_at(self, X: np.ndarray) -> np.ndarray | None:
|
|
280
|
+
"""The response :math:`R` ``(..., J, K)`` at unconstrained points ``X``
|
|
281
|
+
``(..., dim)``, or ``None`` when it is fixed.
|
|
282
|
+
|
|
283
|
+
Parameters
|
|
284
|
+
----------
|
|
285
|
+
X : ndarray (..., dim)
|
|
286
|
+
Unconstrained points.
|
|
287
|
+
"""
|
|
288
|
+
if self.response is None:
|
|
289
|
+
return None
|
|
290
|
+
J = self.response.measured_bins.K
|
|
291
|
+
return np.swapaxes(np.exp(self._response_logits(X))[..., :J], -1, -2)
|
|
292
|
+
|
|
293
|
+
def _joint_info(self, X: np.ndarray) -> dict[str, np.ndarray]:
|
|
294
|
+
"""Draws of what is inferred with the population at points ``X`` (..., dim)."""
|
|
295
|
+
if self.selection_uncertain:
|
|
296
|
+
return {"selection_draws": self.selection_at(X)}
|
|
297
|
+
if self.response is not None:
|
|
298
|
+
return {"response_draws": self.response_at(X)}
|
|
299
|
+
return {}
|
|
300
|
+
|
|
301
|
+
def _joint_log_density(self, x: np.ndarray) -> float:
|
|
302
|
+
"""Log density at ``x`` of what is inferred with the population, given the
|
|
303
|
+
injections or tallies alone, in its own space (S or R, without the Jacobian of
|
|
304
|
+
its unconstrained coordinates)."""
|
|
305
|
+
y = np.asarray(x, dtype=float)[self.prior.dim :]
|
|
306
|
+
if self.selection_uncertain:
|
|
307
|
+
ls, _ = _logit_density(y, self.selection_spec.injections)
|
|
308
|
+
return ls - float(np.sum(special.log_expit(y) + special.log_expit(-y)))
|
|
309
|
+
if self.response is not None:
|
|
310
|
+
return float(np.sum((self._alpha.T - 1.0) * self._response_logits(x)))
|
|
311
|
+
return 0.0
|
|
312
|
+
|
|
313
|
+
def _response_terms(self, value: np.ndarray, R: np.ndarray):
|
|
314
|
+
r"""Log-likelihood of the rows (measured bins) given ``R``, with its gradients in
|
|
315
|
+
the logits of :math:`\rho` and in :math:`R` ``(J, K)``."""
|
|
316
|
+
rows = R[self._mb]
|
|
317
|
+
q = rows @ value
|
|
318
|
+
eff = R.sum(axis=0)
|
|
319
|
+
se = float(eff @ value)
|
|
320
|
+
if not (np.all(q > 0) and se > 0):
|
|
321
|
+
return -np.inf, None, None
|
|
322
|
+
ll = float(self._w @ np.log(q)) - self.W * np.log(se)
|
|
323
|
+
t = self._w / q
|
|
324
|
+
d_eta = value * (rows.T @ t) - self.W * value * eff / se
|
|
325
|
+
G = np.broadcast_to(-self.W * value / se, R.shape).copy()
|
|
326
|
+
np.add.at(G, self._mb, np.outer(t, value))
|
|
327
|
+
return ll, d_eta, G
|
|
328
|
+
|
|
329
|
+
def _response_density(self, logP: np.ndarray, G: np.ndarray) -> tuple[float, np.ndarray]:
|
|
330
|
+
# the columns' Dirichlet density in their log-ratios (with the Jacobian) at the log
|
|
331
|
+
# compositions logP (K, J + 1), and the gradient of it plus the likelihood's (G in R;
|
|
332
|
+
# the lost share has none)
|
|
333
|
+
P = np.exp(logP)
|
|
334
|
+
a = self._alpha.T
|
|
335
|
+
Gf = np.zeros_like(P)
|
|
336
|
+
Gf[:, : G.shape[0]] = G.T
|
|
337
|
+
d = P * (Gf - np.sum(P * Gf, axis=1, keepdims=True)) + a - a.sum(axis=1)[:, None] * P
|
|
338
|
+
return float(np.sum(a * logP)), (d @ helmert_basis(P.shape[1])).ravel()
|
|
339
|
+
|
|
340
|
+
def effective_likelihoods(self, likelihoods: Likelihoods):
|
|
341
|
+
"""Likelihood matrix of (new) objects with this model's selection applied.
|
|
342
|
+
|
|
343
|
+
Parameters
|
|
344
|
+
----------
|
|
345
|
+
likelihoods : Likelihoods
|
|
346
|
+
Likelihoods of the objects, on this model's bins.
|
|
347
|
+
"""
|
|
348
|
+
L = likelihoods.values
|
|
349
|
+
return L if self.selection_row is None else _scale_columns(L, self.selection_row)
|
|
350
|
+
|
|
351
|
+
def __repr__(self) -> str:
|
|
352
|
+
sel = "" if self.selection is None else f", selection={self.selection_mode!r}"
|
|
353
|
+
return f"PopulationModel(N={self.N}, K={self.K}, prior={self.prior_spec!r}{sel})"
|
|
354
|
+
|
|
355
|
+
# ------------------------------------------------- likelihood internals
|
|
356
|
+
def _selection_term(self, value: np.ndarray, S: np.ndarray | None = None):
|
|
357
|
+
# -S.lambda for rates, -W log(S.rho) for fractions with selection; one per row of
|
|
358
|
+
# value (..., K)
|
|
359
|
+
S = self._S if S is None else S
|
|
360
|
+
if self.kind == "rates":
|
|
361
|
+
return -(value @ S)
|
|
362
|
+
if self.selection is None:
|
|
363
|
+
return 0.0
|
|
364
|
+
with np.errstate(divide="ignore"):
|
|
365
|
+
return -self.W * np.log(value @ S)
|
|
366
|
+
|
|
367
|
+
def _rows(self, value: np.ndarray, S: np.ndarray | None = None):
|
|
368
|
+
"""The likelihood rows and the column weights for ``value``: the model's rows
|
|
369
|
+
(selection on the truth applied) and ``value``, or, given a value ``S`` of an
|
|
370
|
+
inferred selection, the bare rows and :math:`\\rho\\circ S` (DECISIONS #79, #80)."""
|
|
371
|
+
if S is None:
|
|
372
|
+
return self._L, value
|
|
373
|
+
return self.likelihoods.values, value * S
|
|
374
|
+
|
|
375
|
+
def _ll_and_deta(
|
|
376
|
+
self, value: np.ndarray, S: np.ndarray | None = None
|
|
377
|
+
) -> tuple[float, np.ndarray]:
|
|
378
|
+
L, v = self._rows(value, S)
|
|
379
|
+
ll, nhat, _ = kern.loglike_and_counts(L, self._w, v)
|
|
380
|
+
if not np.isfinite(ll):
|
|
381
|
+
return -np.inf, np.zeros_like(value)
|
|
382
|
+
ll += self._selection_term(value, S)
|
|
383
|
+
S = self._S if S is None else S
|
|
384
|
+
# gradient in the logits (or log-rates) of ll, selection term included
|
|
385
|
+
if self.kind == "rates":
|
|
386
|
+
return ll, nhat - S * value
|
|
387
|
+
if self.selection is None:
|
|
388
|
+
return ll, nhat - self.W * value
|
|
389
|
+
return ll, nhat - self.W * value * S / float(S @ value)
|
|
390
|
+
|
|
391
|
+
def _value(self, eta: np.ndarray) -> np.ndarray:
|
|
392
|
+
if self.kind == "rates":
|
|
393
|
+
with np.errstate(over="ignore"):
|
|
394
|
+
return np.exp(eta)
|
|
395
|
+
return softmax(eta)
|
|
396
|
+
|
|
397
|
+
# ------------------------------------------------ unconstrained space
|
|
398
|
+
def log_density_unconstrained_and_grad(self, x: np.ndarray) -> tuple[float, np.ndarray]:
|
|
399
|
+
"""Log posterior density and its gradient in the unconstrained coordinates ``x``.
|
|
400
|
+
|
|
401
|
+
This is the density of ``x`` (the prior's isometric log-ratios, whitened
|
|
402
|
+
fields or log-rates), so it includes the Jacobian of the map from ``x``
|
|
403
|
+
to the fractions or rates (DECISIONS #15); its mode differs from that of
|
|
404
|
+
:meth:`log_posterior`. With an inferred selection ``x`` ends with
|
|
405
|
+
:math:`\\mathrm{logit}\\,S`, and with an inferred response with the log-ratios of
|
|
406
|
+
its columns; their densities given the injections or tallies are included.
|
|
407
|
+
NUTS, Laplace and L-BFGS use it. Up to a constant.
|
|
408
|
+
|
|
409
|
+
Parameters
|
|
410
|
+
----------
|
|
411
|
+
x : ndarray (dim,)
|
|
412
|
+
Unconstrained point.
|
|
413
|
+
"""
|
|
414
|
+
x = np.asarray(x, dtype=float)
|
|
415
|
+
d = self.prior.dim
|
|
416
|
+
eta, cache = self.prior.forward(x[:d])
|
|
417
|
+
value = self._value(eta)
|
|
418
|
+
if not np.isfinite(value.sum()):
|
|
419
|
+
return -np.inf, np.zeros_like(x)
|
|
420
|
+
S = self.selection_at(x)
|
|
421
|
+
logP = None if self.response is None else self._response_logits(x)
|
|
422
|
+
R = None if logP is None else np.exp(logP[:, : self.response.measured_bins.K]).T
|
|
423
|
+
if R is None:
|
|
424
|
+
ll, d_eta = self._ll_and_deta(value, S)
|
|
425
|
+
ll += self._ll_const
|
|
426
|
+
else: # absolute already: the response's own rows
|
|
427
|
+
ll, d_eta, G = self._response_terms(value, R)
|
|
428
|
+
if not np.isfinite(ll):
|
|
429
|
+
return -np.inf, np.zeros_like(x)
|
|
430
|
+
lp, g = self.prior.log_prior_and_grad(x[:d], cache, value, d_eta)
|
|
431
|
+
if S is not None: # the selection's block (DECISIONS #80)
|
|
432
|
+
ls, g_y = _logit_density(x[d:], self.selection_spec.injections)
|
|
433
|
+
lp += ls
|
|
434
|
+
g = np.concatenate([g, g_y + d_eta * (1.0 - S)])
|
|
435
|
+
if R is not None: # the response's block (DECISIONS #81)
|
|
436
|
+
lr, g_r = self._response_density(logP, G)
|
|
437
|
+
lp += lr
|
|
438
|
+
g = np.concatenate([g, g_r])
|
|
439
|
+
return ll + lp, g
|
|
440
|
+
|
|
441
|
+
def log_density_unconstrained(self, x: np.ndarray) -> float:
|
|
442
|
+
"""Log posterior density in the unconstrained coordinates ``x`` (with the Jacobian).
|
|
443
|
+
|
|
444
|
+
See :meth:`log_density_unconstrained_and_grad`.
|
|
445
|
+
|
|
446
|
+
Parameters
|
|
447
|
+
----------
|
|
448
|
+
x : ndarray (dim,)
|
|
449
|
+
Unconstrained point.
|
|
450
|
+
"""
|
|
451
|
+
return self.log_density_unconstrained_and_grad(x)[0]
|
|
452
|
+
|
|
453
|
+
def _log_density_many(self, X: np.ndarray) -> np.ndarray:
|
|
454
|
+
# log_density_unconstrained at each row of X (n, dim), without gradients: the
|
|
455
|
+
# prior point by point, the likelihood of many points per pass over the objects
|
|
456
|
+
if self.joint:
|
|
457
|
+
return np.array([self.log_density_unconstrained(x) for x in X])
|
|
458
|
+
V = np.zeros((len(X), self.K))
|
|
459
|
+
lp = np.full(len(X), -np.inf)
|
|
460
|
+
zero = np.zeros(self.K)
|
|
461
|
+
for j, x in enumerate(X): # the prior, point by point
|
|
462
|
+
eta, cache = self.prior.forward(x)
|
|
463
|
+
value = self._value(eta)
|
|
464
|
+
if np.isfinite(value.sum()): # else a zero row: an impossible point
|
|
465
|
+
V[j] = value
|
|
466
|
+
lp[j] = self.prior.log_prior_and_grad(x, cache, value, zero)[0]
|
|
467
|
+
ll = self._log_likelihood_many(V) + self._ll_const
|
|
468
|
+
return np.where(np.isfinite(ll), ll + lp, -np.inf)
|
|
469
|
+
|
|
470
|
+
def _log_likelihood_many(self, V: np.ndarray) -> np.ndarray:
|
|
471
|
+
# log_likelihood(v, absolute=False) at each row of V (n, K), many per pass
|
|
472
|
+
if self.joint:
|
|
473
|
+
return np.array([self.log_likelihood(v, absolute=False) for v in V])
|
|
474
|
+
return kern.loglike_many(self._L, self._w, V) + self._selection_term(V)
|
|
475
|
+
|
|
476
|
+
def _with_bound_prior(self, prior: BoundPrior) -> PopulationModel:
|
|
477
|
+
"""A shallow copy sharing the data but using another parametrisation of the prior."""
|
|
478
|
+
new = copy.copy(self)
|
|
479
|
+
new.prior = prior
|
|
480
|
+
return new
|
|
481
|
+
|
|
482
|
+
def _with_weights(self, weights: np.ndarray) -> PopulationModel:
|
|
483
|
+
"""A shallow copy with other object weights, keeping only the rows they leave positive.
|
|
484
|
+
|
|
485
|
+
Rows of zero weight are dropped, so a bin they alone support cannot make
|
|
486
|
+
the likelihood -inf (audit M-1); the one way engines override weights
|
|
487
|
+
(bootstrap replicates).
|
|
488
|
+
"""
|
|
489
|
+
w = np.asarray(weights, dtype=float)
|
|
490
|
+
keep = w > 0
|
|
491
|
+
lk = self.likelihoods if keep.all() else self.likelihoods.subset(np.flatnonzero(keep))
|
|
492
|
+
new = copy.copy(self)
|
|
493
|
+
new._set_likelihoods(lk._copy(weights=w[keep]))
|
|
494
|
+
return new
|
|
495
|
+
|
|
496
|
+
def _complete_counts(self, value: np.ndarray, nhat: np.ndarray) -> np.ndarray:
|
|
497
|
+
r"""Expected counts with the missed objects imputed (fractions with selection).
|
|
498
|
+
|
|
499
|
+
Adds :math:`W\rho_k(1 - S_k)/(S\cdot\rho)`, with :math:`S` rescaled to a
|
|
500
|
+
maximum of one, which leaves the posterior unchanged; used by EM, VB and
|
|
501
|
+
the interior start (DECISIONS #57).
|
|
502
|
+
"""
|
|
503
|
+
if self.selection is None or self.kind == "rates": # Poisson thinning imputes nothing
|
|
504
|
+
return nhat
|
|
505
|
+
S = self._detect
|
|
506
|
+
rho = value / value.sum()
|
|
507
|
+
return nhat + self.W * rho * (1.0 - S) / float(S @ rho)
|
|
508
|
+
|
|
509
|
+
def _draw_missed(
|
|
510
|
+
self,
|
|
511
|
+
value: np.ndarray,
|
|
512
|
+
counts: np.ndarray,
|
|
513
|
+
rng: np.random.Generator,
|
|
514
|
+
S: np.ndarray | None = None,
|
|
515
|
+
) -> np.ndarray:
|
|
516
|
+
r"""Counts with the missed objects drawn given the fractions (fractions with selection).
|
|
517
|
+
|
|
518
|
+
Their number is :math:`\mathrm{NegBin}(W, S\cdot\rho)` (failures before
|
|
519
|
+
:math:`W` successes) and their bins :math:`\mathrm{Mult}(\rho\circ(1-S))`, with
|
|
520
|
+
:math:`S` rescaled to a maximum of one (or ``S`` itself, absolute, when an
|
|
521
|
+
uncertain selection is being drawn); Gibbs's counterpart of
|
|
522
|
+
:meth:`_complete_counts` (audit G-7).
|
|
523
|
+
"""
|
|
524
|
+
if (self.selection is None and S is None) or self.kind == "rates":
|
|
525
|
+
return counts
|
|
526
|
+
S = self._detect if S is None else S
|
|
527
|
+
rho = value / value.sum()
|
|
528
|
+
n_missed = rng.negative_binomial(self.W, float(np.clip(S @ rho, _TINY, 1.0)))
|
|
529
|
+
if n_missed == 0:
|
|
530
|
+
return counts
|
|
531
|
+
q = rho * np.maximum(1.0 - S, 0.0) # an efficiency may round to above one
|
|
532
|
+
return counts + rng.multinomial(n_missed, q / q.sum())
|
|
533
|
+
|
|
534
|
+
def to_value(self, x: np.ndarray) -> np.ndarray:
|
|
535
|
+
"""Fractions (or rates, for rate priors) at unconstrained ``x``.
|
|
536
|
+
|
|
537
|
+
Parameters
|
|
538
|
+
----------
|
|
539
|
+
x : ndarray (dim,)
|
|
540
|
+
Unconstrained point.
|
|
541
|
+
"""
|
|
542
|
+
eta, _ = self.prior.forward(np.asarray(x, dtype=float)[: self.prior.dim])
|
|
543
|
+
return self._value(eta)
|
|
544
|
+
|
|
545
|
+
def to_rho(self, x: np.ndarray) -> np.ndarray:
|
|
546
|
+
"""Population fractions at unconstrained ``x``.
|
|
547
|
+
|
|
548
|
+
Parameters
|
|
549
|
+
----------
|
|
550
|
+
x : ndarray (dim,)
|
|
551
|
+
Unconstrained point.
|
|
552
|
+
"""
|
|
553
|
+
v = self.to_value(x)
|
|
554
|
+
return v / v.sum() if self.kind == "rates" else v
|
|
555
|
+
|
|
556
|
+
def transform(self, X: np.ndarray) -> np.ndarray:
|
|
557
|
+
"""Map many unconstrained draws ``(n, dim)`` to values ``(n, K)``.
|
|
558
|
+
|
|
559
|
+
Parameters
|
|
560
|
+
----------
|
|
561
|
+
X : ndarray (n, dim)
|
|
562
|
+
Unconstrained draws.
|
|
563
|
+
"""
|
|
564
|
+
X = np.atleast_2d(np.asarray(X, dtype=float))
|
|
565
|
+
return np.array([self.to_value(x) for x in X])
|
|
566
|
+
|
|
567
|
+
def from_value(self, value: np.ndarray) -> np.ndarray:
|
|
568
|
+
"""An unconstrained point corresponding to fractions (or rates) ``value`` (and to
|
|
569
|
+
the selection's :attr:`~Selection.values`, or the response's posterior means, when
|
|
570
|
+
they are inferred).
|
|
571
|
+
|
|
572
|
+
Parameters
|
|
573
|
+
----------
|
|
574
|
+
value : ndarray (K,)
|
|
575
|
+
Fractions or rates.
|
|
576
|
+
"""
|
|
577
|
+
x = self.prior.init_from_value(np.asarray(value, dtype=float))
|
|
578
|
+
if self.selection_uncertain:
|
|
579
|
+
return np.concatenate([x, special.logit(self.selection)])
|
|
580
|
+
if self.response is not None: # the posterior means of the columns
|
|
581
|
+
P = self.response._mean_columns().T
|
|
582
|
+
return np.concatenate([x, (np.log(P) @ helmert_basis(P.shape[1])).ravel()])
|
|
583
|
+
return x
|
|
584
|
+
|
|
585
|
+
# ----------------------------------------------- fraction/rate space
|
|
586
|
+
def log_likelihood(
|
|
587
|
+
self,
|
|
588
|
+
value: np.ndarray,
|
|
589
|
+
absolute: bool = True,
|
|
590
|
+
S: np.ndarray | None = None,
|
|
591
|
+
response: np.ndarray | None = None,
|
|
592
|
+
) -> float:
|
|
593
|
+
"""Marginal log-likelihood of the population (including the selection term).
|
|
594
|
+
|
|
595
|
+
Parameters
|
|
596
|
+
----------
|
|
597
|
+
value : ndarray (K,)
|
|
598
|
+
Fractions or rates.
|
|
599
|
+
absolute : bool, optional
|
|
600
|
+
Include the per-object log-scales of the likelihoods, so values compare
|
|
601
|
+
across binnings and models.
|
|
602
|
+
S : ndarray (K,), optional
|
|
603
|
+
Value of an inferred selection; ``None`` uses its
|
|
604
|
+
:attr:`~Selection.values`.
|
|
605
|
+
response : ndarray (J, K), optional
|
|
606
|
+
Value of an inferred response; ``None`` uses the rows of the likelihoods
|
|
607
|
+
(its posterior means).
|
|
608
|
+
"""
|
|
609
|
+
v = np.asarray(value, dtype=float)
|
|
610
|
+
if response is not None:
|
|
611
|
+
if self.response is None:
|
|
612
|
+
raise ValueError("response applies only to a response inferred from tallies.")
|
|
613
|
+
R = check_nonnegative(response, "response")
|
|
614
|
+
if R.shape != self.response.matrix.shape or np.any(R.sum(axis=0) > 1.0 + 1e-6):
|
|
615
|
+
raise ValueError(
|
|
616
|
+
f"response must have shape {self.response.matrix.shape}, non-negative "
|
|
617
|
+
"columns that sum to at most one."
|
|
618
|
+
)
|
|
619
|
+
ll = self._response_terms(v, R)[0]
|
|
620
|
+
return ll if absolute else ll - self._ll_const
|
|
621
|
+
if S is not None:
|
|
622
|
+
if not self.selection_uncertain:
|
|
623
|
+
raise ValueError("S applies only to a selection inferred from injections.")
|
|
624
|
+
S = np.asarray(S, dtype=float)
|
|
625
|
+
if S.shape != (self.K,) or not np.all((S > 0) & (S <= 1)):
|
|
626
|
+
raise ValueError(f"S must have shape ({self.K},) with values in (0, 1].")
|
|
627
|
+
L, u = self._rows(v, S)
|
|
628
|
+
ll = kern.loglike(L, self._w, u)
|
|
629
|
+
if not np.isfinite(ll):
|
|
630
|
+
return -np.inf
|
|
631
|
+
return ll + self._selection_term(v, S) + (self._ll_const if absolute else 0.0)
|
|
632
|
+
|
|
633
|
+
def log_prior(self, value: np.ndarray) -> float:
|
|
634
|
+
"""Prior log-density of fractions (on the simplex) or rates, where defined.
|
|
635
|
+
|
|
636
|
+
A density in the fractions (or rates) themselves, without the Jacobian of
|
|
637
|
+
any reparametrisation (DECISIONS #15). On the boundary it is -inf where
|
|
638
|
+
the density is zero, +inf where it is unbounded and NaN where it is
|
|
639
|
+
undefined (a Polya tree); NaN for priors without a closed-form density
|
|
640
|
+
(logistic priors).
|
|
641
|
+
|
|
642
|
+
Parameters
|
|
643
|
+
----------
|
|
644
|
+
value : ndarray (K,)
|
|
645
|
+
Fractions or rates.
|
|
646
|
+
"""
|
|
647
|
+
if not self.prior.has_density:
|
|
648
|
+
return np.nan
|
|
649
|
+
with np.errstate(invalid="ignore"):
|
|
650
|
+
return float(self.prior.log_density(np.asarray(value, dtype=float)))
|
|
651
|
+
|
|
652
|
+
def log_posterior(self, value: np.ndarray) -> float:
|
|
653
|
+
"""Unnormalised log posterior density of fractions (on the simplex) or rates.
|
|
654
|
+
|
|
655
|
+
A density in the fractions or rates themselves, without the Jacobian of
|
|
656
|
+
the unconstrained coordinates, so its mode is the MAP estimate on the
|
|
657
|
+
simplex (``MAP(space="simplex")``); compare
|
|
658
|
+
:meth:`log_density_unconstrained` (DECISIONS #15).
|
|
659
|
+
|
|
660
|
+
Parameters
|
|
661
|
+
----------
|
|
662
|
+
value : ndarray (K,)
|
|
663
|
+
Fractions or rates.
|
|
664
|
+
"""
|
|
665
|
+
return self.log_likelihood(value) + self.log_prior(value)
|
|
666
|
+
|
|
667
|
+
def expected_counts(self, value: np.ndarray) -> np.ndarray:
|
|
668
|
+
r"""Expected latent counts :math:`\hat n_k = \sum_i w_i r_{ik}`.
|
|
669
|
+
|
|
670
|
+
Parameters
|
|
671
|
+
----------
|
|
672
|
+
value : ndarray (K,)
|
|
673
|
+
Fractions or rates.
|
|
674
|
+
"""
|
|
675
|
+
_, nhat, _ = kern.loglike_and_counts(self._L, self._w, np.asarray(value, float))
|
|
676
|
+
return nhat
|
|
677
|
+
|
|
678
|
+
def responsibilities(self, value: np.ndarray, idx: Any = None) -> np.ndarray:
|
|
679
|
+
r"""Per-object posteriors over bins given the population: :math:`r_{ik}`.
|
|
680
|
+
|
|
681
|
+
Parameters
|
|
682
|
+
----------
|
|
683
|
+
value : ndarray (K,)
|
|
684
|
+
Fractions or rates.
|
|
685
|
+
idx : index, optional
|
|
686
|
+
Objects to use; ``None`` uses all of them.
|
|
687
|
+
"""
|
|
688
|
+
return kern.responsibilities(self._L, np.asarray(value, dtype=float), idx=idx)
|
|
689
|
+
|
|
690
|
+
def sample_prior(self, n: int, rng: RNGLike = None) -> np.ndarray:
|
|
691
|
+
"""Draws of fractions (or rates) from the prior.
|
|
692
|
+
|
|
693
|
+
Parameters
|
|
694
|
+
----------
|
|
695
|
+
n : int
|
|
696
|
+
Number of draws.
|
|
697
|
+
rng : int, numpy.random.Generator, or None
|
|
698
|
+
Random number generator.
|
|
699
|
+
"""
|
|
700
|
+
return self.prior.sample(int(n), as_generator(rng))
|
|
701
|
+
|
|
702
|
+
def prior_transform(self, u: np.ndarray) -> np.ndarray:
|
|
703
|
+
"""Unit-hypercube transform to prior draws (for nested samplers).
|
|
704
|
+
|
|
705
|
+
Parameters
|
|
706
|
+
----------
|
|
707
|
+
u : ndarray (..., dim)
|
|
708
|
+
Points in the unit hypercube.
|
|
709
|
+
"""
|
|
710
|
+
return self.prior.prior_transform(np.asarray(u, dtype=float))
|
|
711
|
+
|
|
712
|
+
# -------------------------------------------------------- diagnostics
|
|
713
|
+
def kkt_gap(self, value: np.ndarray, include_prior: bool = True) -> float:
|
|
714
|
+
r"""Violation of the first-order optimality conditions at ``value``.
|
|
715
|
+
|
|
716
|
+
Zero (to numerical precision) at the maximum-likelihood / MAP estimate
|
|
717
|
+
on the simplex (or the positive orthant for rates), and infinite where
|
|
718
|
+
an object has zero likelihood. For fractions it is normalised by the
|
|
719
|
+
total weight; for rates it is the larger of :math:`\max_k G_k/S_k` and
|
|
720
|
+
:math:`|\lambda\cdot G|/(S\cdot\lambda)` for the gradient :math:`G`
|
|
721
|
+
in :math:`\lambda`, so it does not depend on the exposure's units
|
|
722
|
+
(DECISIONS #56, #71). Self-consistency of the
|
|
723
|
+
EM fixed point is necessary but not sufficient for optimality; this
|
|
724
|
+
certificate checks the rest.
|
|
725
|
+
|
|
726
|
+
Parameters
|
|
727
|
+
----------
|
|
728
|
+
value : ndarray (K,)
|
|
729
|
+
Fractions or rates.
|
|
730
|
+
include_prior : bool, optional
|
|
731
|
+
Include the prior's pseudo-counts in the optimality conditions.
|
|
732
|
+
"""
|
|
733
|
+
v = np.asarray(value, dtype=float)
|
|
734
|
+
s = kern.matvec(self._L, v)
|
|
735
|
+
if not kern.possible(s):
|
|
736
|
+
return np.inf # an object is impossible here (audit M-3)
|
|
737
|
+
G = kern.rmatvec(self._L, self._w / s)
|
|
738
|
+
pos = v > 0
|
|
739
|
+
if self.kind == "rates":
|
|
740
|
+
G = G - self._S
|
|
741
|
+
elif self.selection is not None:
|
|
742
|
+
G = G - self.W * self.selection / float(self.selection @ v)
|
|
743
|
+
if include_prior and self.prior.has_density:
|
|
744
|
+
# at a zero, the gradient at _TINY (as in the MAP objective): -b for rates of
|
|
745
|
+
# shape one, hugely negative where the density is unbounded there (no violation),
|
|
746
|
+
# hugely positive where it vanishes (DECISIONS #71)
|
|
747
|
+
G = G + self.prior.grad_log_density(np.maximum(v, _TINY))
|
|
748
|
+
mu = float(v[pos] @ G[pos])
|
|
749
|
+
if self.kind == "rates":
|
|
750
|
+
# G <= 0 everywhere and lambda . G = 0 (the total), relative to the exposure so
|
|
751
|
+
# free of its units (audit M-4); one-sided like the fractions' test, so a rate
|
|
752
|
+
# EM drives towards zero is not a violation (audit M-6)
|
|
753
|
+
total_gap = abs(mu) / float(self._S @ v)
|
|
754
|
+
return max(float(np.max(G / self._S)), total_gap)
|
|
755
|
+
return max(0.0, float(np.max(G - mu))) / max(self.W, 1.0)
|
|
756
|
+
|
|
757
|
+
def missing_information(
|
|
758
|
+
self,
|
|
759
|
+
value: np.ndarray,
|
|
760
|
+
max_objects: int = 20000,
|
|
761
|
+
rng: RNGLike = 0,
|
|
762
|
+
include_prior: bool = False,
|
|
763
|
+
chunk: int | None = kern.DEFAULT_CHUNK,
|
|
764
|
+
) -> float:
|
|
765
|
+
"""Largest fraction of missing information at ``value`` (0 to 1).
|
|
766
|
+
|
|
767
|
+
Close to 0 when per-object PDFs are narrow compared with the bins and
|
|
768
|
+
close to 1 when they are broad. It predicts the convergence rate of EM
|
|
769
|
+
and the lag-one autocorrelation of the slowest mode of the Gibbs sampler
|
|
770
|
+
(integrated autocorrelation time roughly ``(1 + f) / (1 - f)``). With a
|
|
771
|
+
selection function the missed objects count as missing data, as EM and
|
|
772
|
+
Gibbs impute them; ``include_prior`` adds a Dirichlet prior's term, as in
|
|
773
|
+
the MAP EM (audit M-2; other priors add none; NaN where a prior with
|
|
774
|
+
concentrations below one outweighs the data). Computed on the support
|
|
775
|
+
of ``value`` and on a random subset of at most ``max_objects`` objects, ``chunk``
|
|
776
|
+
objects at a time.
|
|
777
|
+
|
|
778
|
+
Parameters
|
|
779
|
+
----------
|
|
780
|
+
value : ndarray (K,)
|
|
781
|
+
Fractions or rates.
|
|
782
|
+
max_objects : int, optional
|
|
783
|
+
Largest random subset of objects used.
|
|
784
|
+
rng : int, Generator or None, optional
|
|
785
|
+
Random number generator for the subset; the default ``0`` makes it
|
|
786
|
+
reproducible.
|
|
787
|
+
include_prior : bool, optional
|
|
788
|
+
Add a Dirichlet prior's term, as the MAP EM does.
|
|
789
|
+
chunk : int, optional
|
|
790
|
+
Objects processed per block, which bounds memory.
|
|
791
|
+
"""
|
|
792
|
+
v = np.asarray(value, dtype=float)
|
|
793
|
+
N = self.N
|
|
794
|
+
if N > max_objects:
|
|
795
|
+
idx = np.sort(as_generator(rng).choice(N, size=max_objects, replace=False))
|
|
796
|
+
L = self._L[idx]
|
|
797
|
+
w = self._w[idx] * (self.W / self._w[idx].sum())
|
|
798
|
+
else:
|
|
799
|
+
L, w = self._L, self._w
|
|
800
|
+
# on the support: a value at zero is an active constraint, along which the
|
|
801
|
+
# information is degenerate rather than missing
|
|
802
|
+
on = v > 1e-9 * v.max()
|
|
803
|
+
if on.sum() < 2:
|
|
804
|
+
return 0.0
|
|
805
|
+
nhat, RwR = np.zeros(on.sum()), np.zeros((on.sum(), on.sum()))
|
|
806
|
+
for sl in chunk_slices(L.shape[0], chunk):
|
|
807
|
+
R = kern.responsibilities(L[sl], v, chunk=None)[:, on]
|
|
808
|
+
nhat += w[sl] @ R
|
|
809
|
+
RwR += (R * w[sl, None]).T @ R
|
|
810
|
+
Im = np.diag(nhat) - RwR
|
|
811
|
+
if self.kind == "rates": # Poisson thinning imputes nothing
|
|
812
|
+
I_c = np.diag(nhat)
|
|
813
|
+
I_obs = I_c - Im
|
|
814
|
+
else:
|
|
815
|
+
rho = v[on] / v[on].sum()
|
|
816
|
+
C = np.diag(rho) - np.outer(rho, rho)
|
|
817
|
+
# a Dirichlet prior adds (A - K) to the complete-data count, as in the MAP EM
|
|
818
|
+
a = self.prior.pseudocounts if include_prior else None
|
|
819
|
+
extra = 0.0 if a is None else float(np.sum(a[on]))
|
|
820
|
+
# complete data: the observed objects and, with selection, the imputed missed ones
|
|
821
|
+
S = np.ones(on.sum()) if self.selection is None else self._detect[on]
|
|
822
|
+
phi = S * rho / float(S @ rho)
|
|
823
|
+
n_complete = self.W / float(S @ rho) + extra
|
|
824
|
+
if n_complete <= 0: # a prior with concentrations below one outweighs the data
|
|
825
|
+
return np.nan
|
|
826
|
+
I_c = n_complete * C
|
|
827
|
+
I_obs = self.W * (np.diag(phi) - np.outer(phi, phi)) - Im + extra * C
|
|
828
|
+
V = helmert_basis(rho.size)
|
|
829
|
+
I_c, I_obs = V.T @ I_c @ V, V.T @ I_obs @ V
|
|
830
|
+
I_c = 0.5 * (I_c + I_c.T) + 1e-12 * np.trace(I_c) / len(I_c) * np.eye(len(I_c))
|
|
831
|
+
I_obs = 0.5 * (I_obs + I_obs.T)
|
|
832
|
+
lam = linalg.eigh(I_obs, I_c, eigvals_only=True)
|
|
833
|
+
return float(np.clip(1.0 - lam.min(), 0.0, 1.0))
|