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