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,488 @@
1
+ """MAP / maximum-likelihood estimation: mix-SQP, SQUAREM-accelerated EM and L-BFGS."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import numpy as np
8
+ from scipy import optimize
9
+
10
+ from .. import _kernels as kern
11
+ from .._utils import RNGLike, warn
12
+ from ..likelihoods import _scale_columns
13
+ from ..model import PopulationModel
14
+ from ..priors.base import _TINY
15
+ from ..results import PointEstimate
16
+ from ..warnings import ConvergenceWarning, InputWarning
17
+ from .base import Engine, em_update, interior_start
18
+
19
+ __all__ = ["MAP"]
20
+
21
+ # DECISIONS #71
22
+ #: A MAP run that can no longer improve its objective in floating point is converged
23
+ #: when its optimality gap is below this many times ``tol``.
24
+ _ROUNDOFF = 1e3
25
+ # ten regularised EM steps are the cheapest start measured; from uniform, mix-SQP can
26
+ # need over a hundred iterations (audit M-5)
27
+ #: Regularised EM steps of mix-SQP's start when ``init`` is ``None``.
28
+ _MIXSQP_START_ITER = 10
29
+
30
+
31
+ def _converged(gap: float, tol: float, stalled: bool) -> bool:
32
+ return gap <= tol or (stalled and gap <= _ROUNDOFF * tol)
33
+
34
+
35
+ def _mixsqp(
36
+ L,
37
+ w: np.ndarray,
38
+ a: np.ndarray,
39
+ x0: np.ndarray,
40
+ tol: float = 1e-8,
41
+ max_iter: int = 500,
42
+ ) -> tuple[np.ndarray, int, bool]:
43
+ r"""mix-SQP (Kim, Carbonetto, Stephens & Anitescu 2020) for mixture proportions.
44
+
45
+ Maximises :math:`\sum_i w_i\log(L_i\cdot x)+\sum_k a_k\log x_k` over the
46
+ simplex (a Dirichlet prior with :math:`a_k=\alpha_k-1\ge0`). The relaxed
47
+ objective :math:`f(x)=-W^{-1}[\dots]+c\sum_kx_k` with :math:`c=(W+\sum a)/W`
48
+ has its minimiser on the simplex, so only :math:`x\ge0` is imposed. Each
49
+ iteration solves a non-negative quadratic program for the Newton step
50
+ (as a non-negative least-squares problem) and backtracks along it. It
51
+ stops when the KKT gap :math:`-\min_k g_k` is at most ``tol``, or at most
52
+ ``1e3 * tol`` once the objective no longer resolves progress (round-off).
53
+
54
+ Returns ``(x, n_iter, converged)``.
55
+ """
56
+ W = float(w.sum())
57
+ A = float(a.sum())
58
+ c = (W + A) / W
59
+ pos = a > 0
60
+ K = L.shape[1]
61
+ dense = not hasattr(L, "toarray")
62
+
63
+ def objective(x):
64
+ s = L @ x
65
+ if not np.all(s > 0):
66
+ return np.inf
67
+ val = -float(w @ np.log(s)) / W + c * float(x.sum())
68
+ if A > 0:
69
+ if np.any(x[pos] <= 0):
70
+ return np.inf
71
+ val -= float(a[pos] @ np.log(x[pos])) / W
72
+ return val
73
+
74
+ x = np.maximum(np.asarray(x0, dtype=float), 1e-12)
75
+ x = x / x.sum()
76
+ f = objective(x)
77
+ gap, stalled = np.inf, False
78
+ it = 0
79
+ for it in range(1, max_iter + 1): # noqa: B007 (used after the loop)
80
+ s = np.asarray(L @ x).ravel()
81
+ u = w / s
82
+ g = -np.asarray(L.T @ u).ravel() / W + c
83
+ if A > 0:
84
+ g[pos] -= a[pos] / (W * x[pos])
85
+ gap = -float(np.min(g))
86
+ if gap <= tol:
87
+ break
88
+ d2 = u / s # w_i / s_i^2
89
+ if dense:
90
+ Ls = np.asarray(L) * np.sqrt(d2)[:, None]
91
+ H = Ls.T @ Ls / W
92
+ else:
93
+ H = (L.T @ (L.multiply(d2[:, None]))).toarray() / W
94
+ if A > 0:
95
+ H[pos, pos] += a[pos] / (W * x[pos] ** 2) # only where a_k > 0 (audit T-4)
96
+ H[np.diag_indices(K)] += 1e-10 * max(np.trace(H) / K, _TINY)
97
+ try:
98
+ try:
99
+ Lc = np.linalg.cholesky(H)
100
+ except np.linalg.LinAlgError:
101
+ H[np.diag_indices(K)] += 1e-6 * max(np.trace(H) / K, _TINY)
102
+ Lc = np.linalg.cholesky(H)
103
+ b = -np.linalg.solve(Lc, g - H @ x)
104
+ y, _ = optimize.nnls(Lc.T, b, maxiter=50 * K)
105
+ except (np.linalg.LinAlgError, RuntimeError): # no quadratic step (audit M-13)
106
+ break
107
+ step = y - x
108
+ slope = float(g @ step)
109
+ if slope >= 0: # no descent direction left: at the optimum up to round-off
110
+ stalled = True
111
+ break
112
+ t = 1.0
113
+ while t > 1e-12:
114
+ cand = x + t * step
115
+ fc = objective(cand)
116
+ if fc <= f + 1e-4 * t * slope:
117
+ break
118
+ t *= 0.5
119
+ else:
120
+ stalled = True
121
+ break
122
+ stalled = f - fc <= 1e-15 * max(abs(f), 1.0)
123
+ x, f = cand, fc
124
+ if stalled: # the objective no longer resolves the remaining KKT violation
125
+ break
126
+ x = np.maximum(x, 0.0)
127
+ return x / x.sum(), it, _converged(gap, tol, stalled)
128
+
129
+
130
+ class MAP(Engine):
131
+ r"""Maximum a posteriori (or maximum-likelihood) estimate of the population.
132
+
133
+ For conjugate priors (Dirichlet family, :class:`~binference.priors.GammaRates`)
134
+ the posterior mode on the simplex is found by EM -- "iterated stacking",
135
+ :math:`\rho_k \leftarrow \sum_i w_i r_{ik}(\rho)/W` plus prior pseudo-counts --
136
+ accelerated with SQUAREM (Varadhan & Roland 2008). For a ``Dirichlet``
137
+ prior with all concentrations >= 1 (exactly 1 with a selection function),
138
+ and for ``GammaRates`` with all shapes >= 1, the default uses mix-SQP
139
+ (Kim et al. 2020) instead, a second-order method that converges in tens
140
+ of iterations where EM can need thousands (broad likelihoods). With
141
+ selection it solves for the fractions of the observed population,
142
+ :math:`\phi_k \propto S_k\rho_k`, in which the likelihood is concave,
143
+ and maps the mode back; rates :math:`\lambda = \Lambda\psi` reduce to
144
+ the same problem with :math:`S_k + b_k` in place of :math:`S_k` once the
145
+ total :math:`\Lambda` is profiled out. The
146
+ default ``Dirichlet(1)`` prior gives the maximum-likelihood estimate (the
147
+ NPMLE on the grid). Other priors, or ``space="unconstrained"``, maximise
148
+ the density in the unconstrained coordinates with L-BFGS.
149
+
150
+ Parameters
151
+ ----------
152
+ model : PopulationModel
153
+ space : {"auto", "simplex", "unconstrained"}
154
+ Coordinates the mode is found in. ``"simplex"`` maximises the density
155
+ of the fractions (requires a
156
+ conjugate prior); ``"unconstrained"`` the density in unconstrained
157
+ coordinates (always interior); ``"auto"`` picks ``"simplex"`` unless
158
+ the prior density is unbounded at the boundary (concentrations or
159
+ shapes below one, Polya trees; ``prior.unbounded_density()``), where the
160
+ simplex mode can have exact zeros, or a selection or response is
161
+ inferred jointly. The choice is in ``info["space"]`` and the solver in
162
+ ``info["algorithm"]``.
163
+ tol : float
164
+ Tolerance on first-order optimality. It bounds the KKT gap
165
+ (:meth:`~binference.PopulationModel.kkt_gap`) for EM, and for mix-SQP
166
+ the same gap of the problem it solves (in the observed fractions under
167
+ selection, the composition for rates); the largest absolute gradient
168
+ divided by ``max(1, W)`` for L-BFGS, whose estimates keep the largest
169
+ absolute gradient in ``info["grad_norm"]`` and have ``kkt_gap`` NaN. A
170
+ run that can no longer improve the objective in floating point counts as
171
+ converged when that measure is below ``1e3 * tol``.
172
+ max_iter : int
173
+ Maximum number of (accelerated) EM iterations or L-BFGS iterations.
174
+ accelerate : bool
175
+ Use SQUAREM acceleration (EM only).
176
+ algorithm : {"auto", "mixsqp", "em"}
177
+ Solver on the simplex. ``"auto"`` uses mix-SQP where it applies
178
+ (Dirichlet prior with concentrations >= 1, or exactly 1 with
179
+ selection; GammaRates with shapes >= 1), EM on the simplex otherwise,
180
+ and L-BFGS in the unconstrained space. ``"mixsqp"`` and ``"em"`` solve on
181
+ the simplex, as ``space="simplex"`` does (not for joint models).
182
+ init : array_like (K,), optional
183
+ Starting fractions (or rates). By default mix-SQP starts after 10
184
+ regularised EM steps from uniform, the other algorithms after up to 50.
185
+ rng, progress
186
+ Accepted for a common signature; unused.
187
+ """
188
+
189
+ name = "map"
190
+ joint = True # in the unconstrained space
191
+
192
+ def __init__(
193
+ self,
194
+ model: PopulationModel,
195
+ *,
196
+ space: str = "auto",
197
+ tol: float = 1e-8,
198
+ max_iter: int = 20000,
199
+ accelerate: bool = True,
200
+ algorithm: str = "auto",
201
+ init: Any = None,
202
+ rng: RNGLike = None,
203
+ progress: bool = False,
204
+ ) -> None:
205
+ super().__init__(model, rng=rng, progress=progress)
206
+ if space not in ("auto", "simplex", "unconstrained"):
207
+ raise ValueError("space must be 'auto', 'simplex' or 'unconstrained'.")
208
+ if algorithm not in ("auto", "mixsqp", "em"):
209
+ raise ValueError("algorithm must be 'auto', 'mixsqp' or 'em'.")
210
+ self.algorithm = algorithm
211
+ self.space = space
212
+ self.tol = float(tol)
213
+ self.max_iter = int(max_iter)
214
+ self.accelerate = bool(accelerate)
215
+ self.init = None if init is None else np.asarray(init, dtype=float)
216
+ # an explicit algorithm or space="simplex" solves on the simplex, if it can
217
+ self._on_simplex = algorithm != "auto" or space == "simplex"
218
+ if self._on_simplex:
219
+ asked = f"algorithm={algorithm!r}" if algorithm != "auto" else "space='simplex'"
220
+ if model.joint:
221
+ why = "a joint selection or response is fitted by L-BFGS in space='unconstrained'"
222
+ elif space == "unconstrained":
223
+ why = "space='unconstrained' runs L-BFGS"
224
+ elif not (model.prior.conjugate and model.prior.has_density):
225
+ why = "the simplex needs a conjugate prior"
226
+ elif algorithm == "mixsqp" and not self._mixsqp_applies():
227
+ why = (
228
+ "mix-SQP needs a Dirichlet prior with concentrations >= 1 (exactly 1 with "
229
+ "a selection function) or GammaRates with shapes >= 1"
230
+ )
231
+ else:
232
+ why = None
233
+ if why:
234
+ fix = "algorithm='auto'" if algorithm != "auto" else "space='auto'"
235
+ raise ValueError(
236
+ f"{asked} cannot run here: {why}. Use {fix} (method='map' in infer)."
237
+ )
238
+
239
+ def _resolve_space(self) -> str:
240
+ m = self.model
241
+ if self._on_simplex:
242
+ if m.prior.unbounded_density():
243
+ warn(
244
+ "The prior density is unbounded at the boundary of the simplex (e.g. "
245
+ "concentrations below one, or a Polya tree), so the EM solution can clip "
246
+ "components to exactly zero; space='unconstrained' gives an interior mode.",
247
+ InputWarning,
248
+ )
249
+ return "simplex"
250
+ if self.space == "unconstrained" or m.joint:
251
+ return "unconstrained"
252
+ if m.prior.conjugate and m.prior.has_density and not m.prior.unbounded_density():
253
+ return "simplex"
254
+ return "unconstrained"
255
+
256
+ def run(self) -> PointEstimate:
257
+ """Find the mode."""
258
+ m = self.model
259
+ if self._resolve_space() == "simplex":
260
+ return self._run_mixsqp(m) if self._mixsqp_applies() else self._run_em(m)
261
+ return self._run_lbfgs(m)
262
+
263
+ #: whether results carry their log-likelihood, KKT gap and convergence diagnostics;
264
+ #: the bootstrap turns them off, as it reads only the values
265
+ _diagnostics = True
266
+
267
+ def _estimate(self, m, v, n_iter, converged, algorithm, x=None, **extra) -> PointEstimate:
268
+ # a mode with its certificate: on the simplex (or orthant), or in unconstrained x;
269
+ # log_posterior is the density of the fractions or rates for every algorithm (audit M-8)
270
+ simplex = x is None
271
+ info = {
272
+ "space": "simplex" if simplex else "unconstrained",
273
+ "algorithm": algorithm,
274
+ **extra,
275
+ }
276
+ full = self._diagnostics
277
+ if not simplex: # what is inferred with the population, at the mode
278
+ info.update(m._joint_info(x[None, None]))
279
+ S = None if simplex else m.selection_at(x)
280
+ R = None if simplex else m.response_at(x)
281
+ ll = m.log_likelihood(v, S=S, response=R) if full else np.nan
282
+ lp = ll + m.log_prior(v) if full else np.nan
283
+ if m.joint and not simplex and full: # and the density of S or R given the data
284
+ lp += m._joint_log_density(x)
285
+ return PointEstimate(
286
+ m,
287
+ v,
288
+ method="map",
289
+ x=x,
290
+ log_posterior=lp,
291
+ log_likelihood=ll,
292
+ kkt_gap=m.kkt_gap(v) if simplex and full else np.nan,
293
+ n_iter=n_iter,
294
+ converged=converged,
295
+ info=info,
296
+ )
297
+
298
+ def _mixsqp_applies(self) -> bool:
299
+ m = self.model
300
+ a = m.prior.pseudocounts # Dirichlet or GammaRates
301
+ if self.algorithm not in ("auto", "mixsqp") or a is None or np.any(a < 0.0):
302
+ return False
303
+ # with selection the likelihood is concave in the observed fractions
304
+ # phi = S rho / (S . rho); a flat prior keeps the mode under that map
305
+ return m.kind == "rates" or m.selection is None or bool(np.all(a == 0.0))
306
+
307
+ def _run_mixsqp(self, m: PopulationModel) -> PointEstimate:
308
+ rates = m.kind == "rates"
309
+ a = m.prior.pseudocounts
310
+ # the likelihood is sum_k (L_ik / e_k) psi_k in psi proportional to e * value: the
311
+ # observed fractions (e = S) or, for rates lambda = Lambda psi / e, the composition
312
+ # with e = S + b, whose total Lambda = W + sum(a) profiles out (audit M-6)
313
+ e = m._S + m.prior.b if rates else m._detect
314
+ L = m._L if e is None else _scale_columns(m._L, 1.0 / e)
315
+ x0 = interior_start(m, n_iter=_MIXSQP_START_ITER) if self.init is None else self.init
316
+ if e is not None:
317
+ x0 = x0 * e
318
+ x, n_iter, converged = _mixsqp(L, m._w, a, x0, tol=self.tol, max_iter=self.max_iter)
319
+ if e is not None: # back from psi to the fractions or rates
320
+ x = x / e
321
+ if rates:
322
+ x *= m.W + a.sum()
323
+ else:
324
+ x /= x.sum()
325
+ if not converged: # EM takes over from where mix-SQP stopped (audit M-13)
326
+ warn(
327
+ f"mix-SQP did not converge in {n_iter} iterations; continuing with EM.",
328
+ ConvergenceWarning,
329
+ )
330
+ return self._run_em(m, x)
331
+ return self._estimate(m, x, n_iter, converged, "mixsqp")
332
+
333
+ # ------------------------------------------------------------------- EM
334
+ def _run_em(self, m: PopulationModel, start: np.ndarray | None = None) -> PointEstimate:
335
+ fractions = m.kind == "fractions"
336
+ if start is None:
337
+ start = interior_start(m) if self.init is None else self.init
338
+ v = start.copy()
339
+ if fractions:
340
+ v = v / v.sum()
341
+
342
+ def F(x, s): # the EM update of x, given its evidences s = L @ x
343
+ out = em_update(m, x, s)
344
+ if out is None:
345
+ return None
346
+ return out / out.sum() if fractions else out
347
+
348
+ f_old, s = _objective(m, v)
349
+ if not np.isfinite(f_old):
350
+ raise ValueError(
351
+ "init gives some object zero likelihood; pass an init that is positive "
352
+ "wherever an object has support."
353
+ )
354
+ converged = False
355
+ n_iter = 0
356
+ for n_iter in range(1, self.max_iter + 1):
357
+ if self.accelerate:
358
+ v_new, f_new, s_new = self._squarem_step(m, F, v, s, fractions)
359
+ else:
360
+ v_new = F(v, s)
361
+ f_new, s_new = (-np.inf, None) if v_new is None else _objective(m, v_new)
362
+ if not np.isfinite(f_new): # keep the last population where every object is possible
363
+ warn(
364
+ "EM reached a population under which an object has zero likelihood (a "
365
+ "component clipped to zero, e.g. by concentrations below one); "
366
+ "space='unconstrained' keeps the mode interior.",
367
+ ConvergenceWarning,
368
+ )
369
+ break
370
+ df = (f_new - f_old) / (abs(f_new) + 1.0) # EM never decreases the objective
371
+ v, s, f_old = v_new, s_new, f_new
372
+ # once the objective has nearly stopped, check optimality every tenth step (two
373
+ # passes over the data each), at a fixed point and at the last step (audit M-6)
374
+ if df <= self.tol and (n_iter % 10 == 0 or df <= 0.0 or n_iter == self.max_iter):
375
+ gap = m.kkt_gap(v)
376
+ stalled = df <= 0.0 # EM is monotone: no gain is the round-off floor
377
+ if _converged(gap, self.tol, stalled):
378
+ converged = True
379
+ break
380
+ if stalled:
381
+ warn(
382
+ f"EM stopped improving the objective with a KKT gap of {gap:.1e}.",
383
+ ConvergenceWarning,
384
+ )
385
+ break
386
+ else:
387
+ msg = f"EM did not converge in {self.max_iter} iterations"
388
+ if self._diagnostics and m.K <= 500: # EM is slow when information is missing
389
+ f = m.missing_information(v, include_prior=True)
390
+ if np.isfinite(f):
391
+ msg += f" (missing information {f:.3f})"
392
+ warn(msg + "; raise max_iter.", ConvergenceWarning)
393
+ algorithm = "squarem" if self.accelerate else "em"
394
+ return self._estimate(m, v, n_iter, converged, algorithm)
395
+
396
+ def _squarem_step(self, m, F, v, s, fractions):
397
+ # one SQUAREM step from v with evidences s = L @ v: the new point, its objective
398
+ # and its evidences, so each point visited costs one product L @ x;
399
+ # (None, -inf, None) when the first plain EM step meets an impossible object
400
+ r1 = F(v, s)
401
+ f1, s1 = (-np.inf, None) if r1 is None else _objective(m, r1)
402
+ if not np.isfinite(f1):
403
+ return None, -np.inf, None
404
+ r2 = F(r1, s1)
405
+ f2, s2 = (-np.inf, None) if r2 is None else _objective(m, r2)
406
+ if not np.isfinite(f2):
407
+ return r1, f1, s1 # the next step stops there
408
+ d1 = r1 - v
409
+ d2 = r2 - 2.0 * r1 + v
410
+ n2 = float(np.sqrt(np.sum(d2**2)))
411
+ if n2 == 0.0:
412
+ return r2, f2, s2
413
+ step = min(-float(np.sqrt(np.sum(d1**2))) / n2, -1.0)
414
+ for _ in range(30):
415
+ cand = v - 2.0 * step * d1 + step**2 * d2
416
+ if np.all(cand >= 0) and np.all(np.isfinite(cand)) and cand.sum() > 0:
417
+ if fractions:
418
+ cand = cand / cand.sum()
419
+ # stabilisation step keeps EM's monotonicity guarantees
420
+ cand = F(cand, kern.matvec(m._L, cand))
421
+ # an extrapolation that makes an object impossible is rejected
422
+ if cand is not None:
423
+ fc, sc = _objective(m, cand)
424
+ if fc >= f2: # monotone, so a fall in the objective means round-off
425
+ return cand, fc, sc
426
+ if step == -1.0:
427
+ break
428
+ step = (step - 1.0) / 2.0
429
+ step = min(step, -1.0)
430
+ return r2, f2, s2
431
+
432
+ # --------------------------------------------------------------- L-BFGS
433
+ def _run_lbfgs(self, m: PopulationModel) -> PointEstimate:
434
+ v0 = interior_start(m) if self.init is None else self.init
435
+ x0 = m.from_value(v0)
436
+
437
+ def fun(x):
438
+ lp, g = m.log_density_unconstrained_and_grad(x)
439
+ if not np.isfinite(lp):
440
+ return 1e300, np.zeros_like(x)
441
+ return -lp, -g
442
+
443
+ scale = max(1.0, m.W)
444
+ res = optimize.minimize(
445
+ fun,
446
+ x0,
447
+ jac=True,
448
+ method="L-BFGS-B",
449
+ options={
450
+ "maxiter": self.max_iter,
451
+ "maxcor": 30,
452
+ "ftol": 1e-15,
453
+ "gtol": self.tol * scale,
454
+ },
455
+ )
456
+ x = res.x
457
+ lp, g = m.log_density_unconstrained_and_grad(x)
458
+ gnorm = float(np.max(np.abs(g))) if g.size else 0.0
459
+ # any stop but the iteration limit means the objective stopped improving
460
+ converged = _converged(gnorm / scale, self.tol, stalled=res.status != 1)
461
+ if not converged:
462
+ warn(
463
+ f"L-BFGS did not converge ({res.message}); max |gradient| = {gnorm:.2e}.",
464
+ ConvergenceWarning,
465
+ )
466
+ v = m.to_value(x)
467
+ return self._estimate(
468
+ m,
469
+ v,
470
+ int(res.nit),
471
+ converged,
472
+ "l-bfgs-b",
473
+ x=x,
474
+ grad_norm=gnorm,
475
+ log_density_unconstrained=lp,
476
+ )
477
+
478
+
479
+ def _objective(m: PopulationModel, v: np.ndarray) -> tuple[float, np.ndarray]:
480
+ # log posterior on the simplex (or orthant), up to the likelihoods' log-scales, and the
481
+ # evidences L @ v it uses (m.log_likelihood(v, absolute=False) + the prior); components
482
+ # clipped to zero are active constraints: the prior is evaluated at a tiny positive
483
+ # value so the objective stays finite (and constant) for them
484
+ s = kern.matvec(m._L, v)
485
+ ll = kern.loglike_of_evidences(m._w, s)
486
+ if not np.isfinite(ll):
487
+ return -np.inf, s
488
+ return ll + m._selection_term(v) + m.log_prior(np.maximum(v, _TINY)), s
@@ -0,0 +1,118 @@
1
+ """Mean-field variational Bayes (coordinate ascent) for conjugate priors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+
7
+ from .. import _kernels as kern
8
+ from .._utils import RNGLike, warn
9
+ from ..model import PopulationModel
10
+ from ..priors.base import _TINY
11
+ from ..results import PopulationResult
12
+ from ..warnings import ApproximationWarning, ConvergenceWarning
13
+ from .base import Engine
14
+
15
+ __all__ = ["MeanFieldVB"]
16
+
17
+
18
+ class MeanFieldVB(Engine):
19
+ r"""Mean-field variational Bayes: :math:`q(\rho)\prod_i q(z_i)`.
20
+
21
+ Coordinate ascent alternates :math:`q(z_i) \propto L_{ik}\exp E_q[\log\rho_k]`
22
+ and the conjugate update of :math:`q(\rho)` (e.g. a Dirichlet). This is the
23
+ algorithm of RAIL's ``VarInfStackSummarizer``. It is fast but **under-
24
+ estimates posterior widths**, severely so when per-object PDFs are broad
25
+ compared with the bins (it ignores the correlation between labels and the
26
+ population). Results are always flagged ``approximate=True`` and emit an
27
+ :class:`~binference.ApproximationWarning`; prefer NUTS for error bars.
28
+
29
+ With a selection function on fractions, the expected number of missed
30
+ objects is plugged in at the current mean (an additional approximation).
31
+
32
+ No evidence lower bound is computed (with selection the plug-in update does
33
+ not ascend one): iterations stop when the largest change of the mean is
34
+ below ``tol`` times its largest value.
35
+
36
+ Parameters
37
+ ----------
38
+ model : PopulationModel
39
+ Must use a conjugate prior.
40
+ tol : float
41
+ Relative tolerance on the change of the mean.
42
+ max_iter : int
43
+ Maximum number of coordinate-ascent iterations.
44
+ rng : int, numpy.random.Generator, or None
45
+ For the draws from the variational posterior.
46
+ progress : bool
47
+ Accepted for a common signature; unused.
48
+ """
49
+
50
+ name = "vb"
51
+ approximate = True
52
+
53
+ def __init__(
54
+ self,
55
+ model: PopulationModel,
56
+ *,
57
+ tol: float = 1e-9,
58
+ max_iter: int = 10000,
59
+ rng: RNGLike = None,
60
+ progress: bool = False,
61
+ ) -> None:
62
+ super().__init__(model, rng=rng, progress=progress)
63
+ self.tol = float(tol)
64
+ self.max_iter = int(max_iter)
65
+
66
+ @classmethod
67
+ def unsupported(cls, model: PopulationModel) -> str | None:
68
+ if not model.prior.conjugate:
69
+ return "Mean-field VB needs a conjugate prior; use NUTS or Laplace instead."
70
+ return super().unsupported(model)
71
+
72
+ def fit(self):
73
+ """Run coordinate ascent; returns ``(state, n_iter, converged)``."""
74
+ m = self.model
75
+ prior = m.prior
76
+ exposure = m._exposure
77
+ state = prior.vb_update(np.full(m.K, m.W / m.K), exposure=exposure)
78
+ mean_old = prior.vb_mean(state)
79
+ converged = False
80
+ it = 0
81
+ for it in range(1, self.max_iter + 1): # noqa: B007 (used after the loop)
82
+ elog = prior.vb_expected_log(state)
83
+ t = np.exp(elog - np.max(elog))
84
+ _, nhat, _ = kern.loglike_and_counts(m._L, m._w, t)
85
+ nhat = m._complete_counts(mean_old, nhat)
86
+ state = prior.vb_update(nhat, exposure=exposure)
87
+ mean = prior.vb_mean(state)
88
+ if np.max(np.abs(mean - mean_old)) <= self.tol * max(np.max(np.abs(mean)), _TINY):
89
+ converged = True
90
+ break
91
+ mean_old = mean
92
+ if not converged:
93
+ warn("Mean-field VB did not converge.", ConvergenceWarning)
94
+ return state, it, converged
95
+
96
+ def run(self, n_samples: int = 4000) -> PopulationResult:
97
+ """Fit and return ``n_samples`` independent draws from the variational posterior.
98
+
99
+ Parameters
100
+ ----------
101
+ n_samples : int, optional
102
+ Draws from the variational posterior.
103
+ """
104
+ state, it, converged = self.fit()
105
+ draws = self.model.prior.vb_sample(state, int(n_samples), self.rng)
106
+ warn(
107
+ "Mean-field VB underestimates posterior uncertainty, especially for broad per-object "
108
+ "PDFs; use an exact engine (NUTS or Gibbs) for error bars.",
109
+ ApproximationWarning,
110
+ )
111
+ info = {"n_iter": it, "converged": converged, "state": state}
112
+ return PopulationResult(
113
+ self.model,
114
+ draws[None],
115
+ method="vb",
116
+ approximate=True,
117
+ info=info,
118
+ )