binference 0.1.0__py3-none-any.whl

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