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/bins.py
ADDED
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
"""The support of a population: 1-D/N-D grids of bins, or unordered categories."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
from ._utils import frozen, warn
|
|
12
|
+
from .measure import Measure
|
|
13
|
+
from .warnings import InputWarning
|
|
14
|
+
|
|
15
|
+
__all__ = ["Bins"]
|
|
16
|
+
|
|
17
|
+
MeasureLike = Measure | str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _as_edges(e: Any, axis: int = 0) -> np.ndarray:
|
|
21
|
+
# adding 0.0 turns -0.0 into 0.0, so equal edges also hash equally
|
|
22
|
+
if np.ndim(e) > 1:
|
|
23
|
+
raise ValueError(
|
|
24
|
+
f"Edges along axis {axis} must be 1-D; for a grid pass one array per axis, "
|
|
25
|
+
"e.g. Bins.grid(x_edges, y_edges), or unpack a 2-D array with Bins.grid(*edges)."
|
|
26
|
+
)
|
|
27
|
+
arr = np.array(e, dtype=float).ravel() + 0.0
|
|
28
|
+
if arr.size < 2:
|
|
29
|
+
raise ValueError(f"Edges along axis {axis} need at least two values; got {arr.size}.")
|
|
30
|
+
if not np.all(np.isfinite(arr)):
|
|
31
|
+
raise ValueError(f"Edges along axis {axis} must be finite.")
|
|
32
|
+
if not np.all(np.diff(arr) > 0):
|
|
33
|
+
raise ValueError(f"Edges along axis {axis} must be strictly increasing.")
|
|
34
|
+
return frozen(arr)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _looks_geometric(e: np.ndarray) -> bool:
|
|
38
|
+
if e.size < 4 or e[0] <= 0:
|
|
39
|
+
return False
|
|
40
|
+
d = np.diff(e)
|
|
41
|
+
r = e[1:] / e[:-1]
|
|
42
|
+
return bool(np.allclose(r, r[0], rtol=1e-6) and not np.allclose(d, d[0], rtol=1e-6))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Bins:
|
|
46
|
+
"""The bins (or categories) over which populations and likelihoods are defined.
|
|
47
|
+
|
|
48
|
+
A ``Bins`` object describes the support of the population distribution: a
|
|
49
|
+
one-dimensional set of bins, a rectilinear N-dimensional grid of cells
|
|
50
|
+
(flattened in C order), or a set of unordered categories. It records bin
|
|
51
|
+
*volumes* (widths, areas, ...), which matter whenever bins have unequal
|
|
52
|
+
sizes (see :doc:`/how-it-works/model`).
|
|
53
|
+
|
|
54
|
+
Each grid axis carries a :class:`~binference.measure.Measure`, the
|
|
55
|
+
coordinate ``u = T(z)`` in which the population is flat within a bin
|
|
56
|
+
(DECISIONS #21): linear by default, logarithmic for :meth:`log`. A boolean
|
|
57
|
+
``mask`` over the grid removes cells from the support (DECISIONS #19); the
|
|
58
|
+
remaining *active* cells are numbered ``0 .. K - 1`` in C order.
|
|
59
|
+
|
|
60
|
+
Parameters
|
|
61
|
+
----------
|
|
62
|
+
edges : array_like
|
|
63
|
+
Strictly increasing 1-D bin edges (``K + 1`` values). Use
|
|
64
|
+
:meth:`grid` for N-dimensional grids and :meth:`categories` for
|
|
65
|
+
unordered categories.
|
|
66
|
+
measure : Measure or {"linear", "log"}, optional
|
|
67
|
+
Coordinate in which the population is flat within a bin. Default linear;
|
|
68
|
+
hand-built edges that look geometric then raise an
|
|
69
|
+
:class:`~binference.InputWarning`.
|
|
70
|
+
mask : array_like of bool, optional
|
|
71
|
+
Which bins are part of the support (default: all).
|
|
72
|
+
|
|
73
|
+
Examples
|
|
74
|
+
--------
|
|
75
|
+
>>> import binference
|
|
76
|
+
>>> bins = binference.Bins([0.0, 0.5, 1.0, 2.0])
|
|
77
|
+
>>> bins.K, bins.volumes.tolist()
|
|
78
|
+
(3, [0.5, 0.5, 1.0])
|
|
79
|
+
>>> grid = binference.Bins.grid([0, 1, 2], [0, 1, 2, 3])
|
|
80
|
+
>>> grid.K, grid.shape
|
|
81
|
+
(6, (2, 3))
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
__slots__ = (
|
|
85
|
+
"_active",
|
|
86
|
+
"_cell_index",
|
|
87
|
+
"_centers",
|
|
88
|
+
"_edges",
|
|
89
|
+
"_labels",
|
|
90
|
+
"_lookup",
|
|
91
|
+
"_measures",
|
|
92
|
+
"_shape",
|
|
93
|
+
"_u_edges",
|
|
94
|
+
"_volumes",
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def __init__(self, edges: Any, measure: MeasureLike | None = None, mask: Any = None) -> None:
|
|
98
|
+
self._set_grid((_as_edges(edges, 0),), measure, mask)
|
|
99
|
+
|
|
100
|
+
# ------------------------------------------------------------------ setup
|
|
101
|
+
def _set_grid(self, edges: tuple[np.ndarray, ...], measure: Any, mask: Any) -> None:
|
|
102
|
+
nd = len(edges)
|
|
103
|
+
if measure is None:
|
|
104
|
+
for d, e in enumerate(edges):
|
|
105
|
+
if _looks_geometric(e):
|
|
106
|
+
warn(
|
|
107
|
+
f"The edges of axis {d} are geometrically spaced but the measure is "
|
|
108
|
+
"linear, so the population is taken as flat in z within each bin. "
|
|
109
|
+
"Pass measure='log' (or use Bins.log) if it should be flat in log z.",
|
|
110
|
+
InputWarning,
|
|
111
|
+
)
|
|
112
|
+
measure = "linear"
|
|
113
|
+
if isinstance(measure, Measure | str):
|
|
114
|
+
measures = (Measure.coerce(measure),) * nd
|
|
115
|
+
else:
|
|
116
|
+
measures = tuple(Measure.coerce(m) for m in measure)
|
|
117
|
+
if len(measures) != nd:
|
|
118
|
+
raise ValueError(f"measure needs one entry per axis ({nd}); got {len(measures)}.")
|
|
119
|
+
u_edges = tuple(
|
|
120
|
+
frozen(m._check(e, d)) for d, (m, e) in enumerate(zip(measures, edges, strict=True))
|
|
121
|
+
)
|
|
122
|
+
shape = tuple(len(e) - 1 for e in edges)
|
|
123
|
+
n = math.prod(shape)
|
|
124
|
+
# _active (flat cell of each bin) and _lookup (bin of each cell, -1 if masked)
|
|
125
|
+
# are None when nothing is masked
|
|
126
|
+
active = lookup = None
|
|
127
|
+
if mask is not None:
|
|
128
|
+
mask = np.asarray(mask, dtype=bool)
|
|
129
|
+
if mask.shape != shape:
|
|
130
|
+
raise ValueError(f"mask must have the grid's shape {shape}; got {mask.shape}.")
|
|
131
|
+
cells = np.flatnonzero(mask)
|
|
132
|
+
if cells.size == 0:
|
|
133
|
+
raise ValueError("mask leaves no bins in the support.")
|
|
134
|
+
if cells.size < n:
|
|
135
|
+
active = frozen(cells)
|
|
136
|
+
lookup = np.full(n, -1, dtype=np.int64)
|
|
137
|
+
lookup[active] = np.arange(active.size)
|
|
138
|
+
lookup = frozen(lookup)
|
|
139
|
+
self._edges = edges
|
|
140
|
+
self._shape = shape
|
|
141
|
+
self._labels = None
|
|
142
|
+
self._measures = measures
|
|
143
|
+
self._u_edges = u_edges
|
|
144
|
+
self._active = active
|
|
145
|
+
self._lookup = lookup
|
|
146
|
+
k = np.arange(n) if active is None else active
|
|
147
|
+
self._cell_index = tuple(frozen(i) for i in np.unravel_index(k, shape))
|
|
148
|
+
vol = np.ones(())
|
|
149
|
+
for e in edges:
|
|
150
|
+
vol = np.multiply.outer(vol, np.diff(e))
|
|
151
|
+
vol = vol.ravel()
|
|
152
|
+
mids = [m.inverse(0.5 * (u[1:] + u[:-1])) for m, u in zip(measures, u_edges, strict=True)]
|
|
153
|
+
if nd == 1:
|
|
154
|
+
centers = mids[0]
|
|
155
|
+
else:
|
|
156
|
+
mesh = np.meshgrid(*mids, indexing="ij")
|
|
157
|
+
centers = np.stack([m.ravel() for m in mesh], axis=-1)
|
|
158
|
+
if active is not None:
|
|
159
|
+
vol, centers = vol[active], centers[active]
|
|
160
|
+
self._volumes = frozen(np.ascontiguousarray(vol, dtype=float))
|
|
161
|
+
self._centers = frozen(np.ascontiguousarray(centers, dtype=float))
|
|
162
|
+
|
|
163
|
+
@classmethod
|
|
164
|
+
def _from_grid(cls, edges: tuple[np.ndarray, ...], measure: Any, mask: Any) -> Bins:
|
|
165
|
+
obj = cls.__new__(cls)
|
|
166
|
+
obj._set_grid(edges, measure, mask)
|
|
167
|
+
return obj
|
|
168
|
+
|
|
169
|
+
# ------------------------------------------------------------ constructors
|
|
170
|
+
@classmethod
|
|
171
|
+
def linear(cls, lo: float, hi: float, n: int, mask: Any = None) -> Bins:
|
|
172
|
+
"""``n`` equal-width bins spanning ``[lo, hi]``, linear measure.
|
|
173
|
+
|
|
174
|
+
Parameters
|
|
175
|
+
----------
|
|
176
|
+
lo, hi : float
|
|
177
|
+
Outer edges.
|
|
178
|
+
n : int
|
|
179
|
+
Number of bins.
|
|
180
|
+
mask : array_like of bool, optional
|
|
181
|
+
Which bins are part of the support (default: all).
|
|
182
|
+
"""
|
|
183
|
+
if n < 1:
|
|
184
|
+
raise ValueError("n must be at least 1.")
|
|
185
|
+
return cls(np.linspace(lo, hi, int(n) + 1), measure="linear", mask=mask)
|
|
186
|
+
|
|
187
|
+
@classmethod
|
|
188
|
+
def log(cls, lo: float, hi: float, n: int, mask: Any = None) -> Bins:
|
|
189
|
+
"""``n`` logarithmically spaced bins spanning ``[lo, hi]`` (``lo > 0``), log measure.
|
|
190
|
+
|
|
191
|
+
Parameters
|
|
192
|
+
----------
|
|
193
|
+
lo, hi : float
|
|
194
|
+
Outer edges.
|
|
195
|
+
n : int
|
|
196
|
+
Number of bins.
|
|
197
|
+
mask : array_like of bool, optional
|
|
198
|
+
Which bins are part of the support (default: all).
|
|
199
|
+
"""
|
|
200
|
+
if lo <= 0:
|
|
201
|
+
raise ValueError("Logarithmic bins need lo > 0.")
|
|
202
|
+
if n < 1:
|
|
203
|
+
raise ValueError("n must be at least 1.")
|
|
204
|
+
return cls(np.geomspace(lo, hi, int(n) + 1), measure="log", mask=mask)
|
|
205
|
+
|
|
206
|
+
@classmethod
|
|
207
|
+
def from_centers(cls, centers: Any, measure: MeasureLike = "linear") -> Bins:
|
|
208
|
+
"""1-D bins whose centres in the measure coordinate are ``centers``.
|
|
209
|
+
|
|
210
|
+
Edges sit at the midpoints of ``u = T(centers)`` and the outermost bins
|
|
211
|
+
extend half a spacing beyond the first and last points, all in ``u``.
|
|
212
|
+
With ``measure="log"`` the edges are geometric midpoints and stay
|
|
213
|
+
positive. With non-uniform spacing the returned :attr:`centers` (the
|
|
214
|
+
midpoints of the bins in ``u``) need not equal the input values.
|
|
215
|
+
|
|
216
|
+
Parameters
|
|
217
|
+
----------
|
|
218
|
+
centers : array_like
|
|
219
|
+
At least two strictly increasing values.
|
|
220
|
+
measure : Measure or {"linear", "log"}, optional
|
|
221
|
+
Coordinate in which the edges sit midway between the centres.
|
|
222
|
+
"""
|
|
223
|
+
m = Measure.coerce(measure)
|
|
224
|
+
c = np.asarray(centers, dtype=float).ravel()
|
|
225
|
+
if c.size < 2 or not np.all(np.diff(c) > 0):
|
|
226
|
+
raise ValueError("centers must contain at least two strictly increasing values.")
|
|
227
|
+
if not m._covers(c[0], c[-1]):
|
|
228
|
+
raise ValueError(f"centers lie outside the domain of {m!r}.")
|
|
229
|
+
u = m.forward(c)
|
|
230
|
+
mid = 0.5 * (u[1:] + u[:-1])
|
|
231
|
+
u_edges = np.concatenate(([u[0] - (mid[0] - u[0])], mid, [u[-1] + (u[-1] - mid[-1])]))
|
|
232
|
+
edges = m.inverse(u_edges)
|
|
233
|
+
if not np.allclose(m.forward(edges), u_edges):
|
|
234
|
+
raise ValueError(f"The outer bins extend beyond the range of {m!r}.")
|
|
235
|
+
return cls(edges, measure=m)
|
|
236
|
+
|
|
237
|
+
@classmethod
|
|
238
|
+
def grid(cls, *edges: Any, measure: Any = None, mask: Any = None) -> Bins:
|
|
239
|
+
"""A rectilinear N-dimensional grid of cells from per-axis edges.
|
|
240
|
+
|
|
241
|
+
Cells are flattened in C order (the last axis varies fastest); use
|
|
242
|
+
:meth:`unflatten` to recover the grid shape. ``measure`` is one measure
|
|
243
|
+
for every axis or a sequence with one per axis; ``mask`` has the grid's
|
|
244
|
+
shape.
|
|
245
|
+
|
|
246
|
+
Parameters
|
|
247
|
+
----------
|
|
248
|
+
*edges : array_like
|
|
249
|
+
Strictly increasing edges of each axis.
|
|
250
|
+
measure : Measure, str or sequence, optional
|
|
251
|
+
Coordinate in which the population is flat within a cell, for every axis
|
|
252
|
+
or one per axis; ``None`` is linear.
|
|
253
|
+
mask : array_like of bool, optional
|
|
254
|
+
Which cells are part of the support, in the grid's shape (default: all).
|
|
255
|
+
"""
|
|
256
|
+
if len(edges) == 0 or (hasattr(edges[0], "__len__") and len(edges[0]) == 0):
|
|
257
|
+
raise ValueError("grid() needs one array of edges per axis.")
|
|
258
|
+
if len(edges) == 1 and isinstance(edges[0], Sequence) and np.ndim(edges[0][0]) > 0:
|
|
259
|
+
edges = tuple(edges[0])
|
|
260
|
+
return cls._from_grid(tuple(_as_edges(e, i) for i, e in enumerate(edges)), measure, mask)
|
|
261
|
+
|
|
262
|
+
@classmethod
|
|
263
|
+
def categories(cls, labels: int | Sequence[Any]) -> Bins:
|
|
264
|
+
"""Unordered categories, either ``K`` integer labels or explicit labels.
|
|
265
|
+
|
|
266
|
+
Parameters
|
|
267
|
+
----------
|
|
268
|
+
labels : int or sequence
|
|
269
|
+
The number of categories ``K`` (labelled ``0`` to ``K - 1``) or their labels.
|
|
270
|
+
"""
|
|
271
|
+
if isinstance(labels, int | np.integer):
|
|
272
|
+
if labels < 2:
|
|
273
|
+
raise ValueError("Need at least two categories.")
|
|
274
|
+
labs = tuple(range(int(labels)))
|
|
275
|
+
else:
|
|
276
|
+
labs = tuple(labels)
|
|
277
|
+
if len(labs) < 2:
|
|
278
|
+
raise ValueError("Need at least two categories.")
|
|
279
|
+
if len(set(labs)) != len(labs):
|
|
280
|
+
raise ValueError("Category labels must be unique.")
|
|
281
|
+
obj = cls.__new__(cls)
|
|
282
|
+
K = len(labs)
|
|
283
|
+
obj._edges = obj._measures = obj._u_edges = obj._active = obj._lookup = None
|
|
284
|
+
obj._shape = (K,)
|
|
285
|
+
obj._labels = labs
|
|
286
|
+
obj._cell_index = (frozen(np.arange(K)),)
|
|
287
|
+
obj._volumes = frozen(np.ones(K))
|
|
288
|
+
obj._centers = frozen(np.arange(K, dtype=float))
|
|
289
|
+
return obj
|
|
290
|
+
|
|
291
|
+
# -------------------------------------------------------------- properties
|
|
292
|
+
@property
|
|
293
|
+
def K(self) -> int:
|
|
294
|
+
"""Number of active bins (cells or categories)."""
|
|
295
|
+
return self._cell_index[0].size
|
|
296
|
+
|
|
297
|
+
@property
|
|
298
|
+
def shape(self) -> tuple[int, ...]:
|
|
299
|
+
"""Grid shape, including masked cells (``(K,)`` for categories)."""
|
|
300
|
+
return self._shape
|
|
301
|
+
|
|
302
|
+
@property
|
|
303
|
+
def ndim(self) -> int:
|
|
304
|
+
"""Number of grid dimensions (1 for categories)."""
|
|
305
|
+
return len(self._shape)
|
|
306
|
+
|
|
307
|
+
@property
|
|
308
|
+
def ordered(self) -> bool:
|
|
309
|
+
"""Whether bins have a geometry (grids) as opposed to unordered categories."""
|
|
310
|
+
return not self.is_categorical
|
|
311
|
+
|
|
312
|
+
@property
|
|
313
|
+
def is_categorical(self) -> bool:
|
|
314
|
+
"""True for unordered categories."""
|
|
315
|
+
return self._edges is None
|
|
316
|
+
|
|
317
|
+
@property
|
|
318
|
+
def edges(self) -> tuple[np.ndarray, ...]:
|
|
319
|
+
"""Per-axis bin edges (raises for categories)."""
|
|
320
|
+
if self._edges is None:
|
|
321
|
+
raise AttributeError("Categorical bins have no edges.")
|
|
322
|
+
return self._edges
|
|
323
|
+
|
|
324
|
+
@property
|
|
325
|
+
def measures(self) -> tuple[Measure, ...]:
|
|
326
|
+
"""Per-axis measures (raises for categories)."""
|
|
327
|
+
if self._measures is None:
|
|
328
|
+
raise AttributeError("Categorical bins have no measure.")
|
|
329
|
+
return self._measures
|
|
330
|
+
|
|
331
|
+
@property
|
|
332
|
+
def u_edges(self) -> tuple[np.ndarray, ...]:
|
|
333
|
+
"""Per-axis edges in the measure coordinate ``u = T(z)``."""
|
|
334
|
+
if self._u_edges is None:
|
|
335
|
+
raise AttributeError("Categorical bins have no edges.")
|
|
336
|
+
return self._u_edges
|
|
337
|
+
|
|
338
|
+
@property
|
|
339
|
+
def mask(self) -> np.ndarray:
|
|
340
|
+
"""Boolean array of the grid's shape: which cells are in the support."""
|
|
341
|
+
if self._lookup is None:
|
|
342
|
+
return np.ones(self._shape, dtype=bool)
|
|
343
|
+
return (self._lookup >= 0).reshape(self._shape)
|
|
344
|
+
|
|
345
|
+
@property
|
|
346
|
+
def is_masked(self) -> bool:
|
|
347
|
+
"""Whether some grid cells are excluded from the support."""
|
|
348
|
+
return self._active is not None
|
|
349
|
+
|
|
350
|
+
@property
|
|
351
|
+
def cells(self) -> np.ndarray:
|
|
352
|
+
"""Flat grid index (C order, masked cells included) of each active bin."""
|
|
353
|
+
return np.arange(self.K) if self._active is None else self._active
|
|
354
|
+
|
|
355
|
+
@property
|
|
356
|
+
def cell_index(self) -> tuple[np.ndarray, ...]:
|
|
357
|
+
"""Per-axis grid indices of every active bin, each ``(K,)``."""
|
|
358
|
+
return self._cell_index
|
|
359
|
+
|
|
360
|
+
@property
|
|
361
|
+
def centers(self) -> np.ndarray:
|
|
362
|
+
"""Bin centres in ``z``, the midpoints in ``u``: ``(K,)`` in 1-D, ``(K, ndim)``
|
|
363
|
+
for grids, codes for categories."""
|
|
364
|
+
return self._centers
|
|
365
|
+
|
|
366
|
+
@property
|
|
367
|
+
def volumes(self) -> np.ndarray:
|
|
368
|
+
"""Bin volumes in ``z`` ``(K,)`` (widths in 1-D; ones for categories)."""
|
|
369
|
+
return self._volumes
|
|
370
|
+
|
|
371
|
+
@property
|
|
372
|
+
def u_volumes(self) -> np.ndarray:
|
|
373
|
+
"""Bin volumes in the measure coordinate ``u`` ``(K,)`` (raises for categories)."""
|
|
374
|
+
widths = [np.diff(u)[j] for u, j in zip(self.u_edges, self.cell_index, strict=True)]
|
|
375
|
+
return np.prod(widths, axis=0)
|
|
376
|
+
|
|
377
|
+
@property
|
|
378
|
+
def widths(self) -> tuple[np.ndarray, ...]:
|
|
379
|
+
"""Per-axis bin widths in ``z`` over the whole grid (raises for categories)."""
|
|
380
|
+
return tuple(np.diff(e) for e in self.edges)
|
|
381
|
+
|
|
382
|
+
@property
|
|
383
|
+
def labels(self) -> tuple[Any, ...] | None:
|
|
384
|
+
"""Category labels (``None`` for grids)."""
|
|
385
|
+
return self._labels
|
|
386
|
+
|
|
387
|
+
@property
|
|
388
|
+
def range(self) -> tuple[tuple[float, float], ...]:
|
|
389
|
+
"""Per-axis ``(lo, hi)`` extent of the grid."""
|
|
390
|
+
return tuple((float(e[0]), float(e[-1])) for e in self.edges)
|
|
391
|
+
|
|
392
|
+
# ----------------------------------------------------------------- methods
|
|
393
|
+
def __len__(self) -> int:
|
|
394
|
+
return self.K
|
|
395
|
+
|
|
396
|
+
def __eq__(self, other: object) -> bool:
|
|
397
|
+
if not isinstance(other, Bins):
|
|
398
|
+
return NotImplemented
|
|
399
|
+
if self.is_categorical != other.is_categorical or self.shape != other.shape:
|
|
400
|
+
return False
|
|
401
|
+
if self.is_categorical:
|
|
402
|
+
return self._labels == other._labels
|
|
403
|
+
return (
|
|
404
|
+
all(np.array_equal(a, b) for a, b in zip(self._edges, other._edges, strict=True))
|
|
405
|
+
and self._measures == other._measures
|
|
406
|
+
and (
|
|
407
|
+
np.array_equal(self._active, other._active)
|
|
408
|
+
if self.is_masked and other.is_masked
|
|
409
|
+
else self.is_masked == other.is_masked
|
|
410
|
+
)
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
def __hash__(self) -> int:
|
|
414
|
+
if self.is_categorical:
|
|
415
|
+
return hash(("categories", self._labels))
|
|
416
|
+
mask = None if self._active is None else self._active.tobytes()
|
|
417
|
+
return hash((tuple(e.tobytes() for e in self._edges), self._measures, mask))
|
|
418
|
+
|
|
419
|
+
def __repr__(self) -> str:
|
|
420
|
+
if self.is_categorical:
|
|
421
|
+
labs = list(self._labels)
|
|
422
|
+
shown = labs if len(labs) <= 6 else [*labs[:3], "...", *labs[-2:]]
|
|
423
|
+
return f"Bins.categories({shown}, K={self.K})"
|
|
424
|
+
extra = ""
|
|
425
|
+
if any(m != Measure.linear() for m in self._measures):
|
|
426
|
+
extra += f", measure={'/'.join(m.name for m in self._measures)}"
|
|
427
|
+
if self.is_masked:
|
|
428
|
+
extra += f", {self._lookup.size - self.K} masked"
|
|
429
|
+
if self.ndim == 1:
|
|
430
|
+
e = self._edges[0]
|
|
431
|
+
return f"Bins(K={self.K}, range=({e[0]:g}, {e[-1]:g}){extra})"
|
|
432
|
+
rng = ", ".join(f"({lo:g}, {hi:g})" for lo, hi in self.range)
|
|
433
|
+
return f"Bins.grid(shape={self.shape}, range=[{rng}]{extra})"
|
|
434
|
+
|
|
435
|
+
def __setattr__(self, name: str, value: Any) -> None:
|
|
436
|
+
if name in Bins.__slots__ and not hasattr(self, name):
|
|
437
|
+
object.__setattr__(self, name, value)
|
|
438
|
+
else:
|
|
439
|
+
raise AttributeError("Bins objects are immutable.")
|
|
440
|
+
|
|
441
|
+
def __reduce__(self):
|
|
442
|
+
if self.is_categorical:
|
|
443
|
+
return (Bins.categories, (list(self._labels),))
|
|
444
|
+
edges = tuple(np.array(e) for e in self._edges)
|
|
445
|
+
mask = self.mask if self.is_masked else None
|
|
446
|
+
return (Bins._from_grid, (edges, self._measures, mask))
|
|
447
|
+
|
|
448
|
+
def index(self, values: Any) -> np.ndarray:
|
|
449
|
+
"""Bin index of each value (``-1`` outside the support or in a masked cell).
|
|
450
|
+
|
|
451
|
+
Bins are left-closed, except the last bin along each axis, which also
|
|
452
|
+
includes its upper edge. For 1-D bins ``values`` has shape ``(n,)`` or
|
|
453
|
+
``(n, 1)``; for N-D grids ``(n, ndim)``; for categories it contains labels.
|
|
454
|
+
|
|
455
|
+
Parameters
|
|
456
|
+
----------
|
|
457
|
+
values : array_like
|
|
458
|
+
Values, or labels for categories.
|
|
459
|
+
"""
|
|
460
|
+
if self.is_categorical:
|
|
461
|
+
lookup = {lab: i for i, lab in enumerate(self._labels)}
|
|
462
|
+
vals = np.asarray(values, dtype=object).ravel()
|
|
463
|
+
return np.array([lookup.get(v, -1) for v in vals], dtype=np.int64)
|
|
464
|
+
x = np.asarray(values, dtype=float)
|
|
465
|
+
if self.ndim == 1 and (x.ndim <= 1 or (x.ndim == 2 and x.shape[1] == 1)):
|
|
466
|
+
x = x.reshape(-1, 1)
|
|
467
|
+
elif x.ndim == 1 and x.size == self.ndim:
|
|
468
|
+
x = x.reshape(1, -1)
|
|
469
|
+
if x.ndim != 2 or x.shape[1] != self.ndim:
|
|
470
|
+
shapes = "(n,) or (n, 1)" if self.ndim == 1 else f"(n, {self.ndim})"
|
|
471
|
+
raise ValueError(f"values must have shape {shapes}; got {np.shape(values)}.")
|
|
472
|
+
idx = np.empty(x.shape, dtype=np.int64)
|
|
473
|
+
ok = np.ones(x.shape[0], dtype=bool)
|
|
474
|
+
for d, e in enumerate(self._edges):
|
|
475
|
+
j = np.searchsorted(e, x[:, d], side="right") - 1
|
|
476
|
+
j = np.where(x[:, d] == e[-1], len(e) - 2, j)
|
|
477
|
+
ok &= (j >= 0) & (j < len(e) - 1) & np.isfinite(x[:, d])
|
|
478
|
+
idx[:, d] = j
|
|
479
|
+
flat = np.full(x.shape[0], -1, dtype=np.int64)
|
|
480
|
+
if np.any(ok):
|
|
481
|
+
flat[ok] = np.ravel_multi_index(tuple(idx[ok].T), self._shape)
|
|
482
|
+
if self._lookup is not None:
|
|
483
|
+
flat[ok] = self._lookup[flat[ok]]
|
|
484
|
+
return flat
|
|
485
|
+
|
|
486
|
+
def contains(self, values: Any) -> np.ndarray:
|
|
487
|
+
"""Boolean mask: which values fall inside the support.
|
|
488
|
+
|
|
489
|
+
Parameters
|
|
490
|
+
----------
|
|
491
|
+
values : array_like
|
|
492
|
+
Values, shaped as for :meth:`index`.
|
|
493
|
+
"""
|
|
494
|
+
return self.index(values) >= 0
|
|
495
|
+
|
|
496
|
+
def grid_index(self, k: Any) -> tuple[np.ndarray, ...]:
|
|
497
|
+
"""Per-axis grid indices of the bins ``k`` (indices into each axis's edges).
|
|
498
|
+
|
|
499
|
+
Parameters
|
|
500
|
+
----------
|
|
501
|
+
k : int or array_like of int
|
|
502
|
+
Bin indices.
|
|
503
|
+
"""
|
|
504
|
+
k = np.asarray(k)
|
|
505
|
+
return tuple(i[k] for i in self._cell_index)
|
|
506
|
+
|
|
507
|
+
def sample_within(self, k: Any, rng: np.random.Generator) -> np.ndarray:
|
|
508
|
+
"""Values drawn uniformly in ``u`` inside the bins ``k`` (grids only).
|
|
509
|
+
|
|
510
|
+
Returns an array of ``k``'s shape for 1-D bins, with a trailing axis of
|
|
511
|
+
length ``ndim`` for grids.
|
|
512
|
+
|
|
513
|
+
Parameters
|
|
514
|
+
----------
|
|
515
|
+
k : int or array_like of int
|
|
516
|
+
Bin indices.
|
|
517
|
+
rng : numpy.random.Generator
|
|
518
|
+
Random number generator.
|
|
519
|
+
"""
|
|
520
|
+
k = np.asarray(k)
|
|
521
|
+
vals = []
|
|
522
|
+
for m, u, j in zip(self.measures, self.u_edges, self.grid_index(k), strict=True):
|
|
523
|
+
vals.append(m.inverse(u[j] + (u[j + 1] - u[j]) * rng.random(k.shape)))
|
|
524
|
+
return vals[0] if self.ndim == 1 else np.stack(vals, axis=-1)
|
|
525
|
+
|
|
526
|
+
def unflatten(self, a: Any) -> np.ndarray:
|
|
527
|
+
"""Reshape a trailing axis of length ``K`` to the grid ``shape``.
|
|
528
|
+
|
|
529
|
+
Masked cells are filled with NaN (the result is then floating point).
|
|
530
|
+
|
|
531
|
+
Parameters
|
|
532
|
+
----------
|
|
533
|
+
a : array_like (..., K)
|
|
534
|
+
Values per bin.
|
|
535
|
+
"""
|
|
536
|
+
a = np.asarray(a)
|
|
537
|
+
if a.shape[-1] != self.K:
|
|
538
|
+
raise ValueError(f"Last axis must have length K={self.K}.")
|
|
539
|
+
if self._active is None:
|
|
540
|
+
return a.reshape(*a.shape[:-1], *self._shape)
|
|
541
|
+
out = np.full((*a.shape[:-1], self._lookup.size), np.nan)
|
|
542
|
+
out[..., self._active] = a
|
|
543
|
+
return out.reshape(*a.shape[:-1], *self._shape)
|
|
544
|
+
|
|
545
|
+
def flatten(self, a: Any) -> np.ndarray:
|
|
546
|
+
"""Inverse of :meth:`unflatten`: the active cells of trailing grid axes.
|
|
547
|
+
|
|
548
|
+
Parameters
|
|
549
|
+
----------
|
|
550
|
+
a : array_like
|
|
551
|
+
Values whose trailing axes have the grid ``shape``.
|
|
552
|
+
"""
|
|
553
|
+
a = np.asarray(a)
|
|
554
|
+
nd = self.ndim
|
|
555
|
+
if a.shape[-nd:] != self._shape:
|
|
556
|
+
raise ValueError(f"Trailing axes must have shape {self._shape}.")
|
|
557
|
+
flat = a.reshape(*a.shape[:-nd], math.prod(self._shape))
|
|
558
|
+
return flat if self._active is None else flat[..., self._active]
|
|
559
|
+
|
|
560
|
+
def neighbors(self) -> np.ndarray:
|
|
561
|
+
"""Pairs of adjacent active bins, shape ``(n_pairs, 2)`` (grids only).
|
|
562
|
+
|
|
563
|
+
Adjacent means sharing a face (4-neighbour structure in 2-D).
|
|
564
|
+
"""
|
|
565
|
+
if self.is_categorical:
|
|
566
|
+
raise ValueError("Unordered categories have no neighbour structure.")
|
|
567
|
+
ids = (np.arange(self.K) if self._lookup is None else self._lookup).reshape(self._shape)
|
|
568
|
+
pairs = []
|
|
569
|
+
for d in range(self.ndim):
|
|
570
|
+
a = np.take(ids, range(self._shape[d] - 1), axis=d).ravel()
|
|
571
|
+
b = np.take(ids, range(1, self._shape[d]), axis=d).ravel()
|
|
572
|
+
keep = (a >= 0) & (b >= 0)
|
|
573
|
+
pairs.append(np.stack([a[keep], b[keep]], axis=1))
|
|
574
|
+
return np.concatenate(pairs, axis=0)
|
|
575
|
+
|
|
576
|
+
def refine(self, factor: int | Sequence[int]) -> tuple[Bins, np.ndarray]:
|
|
577
|
+
"""Split each bin into ``factor`` parts of equal width in ``u`` along each axis.
|
|
578
|
+
|
|
579
|
+
Returns the fine bins (same measures, the mask repeated) and the
|
|
580
|
+
``(K_fine,)`` integer map from fine to coarse bins, as :meth:`coarsen`
|
|
581
|
+
returns; ``fine.coarsen(factor)`` recovers these bins.
|
|
582
|
+
|
|
583
|
+
Parameters
|
|
584
|
+
----------
|
|
585
|
+
factor : int or sequence of int
|
|
586
|
+
Parts per bin, for all axes or one per axis.
|
|
587
|
+
"""
|
|
588
|
+
if self.is_categorical:
|
|
589
|
+
raise ValueError("Categories have no geometry to refine.")
|
|
590
|
+
f = np.broadcast_to(np.asarray(factor), (self.ndim,))
|
|
591
|
+
if not all(float(x).is_integer() and x >= 1 for x in f):
|
|
592
|
+
raise ValueError(f"factor must be positive integers; got {factor!r}.")
|
|
593
|
+
f = f.astype(int)
|
|
594
|
+
new_edges = []
|
|
595
|
+
for d, (m, u) in enumerate(zip(self._measures, self._u_edges, strict=True)):
|
|
596
|
+
t = np.arange(f[d]) / f[d]
|
|
597
|
+
uf = np.append((u[:-1, None] + np.diff(u)[:, None] * t).ravel(), u[-1])
|
|
598
|
+
e = m.inverse(uf)
|
|
599
|
+
e[:: f[d]] = self._edges[d] # the coarse edges exactly
|
|
600
|
+
new_edges.append(e)
|
|
601
|
+
mask = None
|
|
602
|
+
if self.is_masked:
|
|
603
|
+
mask = self.mask
|
|
604
|
+
for d in range(self.ndim):
|
|
605
|
+
mask = np.repeat(mask, f[d], axis=d)
|
|
606
|
+
fine = Bins.grid(*new_edges, measure=self._measures, mask=mask)
|
|
607
|
+
return fine, fine.coarsen(f)[1]
|
|
608
|
+
|
|
609
|
+
def coarsen(self, factor: int | Sequence[int]) -> tuple[Bins, np.ndarray]:
|
|
610
|
+
"""Merge blocks of ``factor`` adjacent bins along each axis.
|
|
611
|
+
|
|
612
|
+
Returns the coarse bins (same measures and a coarsened mask) and a
|
|
613
|
+
``(K,)`` integer map from fine to coarse bins, so that coarse fractions
|
|
614
|
+
are ``np.bincount(map, weights=rho)``. Each block must be wholly active
|
|
615
|
+
or wholly masked, since a coarse bin's volume is its full block.
|
|
616
|
+
|
|
617
|
+
Parameters
|
|
618
|
+
----------
|
|
619
|
+
factor : int or sequence of int
|
|
620
|
+
Bins per block, for all axes or one per axis.
|
|
621
|
+
"""
|
|
622
|
+
if self.is_categorical:
|
|
623
|
+
raise ValueError(
|
|
624
|
+
"Categories have no geometry to coarsen; merge them with result.rebin(groups)."
|
|
625
|
+
)
|
|
626
|
+
f = np.broadcast_to(np.asarray(factor, dtype=int), (self.ndim,))
|
|
627
|
+
new_edges = []
|
|
628
|
+
for d, e in enumerate(self._edges):
|
|
629
|
+
n = len(e) - 1
|
|
630
|
+
if f[d] < 1 or n % f[d] != 0:
|
|
631
|
+
raise ValueError(f"Axis {d} has {n} bins, not divisible by factor {f[d]}.")
|
|
632
|
+
new_edges.append(e[:: f[d]])
|
|
633
|
+
coarse_idx = tuple(i // f[d] for d, i in enumerate(self._cell_index))
|
|
634
|
+
coarse_shape = tuple(len(e) - 1 for e in new_edges)
|
|
635
|
+
coarse_flat = np.ravel_multi_index(coarse_idx, coarse_shape)
|
|
636
|
+
mask = None
|
|
637
|
+
if self.is_masked:
|
|
638
|
+
count = np.bincount(coarse_flat, minlength=math.prod(coarse_shape))
|
|
639
|
+
if np.any((count > 0) & (count < math.prod(f))):
|
|
640
|
+
raise ValueError("coarsen needs each block wholly active or wholly masked.")
|
|
641
|
+
mask = (count > 0).reshape(coarse_shape)
|
|
642
|
+
coarse = Bins.grid(*new_edges, measure=self._measures, mask=mask)
|
|
643
|
+
if coarse._lookup is not None:
|
|
644
|
+
coarse_flat = coarse._lookup[coarse_flat]
|
|
645
|
+
return coarse, coarse_flat
|