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,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)