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