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