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