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