binference 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. binference/__init__.py +85 -0
  2. binference/_kernels.py +252 -0
  3. binference/_quadrature.py +202 -0
  4. binference/_simplex.py +54 -0
  5. binference/_utils.py +139 -0
  6. binference/_version.py +1 -0
  7. binference/basis.py +314 -0
  8. binference/bins.py +645 -0
  9. binference/density.py +323 -0
  10. binference/diagnostics.py +400 -0
  11. binference/estimator.py +197 -0
  12. binference/inference.py +368 -0
  13. binference/io.py +309 -0
  14. binference/likelihoods.py +1684 -0
  15. binference/measure.py +246 -0
  16. binference/model.py +833 -0
  17. binference/plotting.py +538 -0
  18. binference/priors/__init__.py +47 -0
  19. binference/priors/base.py +248 -0
  20. binference/priors/dirichlet.py +640 -0
  21. binference/priors/hyper.py +240 -0
  22. binference/priors/logistic.py +676 -0
  23. binference/priors/rates.py +139 -0
  24. binference/response.py +539 -0
  25. binference/results.py +2271 -0
  26. binference/samplers/__init__.py +19 -0
  27. binference/samplers/_curvature.py +67 -0
  28. binference/samplers/_nuts_core.py +284 -0
  29. binference/samplers/base.py +118 -0
  30. binference/samplers/bootstrap.py +136 -0
  31. binference/samplers/gibbs.py +287 -0
  32. binference/samplers/laplace.py +200 -0
  33. binference/samplers/nuts.py +681 -0
  34. binference/samplers/optimize.py +488 -0
  35. binference/samplers/vb.py +118 -0
  36. binference/selection.py +621 -0
  37. binference/simulate.py +720 -0
  38. binference/validation.py +477 -0
  39. binference/warnings.py +48 -0
  40. binference-0.1.0.dist-info/METADATA +130 -0
  41. binference-0.1.0.dist-info/RECORD +43 -0
  42. binference-0.1.0.dist-info/WHEEL +4 -0
  43. binference-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,287 @@
1
+ """Data-augmentation Gibbs sampler for conjugate priors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator
6
+ from typing import Any
7
+
8
+ import numpy as np
9
+
10
+ from .. import _kernels as kern
11
+ from .._utils import RNGLike, check_positive, is_integer_array, progress, spawn
12
+ from ..model import PopulationModel
13
+ from ..priors.dirichlet import _dirichlet_draw
14
+ from ..results import PopulationResult
15
+ from ..selection import _beta_posterior, _draw_beta
16
+ from .base import Engine, interior_start
17
+
18
+ __all__ = ["Gibbs"]
19
+
20
+
21
+ class Gibbs(Engine):
22
+ r"""Data-augmentation Gibbs sampler (the "hierarchical" sampler).
23
+
24
+ Alternates (i) drawing every object's bin label
25
+ :math:`z_i \sim \mathrm{Cat}(r_i(\rho))` and (ii) drawing the population from
26
+ its conjugate conditional given the counts (Dirichlet family:
27
+ :math:`\rho \mid n \sim \mathrm{Dir}(\alpha + n)`; rates:
28
+ :math:`\lambda_k \mid n \sim \mathrm{Gamma}(a_k + n_k, b_k + S_k)`). With a
29
+ selection function on fractions, the number of undetected objects and their
30
+ bins are imputed as well (negative-binomial augmentation), keeping every
31
+ step conjugate.
32
+
33
+ The marginal posterior of the population is the same as NUTS's; Gibbs
34
+ additionally yields joint draws of the labels. It mixes slowly when
35
+ per-object PDFs are broad compared with the bins (a large fraction of
36
+ missing information; see :meth:`PopulationModel.missing_information`), so
37
+ check the effective sample sizes.
38
+
39
+ Parameters
40
+ ----------
41
+ model : PopulationModel
42
+ Must use a conjugate prior and integer object weights.
43
+ n_chains : int
44
+ Independent chains (default 4).
45
+ n_warmup : int
46
+ Burn-in iterations per chain.
47
+ thin : int
48
+ Keep every ``thin``-th draw.
49
+ store_labels : bool or array_like of int
50
+ Store joint draws of the objects' bins. ``True`` stores them for all
51
+ objects (unit weights only), an array for the given object indices
52
+ (sorted and deduplicated, as ``result.label_index`` records).
53
+ init : array_like (K,), optional
54
+ Positive fractions or rates around which the chains start. Each chain
55
+ starts from its own draw of the population's conditional given the
56
+ expected counts there, as by default around a few EM steps.
57
+ chunk : int or None
58
+ Objects processed per block when drawing labels from dense likelihoods, which
59
+ bounds memory; ``None`` processes all at once. Sparse likelihoods are processed at once,
60
+ in memory proportional to their nonzeros.
61
+ rng : int, numpy.random.Generator, or None
62
+ Random number generator.
63
+ progress : bool
64
+ Show a progress bar.
65
+ """
66
+
67
+ name = "gibbs"
68
+ joint = True
69
+ markov = True
70
+
71
+ def __init__(
72
+ self,
73
+ model: PopulationModel,
74
+ *,
75
+ n_chains: int = 4,
76
+ n_warmup: int = 500,
77
+ thin: int = 1,
78
+ store_labels: bool | Any = False,
79
+ init: Any = None,
80
+ chunk: int | None = kern.DEFAULT_CHUNK,
81
+ rng: RNGLike = None,
82
+ progress: bool = False,
83
+ ) -> None:
84
+ super().__init__(model, rng=rng, progress=progress)
85
+ self._w_int = np.round(model.likelihoods.weights)
86
+ self._unit = bool(np.all(self._w_int == 1))
87
+ if store_labels is True:
88
+ if not self._unit:
89
+ raise ValueError("store_labels=True needs unit weights; pass object indices.")
90
+ self.label_index = np.arange(model.N)
91
+ elif store_labels is False or store_labels is None:
92
+ self.label_index = None
93
+ else:
94
+ self.label_index = np.unique(np.atleast_1d(np.asarray(store_labels, dtype=int)))
95
+ if not self._unit:
96
+ raise ValueError("Storing labels needs unit object weights.")
97
+ if self.label_index.size and not (
98
+ 0 <= self.label_index[0] and self.label_index[-1] < model.N
99
+ ):
100
+ raise ValueError(f"store_labels must be object indices in 0..{model.N - 1}.")
101
+ if self.label_index is not None and model.response is not None:
102
+ raise ValueError(
103
+ "store_labels is not available with an inferred response (objects are counts "
104
+ "per measured bin); use result.sample_assignments()."
105
+ )
106
+ if init is not None:
107
+ init = check_positive(init, "init")
108
+ if init.shape != (model.K,):
109
+ raise ValueError(f"init must have shape ({model.K},); got {init.shape}.")
110
+ if n_chains < 1 or thin < 1:
111
+ raise ValueError("n_chains and thin must be positive.")
112
+ self.n_chains = int(n_chains)
113
+ self.n_warmup = int(n_warmup)
114
+ self.thin = int(thin)
115
+ self.chunk = None if chunk is None else int(chunk)
116
+ self.init = init
117
+ self._state: list[np.ndarray] | None = None
118
+ self._rngs: list[np.random.Generator] | None = None
119
+ self._warm = False
120
+ self._draws: list[np.ndarray] = []
121
+ self._labels: list[np.ndarray] = []
122
+ # a selection or response inferred with the population: its current value per chain
123
+ self._extra: list[np.ndarray] | None = None
124
+ self._extra_draws: list[np.ndarray] = []
125
+
126
+ @classmethod
127
+ def unsupported(cls, model: PopulationModel) -> str | None:
128
+ if not model.prior.conjugate:
129
+ return (
130
+ f"Gibbs sampling needs a conjugate prior; {model.prior_spec!r} is not. "
131
+ "Use NUTS instead."
132
+ )
133
+ if model.selection_uncertain and model.selection_spec.injections.smooth is not None:
134
+ return (
135
+ "Gibbs sampling infers independent Betas only; a smooth injection model is not "
136
+ "conjugate. Use NUTS instead."
137
+ )
138
+ if not is_integer_array(model.likelihoods.weights):
139
+ return (
140
+ "Gibbs sampling needs integer object weights (multiplicities); "
141
+ "use NUTS for non-integer weights."
142
+ )
143
+ return super().unsupported(model)
144
+
145
+ def _setup(self) -> None:
146
+ if self._state is not None:
147
+ return
148
+ m = self.model
149
+ self._rngs = spawn(self.rng, self.n_chains)
150
+ # overdispersed starts: each chain draws from the conditional given expected counts
151
+ base = interior_start(m) if self.init is None else self.init
152
+ nhat = m._complete_counts(base, m.expected_counts(base))
153
+ self._state = [m.prior.posterior_sample(nhat, r, exposure=m._exposure) for r in self._rngs]
154
+ # each chain starts from a draw given the injections or the tallies
155
+ if m.selection_uncertain:
156
+ self._extra = [m.selection_spec.draw(1, r)[0] for r in self._rngs] # floored
157
+ elif m.response is not None:
158
+ self._extra = [m.response.draw(1, r)[0] for r in self._rngs]
159
+
160
+ def _iterate(self, c: int) -> tuple[np.ndarray, np.ndarray | None]:
161
+ m = self.model
162
+ rng = self._rngs[c]
163
+ value = self._state[c]
164
+ if m.selection_uncertain:
165
+ return self._iterate_joint(c, m, rng, value)
166
+ if m.response is not None:
167
+ return self._iterate_response(c, m, rng, value)
168
+ counts, labels = kern.sample_counts(
169
+ m._L, self._w_int, value, rng, chunk=self.chunk, unit_weights=self._unit
170
+ )
171
+ counts = m._draw_missed(value, counts, rng)
172
+ self._state[c] = m.prior.posterior_sample(counts, rng, exposure=m._exposure)
173
+ return self._state[c], labels
174
+
175
+ def _iterate_joint(self, c, m, rng, value) -> tuple[np.ndarray, np.ndarray | None]:
176
+ # labels with the current S, the missed objects with the absolute S, then rho and
177
+ # S from their conjugate conditionals (DECISIONS #79)
178
+ S = self._extra[c] # on the truth: rows of L_i scaled by S, detections are successes
179
+ counts, labels = kern.sample_counts(
180
+ m.likelihoods.values,
181
+ self._w_int,
182
+ value * S,
183
+ rng,
184
+ chunk=self.chunk,
185
+ unit_weights=self._unit,
186
+ )
187
+ missed = m._draw_missed(value, counts, rng, S=S) - counts # with the absolute S
188
+ s, f = _beta_posterior(m.selection_spec.injections)
189
+ self._extra[c] = _draw_beta(s + counts, f + missed, rng) # floored
190
+ self._state[c] = m.prior.posterior_sample(counts + missed, rng)
191
+ return self._state[c], labels
192
+
193
+ def _iterate_response(self, c, m, rng, value) -> tuple[np.ndarray, None]:
194
+ # each measured bin's objects split over the truth bins, the missed objects with the
195
+ # current efficiency, then rho and the columns of R (with their lost shares) from
196
+ # their Dirichlet conditionals (DECISIONS #81)
197
+ R = self._extra[c]
198
+ P = R[m._mb] * value # one row per measured bin
199
+ split = rng.multinomial(self._w_int.astype(np.int64), P / P.sum(axis=1)[:, None])
200
+ counts = split.sum(axis=0)
201
+ J = R.shape[0]
202
+ A = m._alpha.copy() # (J + 1, K) with the lost share last, or (J, K) without one
203
+ np.add.at(A, m._mb, split)
204
+ missed = np.zeros_like(counts)
205
+ if A.shape[0] > J:
206
+ missed = m._draw_missed(value, counts, rng, S=R.sum(axis=0)) - counts
207
+ A[-1] += missed
208
+ self._extra[c] = _dirichlet_draw(A.T, rng).T[:J]
209
+ self._state[c] = m.prior.posterior_sample(counts + missed, rng)
210
+ return self._state[c], None
211
+
212
+ def warmup(self) -> None:
213
+ """Run burn-in if it has not been run yet."""
214
+ self._setup()
215
+ if self._warm:
216
+ return
217
+ for _ in progress(range(self.n_warmup), self.n_warmup, "Gibbs burn-in", self.progress):
218
+ for c in range(self.n_chains):
219
+ self._iterate(c)
220
+ self._warm = True
221
+
222
+ def sample(self, n_samples: int) -> Iterator[np.ndarray]:
223
+ """Generator over kept iterations, yielding ``(n_chains, K)`` values.
224
+
225
+ Parameters
226
+ ----------
227
+ n_samples : int
228
+ Kept iterations per chain.
229
+ """
230
+ self.warmup()
231
+ C, K = self.n_chains, self.model.K
232
+ draws = np.empty((C, n_samples, K))
233
+ joint = self._extra is not None
234
+ extra = np.empty((C, n_samples, *self._extra[0].shape)) if joint else None
235
+ store = self.label_index is not None
236
+ labs = np.empty((C, n_samples, len(self.label_index)), dtype=np.int32) if store else None
237
+ n_done = 0
238
+ try:
239
+ for i in progress(range(n_samples), n_samples, "Gibbs sampling", self.progress):
240
+ for c in range(C):
241
+ for _ in range(self.thin):
242
+ value, labels = self._iterate(c)
243
+ draws[c, i] = value
244
+ if joint:
245
+ extra[c, i] = self._extra[c]
246
+ if store:
247
+ labs[c, i] = labels[self.label_index]
248
+ n_done = i + 1
249
+ yield draws[:, i]
250
+ finally:
251
+ if n_done:
252
+ self._draws.append(draws[:, :n_done])
253
+ if joint:
254
+ self._extra_draws.append(extra[:, :n_done])
255
+ if store:
256
+ self._labels.append(labs[:, :n_done])
257
+
258
+ def run(self, n_samples: int = 1000) -> PopulationResult:
259
+ """Burn in (first call only), draw ``n_samples`` per chain, and return all draws so far.
260
+
261
+ Parameters
262
+ ----------
263
+ n_samples : int, optional
264
+ Draws per chain after burn-in.
265
+ """
266
+ for _ in self.sample(int(n_samples)):
267
+ pass
268
+ return self.result
269
+
270
+ @property
271
+ def result(self) -> PopulationResult:
272
+ if not self._draws:
273
+ raise RuntimeError("No draws yet; call run().")
274
+ D = np.concatenate(self._draws, axis=1)
275
+ labels = np.concatenate(self._labels, axis=1) if self._labels else None
276
+ info = {"thin": self.thin, "n_warmup": self.n_warmup}
277
+ if self._extra_draws: # inferred with the population (DECISIONS #79, #81)
278
+ key = "selection_draws" if self.model.selection_uncertain else "response_draws"
279
+ info[key] = np.concatenate(self._extra_draws, axis=1)
280
+ return PopulationResult(
281
+ self.model,
282
+ D,
283
+ method="gibbs",
284
+ labels=labels,
285
+ label_index=self.label_index,
286
+ info=info,
287
+ )
@@ -0,0 +1,200 @@
1
+ """Laplace approximation with optional Pareto-smoothed importance resampling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import warnings
6
+ from typing import Any
7
+
8
+ import numpy as np
9
+
10
+ from .. import diagnostics as diag
11
+ from .._utils import RNGLike, check_positive, warn
12
+ from ..model import PopulationModel
13
+ from ..results import PopulationResult
14
+ from ..warnings import ApproximationWarning, ConvergenceWarning
15
+ from ._curvature import fd_hessian, neg_hessian_eigen
16
+ from .base import Engine
17
+ from .optimize import MAP
18
+
19
+ __all__ = ["Laplace"]
20
+
21
+ #: Importance proposals per draw when ``n_proposals`` is ``None``.
22
+ PROPOSAL_FACTOR = 2
23
+ #: Eigenvalues of a Hessian that is not positive definite are raised to this times the
24
+ #: largest (at least one).
25
+ EIGEN_FLOOR = 1e-8
26
+
27
+
28
+ def _systematic_resample(w: np.ndarray, n: int, rng: np.random.Generator) -> np.ndarray:
29
+ c = np.cumsum(w)
30
+ c /= c[-1]
31
+ u = (rng.random() + np.arange(n)) / n
32
+ return np.minimum(np.searchsorted(c, u), w.size - 1)
33
+
34
+
35
+ class Laplace(Engine):
36
+ """Gaussian (Laplace) approximation in unconstrained coordinates.
37
+
38
+ The posterior mode in unconstrained coordinates is found with L-BFGS, the
39
+ Hessian is computed from central differences of the exact gradient, and
40
+ draws from the Gaussian are mapped to the simplex (or rates). With
41
+ ``importance=True`` (default) the draws are corrected towards the exact
42
+ posterior by Pareto-smoothed importance resampling; the Pareto shape
43
+ ``k_hat`` (in ``info``) diagnoses whether the correction is reliable:
44
+ below ``min(1 - 1 / log10(S), 0.7)`` for ``S`` proposals (Vehtari et al.
45
+ 2024), in ``info["k_hat_threshold"]``. Without a reliable correction the
46
+ result is flagged ``approximate=True``.
47
+
48
+ The approximation is excellent for well-populated bins with many objects
49
+ and poorer for nearly empty bins, whose log-fractions have long tails.
50
+
51
+ Parameters
52
+ ----------
53
+ model : PopulationModel
54
+ importance : bool
55
+ Apply PSIS importance resampling.
56
+ n_proposals : int, optional
57
+ Number of proposals for importance resampling (default
58
+ ``2 * n_samples``); without it the draws are the proposals themselves.
59
+ df : float or None
60
+ Degrees of freedom of the multivariate Student-t importance proposal
61
+ (default 5); ``None`` uses the Gaussian itself. Heavier tails make the
62
+ importance correction more robust.
63
+ inflate : float
64
+ Scale applied to the proposal's standard deviations.
65
+ step : float
66
+ Relative step for the finite-difference Hessian.
67
+ rng : int, numpy.random.Generator, or None
68
+ For the proposals and the resampling.
69
+ progress : bool
70
+ Accepted for a common signature; unused.
71
+
72
+ Notes
73
+ -----
74
+ In ILR coordinates the posterior of nearly empty bins has a long one-sided
75
+ tail, so for more than a handful of bins the importance correction often
76
+ fails (``k_hat`` above the threshold) and the result is flagged approximate: the Laplace
77
+ approximation is then a fast summary of well-populated bins, not a
78
+ replacement for NUTS.
79
+ """
80
+
81
+ name = "laplace"
82
+ joint = True
83
+
84
+ def __init__(
85
+ self,
86
+ model: PopulationModel,
87
+ *,
88
+ importance: bool = True,
89
+ n_proposals: int | None = None,
90
+ df: float | None = 5.0,
91
+ inflate: float = 1.0,
92
+ step: float = 1e-5,
93
+ rng: RNGLike = None,
94
+ progress: bool = False,
95
+ ) -> None:
96
+ super().__init__(model, rng=rng, progress=progress)
97
+ if n_proposals is not None and n_proposals < 5:
98
+ raise ValueError("n_proposals must be at least 5 (for Pareto smoothing).")
99
+ self.importance = bool(importance)
100
+ self.n_proposals = n_proposals
101
+ if df is not None and df <= 0:
102
+ raise ValueError("df must be positive (or None for a Gaussian proposal).")
103
+ self.df = None if df is None else float(df)
104
+ self.inflate = float(check_positive(inflate, "inflate"))
105
+ self.step = float(check_positive(step, "step"))
106
+ self._eigen: tuple | None = None # the mode and curvature, found once per engine
107
+
108
+ def _mode_and_eigen(self):
109
+ # the mode, the Hessian there, the eigen-decomposition of -H and the covariance
110
+ # (N-11): found once per engine (and step), with the warnings that raised, which
111
+ # every run repeats
112
+ if self._eigen is None or self._eigen[0] != self.step:
113
+ with warnings.catch_warnings(record=True) as caught:
114
+ warnings.simplefilter("always")
115
+ found = self._find_mode_and_eigen()
116
+ self._eigen = (self.step, found, caught)
117
+ _, found, caught = self._eigen
118
+ for w in caught:
119
+ warnings.warn_explicit(w.message, w.category, w.filename, w.lineno)
120
+ return tuple(a.copy() for a in found) # callers may modify what they get
121
+
122
+ def _find_mode_and_eigen(self):
123
+ m = self.model
124
+ with warnings.catch_warnings():
125
+ warnings.simplefilter("ignore", ConvergenceWarning)
126
+ x0 = MAP(m, space="unconstrained").run().x
127
+ H = fd_hessian(m.log_density_unconstrained_and_grad, x0, h=self.step)
128
+ eig = neg_hessian_eigen(H)
129
+ if eig is None:
130
+ raise ValueError("The Hessian at the mode is not finite (a non-finite gradient).")
131
+ lam, U = eig
132
+ if np.any(lam <= 0):
133
+ warn(
134
+ "The Hessian at the mode is not positive definite; clipping eigenvalues.",
135
+ ApproximationWarning,
136
+ )
137
+ lam = np.maximum(lam, EIGEN_FLOOR * max(1.0, lam.max()))
138
+ return x0, H, lam, U, (U / lam) @ U.T
139
+
140
+ def mode_and_covariance(self) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
141
+ """Mode, covariance and Hessian in unconstrained coordinates."""
142
+ x0, H, _, _, cov = self._mode_and_eigen()
143
+ return x0, cov, H
144
+
145
+ def run(self, n_samples: int = 4000) -> PopulationResult:
146
+ """Draw ``n_samples`` (approximately) from the posterior.
147
+
148
+ Parameters
149
+ ----------
150
+ n_samples : int, optional
151
+ Draws returned in total.
152
+ """
153
+ m = self.model
154
+ x0, _, lam, U, cov = self._mode_and_eigen()
155
+ d = x0.size
156
+ sd_mat = (U / np.sqrt(lam)) * self.inflate
157
+ # without importance resampling the proposals are the draws (audit N-4)
158
+ M = (self.n_proposals or PROPOSAL_FACTOR * n_samples) if self.importance else n_samples
159
+ Z = self.rng.standard_normal((M, d))
160
+ if self.importance and self.df is not None:
161
+ Z = Z / np.sqrt(self.rng.chisquare(self.df, size=M) / self.df)[:, None]
162
+ X = x0 + Z @ sd_mat.T
163
+ info: dict[str, Any] = {"mode": x0, "cov": cov}
164
+ approximate = True
165
+ if self.importance:
166
+ # log q up to a constant shared by all proposals
167
+ if self.df is None:
168
+ log_q = -0.5 * np.sum(Z**2, axis=1)
169
+ else:
170
+ log_q = -0.5 * (self.df + d) * np.log1p(np.sum(Z**2, axis=1) / self.df)
171
+ log_p = m._log_density_many(X)
172
+ lw, k_hat = diag.psis(log_p - log_q)
173
+ w = np.exp(lw)
174
+ threshold = diag.psis_threshold(M) # depends on the number of proposals (N-6)
175
+ info["k_hat"] = float(k_hat)
176
+ info["k_hat_threshold"] = float(threshold)
177
+ info["ess_importance"] = diag.importance_ess(w)
178
+ idx = _systematic_resample(w, n_samples, self.rng)
179
+ X = X[idx]
180
+ approximate = not (k_hat < threshold)
181
+ if approximate:
182
+ warn(
183
+ f"Pareto k-hat = {k_hat:.2f} >= {threshold:.2f}: the Laplace approximation "
184
+ "is not a good importance proposal; results are approximate (consider NUTS).",
185
+ ApproximationWarning,
186
+ )
187
+ else:
188
+ warn(
189
+ "Uncorrected Laplace approximation: results are approximate.", ApproximationWarning
190
+ )
191
+ vals = m.transform(X)
192
+ info.update(m._joint_info(X[None]))
193
+ return PopulationResult(
194
+ m,
195
+ vals[None],
196
+ method="laplace",
197
+ x=X[None],
198
+ approximate=approximate,
199
+ info=info,
200
+ )