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,676 @@
|
|
|
1
|
+
r"""Logistic-normal (smooth) priors: Gaussian priors on log-fractions.
|
|
2
|
+
|
|
3
|
+
Fractions are :math:`\rho = \mathrm{softmax}(\eta)` with a Gaussian prior on
|
|
4
|
+
:math:`\eta`, written in whitened ("non-centred") form
|
|
5
|
+
|
|
6
|
+
.. math:: \eta = \mu + B\,(s(\theta) \odot \xi), \qquad \xi \sim \mathcal N(0, I),
|
|
7
|
+
|
|
8
|
+
so that the hyperparameters :math:`\theta` (scales, length scales) are sampled
|
|
9
|
+
jointly with :math:`\xi` by gradient-based engines. ``B`` is a fixed basis:
|
|
10
|
+
|
|
11
|
+
* :class:`LogisticNormal` -- eigenvectors of a fixed covariance (or the
|
|
12
|
+
zero-sum Helmert basis for i.i.d. shrinkage);
|
|
13
|
+
* :class:`LogisticRandomWalk` -- eigenvectors of a random-walk / ICAR precision
|
|
14
|
+
on the grid (Kronecker structure for N-D grids);
|
|
15
|
+
* :class:`LogisticGP` -- the Hilbert-space Gaussian-process basis of Solin &
|
|
16
|
+
Sarkka (2020) on 1-D/N-D grids.
|
|
17
|
+
|
|
18
|
+
The softmax is invariant to adding a constant to :math:`\eta`; the constant
|
|
19
|
+
mode is dropped wherever it is explicit.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import copy
|
|
25
|
+
from collections.abc import Sequence
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
import numpy as np
|
|
29
|
+
from scipy import special
|
|
30
|
+
|
|
31
|
+
from .._simplex import helmert_basis, softmax
|
|
32
|
+
from .._utils import check_positive, warn
|
|
33
|
+
from ..bins import Bins
|
|
34
|
+
from ..density import Density
|
|
35
|
+
from ..warnings import InputWarning
|
|
36
|
+
from .base import BoundPrior, PopulationPrior, _per_item
|
|
37
|
+
from .hyper import Fixed, HalfNormal, Hyperprior, InverseGamma, LogNormal, as_hyperprior
|
|
38
|
+
|
|
39
|
+
__all__ = ["LogisticGP", "LogisticNormal", "LogisticRandomWalk"]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _require_full_grid(bins: Bins) -> None:
|
|
43
|
+
if bins.is_categorical:
|
|
44
|
+
raise ValueError("Smoothness priors need ordered (grid) bins, not categories.")
|
|
45
|
+
if bins.is_masked:
|
|
46
|
+
raise NotImplementedError("Smoothness priors do not yet support masked grids.")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ----------------------------------------------------------------------------
|
|
50
|
+
# bases
|
|
51
|
+
# ----------------------------------------------------------------------------
|
|
52
|
+
class _KronBasis:
|
|
53
|
+
"""Matrix-free product basis ``B = F_1 (x) F_2 (x) ...`` (C-order cells)."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, factors: Sequence[np.ndarray]) -> None:
|
|
56
|
+
self.factors = [np.asarray(f, dtype=float) for f in factors]
|
|
57
|
+
self.in_shape = tuple(f.shape[1] for f in self.factors)
|
|
58
|
+
self.out_shape = tuple(f.shape[0] for f in self.factors)
|
|
59
|
+
self.J = int(np.prod(self.in_shape))
|
|
60
|
+
self.K = int(np.prod(self.out_shape))
|
|
61
|
+
|
|
62
|
+
def matvec(self, v: np.ndarray) -> np.ndarray:
|
|
63
|
+
"""``B @ v`` for ``v`` of shape (J,), or row by row for shape (n, J)."""
|
|
64
|
+
if len(self.factors) == 1:
|
|
65
|
+
return v @ self.factors[0].T
|
|
66
|
+
t = v.reshape(*v.shape[:-1], *self.in_shape)
|
|
67
|
+
lead = v.ndim - 1
|
|
68
|
+
for d, f in enumerate(self.factors):
|
|
69
|
+
t = np.moveaxis(np.tensordot(f, t, axes=([1], [lead + d])), 0, lead + d)
|
|
70
|
+
return t.reshape(*v.shape[:-1], self.K)
|
|
71
|
+
|
|
72
|
+
def rmatvec(self, u: np.ndarray) -> np.ndarray:
|
|
73
|
+
if len(self.factors) == 1:
|
|
74
|
+
return self.factors[0].T @ u
|
|
75
|
+
t = u.reshape(self.out_shape)
|
|
76
|
+
for d, f in enumerate(self.factors):
|
|
77
|
+
t = np.moveaxis(np.tensordot(f.T, t, axes=([1], [d])), 0, d)
|
|
78
|
+
return t.reshape(self.J)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _orthonormal_polys(n: int, degree: int) -> np.ndarray:
|
|
82
|
+
"""Orthonormal basis of polynomials of degree < ``degree`` on 0..n-1 (columns)."""
|
|
83
|
+
x = np.arange(n, dtype=float)
|
|
84
|
+
x = (x - x.mean()) / max(1.0, x.std())
|
|
85
|
+
P = np.stack([x**p for p in range(degree)], axis=1)
|
|
86
|
+
q, _ = np.linalg.qr(P)
|
|
87
|
+
# make the constant column positive for reproducibility
|
|
88
|
+
q *= np.sign(q[0, 0]) if q[0, 0] != 0 else 1.0
|
|
89
|
+
return q
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _rw_precision(n: int, order: int) -> np.ndarray:
|
|
93
|
+
"""The 1-D random-walk precision ``D_r^T D_r`` of the ``order``-th differences."""
|
|
94
|
+
if n <= order:
|
|
95
|
+
raise ValueError(f"A random walk of order {order} needs more than {order} bins per axis.")
|
|
96
|
+
D = np.diff(np.eye(n), n=order, axis=0)
|
|
97
|
+
return D.T @ D
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _rw_eigen(n: int, order: int) -> tuple[np.ndarray, np.ndarray]:
|
|
101
|
+
"""Eigen-decomposition of the 1-D random-walk precision ``D_r^T D_r``.
|
|
102
|
+
|
|
103
|
+
The first ``order`` eigenvectors are replaced by an explicit orthonormal
|
|
104
|
+
polynomial basis of the null space (constant first), with eigenvalue 0.
|
|
105
|
+
"""
|
|
106
|
+
Q = _rw_precision(n, order)
|
|
107
|
+
_, U = np.linalg.eigh(Q)
|
|
108
|
+
null = _orthonormal_polys(n, order)
|
|
109
|
+
# project the non-null eigenvectors off the polynomial null space for safety
|
|
110
|
+
U_rest = U[:, order:]
|
|
111
|
+
U_rest = U_rest - null @ (null.T @ U_rest)
|
|
112
|
+
U_rest, _ = np.linalg.qr(U_rest)
|
|
113
|
+
U_rest = U_rest[:, : n - order]
|
|
114
|
+
lam_rest = np.sum(U_rest * (Q @ U_rest), axis=0) # Rayleigh quotients
|
|
115
|
+
return np.concatenate([np.zeros(order), lam_rest]), np.concatenate([null, U_rest], axis=1)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# ----------------------------------------------------------------------------
|
|
119
|
+
# bound class shared by the logistic family
|
|
120
|
+
# ----------------------------------------------------------------------------
|
|
121
|
+
class _BoundLogistic(BoundPrior):
|
|
122
|
+
kind = "fractions"
|
|
123
|
+
conjugate = False
|
|
124
|
+
has_density = False
|
|
125
|
+
has_centering = True
|
|
126
|
+
|
|
127
|
+
def __init__(
|
|
128
|
+
self,
|
|
129
|
+
prior: PopulationPrior,
|
|
130
|
+
bins: Bins,
|
|
131
|
+
basis: _KronBasis,
|
|
132
|
+
active: np.ndarray,
|
|
133
|
+
mu: np.ndarray,
|
|
134
|
+
hypers: list[tuple[str, Hyperprior]],
|
|
135
|
+
) -> None:
|
|
136
|
+
super().__init__(prior, bins)
|
|
137
|
+
self.basis = basis
|
|
138
|
+
self.active = np.asarray(active, dtype=np.int64)
|
|
139
|
+
self.n_active = int(self.active.size)
|
|
140
|
+
self.mu = np.asarray(mu, dtype=float)
|
|
141
|
+
self.hypers = hypers
|
|
142
|
+
self.free = [i for i, (_, h) in enumerate(hypers) if not h.fixed]
|
|
143
|
+
self.n_free = len(self.free)
|
|
144
|
+
self.dim = self.n_active + self.n_free
|
|
145
|
+
self.hyper_names = [hypers[i][0] for i in self.free]
|
|
146
|
+
# hyperparameter values with the fixed ones in place (the free ones are filled per x)
|
|
147
|
+
self._theta0 = np.array([h.median() if h.fixed else 0.0 for _, h in hypers])
|
|
148
|
+
# Partial non-centring: coefficient j is c_j = s_j**lam_j * x_j with
|
|
149
|
+
# x_j ~ N(0, s_j**(2 (1 - lam_j))); lam = 1 is the whitened
|
|
150
|
+
# (non-centred) form, lam = 0 the centred one.
|
|
151
|
+
self.lam = np.ones(self.n_active)
|
|
152
|
+
|
|
153
|
+
def with_centering(self, lam: np.ndarray) -> _BoundLogistic:
|
|
154
|
+
"""A copy using partial non-centring weights ``lam`` (1 = non-centred, 0 = centred)."""
|
|
155
|
+
lam = np.clip(np.asarray(lam, dtype=float), 0.0, 1.0)
|
|
156
|
+
if lam.shape != (self.n_active,):
|
|
157
|
+
raise ValueError(f"lam must have shape ({self.n_active},).")
|
|
158
|
+
new = copy.copy(self)
|
|
159
|
+
new.lam = lam
|
|
160
|
+
return new
|
|
161
|
+
|
|
162
|
+
def recentre(self, x: np.ndarray, lam: np.ndarray) -> np.ndarray:
|
|
163
|
+
"""Map unconstrained ``x`` in this parametrisation to the one with weights ``lam``."""
|
|
164
|
+
x = np.array(x, dtype=float, copy=True)
|
|
165
|
+
log_s, _ = self.log_scales(self._theta(x[self.n_active :]))
|
|
166
|
+
z = x[: self.n_active]
|
|
167
|
+
with np.errstate(divide="ignore", over="ignore"): # in log space, as the density
|
|
168
|
+
z[:] = np.sign(z) * np.exp(np.log(np.abs(z)) + (self.lam - np.clip(lam, 0, 1)) * log_s)
|
|
169
|
+
return x
|
|
170
|
+
|
|
171
|
+
def to_noncentred(self, X: np.ndarray, log_density: np.ndarray):
|
|
172
|
+
"""Points ``X`` (rows) in this parametrisation, and their log densities, in the
|
|
173
|
+
non-centred one (all weights 1).
|
|
174
|
+
|
|
175
|
+
The log density gains the Jacobian ``sum_j (1 - lam_j) log s_j(theta)``.
|
|
176
|
+
"""
|
|
177
|
+
X = np.array(X, dtype=float, copy=True)
|
|
178
|
+
na = self.n_active
|
|
179
|
+
LS = np.array([self.log_scales(self._theta(x[na:]))[0] for x in X]).reshape(-1, na)
|
|
180
|
+
one_m = 1.0 - self.lam
|
|
181
|
+
z = X[:, :na]
|
|
182
|
+
with np.errstate(divide="ignore", over="ignore"): # in log space, as the density
|
|
183
|
+
z[:] = np.sign(z) * np.exp(np.log(np.abs(z)) - one_m * LS)
|
|
184
|
+
return X, np.asarray(log_density, dtype=float) + LS @ one_m
|
|
185
|
+
|
|
186
|
+
# subclasses implement: log_scales(theta) -> (log s, dlogs), dlogs (n_hypers, n_active);
|
|
187
|
+
# logarithms, because the scales of high-frequency modes underflow (audit P-5)
|
|
188
|
+
def log_scales(self, theta: np.ndarray) -> tuple[np.ndarray, np.ndarray]: # pragma: no cover
|
|
189
|
+
raise NotImplementedError
|
|
190
|
+
|
|
191
|
+
def _theta(self, u: np.ndarray) -> np.ndarray:
|
|
192
|
+
theta = self._theta0.copy()
|
|
193
|
+
if self.n_free:
|
|
194
|
+
# clipping keeps absurd excursions (e.g. in a diverging trajectory) finite
|
|
195
|
+
theta[self.free] = np.exp(np.clip(u, -350.0, 350.0))
|
|
196
|
+
return theta
|
|
197
|
+
|
|
198
|
+
def theta_from_x(self, x: np.ndarray) -> np.ndarray:
|
|
199
|
+
"""All hyperparameter values (fixed and free) at unconstrained ``x``."""
|
|
200
|
+
return self._theta(np.asarray(x)[self.n_active :])
|
|
201
|
+
|
|
202
|
+
def _eta(self, c: np.ndarray) -> np.ndarray:
|
|
203
|
+
"""Log-fractions from basis coefficients ``c`` on the active modes (last axis)."""
|
|
204
|
+
v = np.zeros((*c.shape[:-1], self.basis.J))
|
|
205
|
+
v[..., self.active] = c
|
|
206
|
+
return self.mu + self.basis.matvec(v)
|
|
207
|
+
|
|
208
|
+
def forward(self, x):
|
|
209
|
+
z = x[: self.n_active]
|
|
210
|
+
u = x[self.n_active :]
|
|
211
|
+
log_s, dlogs = self.log_scales(self._theta(u))
|
|
212
|
+
m = np.exp(self.lam * log_s)
|
|
213
|
+
c = m * z
|
|
214
|
+
return self._eta(c), (z, log_s, m, c, dlogs, u)
|
|
215
|
+
|
|
216
|
+
def log_prior_and_grad(self, x, cache, value, d_eta):
|
|
217
|
+
z, log_s, m, c, dlogs, u = cache
|
|
218
|
+
if u.size and np.max(np.abs(u)) > 350.0: # outside the clipped range of _theta
|
|
219
|
+
return -np.inf, np.zeros(self.dim)
|
|
220
|
+
bt = self.basis.rmatvec(d_eta)[self.active]
|
|
221
|
+
lam = self.lam
|
|
222
|
+
one_m = 1.0 - lam
|
|
223
|
+
# prior z_j ~ N(0, s_j**(2 (1 - lam_j))). From the log scales, so that value and
|
|
224
|
+
# gradient agree however small a scale is (audit P-5), and in log space, since a
|
|
225
|
+
# centred coefficient underflows to zero with its scale
|
|
226
|
+
if one_m.any():
|
|
227
|
+
with np.errstate(divide="ignore", over="ignore"):
|
|
228
|
+
a = np.log(np.abs(z)) - one_m * log_s # log |z_j| / s_j**(1 - lam_j)
|
|
229
|
+
q = np.exp(2.0 * a)
|
|
230
|
+
z_prec = np.sign(z) * np.exp(a - one_m * log_s) # z_j / s_j**(2 (1 - lam_j))
|
|
231
|
+
else: # fully non-centred: the same values, without the logarithms
|
|
232
|
+
q, z_prec = z * z, z
|
|
233
|
+
log_s_term = float(one_m @ log_s)
|
|
234
|
+
lp = -0.5 * float(q.sum()) - log_s_term
|
|
235
|
+
if not np.isfinite(lp):
|
|
236
|
+
return -np.inf, np.zeros(self.dim)
|
|
237
|
+
lp -= 0.5 * self.n_active * np.log(2 * np.pi)
|
|
238
|
+
g_z = m * bt - z_prec
|
|
239
|
+
g_u = np.empty(self.n_free)
|
|
240
|
+
common = lam * bt * c + one_m * (q - 1.0)
|
|
241
|
+
for j, i in enumerate(self.free):
|
|
242
|
+
h = self.hypers[i][1]
|
|
243
|
+
lp += h.logpdf_u(u[j])
|
|
244
|
+
g_u[j] = float(common @ dlogs[i]) + h.dlogpdf_u(u[j])
|
|
245
|
+
return lp, np.concatenate([g_z, g_u])
|
|
246
|
+
|
|
247
|
+
def init_from_value(self, value: np.ndarray) -> np.ndarray:
|
|
248
|
+
r = np.asarray(value, dtype=float)
|
|
249
|
+
r = np.maximum(r, 1e-8 * np.max(r))
|
|
250
|
+
target = np.log(r / r.sum())
|
|
251
|
+
target = target - target.mean() - (self.mu - self.mu.mean())
|
|
252
|
+
theta0 = np.array([h.median() for _, h in self.hypers])
|
|
253
|
+
s0 = np.exp(self.log_scales(theta0)[0])
|
|
254
|
+
|
|
255
|
+
def A(z):
|
|
256
|
+
out = self._eta(s0 * z) - self.mu
|
|
257
|
+
return out - out.mean()
|
|
258
|
+
|
|
259
|
+
def AT(y):
|
|
260
|
+
y = y - y.mean()
|
|
261
|
+
return s0 * self.basis.rmatvec(y)[self.active]
|
|
262
|
+
|
|
263
|
+
# conjugate gradients on (A^T A + I) xi = A^T target (prior acts as ridge)
|
|
264
|
+
b = AT(target)
|
|
265
|
+
xi = np.zeros(self.n_active)
|
|
266
|
+
r_ = b - (AT(A(xi)) + xi)
|
|
267
|
+
p = r_.copy()
|
|
268
|
+
rs = float(r_ @ r_)
|
|
269
|
+
for _ in range(200):
|
|
270
|
+
if rs < 1e-20 * max(1.0, float(b @ b)):
|
|
271
|
+
break
|
|
272
|
+
Ap = AT(A(p)) + p
|
|
273
|
+
step = rs / float(p @ Ap)
|
|
274
|
+
xi = xi + step * p
|
|
275
|
+
r_ = r_ - step * Ap
|
|
276
|
+
rs_new = float(r_ @ r_)
|
|
277
|
+
p = r_ + (rs_new / rs) * p
|
|
278
|
+
rs = rs_new
|
|
279
|
+
u0 = np.log(theta0[self.free]) if self.n_free else np.empty(0)
|
|
280
|
+
return np.concatenate([xi * s0 ** (1.0 - self.lam), u0])
|
|
281
|
+
|
|
282
|
+
def sample(self, n, rng):
|
|
283
|
+
theta = np.stack([h.sample(n, rng) for _, h in self.hypers], axis=1)
|
|
284
|
+
log_s = np.array([self.log_scales(t)[0] for t in theta]).reshape(n, self.n_active)
|
|
285
|
+
s = np.exp(log_s)
|
|
286
|
+
return softmax(self._eta(s * rng.standard_normal((n, self.n_active))), axis=-1)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _check_mean(mean: Any) -> Any:
|
|
290
|
+
if mean is not None and not np.all(np.isfinite(np.asarray(mean, dtype=float))):
|
|
291
|
+
raise ValueError("mean must be finite.")
|
|
292
|
+
return mean
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _resolve_mean(mean: Any, bins: Bins) -> np.ndarray:
|
|
296
|
+
"""Prior mean of the log-fractions, centred; default: flat in the measure coordinate."""
|
|
297
|
+
K = bins.K
|
|
298
|
+
if mean is None:
|
|
299
|
+
m = np.log(Density.flat().masses(bins))
|
|
300
|
+
return m - m.mean()
|
|
301
|
+
m = np.asarray(mean, dtype=float).ravel()
|
|
302
|
+
if m.shape != (K,):
|
|
303
|
+
raise ValueError(f"mean must have K={K} values; got {m.size}.")
|
|
304
|
+
return m - m.mean()
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
# ----------------------------------------------------------------------------
|
|
308
|
+
# LogisticNormal
|
|
309
|
+
# ----------------------------------------------------------------------------
|
|
310
|
+
class LogisticNormal(PopulationPrior):
|
|
311
|
+
r"""Logistic-normal prior on the population fractions.
|
|
312
|
+
|
|
313
|
+
:math:`\eta \sim \mathcal N(\mu, \Sigma)` and :math:`\rho = \mathrm{softmax}(\eta)`.
|
|
314
|
+
|
|
315
|
+
Parameters
|
|
316
|
+
----------
|
|
317
|
+
mean : array_like (K,), optional
|
|
318
|
+
Prior mean of the log-fractions (up to a constant), e.g. the log of
|
|
319
|
+
reference fractions. Defaults to a density flat in the measure
|
|
320
|
+
coordinate, so the prior acts on the log-density whatever the binning.
|
|
321
|
+
cov : array_like (K, K) or float, optional
|
|
322
|
+
Covariance of :math:`\eta`: a positive variance, or a symmetric matrix
|
|
323
|
+
that is positive semi-definite on zero-sum vectors. Only that zero-sum
|
|
324
|
+
part matters, since adding a constant to :math:`\eta` leaves
|
|
325
|
+
:math:`\rho` unchanged. ``None`` means i.i.d. components with standard
|
|
326
|
+
deviation ``scale``.
|
|
327
|
+
scale : Hyperprior or float
|
|
328
|
+
Multiplies the standard deviations. Defaults to ``HalfNormal(2.0)``
|
|
329
|
+
(inferred) when ``cov`` is ``None`` and to ``Fixed(1.0)`` otherwise.
|
|
330
|
+
"""
|
|
331
|
+
|
|
332
|
+
def __init__(self, mean: Any = None, cov: Any = None, scale: Any = None) -> None:
|
|
333
|
+
self.mean = _check_mean(mean)
|
|
334
|
+
self.cov = cov
|
|
335
|
+
# basis and scales of a covariance matrix, or None (i.i.d. with a common variance)
|
|
336
|
+
self._eig = None if cov is None else _zero_sum_eigen(cov)
|
|
337
|
+
if scale is None:
|
|
338
|
+
scale = HalfNormal(2.0) if cov is None else Fixed(1.0)
|
|
339
|
+
self.scale = as_hyperprior(scale)
|
|
340
|
+
|
|
341
|
+
def bind(self, bins: Bins) -> _BoundLogisticNormal:
|
|
342
|
+
K = bins.K
|
|
343
|
+
mu = _resolve_mean(self.mean, bins)
|
|
344
|
+
if self._eig is None:
|
|
345
|
+
base = 1.0 if self.cov is None else float(np.sqrt(self.cov))
|
|
346
|
+
B = helmert_basis(K)
|
|
347
|
+
s0 = np.full(K - 1, base)
|
|
348
|
+
else:
|
|
349
|
+
B, s0 = self._eig
|
|
350
|
+
if B.shape[0] != K:
|
|
351
|
+
raise ValueError(
|
|
352
|
+
f"cov must be a (K, K) array with K={K}; got {np.shape(self.cov)}."
|
|
353
|
+
)
|
|
354
|
+
basis = _KronBasis([np.asarray(B)])
|
|
355
|
+
return _BoundLogisticNormal(
|
|
356
|
+
self, bins, basis, np.arange(B.shape[1]), mu, [("scale", self.scale)], s0
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _zero_sum_eigen(cov: Any) -> tuple[np.ndarray, np.ndarray] | None:
|
|
361
|
+
# validates cov; for a matrix, the eigenvectors and square-root eigenvalues of its
|
|
362
|
+
# zero-sum part, on its numerical rank
|
|
363
|
+
C = np.asarray(cov, dtype=float)
|
|
364
|
+
if C.ndim == 0:
|
|
365
|
+
check_positive(C, "A scalar cov (a variance)")
|
|
366
|
+
return None
|
|
367
|
+
if not np.all(np.isfinite(C)):
|
|
368
|
+
raise ValueError("cov must be finite.")
|
|
369
|
+
if C.ndim != 2 or C.shape[0] != C.shape[1] or not np.allclose(C, C.T):
|
|
370
|
+
raise ValueError("cov must be a positive number or a symmetric (K, K) array.")
|
|
371
|
+
K = C.shape[0]
|
|
372
|
+
P = np.eye(K) - 1.0 / K
|
|
373
|
+
lam, U = np.linalg.eigh(P @ C @ P)
|
|
374
|
+
# a constant matrix leaves only round-off, so judge against the size of cov itself
|
|
375
|
+
if lam.max() <= 1e-12 * np.abs(C).max() or lam.min() < -1e-8 * lam.max():
|
|
376
|
+
raise ValueError(
|
|
377
|
+
"cov must be positive semi-definite on zero-sum vectors and not constant."
|
|
378
|
+
)
|
|
379
|
+
keep = lam > 1e-10 * lam.max()
|
|
380
|
+
return U[:, keep], np.sqrt(lam[keep])
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
class _BoundLogisticNormal(_BoundLogistic):
|
|
384
|
+
def __init__(self, prior, bins, basis, active, mu, hypers, s0):
|
|
385
|
+
super().__init__(prior, bins, basis, active, mu, hypers)
|
|
386
|
+
self.log_s0 = np.log(s0)
|
|
387
|
+
|
|
388
|
+
def log_scales(self, theta):
|
|
389
|
+
return np.log(theta[0]) + self.log_s0, np.ones((1, self.log_s0.size))
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
# ----------------------------------------------------------------------------
|
|
393
|
+
# LogisticRandomWalk
|
|
394
|
+
# ----------------------------------------------------------------------------
|
|
395
|
+
class LogisticRandomWalk(PopulationPrior):
|
|
396
|
+
r"""Random-walk (intrinsic GMRF) smoothness prior on log-fractions over a grid.
|
|
397
|
+
|
|
398
|
+
The ``order``-th differences of :math:`\eta = \log\rho` (between adjacent
|
|
399
|
+
bins along each axis) are Gaussian with standard deviation ``scale``:
|
|
400
|
+
``order=1`` penalises jumps, ``order=2`` penalises curvature. N-D grids
|
|
401
|
+
use the Kronecker-sum (ICAR-type) precision. Polynomial modes not
|
|
402
|
+
penalised by the random walk (e.g. a linear trend for ``order=2``) get a
|
|
403
|
+
broad Gaussian prior set by ``trend_scale``. On N-D grids these are the
|
|
404
|
+
products of each axis's unpenalised polynomials, so for ``order=2`` in 2-D
|
|
405
|
+
the surface :math:`xy` is unpenalised too (and :math:`x^2y^2` for
|
|
406
|
+
``order=3``).
|
|
407
|
+
|
|
408
|
+
Smoothness is measured per bin step, so the natural scale of ``scale``
|
|
409
|
+
depends on the binning.
|
|
410
|
+
|
|
411
|
+
Parameters
|
|
412
|
+
----------
|
|
413
|
+
order : {1, 2, 3}
|
|
414
|
+
Order of the differences.
|
|
415
|
+
scale : Hyperprior or float
|
|
416
|
+
Standard deviation of the differences; inferred with a
|
|
417
|
+
``HalfNormal(1.0)`` prior by default.
|
|
418
|
+
mean : array_like (K,), optional
|
|
419
|
+
Mean of the log-fractions, e.g. ``log(reference)`` to shrink toward
|
|
420
|
+
reference fractions. Defaults to a density flat in the measure
|
|
421
|
+
coordinate, so differences penalise the log-density.
|
|
422
|
+
trend_scale : float
|
|
423
|
+
Prior size of the unpenalised polynomial trends. It is the standard
|
|
424
|
+
deviation of the root-mean-square size, across the grid, of each such
|
|
425
|
+
mode (a linear trend of root-mean-square
|
|
426
|
+
size ``t`` changes the log-density by about ``3.5 t`` end to end),
|
|
427
|
+
whatever the number of bins.
|
|
428
|
+
"""
|
|
429
|
+
|
|
430
|
+
def __init__(
|
|
431
|
+
self,
|
|
432
|
+
order: int = 2,
|
|
433
|
+
scale: Any = None,
|
|
434
|
+
mean: Any = None,
|
|
435
|
+
trend_scale: float = 3.0,
|
|
436
|
+
) -> None:
|
|
437
|
+
if order not in (1, 2, 3):
|
|
438
|
+
raise ValueError("order must be 1, 2 or 3.")
|
|
439
|
+
check_positive(trend_scale, "trend_scale")
|
|
440
|
+
self.order = int(order)
|
|
441
|
+
self.scale = as_hyperprior(HalfNormal(1.0) if scale is None else scale)
|
|
442
|
+
self.mean = _check_mean(mean)
|
|
443
|
+
self.trend_scale = float(trend_scale)
|
|
444
|
+
|
|
445
|
+
def bind(self, bins: Bins) -> _BoundRandomWalk:
|
|
446
|
+
_require_full_grid(bins)
|
|
447
|
+
eig = [_rw_eigen(n, self.order) for n in bins.shape]
|
|
448
|
+
basis = _KronBasis([U for _, U in eig])
|
|
449
|
+
lam = np.zeros(1)
|
|
450
|
+
null = np.ones(1, dtype=bool)
|
|
451
|
+
for lam_d, _ in eig:
|
|
452
|
+
nd = np.zeros(lam_d.size, dtype=bool)
|
|
453
|
+
nd[: self.order] = True
|
|
454
|
+
lam = (lam[:, None] + lam_d[None, :]).ravel()
|
|
455
|
+
null = (null[:, None] & nd[None, :]).ravel()
|
|
456
|
+
# drop the global constant mode (index 0: product of constants)
|
|
457
|
+
active = np.arange(1, basis.J)
|
|
458
|
+
mu = _resolve_mean(self.mean, bins)
|
|
459
|
+
return _BoundRandomWalk(
|
|
460
|
+
self,
|
|
461
|
+
bins,
|
|
462
|
+
basis,
|
|
463
|
+
active,
|
|
464
|
+
mu,
|
|
465
|
+
[("scale", self.scale)],
|
|
466
|
+
lam[active],
|
|
467
|
+
null[active],
|
|
468
|
+
# the modes have unit norm, so a root-mean-square size of 1/sqrt(K) (audit P-6)
|
|
469
|
+
null_sd=self.trend_scale * np.sqrt(bins.K),
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
class _BoundRandomWalk(_BoundLogistic):
|
|
474
|
+
def __init__(self, prior, bins, basis, active, mu, hypers, lam, null, null_sd):
|
|
475
|
+
super().__init__(prior, bins, basis, active, mu, hypers)
|
|
476
|
+
self.null = null
|
|
477
|
+
# log s_j = log(tau) - log(lam_j) / 2 on penalised modes, log(null_sd) on the others
|
|
478
|
+
self._pen = np.where(null, 0.0, 1.0) # 1 on the modes the scale tau multiplies
|
|
479
|
+
self._log_base = np.where(null, np.log(null_sd), -0.5 * np.log(np.where(null, 1.0, lam)))
|
|
480
|
+
|
|
481
|
+
def log_scales(self, theta):
|
|
482
|
+
return self._log_base + self._pen * np.log(theta[0]), self._pen[None, :]
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
# ----------------------------------------------------------------------------
|
|
486
|
+
# LogisticGP (Hilbert-space approximation)
|
|
487
|
+
# ----------------------------------------------------------------------------
|
|
488
|
+
# kernel: (Matérn smoothness nu, or None for the RBF; f), where the shortest length scale m
|
|
489
|
+
# sines on a box of half-width c*S resolve is f * c * S / m: the centred field SD within 5%
|
|
490
|
+
# and the covariance within 5% of the variance of the exact kernel, measured for ell/S from
|
|
491
|
+
# 0.02 to 0.2 (DECISIONS #73)
|
|
492
|
+
#: Smoothness ``nu`` (``None`` for the RBF) and resolution factor ``f`` of each kernel of
|
|
493
|
+
#: :class:`LogisticGP`: ``m`` sines resolve length scales down to ``f`` times the box
|
|
494
|
+
#: half-width over ``m``.
|
|
495
|
+
_KERNELS = {
|
|
496
|
+
"rbf": (None, 1.3),
|
|
497
|
+
"matern52": (2.5, 1.8),
|
|
498
|
+
"matern32": (1.5, 2.2),
|
|
499
|
+
"matern12": (0.5, 8.5),
|
|
500
|
+
}
|
|
501
|
+
#: Most basis functions of :class:`LogisticGP` in all when ``n_basis`` is ``None``.
|
|
502
|
+
_MAX_BASIS = 1024
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
class LogisticGP(PopulationPrior):
|
|
506
|
+
r"""Gaussian-process prior on log-fractions over a 1-D or N-D grid.
|
|
507
|
+
|
|
508
|
+
Uses the Hilbert-space reduced-rank approximation (Solin & Sarkka 2020):
|
|
509
|
+
a fixed sine basis on a box ``boundary_factor`` times larger than the grid,
|
|
510
|
+
with weights scaled by the kernel's spectral density, so amplitude and
|
|
511
|
+
(per-axis) length scales are inferred jointly with the field. The field
|
|
512
|
+
is stationary in the measure coordinate of the bins (log z for
|
|
513
|
+
:meth:`Bins.log <binference.Bins.log>`), so length scales are in that
|
|
514
|
+
coordinate.
|
|
515
|
+
|
|
516
|
+
Parameters
|
|
517
|
+
----------
|
|
518
|
+
kernel : {"matern32", "matern52", "matern12", "rbf"}
|
|
519
|
+
Covariance kernel.
|
|
520
|
+
amplitude : Hyperprior or float
|
|
521
|
+
Marginal standard deviation of the log-density field; inferred with a
|
|
522
|
+
``HalfNormal(2.0)`` prior by default.
|
|
523
|
+
length_scale : Hyperprior, float, or sequence or array (one per axis)
|
|
524
|
+
Length scale(s) in the measure coordinate. Default: per axis, an
|
|
525
|
+
inverse gamma with 1% prior mass below the shortest length scale the
|
|
526
|
+
grid and the basis resolve (the larger of two bin widths and
|
|
527
|
+
``f * boundary_factor * S / n_basis``, with ``S`` half the range and
|
|
528
|
+
``f`` = 1.3, 1.8, 2.2 and 8.5 for the RBF, Matérn-5/2, -3/2 and -1/2
|
|
529
|
+
kernels) and 1% above the axis range, so the field is neither rougher
|
|
530
|
+
than the prior can represent nor flatter than the data can constrain.
|
|
531
|
+
Where that lower bound exceeds a quarter of the range (coarse grids),
|
|
532
|
+
a ``LogNormal`` with standard deviation 1 in log length and median the
|
|
533
|
+
larger of the bound and half the range instead. A fixed length scale
|
|
534
|
+
below the bound raises an :class:`~binference.InputWarning`.
|
|
535
|
+
mean : array_like (K,), optional
|
|
536
|
+
Mean log-fractions (e.g. the log of reference fractions). Defaults to
|
|
537
|
+
a density flat in the measure coordinate: the GP acts on the
|
|
538
|
+
log-density.
|
|
539
|
+
n_basis : int or sequence, optional
|
|
540
|
+
Basis functions per axis. Default: one per bin, up to a total of 1024
|
|
541
|
+
(``min(K_d, floor(1024 ** (1 / D)))``: up to 1024 on 1-D grids, 32 per
|
|
542
|
+
axis on 2-D grids); increase it for shorter length scales, which also
|
|
543
|
+
lowers the default length-scale prior.
|
|
544
|
+
boundary_factor : float
|
|
545
|
+
Ratio of the approximation box to the grid half-width. With the default
|
|
546
|
+
3, the centred covariance is within 4% of the exact kernel's for length
|
|
547
|
+
scales up to the range, where 1.5 pulled the edge bins towards the
|
|
548
|
+
mean).
|
|
549
|
+
"""
|
|
550
|
+
|
|
551
|
+
def __init__(
|
|
552
|
+
self,
|
|
553
|
+
kernel: str = "matern32",
|
|
554
|
+
amplitude: Any = None,
|
|
555
|
+
length_scale: Any = None,
|
|
556
|
+
mean: Any = None,
|
|
557
|
+
n_basis: int | Sequence[int] | None = None,
|
|
558
|
+
boundary_factor: float = 3.0,
|
|
559
|
+
) -> None:
|
|
560
|
+
if kernel not in _KERNELS:
|
|
561
|
+
raise ValueError(f"kernel must be one of {sorted(_KERNELS)}.")
|
|
562
|
+
if not boundary_factor > 1.0:
|
|
563
|
+
raise ValueError("boundary_factor must exceed 1.")
|
|
564
|
+
if n_basis is not None:
|
|
565
|
+
nb = np.asarray(n_basis)
|
|
566
|
+
if nb.ndim > 1 or nb.dtype.kind not in "iu" or np.any(nb < 1):
|
|
567
|
+
raise ValueError("n_basis must be a positive integer or one per axis.")
|
|
568
|
+
self.kernel = kernel
|
|
569
|
+
self.amplitude = as_hyperprior(HalfNormal(2.0) if amplitude is None else amplitude)
|
|
570
|
+
self.length_scale = length_scale
|
|
571
|
+
per_axis = isinstance(length_scale, list | tuple) or np.ndim(length_scale) == 1
|
|
572
|
+
if length_scale is None:
|
|
573
|
+
self._ls = None
|
|
574
|
+
elif per_axis:
|
|
575
|
+
self._ls = [as_hyperprior(v) for v in length_scale]
|
|
576
|
+
else:
|
|
577
|
+
self._ls = as_hyperprior(length_scale)
|
|
578
|
+
self.mean = _check_mean(mean)
|
|
579
|
+
self.n_basis = n_basis
|
|
580
|
+
self.boundary_factor = float(boundary_factor)
|
|
581
|
+
|
|
582
|
+
def bind(self, bins: Bins) -> _BoundGP:
|
|
583
|
+
_require_full_grid(bins)
|
|
584
|
+
D = bins.ndim
|
|
585
|
+
if self.n_basis is None: # one per bin, at most _MAX_BASIS modes in all
|
|
586
|
+
nb = [min(n, int(_MAX_BASIS ** (1 / D) + 1e-9)) for n in bins.shape]
|
|
587
|
+
else:
|
|
588
|
+
nb = list(_per_item(self.n_basis, D, "n_basis (one per axis)"))
|
|
589
|
+
factors, omegas = [], []
|
|
590
|
+
for d, u in enumerate(bins.u_edges): # in the measure coordinate (audit P-1)
|
|
591
|
+
lo, hi = float(u[0]), float(u[-1])
|
|
592
|
+
c, L = 0.5 * (lo + hi), self.boundary_factor * 0.5 * (hi - lo)
|
|
593
|
+
x = 0.5 * (u[1:] + u[:-1])
|
|
594
|
+
j = np.arange(1, nb[d] + 1)
|
|
595
|
+
factors.append(
|
|
596
|
+
np.sin(np.pi * j[None, :] * (x[:, None] - c + L) / (2 * L)) / np.sqrt(L)
|
|
597
|
+
)
|
|
598
|
+
omegas.append(np.pi * j / (2 * L))
|
|
599
|
+
basis = _KronBasis(factors)
|
|
600
|
+
grids = np.meshgrid(*omegas, indexing="ij")
|
|
601
|
+
omega = np.stack([g.ravel() for g in grids], axis=1) # (J, D)
|
|
602
|
+
nu, f = _KERNELS[self.kernel]
|
|
603
|
+
# the shortest length scale the basis resolves on each axis
|
|
604
|
+
resolved = [
|
|
605
|
+
f * self.boundary_factor * 0.5 * (u[-1] - u[0]) / m
|
|
606
|
+
for u, m in zip(bins.u_edges, nb, strict=True)
|
|
607
|
+
]
|
|
608
|
+
if self._ls is None:
|
|
609
|
+
ls_h = [
|
|
610
|
+
_default_length_scale(u, r) for u, r in zip(bins.u_edges, resolved, strict=True)
|
|
611
|
+
]
|
|
612
|
+
elif isinstance(self._ls, list):
|
|
613
|
+
if len(self._ls) != D:
|
|
614
|
+
raise ValueError(f"Need one length scale per axis ({D}).")
|
|
615
|
+
ls_h = self._ls
|
|
616
|
+
else:
|
|
617
|
+
ls_h = [self._ls] * D
|
|
618
|
+
for d, (h, r) in enumerate(zip(ls_h, resolved, strict=True)):
|
|
619
|
+
if h.fixed and h.median() < r:
|
|
620
|
+
warn(
|
|
621
|
+
f"length_scale {h.median():g} on axis {d} is below the {r:.3g} that "
|
|
622
|
+
f"{nb[d]} basis functions resolve; the field loses variance. Raise n_basis.",
|
|
623
|
+
InputWarning,
|
|
624
|
+
)
|
|
625
|
+
hypers = [("amplitude", self.amplitude)] + [
|
|
626
|
+
(f"length_scale_{d}" if D > 1 else "length_scale", h) for d, h in enumerate(ls_h)
|
|
627
|
+
]
|
|
628
|
+
mu = _resolve_mean(self.mean, bins)
|
|
629
|
+
return _BoundGP(self, bins, basis, np.arange(basis.J), mu, hypers, omega, nu)
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
class _BoundGP(_BoundLogistic):
|
|
633
|
+
def __init__(self, prior, bins, basis, active, mu, hypers, omega, nu):
|
|
634
|
+
super().__init__(prior, bins, basis, active, mu, hypers)
|
|
635
|
+
self.omega2 = omega**2 # (J, D)
|
|
636
|
+
self.D = omega.shape[1]
|
|
637
|
+
self.nu = nu
|
|
638
|
+
D = self.D
|
|
639
|
+
if nu is None:
|
|
640
|
+
self._logc = 0.5 * D * np.log(2 * np.pi)
|
|
641
|
+
else:
|
|
642
|
+
self._logc = (
|
|
643
|
+
D * np.log(2.0)
|
|
644
|
+
+ 0.5 * D * np.log(np.pi)
|
|
645
|
+
+ special.gammaln(nu + D / 2)
|
|
646
|
+
- special.gammaln(nu)
|
|
647
|
+
+ nu * np.log(2 * nu)
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
def log_scales(self, theta):
|
|
651
|
+
amp = theta[0]
|
|
652
|
+
ell = theta[1 : 1 + self.D]
|
|
653
|
+
a2 = self.omega2 * ell[None, :] ** 2 # (J, D)
|
|
654
|
+
r2 = a2.sum(axis=1)
|
|
655
|
+
if self.nu is None:
|
|
656
|
+
log_su = self._logc - 0.5 * r2
|
|
657
|
+
dlog_dr2 = -0.5 * np.ones_like(r2)
|
|
658
|
+
else:
|
|
659
|
+
k = self.nu + self.D / 2
|
|
660
|
+
log_su = self._logc - k * np.log(2 * self.nu + r2)
|
|
661
|
+
dlog_dr2 = -k / (2 * self.nu + r2)
|
|
662
|
+
log_S = 2 * np.log(amp) + np.sum(np.log(ell)) + log_su
|
|
663
|
+
dlogs = np.empty((1 + self.D, log_S.size))
|
|
664
|
+
dlogs[0] = 1.0
|
|
665
|
+
for d in range(self.D):
|
|
666
|
+
dlogs[1 + d] = 0.5 * (1.0 + 2.0 * a2[:, d] * dlog_dr2)
|
|
667
|
+
return 0.5 * log_S, dlogs
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def _default_length_scale(u: np.ndarray, resolved: float) -> Hyperprior:
|
|
671
|
+
# 1% below what the grid and the basis resolve, 1% above the range (audit P-2, P-7)
|
|
672
|
+
span = float(u[-1] - u[0])
|
|
673
|
+
lower = max(2.0 * float(np.min(np.diff(u))), resolved)
|
|
674
|
+
if span < 4.0 * lower: # an inverse gamma between them would all but fix the length
|
|
675
|
+
return LogNormal(np.log(max(lower, 0.5 * span)), 1.0)
|
|
676
|
+
return InverseGamma.from_quantiles(lower, span)
|