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/density.py
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""Fixed densities over the bin coordinate: interim priors, base measures, simulation truth.
|
|
2
|
+
|
|
3
|
+
A :class:`Density` is one fixed probability density :math:`\\pi(z)` with a single
|
|
4
|
+
meaning wherever it appears (DECISIONS #26). It is resolved against bins with
|
|
5
|
+
:meth:`Density.masses`, which returns the normalised mass :math:`\\Pi_k` of each bin.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from typing import Any, Literal
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
from ._quadrature import bin_log_average
|
|
16
|
+
from ._utils import check_nonnegative, frozen
|
|
17
|
+
from .bins import Bins
|
|
18
|
+
|
|
19
|
+
__all__ = ["Density"]
|
|
20
|
+
|
|
21
|
+
Representation = Literal["mass", "density"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Density:
|
|
25
|
+
"""A fixed probability density over the bin coordinate.
|
|
26
|
+
|
|
27
|
+
Build one with :meth:`flat`, :meth:`from_masses`, :meth:`from_densities`,
|
|
28
|
+
:meth:`from_function` or :meth:`from_distribution`. Wherever a density is
|
|
29
|
+
expected, the string ``"flat"``, a callable, a distribution object and an
|
|
30
|
+
array are also accepted (see :meth:`coerce`).
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
# exactly one of _flat ("measure" or "z"), _values (per-bin array, read as
|
|
34
|
+
# masses, densities or, when _per_volume is None, either on equal-volume bins),
|
|
35
|
+
# _func (callable density) or _dist (distribution object) is set
|
|
36
|
+
__slots__ = ("_dist", "_flat", "_func", "_per_volume", "_values")
|
|
37
|
+
|
|
38
|
+
def __init__(self, *, flat=None, values=None, per_volume=False, func=None, dist=None):
|
|
39
|
+
if sum(x is not None for x in (flat, values, func, dist)) != 1:
|
|
40
|
+
raise TypeError(
|
|
41
|
+
"Build a Density with flat, from_masses, from_densities, from_function, "
|
|
42
|
+
"from_distribution or coerce."
|
|
43
|
+
)
|
|
44
|
+
self._flat = flat
|
|
45
|
+
self._values = values
|
|
46
|
+
self._per_volume = per_volume
|
|
47
|
+
self._func = func
|
|
48
|
+
self._dist = dist
|
|
49
|
+
|
|
50
|
+
# ------------------------------------------------------------ constructors
|
|
51
|
+
@classmethod
|
|
52
|
+
def flat(cls, coordinate: Literal["measure", "z"] = "measure") -> Density:
|
|
53
|
+
"""Uniform in the measure coordinate ``u`` of each axis (default) or in ``z``.
|
|
54
|
+
|
|
55
|
+
On linear bins the two agree. On log bins, flat in ``u`` gives equal
|
|
56
|
+
mass per decade. On categories, every category has equal mass.
|
|
57
|
+
|
|
58
|
+
Parameters
|
|
59
|
+
----------
|
|
60
|
+
coordinate : {"measure", "z"}, optional
|
|
61
|
+
Flat in the measure coordinate ``u`` or in ``z``.
|
|
62
|
+
"""
|
|
63
|
+
if coordinate not in ("measure", "z"):
|
|
64
|
+
raise ValueError(f"coordinate must be 'measure' or 'z'; got {coordinate!r}.")
|
|
65
|
+
return cls(flat=coordinate)
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def from_masses(cls, masses: Any) -> Density:
|
|
69
|
+
"""Probability mass per bin (normalised on use).
|
|
70
|
+
|
|
71
|
+
Parameters
|
|
72
|
+
----------
|
|
73
|
+
masses : array_like (K,)
|
|
74
|
+
Non-negative mass per bin.
|
|
75
|
+
"""
|
|
76
|
+
return cls(values=_nonneg(masses, "masses"))
|
|
77
|
+
|
|
78
|
+
@classmethod
|
|
79
|
+
def from_densities(cls, densities: Any) -> Density:
|
|
80
|
+
"""Density per unit ``z``-volume in each bin; masses are densities times volumes.
|
|
81
|
+
|
|
82
|
+
Parameters
|
|
83
|
+
----------
|
|
84
|
+
densities : array_like (K,)
|
|
85
|
+
Non-negative density per bin.
|
|
86
|
+
"""
|
|
87
|
+
return cls(values=_nonneg(densities, "densities"), per_volume=True)
|
|
88
|
+
|
|
89
|
+
@classmethod
|
|
90
|
+
def from_function(cls, func: Callable[[np.ndarray], np.ndarray]) -> Density:
|
|
91
|
+
"""A callable density of ``z``, averaged over each bin by quadrature.
|
|
92
|
+
|
|
93
|
+
On grids ``func`` receives points of shape ``(M,)`` (1-D) or
|
|
94
|
+
``(M, ndim)``; on categories it receives the category labels.
|
|
95
|
+
|
|
96
|
+
Parameters
|
|
97
|
+
----------
|
|
98
|
+
func : callable
|
|
99
|
+
Non-negative density of ``z``.
|
|
100
|
+
"""
|
|
101
|
+
if not callable(func):
|
|
102
|
+
raise TypeError("func must be callable.")
|
|
103
|
+
return cls(func=func)
|
|
104
|
+
|
|
105
|
+
@classmethod
|
|
106
|
+
def from_distribution(cls, dist: Any) -> Density:
|
|
107
|
+
"""A distribution object with ``cdf``, ``logpdf`` or ``pdf`` (e.g. frozen ``scipy.stats``).
|
|
108
|
+
|
|
109
|
+
On 1-D grids a ``cdf`` gives exact bin masses; otherwise the density is
|
|
110
|
+
averaged over each bin by quadrature.
|
|
111
|
+
|
|
112
|
+
Parameters
|
|
113
|
+
----------
|
|
114
|
+
dist : object
|
|
115
|
+
A distribution with ``cdf``, ``logpdf`` or ``pdf``.
|
|
116
|
+
"""
|
|
117
|
+
if not _is_distribution(dist):
|
|
118
|
+
raise TypeError("dist needs a cdf, logpdf or pdf method.")
|
|
119
|
+
return cls(dist=dist)
|
|
120
|
+
|
|
121
|
+
@classmethod
|
|
122
|
+
def coerce(
|
|
123
|
+
cls, value: Any, representation: Representation | None = None, name: str = "interim"
|
|
124
|
+
) -> Density:
|
|
125
|
+
"""A :class:`Density` from a density, ``"flat"``, a callable, a distribution or an array.
|
|
126
|
+
|
|
127
|
+
An array is read as masses or densities by ``representation`` (which
|
|
128
|
+
applies to arrays only). Without it the array is accepted only on bins
|
|
129
|
+
where both readings agree (categories and bins of equal volume);
|
|
130
|
+
:meth:`masses` raises otherwise.
|
|
131
|
+
|
|
132
|
+
Parameters
|
|
133
|
+
----------
|
|
134
|
+
value : Density, "flat", callable, distribution or array_like (K,)
|
|
135
|
+
What to read as a density.
|
|
136
|
+
representation : {"mass", "density"}, optional
|
|
137
|
+
Whether an array holds masses or densities.
|
|
138
|
+
name : str, optional
|
|
139
|
+
Name of the argument, used in error messages.
|
|
140
|
+
"""
|
|
141
|
+
if representation not in (None, "mass", "density"):
|
|
142
|
+
raise ValueError(f"{name} representation must be 'mass' or 'density'.")
|
|
143
|
+
if isinstance(value, Density):
|
|
144
|
+
return value
|
|
145
|
+
if isinstance(value, str):
|
|
146
|
+
if value != "flat":
|
|
147
|
+
raise ValueError(f"{name} must be 'flat', a Density, a callable or an array.")
|
|
148
|
+
return cls.flat()
|
|
149
|
+
if _is_distribution(value):
|
|
150
|
+
return cls.from_distribution(value)
|
|
151
|
+
if callable(value):
|
|
152
|
+
return cls.from_function(value)
|
|
153
|
+
per_volume = {None: None, "mass": False, "density": True}[representation]
|
|
154
|
+
return cls(values=_nonneg(value, name), per_volume=per_volume)
|
|
155
|
+
|
|
156
|
+
# ----------------------------------------------------------------- methods
|
|
157
|
+
def masses(
|
|
158
|
+
self, bins: Bins, *, tol: float = 1e-3, max_depth: int = 12, n_sub: int | None = None
|
|
159
|
+
) -> np.ndarray:
|
|
160
|
+
"""Normalised probability mass of each bin, shape ``(K,)``.
|
|
161
|
+
|
|
162
|
+
Callables and distributions without a usable ``cdf`` are integrated by
|
|
163
|
+
adaptive quadrature to ``tol`` (at most ``max_depth`` refinements), or with
|
|
164
|
+
``n_sub`` fixed Gauss-Legendre
|
|
165
|
+
points per bin and axis (see :meth:`Likelihoods.from_density
|
|
166
|
+
<binference.Likelihoods.from_density>`).
|
|
167
|
+
|
|
168
|
+
Parameters
|
|
169
|
+
----------
|
|
170
|
+
bins : Bins
|
|
171
|
+
The bins.
|
|
172
|
+
tol : float, optional
|
|
173
|
+
Relative tolerance of the adaptive quadrature over each bin.
|
|
174
|
+
max_depth : int, optional
|
|
175
|
+
Most refinements of the adaptive quadrature.
|
|
176
|
+
n_sub : int, optional
|
|
177
|
+
Fixed Gauss-Legendre points per bin and axis in place of adaptive quadrature.
|
|
178
|
+
"""
|
|
179
|
+
m = check_nonnegative(self._bin_masses(bins, tol, max_depth, n_sub), f"{self!r} masses")
|
|
180
|
+
total = m.sum()
|
|
181
|
+
if total <= 0:
|
|
182
|
+
raise ValueError(f"{self!r} has no mass on the bins.")
|
|
183
|
+
return m / total
|
|
184
|
+
|
|
185
|
+
def _bin_masses(self, bins: Bins, tol: float, max_depth: int, n_sub: int | None) -> np.ndarray:
|
|
186
|
+
# unnormalised bin masses
|
|
187
|
+
if self._flat is not None:
|
|
188
|
+
if bins.is_categorical or self._flat == "z":
|
|
189
|
+
return bins.volumes
|
|
190
|
+
return bins.u_volumes
|
|
191
|
+
if self._values is not None:
|
|
192
|
+
v = self._values
|
|
193
|
+
if v.size != bins.K:
|
|
194
|
+
raise ValueError(f"{self!r} has {v.size} values but the bins have K={bins.K}.")
|
|
195
|
+
if self._per_volume is None and not _equal_volumes(bins.volumes):
|
|
196
|
+
raise ValueError(
|
|
197
|
+
"An array interim prior on bins of unequal volume is ambiguous: pass its "
|
|
198
|
+
"representation ('mass' or 'density'), or Density.from_masses / "
|
|
199
|
+
"Density.from_densities."
|
|
200
|
+
)
|
|
201
|
+
return v * bins.volumes if self._per_volume else v
|
|
202
|
+
if bins.is_categorical:
|
|
203
|
+
if self._func is None:
|
|
204
|
+
raise ValueError("A distribution needs grid bins, not categories.")
|
|
205
|
+
m = np.asarray(self._func(np.asarray(bins.labels)), dtype=float).ravel()
|
|
206
|
+
if m.size != bins.K:
|
|
207
|
+
raise ValueError(f"{self!r} must return one value per category.")
|
|
208
|
+
return m
|
|
209
|
+
if self._dist is not None and bins.ndim == 1 and _has_cdf(self._dist):
|
|
210
|
+
return np.exp(_dist_log_masses(self._dist, bins.edges[0]))[bins.cell_index[0]]
|
|
211
|
+
if self._dist is not None:
|
|
212
|
+
logf = lambda z: _dist_logpdf(self._dist, z)[None] # noqa: E731
|
|
213
|
+
else:
|
|
214
|
+
logf = lambda z: _log_nonneg(self._func(z))[None] # noqa: E731
|
|
215
|
+
if self._dist is not None and not _has_pdf(self._dist):
|
|
216
|
+
raise TypeError(f"{self!r} needs a logpdf or pdf here (or a cdf on 1-D bins).")
|
|
217
|
+
log_avg = bin_log_average(
|
|
218
|
+
logf, bins, coordinate="z", tol=tol, max_depth=max_depth, n_sub=n_sub
|
|
219
|
+
)
|
|
220
|
+
return np.exp(log_avg[0]) * bins.volumes
|
|
221
|
+
|
|
222
|
+
def _log_pointwise(self) -> Callable[[np.ndarray], np.ndarray] | None:
|
|
223
|
+
# the log density as a function of z, or None when only bin masses are known
|
|
224
|
+
if self._flat == "z":
|
|
225
|
+
return lambda z: np.zeros(np.shape(z)[0])
|
|
226
|
+
if self._func is not None:
|
|
227
|
+
return lambda z: _log_nonneg(self._func(z))
|
|
228
|
+
if self._dist is not None and _has_pdf(self._dist):
|
|
229
|
+
return lambda z: _dist_logpdf(self._dist, z)
|
|
230
|
+
return None
|
|
231
|
+
|
|
232
|
+
def __repr__(self) -> str:
|
|
233
|
+
if self._flat is not None:
|
|
234
|
+
return "Density.flat()" if self._flat == "measure" else "Density.flat('z')"
|
|
235
|
+
if self._values is not None:
|
|
236
|
+
kind = "densities" if self._per_volume else "masses"
|
|
237
|
+
return f"Density.from_{kind}(<{self._values.size} values>)"
|
|
238
|
+
if self._dist is not None:
|
|
239
|
+
return f"Density.from_distribution({self._dist!r})"
|
|
240
|
+
return f"Density.from_function({self._func!r})"
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _equal_volumes(vol: np.ndarray) -> bool:
|
|
244
|
+
"""Whether all bins have the same volume, so masses and densities read alike."""
|
|
245
|
+
return bool(np.allclose(vol, vol[0], rtol=1e-9, atol=0.0))
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _is_distribution(x: Any) -> bool:
|
|
249
|
+
return any(hasattr(x, m) for m in ("cdf", "logcdf", "logpdf", "pdf"))
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _dist_logpdf(dist: Any, z: np.ndarray) -> np.ndarray:
|
|
253
|
+
if hasattr(dist, "logpdf"):
|
|
254
|
+
return np.asarray(dist.logpdf(z), dtype=float)
|
|
255
|
+
with np.errstate(divide="ignore"):
|
|
256
|
+
return np.log(np.asarray(dist.pdf(z), dtype=float))
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _has_pdf(dist: Any) -> bool:
|
|
260
|
+
return hasattr(dist, "logpdf") or hasattr(dist, "pdf")
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _has_cdf(dist: Any) -> bool:
|
|
264
|
+
return hasattr(dist, "cdf") or hasattr(dist, "logcdf")
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _dist_log_masses(dist: Any, edges: np.ndarray) -> np.ndarray:
|
|
268
|
+
"""Log mass between consecutive edges of a distribution with a CDF."""
|
|
269
|
+
with np.errstate(divide="ignore"):
|
|
270
|
+
if hasattr(dist, "logcdf"):
|
|
271
|
+
lc = np.asarray(dist.logcdf(edges), dtype=float)
|
|
272
|
+
else:
|
|
273
|
+
lc = np.log(np.asarray(dist.cdf(edges), dtype=float))
|
|
274
|
+
if hasattr(dist, "logsf"):
|
|
275
|
+
ls = np.asarray(dist.logsf(edges), dtype=float)
|
|
276
|
+
else:
|
|
277
|
+
ls = np.log1p(-np.exp(lc))
|
|
278
|
+
out = log_interval_masses(lc, ls)
|
|
279
|
+
# near the median a small interval's CDF difference cancels; the density is then
|
|
280
|
+
# nearly constant across it, so a 5-point quadrature of the pdf keeps full precision
|
|
281
|
+
near = (out < np.log(1e-3)) & (np.abs(np.exp(lc[:-1]) - 0.5) < 0.25)
|
|
282
|
+
if near.any() and _has_pdf(dist):
|
|
283
|
+
x, w = np.polynomial.legendre.leggauss(5)
|
|
284
|
+
lo, hi = edges[:-1][near], edges[1:][near]
|
|
285
|
+
pts = lo[:, None] + (hi - lo)[:, None] * (0.5 * (x + 1.0))
|
|
286
|
+
dens = np.exp(_dist_logpdf(dist, pts.ravel()).reshape(pts.shape))
|
|
287
|
+
with np.errstate(divide="ignore"):
|
|
288
|
+
out[near] = np.log((dens @ (0.5 * w)) * (hi - lo))
|
|
289
|
+
return out
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def log_interval_masses(
|
|
293
|
+
lc: np.ndarray, ls: np.ndarray, centred: np.ndarray | None = None
|
|
294
|
+
) -> np.ndarray:
|
|
295
|
+
"""Log mass between consecutive edges from the log-CDF ``lc`` and log-survival ``ls``.
|
|
296
|
+
|
|
297
|
+
Uses the side that keeps precision: the survival function for intervals
|
|
298
|
+
above the median. Near the median both logs are close to ``log(1/2)`` and
|
|
299
|
+
their difference cancels; ``centred``, the CDF minus one half computed
|
|
300
|
+
without that loss (``erf(x/sqrt 2)/2`` for a Gaussian), is then used for
|
|
301
|
+
intervals inside the central half of the mass. The edges run along the last axis.
|
|
302
|
+
"""
|
|
303
|
+
a, b = (..., slice(None, -1)), (..., slice(1, None))
|
|
304
|
+
upper = lc[a] > np.log(0.5) # the interval lies above the median: use survival functions
|
|
305
|
+
hi = np.where(upper, ls[a], lc[b])
|
|
306
|
+
lo = np.where(upper, ls[b], lc[a])
|
|
307
|
+
near = False
|
|
308
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
309
|
+
out = hi + np.log1p(-np.exp(lo - hi))
|
|
310
|
+
if centred is not None:
|
|
311
|
+
near = (np.abs(centred[a]) < 0.25) & (np.abs(centred[b]) < 0.25)
|
|
312
|
+
out = np.where(near, np.log(centred[b] - centred[a]), out)
|
|
313
|
+
return np.where(near | (hi > lo), out, -np.inf)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _log_nonneg(values: Any, name: str = "The values of a density") -> np.ndarray:
|
|
317
|
+
v = check_nonnegative(values, name)
|
|
318
|
+
with np.errstate(divide="ignore"):
|
|
319
|
+
return np.log(v)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _nonneg(a: Any, name: str) -> np.ndarray:
|
|
323
|
+
return frozen(check_nonnegative(np.array(a, dtype=float).ravel(), name))
|