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
binference/__init__.py ADDED
@@ -0,0 +1,85 @@
1
+ """binference: population and hierarchical inference with PDFs.
2
+
3
+ Infer population distributions -- bin fractions or rates, with honest
4
+ uncertainties -- from noisy, heteroscedastic per-object PDFs or likelihoods
5
+ over bins or categories, together with consistent posteriors for every
6
+ individual object.
7
+
8
+ Quick start
9
+ -----------
10
+ >>> import binference
11
+ >>> mock = binference.simulate.mock_1d(500, rng=1)
12
+ >>> result = binference.infer(mock.likelihoods, rng=2)
13
+ >>> result.summary() # doctest: +SKIP
14
+
15
+ Main entry points
16
+ -----------------
17
+ * :class:`Bins` -- the support (1-D/N-D grids or categories).
18
+ * :class:`Likelihoods` -- per-object likelihoods, with converters from
19
+ tables, posteriors (+ interim priors), densities, samples, Gaussian
20
+ measurements and binned measurements.
21
+ * :class:`Response` -- the response of a binned measurement (a detector, or a
22
+ classifier's scores on a labelled validation set).
23
+ * :func:`infer` -- one-call inference (``method="auto"`` by default: Gibbs, Laplace or NUTS).
24
+ * :mod:`binference.priors` -- Dirichlet family, smoothness priors, rates.
25
+ * :mod:`binference.samplers` -- inference engines.
26
+ * :class:`PopulationResult` -- draws, summaries, per-object outputs, plots.
27
+ * :mod:`binference.simulate` and :mod:`binference.validation` -- mocks, SBC, coverage.
28
+ """
29
+
30
+ from . import diagnostics, priors, samplers, simulate, validation
31
+ from ._version import __version__
32
+ from .basis import Basis
33
+ from .bins import Bins
34
+ from .density import Density
35
+ from .estimator import PopulationEstimator
36
+ from .inference import infer
37
+ from .likelihoods import Likelihoods
38
+ from .measure import Measure
39
+ from .model import PopulationModel
40
+ from .response import Response
41
+ from .results import PointEstimate, PopulationResult, Summary
42
+ from .selection import Selection
43
+ from .warnings import (
44
+ ApproximationWarning,
45
+ BinferenceWarning,
46
+ ConvergenceWarning,
47
+ DiagnosticsError,
48
+ InputWarning,
49
+ )
50
+
51
+ __all__ = [
52
+ "ApproximationWarning",
53
+ "Basis",
54
+ "BinferenceWarning",
55
+ "Bins",
56
+ "ConvergenceWarning",
57
+ "Density",
58
+ "DiagnosticsError",
59
+ "InputWarning",
60
+ "Likelihoods",
61
+ "Measure",
62
+ "PointEstimate",
63
+ "PopulationEstimator",
64
+ "PopulationModel",
65
+ "PopulationResult",
66
+ "Response",
67
+ "Selection",
68
+ "Summary",
69
+ "__version__",
70
+ "diagnostics",
71
+ "infer",
72
+ "priors",
73
+ "samplers",
74
+ "simulate",
75
+ "validation",
76
+ ]
77
+
78
+
79
+ def __getattr__(name: str):
80
+ # binference.plotting and binference.io load on first use (matplotlib stays optional)
81
+ if name in ("io", "plotting"):
82
+ import importlib
83
+
84
+ return importlib.import_module(f".{name}", __name__)
85
+ raise AttributeError(f"module 'binference' has no attribute {name!r}")
binference/_kernels.py ADDED
@@ -0,0 +1,252 @@
1
+ """Low-level numerical kernels shared by models and engines.
2
+
3
+ Every function accepts the likelihood matrix ``L`` (shape ``(N, K)``) either as
4
+ a dense ``numpy.ndarray`` or as a ``scipy.sparse`` CSR array/matrix. The hot
5
+ paths are two matrix-vector products:
6
+
7
+ s = L @ rho (per-object marginal likelihoods)
8
+ n_hat = rho * (L.T @ (w / s)) (expected counts / EM "stack")
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import math
14
+
15
+ import numpy as np
16
+ import scipy.sparse as sp
17
+
18
+ from ._utils import chunk_slices
19
+
20
+ __all__ = [
21
+ "loglike",
22
+ "loglike_and_counts",
23
+ "loglike_many",
24
+ "matvec",
25
+ "possible",
26
+ "responsibilities",
27
+ "rmatvec",
28
+ "sample_counts",
29
+ "sample_labels",
30
+ ]
31
+
32
+ # Gibbs labels were drawn as fast in blocks of 4096 as of 65536 objects (N = 2e4 to 2e5,
33
+ # K = 20 to 80)
34
+ #: Objects per block of the chunked likelihood kernels.
35
+ DEFAULT_CHUNK = 4096
36
+
37
+ # rows are normalised to a maximum of one, so this is a log-likelihood below -575
38
+ #: An object whose normalised evidence is below this is impossible.
39
+ MIN_EVIDENCE = 1e-250
40
+
41
+ # the largest fill at which sparse beat dense in every measured case (DECISIONS #77)
42
+ #: Likelihoods are stored sparse below this share of non-zero entries.
43
+ SPARSE_FILL = 0.1
44
+
45
+
46
+ def matvec(L, x: np.ndarray) -> np.ndarray:
47
+ """``L @ x`` for dense or sparse ``L``."""
48
+ return np.asarray(L @ x).ravel()
49
+
50
+
51
+ def rmatvec(L, y: np.ndarray) -> np.ndarray:
52
+ """``L.T @ y`` for dense or sparse ``L``."""
53
+ return np.asarray(L.T @ y).ravel()
54
+
55
+
56
+ def possible(s: np.ndarray) -> bool:
57
+ """Whether every object's evidence ``s_i = L_i . rho`` is positive.
58
+
59
+ Evidences below :data:`MIN_EVIDENCE` count as zero; the one test used by the
60
+ log-likelihood, EM and the KKT gap (it also keeps ``w / s`` finite).
61
+ """
62
+ return bool(s.min() > MIN_EVIDENCE) # the comparison also catches NaN
63
+
64
+
65
+ def loglike(L, w: np.ndarray, rho: np.ndarray) -> float:
66
+ """``sum_i w_i log(L_i . rho)`` (``-inf`` if some object is impossible), one pass."""
67
+ return loglike_of_evidences(w, matvec(L, rho))
68
+
69
+
70
+ def loglike_of_evidences(w: np.ndarray, s: np.ndarray) -> float:
71
+ """``sum_i w_i log s_i`` for the evidences ``s = L @ rho`` (``-inf`` if one is zero)."""
72
+ return float(np.dot(w, np.log(s))) if possible(s) else -np.inf
73
+
74
+
75
+ def loglike_many(
76
+ L, w: np.ndarray, V: np.ndarray, block: int = 256, chunk: int | None = DEFAULT_CHUNK
77
+ ) -> np.ndarray:
78
+ """:func:`loglike` at each row of ``V`` ``(n, K)``: ``chunk`` objects at a time (each
79
+ sliced once), ``block`` rows of ``V`` per product (so at most ``block * chunk``
80
+ evidences held)."""
81
+ N, n = L.shape[0], V.shape[0]
82
+ out, low = np.zeros(n), np.full(n, np.inf)
83
+ for sl in chunk_slices(N, chunk):
84
+ Ls = L if sl.stop - sl.start == N else L[sl]
85
+ for pts in chunk_slices(n, block):
86
+ s = np.asarray(Ls @ V[pts].T)
87
+ low[pts] = np.minimum(low[pts], s.min(axis=0))
88
+ with np.errstate(divide="ignore", invalid="ignore"):
89
+ out[pts] += w[sl] @ np.log(s)
90
+ # the test of possible() per point
91
+ return np.where((low > MIN_EVIDENCE) & np.isfinite(out), out, -np.inf)
92
+
93
+
94
+ def loglike_and_counts(L, w: np.ndarray, rho: np.ndarray) -> tuple[float, np.ndarray, np.ndarray]:
95
+ """Weighted marginal log-likelihood, expected counts and per-object evidences.
96
+
97
+ Parameters
98
+ ----------
99
+ L : (N, K) array or sparse matrix
100
+ Bin-averaged likelihoods (rows may carry arbitrary positive scales).
101
+ w : (N,) array
102
+ Object weights.
103
+ rho : (K,) array
104
+ Population fractions (or rates); must be non-negative.
105
+
106
+ Returns
107
+ -------
108
+ loglike : float
109
+ ``sum_i w_i log(L_i . rho)`` (``-inf`` if any object has zero evidence).
110
+ n_hat : (K,) array
111
+ ``rho_k * sum_i w_i L_ik / (L_i . rho)``.
112
+ s : (N,) array
113
+ ``L_i . rho``.
114
+ """
115
+ s = matvec(L, rho)
116
+ if not possible(s):
117
+ return -np.inf, np.zeros_like(rho), s
118
+ ll = float(np.dot(w, np.log(s)))
119
+ if not math.isfinite(ll):
120
+ return -np.inf, np.zeros_like(rho), s
121
+ n_hat = rho * rmatvec(L, w / s)
122
+ return ll, n_hat, s
123
+
124
+
125
+ def responsibilities(
126
+ L, rho: np.ndarray, idx=None, chunk: int | None = DEFAULT_CHUNK
127
+ ) -> np.ndarray:
128
+ """Per-object posterior over bins given ``rho``: ``r_ik = L_ik rho_k / (L_i . rho)``.
129
+
130
+ Returns a dense ``(n, K)`` array for the selected objects ``idx`` (all by
131
+ default). Memory scales as ``n * K``; select a subset for large catalogues.
132
+ """
133
+ if idx is not None:
134
+ L = L[np.atleast_1d(idx)]
135
+ N = L.shape[0]
136
+ out = np.empty((N, rho.size))
137
+ for sl in chunk_slices(N, chunk):
138
+ block = L[sl]
139
+ block = block.toarray() if sp.issparse(block) else np.asarray(block)
140
+ P = block * rho
141
+ tot = P.sum(axis=1, keepdims=True)
142
+ with np.errstate(invalid="ignore", divide="ignore"):
143
+ out[sl] = np.where(tot > 0, P / tot, 0.0)
144
+ return out
145
+
146
+
147
+ def _sample_labels_dense(Lc: np.ndarray, rho: np.ndarray, rng: np.random.Generator) -> np.ndarray:
148
+ c = Lc * rho
149
+ np.cumsum(c, axis=1, out=c)
150
+ # u in (0, total]: the first index with c_k >= u always has P_k > 0
151
+ u = (1.0 - rng.random(Lc.shape[0])) * c[:, -1]
152
+ idx = np.sum(c < u[:, None], axis=1)
153
+ return np.minimum(idx, Lc.shape[1] - 1)
154
+
155
+
156
+ def _sample_labels_csr(Lc, rho: np.ndarray, rng: np.random.Generator) -> np.ndarray:
157
+ Lc = sp.csr_array(Lc)
158
+ indptr, indices = Lc.indptr, Lc.indices
159
+ data = Lc.data * rho[indices]
160
+ n = Lc.shape[0]
161
+ # normalise each row so that cumulative sums stay O(n): avoids cancellation
162
+ row_tot = np.asarray(Lc @ rho).ravel()
163
+ counts = np.diff(indptr)
164
+ data = data / np.repeat(np.where(row_tot > 0, row_tot, 1.0), counts)
165
+ cs = np.cumsum(data)
166
+ start = np.concatenate(([0.0], cs))[indptr[:-1]]
167
+ u = start + (1.0 - rng.random(n))
168
+ pos = np.searchsorted(cs, u, side="left")
169
+ pos = np.clip(pos, indptr[:-1], np.maximum(indptr[1:] - 1, indptr[:-1]))
170
+ return indices[pos]
171
+
172
+
173
+ def sample_labels(L, rho: np.ndarray, rng: np.random.Generator, chunk: int | None = DEFAULT_CHUNK):
174
+ """Draw one bin label per object from ``Cat(r_i(rho))`` (vectorised; dense rows
175
+ ``chunk`` at a time, sparse rows at once, whose cost and memory follow the nonzeros:
176
+ slicing CSR rows per chunk made them slower, 57% at N = 2e5, K = 200, 2% filled)."""
177
+ if sp.issparse(L):
178
+ return _sample_labels_csr(sp.csr_array(L), rho, rng)
179
+ N = L.shape[0]
180
+ out = np.empty(N, dtype=np.int64)
181
+ for sl in chunk_slices(N, chunk):
182
+ block = L if sl.stop - sl.start == N else L[sl] # one chunk: no copy
183
+ out[sl] = _sample_labels_dense(np.asarray(block), rho, rng)
184
+ return out
185
+
186
+
187
+ def _multinomial_csr(Lc, m: np.ndarray, rho: np.ndarray, rng: np.random.Generator) -> np.ndarray:
188
+ """Draws of ``Mult(m_i, r_i)`` on the nonzeros of each CSR row, one per stored entry.
189
+
190
+ Conditional binomials position by position within rows, rows sorted by
191
+ their number of nonzeros: the cost follows the nonzeros, not K or ``m_i``.
192
+ """
193
+ nnz = np.diff(Lc.indptr)
194
+ order = np.argsort(-nnz, kind="stable")
195
+ n_sorted = nnz[order]
196
+ start = Lc.indptr[:-1][order]
197
+ p = Lc.data * rho[Lc.indices]
198
+ mass = np.asarray(Lc @ rho, dtype=float).ravel()[order]
199
+ left = m[order].astype(np.int64)
200
+ out = np.zeros(p.size)
201
+ for j in range(int(n_sorted[0]) if n_sorted.size else 0):
202
+ a = int(np.searchsorted(-n_sorted, -j, side="left")) # rows with more than j nonzeros
203
+ pos = start[:a] + j
204
+ q = np.divide(p[pos], mass[:a], out=np.ones(a), where=mass[:a] > 0)
205
+ q = np.where(n_sorted[:a] == j + 1, 1.0, np.clip(q, 0.0, 1.0)) # last entry takes the rest
206
+ d = rng.binomial(left[:a], q)
207
+ out[pos] = d
208
+ left[:a] -= d
209
+ mass[:a] -= p[pos]
210
+ return out
211
+
212
+
213
+ def sample_counts(
214
+ L,
215
+ w_int: np.ndarray,
216
+ rho: np.ndarray,
217
+ rng: np.random.Generator,
218
+ chunk: int | None = DEFAULT_CHUNK,
219
+ unit_weights: bool | None = None,
220
+ ) -> tuple[np.ndarray, np.ndarray | None]:
221
+ """Draw latent bin counts ``n_k = sum_i w_i 1[z_i = k]``.
222
+
223
+ For unit weights a label is drawn per object (and returned). Integer weights
224
+ ``m_i`` (compressed duplicate rows) draw one ``Mult(m_i, r_i)`` per row and
225
+ return ``labels=None``; for sparse ``L`` only over the row's nonzeros, so
226
+ the cost follows the nonzeros whatever K and the ``m_i`` (audit G-2).
227
+
228
+ Returns
229
+ -------
230
+ counts : (K,) float array
231
+ labels : (N,) int array or None
232
+ """
233
+ K = rho.size
234
+ if unit_weights is None:
235
+ unit_weights = bool(np.all(w_int == 1))
236
+ if unit_weights:
237
+ labels = sample_labels(L, rho, rng, chunk)
238
+ return np.bincount(labels, minlength=K).astype(float), labels
239
+ counts = np.zeros(K)
240
+ N = L.shape[0]
241
+ m_all = np.asarray(np.round(w_int), dtype=np.int64)
242
+ if sp.issparse(L): # all rows at once, as sample_labels
243
+ Lc = sp.csr_array(L)
244
+ n = _multinomial_csr(Lc, m_all, rho, rng)
245
+ return np.bincount(Lc.indices, weights=n, minlength=K), None
246
+ for sl in chunk_slices(N, chunk):
247
+ R = responsibilities(L[sl], rho, chunk=None)
248
+ # guard tiny negative round-off and renormalise
249
+ R = np.clip(R, 0.0, None)
250
+ R /= R.sum(axis=1, keepdims=True)
251
+ counts += rng.multinomial(m_all[sl], R).sum(axis=0)
252
+ return counts, None
@@ -0,0 +1,202 @@
1
+ """Adaptive bin averages, in the measure coordinate or in ``z`` (DECISIONS #23)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from itertools import product
7
+ from typing import Literal
8
+
9
+ import numpy as np
10
+ from scipy import special
11
+
12
+ from ._utils import chunk_slices, warn
13
+ from .bins import Bins
14
+ from .measure import _identity
15
+ from .warnings import InputWarning
16
+
17
+ #: Most function values a quadrature evaluates in one call.
18
+ MAX_ELEMENTS = 2**22
19
+
20
+
21
+ class _Rule:
22
+ # a tensor-product Gauss-Legendre rule on the unit cube, kept per axis
23
+ def __init__(self, n: int, ndim: int) -> None:
24
+ x, w = np.polynomial.legendre.leggauss(int(n))
25
+ self.t = 0.5 * (x + 1.0)
26
+ idx = np.array(list(product(range(int(n)), repeat=ndim))) # (M, ndim)
27
+ self.idx = idx.T
28
+ self.logw = np.log(0.5 * w)[idx].sum(axis=1) # (M,)
29
+
30
+ def points(self, lo: np.ndarray, hi: np.ndarray, inverse) -> np.ndarray:
31
+ # (C, M, ndim) points in z for cells [lo, hi] given in the integration coordinate
32
+ axes = [
33
+ inv(lo[:, d, None] + (hi - lo)[:, d, None] * self.t)[:, i]
34
+ for d, (inv, i) in enumerate(zip(inverse, self.idx, strict=True))
35
+ ]
36
+ return np.stack(axes, axis=-1)
37
+
38
+
39
+ def bin_log_average(
40
+ logf: Callable[..., np.ndarray],
41
+ bins: Bins,
42
+ *,
43
+ rows: int | None = None,
44
+ coordinate: Literal["measure", "z"] = "measure",
45
+ tol: float = 1e-3,
46
+ max_depth: int = 12,
47
+ n_sub: int | None = None,
48
+ must_split: Callable[[np.ndarray, np.ndarray, np.ndarray], np.ndarray] | None = None,
49
+ ) -> np.ndarray:
50
+ """Log of the average of ``exp(logf)`` over each active bin, shape ``(N, K)``.
51
+
52
+ The average is taken uniformly in the measure coordinate ``u`` (the
53
+ within-bin population shape) or in ``z``. Two calling conventions:
54
+
55
+ * ``rows=None``: ``logf(z)`` receives points of shape ``(M,)`` (1-D) or
56
+ ``(M, ndim)`` and returns ``(N, M)`` log-values for all ``N`` rows; a
57
+ cell is refined for every row at once.
58
+ * ``rows=N``: ``logf(z, r)`` receives points of shape ``(P, M)`` or
59
+ ``(P, M, ndim)`` and row indices ``r`` of shape ``(P,)``, and returns
60
+ ``(P, M)``: row ``r[p]`` at the points ``z[p]``. Cells are refined per row.
61
+
62
+ Each bin starts as one cell. A cell is estimated with 5 and 3 Gauss-Legendre
63
+ points per axis; while their difference exceeds ``tol`` relative to the row's
64
+ largest bin average (in ``u``) or bin mass (in ``z``), it is split in half
65
+ along every axis, at most ``max_depth`` times, after which an
66
+ :class:`~binference.InputWarning` is raised. With ``n_sub`` a fixed
67
+ ``n_sub``-point rule is used instead.
68
+
69
+ ``must_split(z_lo, z_hi, r)`` (per-row mode) marks cells, given by their
70
+ bounds in ``z``, that must be split whatever the error estimate says: a
71
+ function narrower than a cell can fall between the points of both rules,
72
+ which then agree on a wrong value.
73
+ """
74
+ if not tol > 0 or max_depth < 0:
75
+ raise ValueError(
76
+ f"tol must be positive and max_depth non-negative; got {tol}, {max_depth}."
77
+ )
78
+ if rows is not None:
79
+ # refine blocks of rows separately, so the per-row state stays bounded
80
+ block = max(1, MAX_ELEMENTS // (8 * bins.K))
81
+ if rows > block:
82
+ kw = {"coordinate": coordinate, "tol": tol, "max_depth": max_depth, "n_sub": n_sub}
83
+ return np.concatenate(
84
+ [
85
+ bin_log_average(
86
+ lambda z, r, start=sl.start: logf(z, r + start),
87
+ bins,
88
+ rows=sl.stop - sl.start,
89
+ must_split=None
90
+ if must_split is None
91
+ else (lambda a, b, r, start=sl.start: must_split(a, b, r + start)),
92
+ **kw,
93
+ )
94
+ for sl in chunk_slices(rows, block)
95
+ ]
96
+ )
97
+ nd = bins.ndim
98
+ if coordinate == "measure":
99
+ edges, inverse = bins.u_edges, [m.inverse for m in bins.measures]
100
+ else:
101
+ edges, inverse = bins.edges, [_identity] * nd
102
+ j = bins.cell_index
103
+ lo = np.stack([e[:-1][i] for e, i in zip(edges, j, strict=True)], axis=-1)
104
+ hi = np.stack([e[1:][i] for e, i in zip(edges, j, strict=True)], axis=-1)
105
+ owner = np.arange(bins.K)
106
+ rules = [_Rule(n_sub, nd)] if n_sub is not None else [_Rule(5, nd), _Rule(3, nd)]
107
+
108
+ if rows is None:
109
+
110
+ def integrate(rule, lo, hi, row):
111
+ # (N, C) log averages over the cells, chunked to bound memory
112
+ z = rule.points(lo, hi, inverse)
113
+ C, M = z.shape[:2]
114
+ first = _call(logf, z[:1], M)
115
+ if first.shape[0] == 0:
116
+ raise ValueError("The function returned no rows.")
117
+ size = max(1, MAX_ELEMENTS // (first.shape[0] * M))
118
+ parts = [special.logsumexp(first.reshape(-1, 1, M) + rule.logw, axis=2)]
119
+ for sl in chunk_slices(C - 1, size):
120
+ v = _call(logf, z[1:][sl], M)
121
+ parts.append(special.logsumexp(v.reshape(v.shape[0], -1, M) + rule.logw, axis=2))
122
+ return np.concatenate(parts, axis=1)
123
+
124
+ row = None
125
+ else:
126
+
127
+ def integrate(rule, lo, hi, row):
128
+ # (P,) log averages, one row per cell
129
+ out = np.empty(lo.shape[0])
130
+ for sl in chunk_slices(lo.shape[0], max(1, MAX_ELEMENTS // len(rule.logw))):
131
+ z = rule.points(lo[sl], hi[sl], inverse)
132
+ v = np.asarray(logf(z[..., 0] if nd == 1 else z, row[sl]), dtype=float)
133
+ out[sl] = special.logsumexp(v + rule.logw, axis=1)
134
+ return out
135
+
136
+ K = bins.K
137
+ row = np.repeat(np.arange(rows), K)
138
+ owner = np.tile(owner, rows)
139
+ lo, hi = np.tile(lo, (rows, 1)), np.tile(hi, (rows, 1))
140
+
141
+ logfrac = np.zeros(owner.size)
142
+ fine, *coarse = (integrate(rule, lo, hi, row) for rule in rules)
143
+ N = fine.shape[0] if rows is None else rows
144
+ out = np.full((N, bins.K), -np.inf)
145
+ if n_sub is not None:
146
+ return fine if rows is None else fine.reshape(N, bins.K)
147
+ # errors are measured on averages (in u) or on masses (in z), relative to the
148
+ # row's largest; the reference grows as refinement resolves narrow peaks
149
+ logvol = np.zeros(bins.K) if coordinate == "measure" else np.log(bins.volumes)
150
+ rowmax = np.full(N, -np.inf)
151
+ corners = np.array(list(product((0.0, 0.5), repeat=nd)))
152
+ unconverged = np.zeros(bins.K, dtype=bool)
153
+ for depth in range(max_depth + 1):
154
+ shift = logfrac + logvol[owner]
155
+ if rows is None:
156
+ rowmax = np.maximum(rowmax, (fine + shift).max(axis=1))
157
+ else:
158
+ np.maximum.at(rowmax, row, fine + shift)
159
+ rowmax = np.maximum(rowmax, (out + logvol).max(axis=1))
160
+ scale = (rowmax[:, None] if rows is None else rowmax[row]) - logvol[owner]
161
+ with np.errstate(invalid="ignore"):
162
+ diff = np.nan_to_num(np.abs(np.exp(fine - scale) - np.exp(coarse[0] - scale)))
163
+ err = np.exp(logfrac) * (diff.max(axis=0) if rows is None else diff)
164
+ ok = err <= tol
165
+ if must_split is not None:
166
+ z_lo = np.stack([inv(lo[:, d]) for d, inv in enumerate(inverse)], axis=-1)
167
+ z_hi = np.stack([inv(hi[:, d]) for d, inv in enumerate(inverse)], axis=-1)
168
+ ok &= ~must_split(z_lo, z_hi, row)
169
+ if depth == max_depth:
170
+ unconverged[owner[~ok]] = True
171
+ ok[:] = True
172
+ if rows is None:
173
+ np.logaddexp.at(out.T, owner[ok], (fine[:, ok] + logfrac[ok]).T)
174
+ else:
175
+ np.logaddexp.at(out, (row[ok], owner[ok]), fine[ok] + logfrac[ok])
176
+ if ok.all():
177
+ break
178
+ keep = ~ok
179
+ half = 0.5 * (hi[keep] - lo[keep])
180
+ lo = (lo[keep][:, None, :] + 2 * half[:, None, :] * corners[None]).reshape(-1, nd)
181
+ hi = lo + np.repeat(half, len(corners), axis=0)
182
+ owner = np.repeat(owner[keep], len(corners))
183
+ logfrac = np.repeat(logfrac[keep], len(corners)) - nd * np.log(2.0)
184
+ if row is not None:
185
+ row = np.repeat(row[keep], len(corners))
186
+ fine, *coarse = (integrate(rule, lo, hi, row) for rule in rules)
187
+ if unconverged.any():
188
+ warn(
189
+ f"Bin averages did not reach tol={tol:g} in {int(unconverged.sum())} bin(s) after "
190
+ f"{max_depth} refinements; raise max_depth or tol.",
191
+ InputWarning,
192
+ )
193
+ return out
194
+
195
+
196
+ def _call(logf: Callable, z: np.ndarray, M: int) -> np.ndarray:
197
+ # logf at the points of the cells in z (C, M, ndim), as (N, C * M)
198
+ pts = z.reshape(-1, z.shape[-1])
199
+ v = np.asarray(logf(pts[:, 0] if z.shape[-1] == 1 else pts), dtype=float)
200
+ if v.ndim != 2 or v.shape[1] != pts.shape[0]:
201
+ raise ValueError("The function must return an array of shape (N, n_points).")
202
+ return v
binference/_simplex.py ADDED
@@ -0,0 +1,54 @@
1
+ """Simplex helpers: softmax, isometric log-ratio (ILR) and centred log-ratio maps.
2
+
3
+ The ILR map uses the orthonormal Helmert basis ``V`` (shape ``(K, K-1)``,
4
+ columns orthonormal and summing to zero):
5
+
6
+ eta = V.T @ log(rho) (ILR)
7
+ rho = softmax(V @ eta) (inverse ILR)
8
+
9
+ The Jacobian of ``eta -> rho[:K-1]`` has determinant ``sqrt(K) * prod(rho)``,
10
+ so the log-density of ``eta`` is ``log p(rho) + sum(log rho) + 0.5 log K``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from functools import lru_cache
16
+
17
+ import numpy as np
18
+
19
+ from ._utils import frozen
20
+
21
+ __all__ = ["helmert_basis", "log_softmax", "softmax"]
22
+
23
+
24
+ @lru_cache(maxsize=64)
25
+ def _helmert(K: int) -> np.ndarray:
26
+ V = np.zeros((K, K - 1))
27
+ for j in range(1, K):
28
+ V[:j, j - 1] = 1.0 / j
29
+ V[j, j - 1] = -1.0
30
+ V[:, j - 1] *= np.sqrt(j / (j + 1.0))
31
+ return frozen(V)
32
+
33
+
34
+ def helmert_basis(K: int) -> np.ndarray:
35
+ """Orthonormal ``(K, K-1)`` basis of the zero-sum subspace of R^K."""
36
+ if K < 2:
37
+ raise ValueError("The simplex needs at least two bins (K >= 2).")
38
+ return _helmert(int(K))
39
+
40
+
41
+ def softmax(x: np.ndarray, axis: int = -1) -> np.ndarray:
42
+ """Numerically stable softmax along ``axis``."""
43
+ x = np.asarray(x, dtype=float)
44
+ m = np.max(x, axis=axis, keepdims=True)
45
+ e = np.exp(x - m)
46
+ return e / np.sum(e, axis=axis, keepdims=True)
47
+
48
+
49
+ def log_softmax(x: np.ndarray, axis: int = -1) -> np.ndarray:
50
+ """Numerically stable log-softmax along ``axis``."""
51
+ x = np.asarray(x, dtype=float)
52
+ m = np.max(x, axis=axis, keepdims=True)
53
+ z = x - m
54
+ return z - np.log(np.sum(np.exp(z), axis=axis, keepdims=True))