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/_utils.py ADDED
@@ -0,0 +1,139 @@
1
+ """Small internal helpers: random-number handling, validation and progress."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ import warnings
8
+ from collections.abc import Iterable, Iterator
9
+ from typing import Any
10
+
11
+ import numpy as np
12
+
13
+ __all__ = [
14
+ "as_generator",
15
+ "check_nonnegative",
16
+ "check_positive",
17
+ "check_positive_weights",
18
+ "chunk_slices",
19
+ "progress",
20
+ "spawn",
21
+ ]
22
+
23
+ RNGLike = int | np.random.Generator | np.random.SeedSequence | None
24
+
25
+ #: frames under this directory are skipped, so warnings point at the caller's line
26
+ PACKAGE_DIR = (os.path.dirname(os.path.abspath(__file__)) + os.sep,)
27
+
28
+
29
+ def warn(message: str, category: type[Warning]) -> None:
30
+ """Issue a warning attributed to the first line outside the package."""
31
+ warnings.warn(message, category, skip_file_prefixes=PACKAGE_DIR)
32
+
33
+
34
+ def frozen(a: Any) -> Any:
35
+ """``a`` made read-only (a dense array, or the arrays of a sparse CSR matrix)."""
36
+ for arr in (a.data, a.indices, a.indptr) if hasattr(a, "indptr") else (a,):
37
+ arr.setflags(write=False)
38
+ return a
39
+
40
+
41
+ def as_generator(rng: RNGLike = None) -> np.random.Generator:
42
+ """Return a :class:`numpy.random.Generator` following SPEC 7.
43
+
44
+ Parameters
45
+ ----------
46
+ rng : None, int, SeedSequence or Generator
47
+ ``None`` draws fresh entropy from the OS; an ``int`` or
48
+ :class:`~numpy.random.SeedSequence` seeds a new generator; a
49
+ :class:`~numpy.random.Generator` is used as is (and therefore advanced).
50
+ """
51
+ if isinstance(rng, np.random.Generator):
52
+ return rng
53
+ if rng is None or isinstance(rng, int | np.integer | np.random.SeedSequence):
54
+ return np.random.default_rng(rng)
55
+ if isinstance(rng, np.random.RandomState):
56
+ raise TypeError(
57
+ "Legacy numpy.random.RandomState objects are not supported; pass an int seed "
58
+ "or a numpy.random.Generator (e.g. numpy.random.default_rng(seed))."
59
+ )
60
+ raise TypeError(f"Cannot interpret {type(rng).__name__} as a random number generator.")
61
+
62
+
63
+ def spawn(rng: np.random.Generator, n: int) -> list[np.random.Generator]:
64
+ """Independent child generators (one per chain)."""
65
+ return list(rng.spawn(n))
66
+
67
+
68
+ def chunk_slices(n: int, chunk: int | None) -> Iterator[slice]:
69
+ """Yield slices covering ``range(n)`` in blocks of at most ``chunk``."""
70
+ if chunk is None or chunk <= 0 or chunk >= n:
71
+ yield slice(0, n)
72
+ return
73
+ for start in range(0, n, chunk):
74
+ yield slice(start, min(start + chunk, n))
75
+
76
+
77
+ def check_positive_weights(weights: Any, n: int) -> np.ndarray:
78
+ """Validate per-object weights (default ones)."""
79
+ if weights is None:
80
+ return np.ones(n)
81
+ w = np.asarray(weights, dtype=float)
82
+ if w.ndim == 0:
83
+ w = np.full(n, float(w))
84
+ if w.shape != (n,):
85
+ raise ValueError(f"weights must have shape ({n},); got {w.shape}.")
86
+ return check_positive(w, "weights")
87
+
88
+
89
+ def check_positive(value: Any, name: str) -> np.ndarray:
90
+ """``value`` as a float array, after checking that it is finite and strictly positive."""
91
+ a = np.asarray(value, dtype=float)
92
+ if not np.all(np.isfinite(a)) or np.any(a <= 0):
93
+ raise ValueError(f"{name} must be finite and strictly positive.")
94
+ return a
95
+
96
+
97
+ def check_nonnegative(value: Any, name: str) -> np.ndarray:
98
+ """``value`` as a float array, after checking that it is finite and non-negative."""
99
+ a = np.asarray(value, dtype=float)
100
+ if not np.all(np.isfinite(a)) or np.any(a < 0):
101
+ raise ValueError(f"{name} must be finite and non-negative.")
102
+ return a
103
+
104
+
105
+ def is_integer_array(x: np.ndarray, atol: float = 1e-12) -> bool:
106
+ """True if every entry of ``x`` is (numerically) an integer."""
107
+ return bool(np.all(np.abs(x - np.round(x)) <= atol))
108
+
109
+
110
+ def progress(
111
+ iterable: Iterable, total: int | None = None, desc: str = "", enable: bool = False
112
+ ) -> Iterable:
113
+ """Wrap an iterable with a progress bar (tqdm if available) when ``enable``."""
114
+ if not enable:
115
+ return iterable
116
+ try:
117
+ from tqdm.auto import tqdm
118
+ except ImportError: # pragma: no cover - depends on environment
119
+ return _TextProgress(iterable, total=total, desc=desc)
120
+ return tqdm(iterable, total=total, desc=desc)
121
+
122
+
123
+ class _TextProgress: # pragma: no cover - cosmetic
124
+ """Minimal text progress reporting to stderr (no dependencies)."""
125
+
126
+ def __init__(self, iterable: Iterable, total: int | None, desc: str) -> None:
127
+ self.iterable = iterable
128
+ self.total = total
129
+ self.desc = desc
130
+
131
+ def __iter__(self) -> Iterator:
132
+ step = max(1, (self.total or 100) // 20)
133
+ for i, item in enumerate(self.iterable):
134
+ if i % step == 0:
135
+ frac = f"{i}/{self.total}" if self.total else str(i)
136
+ sys.stderr.write(f"\r{self.desc} {frac}")
137
+ sys.stderr.flush()
138
+ yield item
139
+ sys.stderr.write("\n")
binference/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
binference/basis.py ADDED
@@ -0,0 +1,314 @@
1
+ r"""Mixture bases: the model's components and how they map to the reporting bins.
2
+
3
+ The population is a mixture :math:`f(z) = \sum_k w_k \phi_k(z)` of fixed
4
+ non-negative components of unit mass, so every per-object likelihood stays
5
+ linear in the weights, :math:`L_{ik} = \int p(d_i \mid z)\,\phi_k(z)\,du`, and
6
+ the engines and priors are unchanged (DECISIONS #4, #5, #76). Results are
7
+ reported in the bins: the fraction in bin :math:`j` is
8
+ :math:`\rho_j = \sum_k A_{jk} w_k` with :math:`A_{jk} = \int_{B_j} \phi_k\,du`.
9
+
10
+ The components sit on the reporting bins refined ``resolution`` times per axis
11
+ in the measure coordinate ``u``. ``order=0`` makes each component flat in ``u``
12
+ on its cell (the histogram; at ``resolution=1`` exactly the plain bins);
13
+ ``order=1`` makes it a linear B-spline (hat) centred on its cell, linear in
14
+ ``u`` between neighbouring centres and constant from the outermost centres to
15
+ the range edges, so the components form a partition of unity and the density
16
+ has no steps at bin edges. Grids use tensor products; order 1 needs an
17
+ unmasked grid, and on masked grids flat components cover the active cells.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from typing import Any
23
+
24
+ import numpy as np
25
+ import scipy.sparse as sp
26
+
27
+ from ._utils import frozen
28
+ from .bins import Bins
29
+
30
+ __all__ = ["Basis"]
31
+
32
+ #: Quadrature cells per order-1 component and axis when ``oversample`` is ``None``, by
33
+ #: dimension (2 above two dimensions; order 0 takes 1).
34
+ DEFAULT_OVERSAMPLE = {1: 8, 2: 4}
35
+
36
+
37
+ class Basis:
38
+ """Components of the population model over reporting bins.
39
+
40
+ Parameters
41
+ ----------
42
+ bins : Bins
43
+ The reporting bins (a grid, not categories).
44
+ order : {0, 1}
45
+ B-spline order of the components: 0 flat in ``u`` on each cell, 1
46
+ linear (hats). See the module documentation.
47
+ resolution : int or sequence of int
48
+ Components per reporting bin along each axis (default 1).
49
+ oversample : int, optional
50
+ Quadrature cells per component and axis. They integrate likelihoods
51
+ and selection functions against the components (default 1 for order 0,
52
+ where the integrals are exact; for order 1, 8 in 1-D, 4 in 2-D and 2
53
+ in more dimensions).
54
+
55
+ Attributes
56
+ ----------
57
+ bins : Bins
58
+ The reporting bins.
59
+ components : Bins
60
+ The component cells (the bins refined ``resolution`` times); priors are
61
+ placed on these.
62
+ grid : Bins
63
+ The quadrature cells (the components refined ``oversample`` times).
64
+ grid_to_bins : ndarray (grid.K,)
65
+ The reporting bin of each quadrature cell.
66
+ matrix : sparse array (K, C)
67
+ :math:`A_{jk}`, the mass of component *k* in reporting bin *j*; each
68
+ column sums to one.
69
+
70
+ Examples
71
+ --------
72
+ >>> import binference
73
+ >>> basis = binference.Basis(binference.Bins.linear(0, 3, 10), order=1)
74
+ >>> basis.K, basis.C
75
+ (10, 10)
76
+ """
77
+
78
+ def __init__(
79
+ self, bins: Bins, order: int = 0, resolution: Any = 1, oversample: int | None = None
80
+ ) -> None:
81
+ if not isinstance(bins, Bins):
82
+ raise TypeError("bins must be a binference.Bins instance.")
83
+ if bins.is_categorical:
84
+ raise ValueError("A basis needs a grid; categories have no within-bin shape.")
85
+ if order not in (0, 1):
86
+ raise ValueError(f"order must be 0 (flat) or 1 (linear); got {order!r}.")
87
+ if order == 1 and bins.is_masked:
88
+ raise ValueError(
89
+ "Linear components need an unmasked grid: next to a masked cell the hats "
90
+ "would not sum to one, biasing the bins there. Use order=0 on masked grids."
91
+ )
92
+ try:
93
+ res = np.broadcast_to(np.asarray(resolution), (bins.ndim,))
94
+ valid = all(float(r).is_integer() and r >= 1 for r in res)
95
+ except (TypeError, ValueError):
96
+ valid = False
97
+ if not valid:
98
+ raise ValueError(
99
+ f"resolution must be a positive integer, or one per axis ({bins.ndim}); "
100
+ f"got {resolution!r}."
101
+ )
102
+ if oversample is None:
103
+ oversample = 1 if order == 0 else DEFAULT_OVERSAMPLE.get(bins.ndim, 2)
104
+ if not float(oversample).is_integer() or oversample < 1:
105
+ raise ValueError(f"oversample must be a positive integer; got {oversample!r}.")
106
+ self.bins = bins
107
+ self.order = int(order)
108
+ self.oversample = int(oversample)
109
+ self.components, _ = bins.refine(res.astype(int))
110
+ self.resolution = tuple(
111
+ (len(c) - 1) // (len(b) - 1)
112
+ for c, b in zip(self.components.edges, bins.edges, strict=True)
113
+ )
114
+ fine = tuple(r * self.oversample for r in self.resolution)
115
+ self.grid, grid_to_bins = bins.refine(fine)
116
+ self.grid_to_bins = frozen(grid_to_bins)
117
+ self.matrix = self.masses(bins)
118
+ self._grid_masses: sp.csr_array | None = None
119
+ self._shares: sp.csr_array | None = None
120
+
121
+ @property
122
+ def K(self) -> int:
123
+ """Number of reporting bins."""
124
+ return self.bins.K
125
+
126
+ @property
127
+ def C(self) -> int:
128
+ """Number of components."""
129
+ return self.components.K
130
+
131
+ @property
132
+ def is_identity(self) -> bool:
133
+ """True when the components are the bins themselves (order 0, resolution 1)."""
134
+ return self.order == 0 and self.C == self.K
135
+
136
+ @property
137
+ def grid_masses(self) -> sp.csr_array:
138
+ """``masses(grid)``: the components' masses in the quadrature cells (cached)."""
139
+ if self._grid_masses is None:
140
+ self._grid_masses = self.masses(self.grid)
141
+ return self._grid_masses
142
+
143
+ @property
144
+ def shares(self) -> sp.csr_array:
145
+ """``(C, K)``: each component's mass in each reporting bin (rows sum to one; cached)."""
146
+ if self._shares is None:
147
+ self._shares = sp.csr_array(self.matrix.T)
148
+ return self._shares
149
+
150
+ def axis_masses(self, grid: Bins) -> list[sp.csr_array]:
151
+ """Per-axis masses of the components' factors in ``grid``'s intervals (no mask).
152
+
153
+ On an unmasked grid :meth:`masses` is their Kronecker product, so
154
+ separable inputs can be projected one axis at a time.
155
+
156
+ Parameters
157
+ ----------
158
+ grid : Bins
159
+ Grid covering the components' range with the same measures.
160
+ """
161
+ comp = self.components
162
+ if grid.is_categorical or grid.ndim != comp.ndim or grid.measures != comp.measures:
163
+ raise ValueError("grid must be a grid with the components' measures.")
164
+ out = []
165
+ for u_grid, u_comp in zip(grid.u_edges, comp.u_edges, strict=True):
166
+ if not np.allclose([u_grid[0], u_grid[-1]], [u_comp[0], u_comp[-1]]):
167
+ raise ValueError("grid must cover the same range as the basis.")
168
+ out.append(_axis_masses(u_grid, u_comp, self.order))
169
+ return out
170
+
171
+ def masses(self, grid: Bins) -> sp.csr_array:
172
+ r"""``(grid.K, C)`` sparse masses :math:`M_{qk} = \int_{B_q} \phi_k\,du`.
173
+
174
+ ``grid`` must cover the components' range with the same measures (the
175
+ reporting bins, the components or any refinement of them). Each column
176
+ sums to one over the active cells of ``grid``.
177
+
178
+ Parameters
179
+ ----------
180
+ grid : Bins
181
+ Grid covering the components' range with the same measures.
182
+ """
183
+ M = None
184
+ for m in self.axis_masses(grid):
185
+ M = m if M is None else sp.kron(M, m, format="csr")
186
+ M = sp.csr_array(M)[grid.cells][:, self.components.cells]
187
+ tot = np.asarray(M.sum(axis=0)).ravel() # below one where a hat crosses a mask
188
+ M.data /= tot[M.indices]
189
+ for a in (M.data, M.indices, M.indptr): # shared by every object on this basis
190
+ frozen(a)
191
+ return M
192
+
193
+ def support(self, k: Any) -> tuple[np.ndarray, np.ndarray]:
194
+ """``(lo, hi)`` in ``u`` of the supports of components ``k``, each ``(n, ndim)``.
195
+
196
+ Parameters
197
+ ----------
198
+ k : int or array_like of int
199
+ Component indices.
200
+ """
201
+ k = np.asarray(k).ravel()
202
+ lo, hi = [], []
203
+ for u, j in zip(self.components.u_edges, self.components.grid_index(k), strict=True):
204
+ a, _, b, _, _ = _hat(u, j) if self.order == 1 else (u[j], 0, u[j + 1], 0, 0)
205
+ lo.append(a)
206
+ hi.append(b)
207
+ return np.stack(lo, axis=-1), np.stack(hi, axis=-1)
208
+
209
+ def height(self, k: Any, d: int, u: Any) -> np.ndarray:
210
+ """Height (at most one) along axis ``d`` of the unnormalised components ``k`` at
211
+ ``u``; ``u`` has ``k``'s shape, or one more trailing axis of points per component.
212
+
213
+ A component's shape is the product of its heights over the axes (one for
214
+ flat components inside their cell).
215
+
216
+ Parameters
217
+ ----------
218
+ k : int or array_like of int
219
+ Component indices.
220
+ d : int
221
+ Axis.
222
+ u : array_like
223
+ Points in ``u`` along axis ``d``.
224
+ """
225
+ k = np.asarray(k)
226
+ x = np.asarray(u, dtype=float)
227
+ if self.order == 0:
228
+ return np.ones(x.shape)
229
+ j = self.components.grid_index(k)[d].reshape(k.shape + (1,) * (x.ndim - k.ndim))
230
+ a, mid, b, first, last = _hat(self.components.u_edges[d], j)
231
+ with np.errstate(divide="ignore", invalid="ignore"):
232
+ up = np.where(first, 1.0, (x - a) / (mid - a))
233
+ down = np.where(last, 1.0, (b - x) / (b - mid))
234
+ return np.clip(np.where(x < mid, up, down), 0.0, 1.0)
235
+
236
+ def to_bins(self, weights: Any) -> np.ndarray:
237
+ """Map component weights ``(..., C)`` to reporting-bin fractions ``(..., K)``.
238
+
239
+ Parameters
240
+ ----------
241
+ weights : array_like (..., C)
242
+ Component weights.
243
+ """
244
+ w = np.asarray(weights, dtype=float)
245
+ return np.asarray(self.matrix @ w.reshape(-1, self.C).T).T.reshape(*w.shape[:-1], self.K)
246
+
247
+ def __eq__(self, other: object) -> bool:
248
+ if not isinstance(other, Basis):
249
+ return NotImplemented
250
+ return (
251
+ self.bins == other.bins
252
+ and self.order == other.order
253
+ and self.resolution == other.resolution
254
+ and self.oversample == other.oversample
255
+ )
256
+
257
+ def __hash__(self) -> int:
258
+ return hash((self.bins, self.order, self.resolution, self.oversample))
259
+
260
+ def __reduce__(self):
261
+ return Basis, (self.bins, self.order, self.resolution, self.oversample)
262
+
263
+ def __repr__(self) -> str:
264
+ res = self.resolution[0] if len(set(self.resolution)) == 1 else self.resolution
265
+ return f"Basis(order={self.order}, resolution={res}, K={self.K}, C={self.C})"
266
+
267
+
268
+ def _axis_masses(u_grid: np.ndarray, u_comp: np.ndarray, order: int) -> sp.csr_array:
269
+ """Masses of one axis's components in the grid intervals, ``(n_grid, n_comp)``, sparse.
270
+
271
+ Each entry is the difference of the component's antiderivative at the
272
+ interval's ends (exact), for the components whose support meets the
273
+ interval; columns are normalised to unit total.
274
+ """
275
+ n = len(u_comp) - 1
276
+ a, b = u_grid[:-1], u_grid[1:]
277
+ j0 = np.clip(np.searchsorted(u_comp, a, side="right") - 1, 0, n - 1)
278
+ j1 = np.clip(np.searchsorted(u_comp, b, side="left") - 1, 0, n - 1)
279
+ lo, hi = (j0, j1) if order == 0 else (np.maximum(j0 - 1, 0), np.minimum(j1 + 1, n - 1))
280
+ span = hi - lo + 1
281
+ rows = np.repeat(np.arange(a.size), span)
282
+ cols = np.arange(span.sum()) - np.repeat(np.cumsum(span) - span, span) + lo[rows]
283
+ m = _antiderivative(u_comp, cols, b[rows], order) - _antiderivative(
284
+ u_comp, cols, a[rows], order
285
+ )
286
+ total = _antiderivative(u_comp, np.arange(n), np.full(n, u_comp[-1]), order)
287
+ keep = m > 1e-14 * total[cols] # round-off outside the support
288
+ return sp.csr_array((m[keep] / total[cols[keep]], (rows[keep], cols[keep])), shape=(a.size, n))
289
+
290
+
291
+ def _antiderivative(u_comp: np.ndarray, k: np.ndarray, x: np.ndarray, order: int) -> np.ndarray:
292
+ """Integral from the range's start to ``x`` of component ``k``'s unnormalised shape."""
293
+ if order == 0:
294
+ return np.clip(x, u_comp[k], u_comp[k + 1]) - u_comp[k]
295
+ a, mid, b, first, last = _hat(u_comp, k)
296
+ y = np.clip(x, a, mid)
297
+ with np.errstate(divide="ignore", invalid="ignore"):
298
+ up = np.where(first, y - a, (y - a) ** 2 / (2 * (mid - a)))
299
+ y = np.clip(x, mid, b)
300
+ down = np.where(last, y - mid, ((b - mid) ** 2 - (b - y) ** 2) / (2 * (b - mid)))
301
+ return up + down
302
+
303
+
304
+ def _hat(u: np.ndarray, j: np.ndarray) -> tuple[np.ndarray, ...]:
305
+ """Support ``(a, b)``, peak and first/last flags of the hats of an axis's cells ``j``.
306
+
307
+ A hat peaks at its cell's centre and falls to zero at the neighbouring
308
+ centres; the first and last stay at one out to the range's edges.
309
+ """
310
+ c, n = 0.5 * (u[1:] + u[:-1]), len(u) - 1
311
+ first, last = j == 0, j == n - 1
312
+ a = np.where(first, u[0], c[np.maximum(j - 1, 0)])
313
+ b = np.where(last, u[-1], c[np.minimum(j + 1, n - 1)])
314
+ return a, c[j], b, first, last