PyCDFt 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.
- pycdft/__init__.py +59 -0
- pycdft/api.py +199 -0
- pycdft/core.py +489 -0
- pycdft/gpu/__init__.py +42 -0
- pycdft/gpu/_torch.py +29 -0
- pycdft/gpu/api.py +634 -0
- pycdft/gpu/devices.py +160 -0
- pycdft/gpu/kernels.py +554 -0
- pycdft/math.py +561 -0
- pycdft/py.typed +0 -0
- pycdft-0.1.0.dist-info/METADATA +289 -0
- pycdft-0.1.0.dist-info/RECORD +15 -0
- pycdft-0.1.0.dist-info/WHEEL +5 -0
- pycdft-0.1.0.dist-info/licenses/LICENSE +201 -0
- pycdft-0.1.0.dist-info/top_level.txt +1 -0
pycdft/gpu/kernels.py
ADDED
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
"""Low-level torch kernels for the CDF-t transform (GPU backend).
|
|
2
|
+
|
|
3
|
+
This module mirrors :mod:`pycdft.math` with torch operators, so that the
|
|
4
|
+
same math can run on CUDA / ROCm / MPS / CPU devices:
|
|
5
|
+
|
|
6
|
+
- ECDF: right-continuous step function ``F(x) = #{finite values <= x} / n``
|
|
7
|
+
(NaNs excluded from the count; ``torch.sort`` puts NaNs last, exactly
|
|
8
|
+
like ``numpy.sort``);
|
|
9
|
+
- quantiles: numpy / R ``type-7`` linear interpolation, re-implemented on
|
|
10
|
+
pre-sorted arrays with the same index / weight arithmetic as
|
|
11
|
+
:func:`pycdft.math.quantile_from_sorted` (including the ``w >= 0.5``
|
|
12
|
+
branch), so that the results agree with the numpy backend to the last
|
|
13
|
+
significant digit on the same data;
|
|
14
|
+
- the transfer interpolation: a vectorized ``np.interp`` equivalent
|
|
15
|
+
(``torch.searchsorted(..., right=True)`` with the same duplicate /
|
|
16
|
+
endpoint convention), with the two tail options (``clip`` / ``linear``);
|
|
17
|
+
- histogram-based ECDF / quantile variants.
|
|
18
|
+
|
|
19
|
+
All kernels accept 2-D tensors ``(T, N)`` (or ``(Q, N)``) of float64 and
|
|
20
|
+
operate independently per column (per series / per grid cell), i.e. the
|
|
21
|
+
same conventions as the numpy backend. All math is done in float64 (the
|
|
22
|
+
CDF-t kernels are memory-bound; float64 keeps the results identical to the
|
|
23
|
+
numpy backend -- the input dtype is widened on conversion, see
|
|
24
|
+
:mod:`pycdft.gpu.api`).
|
|
25
|
+
|
|
26
|
+
Notes
|
|
27
|
+
-----
|
|
28
|
+
The batched :func:`searchsorted_columns` uses a transposed ``(N, ...)``
|
|
29
|
+
layout internally (torch searches along the last dimension); the transpose
|
|
30
|
+
is a view and the explicit ``contiguous()`` copy is the only extra
|
|
31
|
+
allocation of that kernel.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import numpy as np
|
|
37
|
+
|
|
38
|
+
from ._torch import require_torch
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"quantile_grid",
|
|
43
|
+
"searchsorted_columns",
|
|
44
|
+
"ecdf",
|
|
45
|
+
"ecdf_at",
|
|
46
|
+
"quantile",
|
|
47
|
+
"nanquantile",
|
|
48
|
+
"quantile_from_sorted",
|
|
49
|
+
"windowed_slope",
|
|
50
|
+
"interp_transfer",
|
|
51
|
+
"histogram_accumulate",
|
|
52
|
+
"hist_quantile",
|
|
53
|
+
"hist_quantile_pc",
|
|
54
|
+
"ecdf_hist",
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
# Probability grid
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
def quantile_grid(n_quantiles: int = 1001, device=None):
|
|
63
|
+
"""Uniform probability grid ``linspace(0, 1, n_quantiles)`` on a device.
|
|
64
|
+
|
|
65
|
+
Bit-identical to ``numpy.linspace(0.0, 1.0, n_quantiles)`` (computed as
|
|
66
|
+
``arange(n) * (1 / (n - 1))``, the same arithmetic numpy uses).
|
|
67
|
+
"""
|
|
68
|
+
torch = require_torch()
|
|
69
|
+
n_quantiles = int(n_quantiles)
|
|
70
|
+
if n_quantiles < 2:
|
|
71
|
+
raise ValueError(f"n_quantiles must be >= 2, got {n_quantiles}")
|
|
72
|
+
return torch.arange(n_quantiles, dtype=torch.float64,
|
|
73
|
+
device=device) * (1.0 / (n_quantiles - 1))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
# Batched per-column searchsorted
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
def _as_2d(x, name: str = "array"):
|
|
81
|
+
torch = require_torch()
|
|
82
|
+
t = x if isinstance(x, torch.Tensor) else torch.as_tensor(x)
|
|
83
|
+
if t.ndim != 2:
|
|
84
|
+
raise ValueError(f"expected a 2-D tensor, got shape {tuple(t.shape)}")
|
|
85
|
+
return t
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def searchsorted_columns(a, v, side: str = "right"):
|
|
89
|
+
"""Per-column ``torch.searchsorted`` on the (T, N) / (M, N) layouts.
|
|
90
|
+
|
|
91
|
+
Equivalent to looping ``torch.searchsorted(a[:, j], v[:, j],
|
|
92
|
+
side=side)`` over columns; identical to
|
|
93
|
+
:func:`pycdft.math.searchsorted_columns` (same ``side`` convention,
|
|
94
|
+
NaNs treated as ``+inf`` in both the sequence and the queries, as numpy
|
|
95
|
+
does).
|
|
96
|
+
|
|
97
|
+
Parameters
|
|
98
|
+
----------
|
|
99
|
+
a : (T, N) tensor, sorted along dim 0 (NaNs allowed, sorted last).
|
|
100
|
+
v : (M, N) tensor of query points.
|
|
101
|
+
side : {"left", "right"}, default "right" (``F(x) = #(<= x)``).
|
|
102
|
+
|
|
103
|
+
Returns
|
|
104
|
+
-------
|
|
105
|
+
(M, N) int64 tensor of insertion indices.
|
|
106
|
+
"""
|
|
107
|
+
torch = require_torch()
|
|
108
|
+
a = _as_2d(a, "a")
|
|
109
|
+
v = _as_2d(v, "v")
|
|
110
|
+
if a.shape[1] != v.shape[1]:
|
|
111
|
+
raise ValueError(
|
|
112
|
+
f"expected (T, N) and (M, N) arrays with the same N, "
|
|
113
|
+
f"got {tuple(a.shape)} and {tuple(v.shape)}")
|
|
114
|
+
if side not in ("left", "right"):
|
|
115
|
+
raise ValueError(f"side must be 'left' or 'right', got {side!r}")
|
|
116
|
+
# torch.searchsorted is not well-defined for NaN entries; numpy treats
|
|
117
|
+
# NaN as +inf in both the sequence and the query (hence a NaN query
|
|
118
|
+
# lands at the end and is masked to NaN by the callers).
|
|
119
|
+
a_ss = torch.nan_to_num(a, nan=float("inf"), posinf=float("inf"),
|
|
120
|
+
neginf=float("-inf"))
|
|
121
|
+
v_ss = torch.nan_to_num(v, nan=float("inf"), posinf=float("inf"),
|
|
122
|
+
neginf=float("-inf"))
|
|
123
|
+
# torch searches along the last dim: use the transposed (N, ...) layout
|
|
124
|
+
# so that the batch dimension is the number of columns.
|
|
125
|
+
idx = torch.searchsorted(a_ss.transpose(0, 1).contiguous(),
|
|
126
|
+
v_ss.transpose(0, 1).contiguous(),
|
|
127
|
+
right=(side == "right"))
|
|
128
|
+
return idx.transpose(0, 1)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# ---------------------------------------------------------------------------
|
|
132
|
+
# ECDF
|
|
133
|
+
# ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
def _ecdf_at_sorted(v_sorted, points):
|
|
136
|
+
"""Step ECDF of a sorted (T, N) tensor (NaNs at the end) at (M, N) points.
|
|
137
|
+
|
|
138
|
+
``F(x) = #{finite values <= x} / n_valid`` (right-continuous,
|
|
139
|
+
side="right"); NaNs excluded from the count; NaN query points and empty
|
|
140
|
+
(all-NaN) columns give NaN. Identical to
|
|
141
|
+
:func:`pycdft.math._ecdf_at_sorted`.
|
|
142
|
+
"""
|
|
143
|
+
torch = require_torch()
|
|
144
|
+
v = v_sorted.to(torch.float64)
|
|
145
|
+
pts = points.to(torch.float64)
|
|
146
|
+
nv = torch.isfinite(v).sum(dim=0).to(torch.float64)
|
|
147
|
+
idx = searchsorted_columns(v, pts, side="right")
|
|
148
|
+
out = idx.to(torch.float64) / nv.clamp(min=1.0)[None, :]
|
|
149
|
+
out = out.masked_fill(nv[None, :] <= 0, float("nan"))
|
|
150
|
+
out = out.masked_fill(~torch.isfinite(pts), float("nan"))
|
|
151
|
+
return out.clamp(0.0, 1.0)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def ecdf_at(values, points):
|
|
155
|
+
"""Step ECDF of ``values`` (T, N) evaluated at ``points`` (M, N).
|
|
156
|
+
|
|
157
|
+
Returns the (M, N) tensor ``F(points)`` with
|
|
158
|
+
``F(x) = #{finite values <= x} / n`` (NaN-excluded); NaN points and
|
|
159
|
+
all-NaN columns give NaN.
|
|
160
|
+
"""
|
|
161
|
+
torch = require_torch()
|
|
162
|
+
values = _as_2d(values, "values")
|
|
163
|
+
points = _as_2d(points, "points")
|
|
164
|
+
v, _ = torch.sort(values, dim=0) # NaNs sort to the end
|
|
165
|
+
return _ecdf_at_sorted(v, points)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def ecdf(values, points=None):
|
|
169
|
+
"""Empirical (step) CDF of a sample, at the sample or at given points.
|
|
170
|
+
|
|
171
|
+
``F(x) = #{finite values <= x} / n`` (right-continuous step; NaNs
|
|
172
|
+
excluded from the count). With ``points=None`` returns the ECDF
|
|
173
|
+
evaluated at the (finite) sample values themselves. Accepts 1-D
|
|
174
|
+
``(T,)`` (returns ``(T,)``) or 2-D ``(T, N)`` (returns ``(T, N)``).
|
|
175
|
+
"""
|
|
176
|
+
torch = require_torch()
|
|
177
|
+
values = torch.as_tensor(values) if not isinstance(values, torch.Tensor) \
|
|
178
|
+
else values
|
|
179
|
+
squeeze = values.ndim == 1
|
|
180
|
+
if squeeze:
|
|
181
|
+
values = values[:, None]
|
|
182
|
+
if values.ndim != 2:
|
|
183
|
+
raise ValueError(f"expected a 1-D or 2-D tensor, got {tuple(values.shape)}")
|
|
184
|
+
v, _ = torch.sort(values, dim=0)
|
|
185
|
+
if points is None:
|
|
186
|
+
out = _ecdf_at_sorted(v, values)
|
|
187
|
+
else:
|
|
188
|
+
pts = torch.as_tensor(points) if not isinstance(points, torch.Tensor) \
|
|
189
|
+
else points
|
|
190
|
+
if pts.ndim == 1:
|
|
191
|
+
pts = pts[:, None]
|
|
192
|
+
out = _ecdf_at_sorted(v, pts)
|
|
193
|
+
return out[:, 0] if squeeze else out
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# ---------------------------------------------------------------------------
|
|
197
|
+
# Quantiles (type-7 linear, identical to np.quantile(..., method="linear"))
|
|
198
|
+
# ---------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
def quantile_from_sorted(v_sorted, q):
|
|
201
|
+
"""Type-7 linear quantiles of a sorted (T, N) tensor at probabilities ``q``.
|
|
202
|
+
|
|
203
|
+
The tensor is sorted along dim 0 with NaNs at the end; NaNs are excluded
|
|
204
|
+
via the per-column valid counts, so they can never enter the
|
|
205
|
+
interpolation (empty columns -> NaN). ``q`` may be ``(Q,)`` (same
|
|
206
|
+
probabilities for all columns) or ``(Q, N)`` (per-column probabilities);
|
|
207
|
+
the result is ``(Q, N)``.
|
|
208
|
+
|
|
209
|
+
Bit-identical to :func:`pycdft.math.quantile_from_sorted` (same index
|
|
210
|
+
and weight arithmetic, including the ``w >= 0.5`` branch).
|
|
211
|
+
"""
|
|
212
|
+
torch = require_torch()
|
|
213
|
+
v = v_sorted.to(torch.float64)
|
|
214
|
+
q = q.to(torch.float64)
|
|
215
|
+
if v.ndim != 2:
|
|
216
|
+
raise ValueError(f"expected a 2-D (T, N) sorted tensor, got {v.shape}")
|
|
217
|
+
if q.ndim == 1:
|
|
218
|
+
q = q[:, None].expand(q.size(0), v.shape[1])
|
|
219
|
+
elif q.ndim != 2 or q.shape[1] != v.shape[1]:
|
|
220
|
+
raise ValueError("q must be (Q,) or (Q, N) with the same N as v")
|
|
221
|
+
nv = torch.isfinite(v).sum(dim=0).to(torch.float64)
|
|
222
|
+
scale = (nv - 1.0).clamp(min=0.0)[None, :]
|
|
223
|
+
idx = (q * scale).clamp(min=0.0).clamp(max=scale)
|
|
224
|
+
i0 = torch.floor(idx).to(torch.int64)
|
|
225
|
+
i1 = torch.ceil(idx).to(torch.int64).clamp(min=0).clamp(
|
|
226
|
+
max=(nv.to(torch.int64) - 1).clamp(min=0)[None, :])
|
|
227
|
+
w = idx - i0.to(torch.float64)
|
|
228
|
+
a = torch.take_along_dim(v, i0, dim=0)
|
|
229
|
+
b = torch.take_along_dim(v, i1, dim=0)
|
|
230
|
+
diff = b - a
|
|
231
|
+
out = a + diff * w
|
|
232
|
+
out = torch.where(w >= 0.5, b - diff * (1.0 - w), out)
|
|
233
|
+
return out.masked_fill(nv[None, :] <= 0, float("nan"))
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def quantile(values, p):
|
|
237
|
+
"""Type-7 quantiles of ``values`` (T, N) at probabilities ``p`` (Q,).
|
|
238
|
+
|
|
239
|
+
Returns ``(Q, N)``; identical to ``np.quantile(values, p, method=
|
|
240
|
+
"linear", axis=0)`` with NaNs excluded per column (empty columns ->
|
|
241
|
+
NaN).
|
|
242
|
+
"""
|
|
243
|
+
torch = require_torch()
|
|
244
|
+
values = _as_2d(values, "values")
|
|
245
|
+
v, _ = torch.sort(values, dim=0) # NaNs to the end
|
|
246
|
+
return quantile_from_sorted(v, p)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def nanquantile(values, p):
|
|
250
|
+
"""Alias of :func:`quantile` (NaN-excluded type-7 quantiles)."""
|
|
251
|
+
return quantile(values, p)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# ---------------------------------------------------------------------------
|
|
255
|
+
# Windowed least-squares slope (tail extrapolation)
|
|
256
|
+
# ---------------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
def _nanmedian_axis0(x):
|
|
259
|
+
"""Column-wise nanmedian (average of the two middle values, as numpy).
|
|
260
|
+
|
|
261
|
+
``torch.nanmedian`` returns the *lower* median for an even number of
|
|
262
|
+
values (documented torch behaviour), so the median is computed from the
|
|
263
|
+
sorted finite values to match ``numpy.nanmedian`` exactly.
|
|
264
|
+
"""
|
|
265
|
+
torch = require_torch()
|
|
266
|
+
s, _ = torch.sort(x, dim=0) # NaNs at the end
|
|
267
|
+
if s.shape[0] == 0: # empty window -> NaN (as numpy)
|
|
268
|
+
return torch.full((s.shape[1],), float("nan"), dtype=s.dtype,
|
|
269
|
+
device=s.device)
|
|
270
|
+
nv = torch.isfinite(s).sum(dim=0).to(torch.int64)
|
|
271
|
+
lo = ((nv - 1) // 2).clamp(min=0)
|
|
272
|
+
hi = (nv // 2).clamp(min=0)
|
|
273
|
+
v_lo = torch.take_along_dim(s, lo[None, :], dim=0)
|
|
274
|
+
v_hi = torch.take_along_dim(s, hi[None, :], dim=0)
|
|
275
|
+
med = (v_lo + v_hi) / 2.0
|
|
276
|
+
return med.masked_fill(nv[None, :] <= 0, float("nan"))[0]
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def windowed_slope(x_grid, T, lo, hi):
|
|
280
|
+
"""Least-squares slope of ``T`` vs ``x_grid`` over the rank window per cell.
|
|
281
|
+
|
|
282
|
+
Same closed-form computation as :func:`pycdft.math.windowed_slope`:
|
|
283
|
+
per column, the slope of the linear least-squares fit of the window
|
|
284
|
+
``[lo[j], hi[j]]`` (``0`` when the window is degenerate or empty).
|
|
285
|
+
"""
|
|
286
|
+
torch = require_torch()
|
|
287
|
+
x_grid = x_grid.to(torch.float64)
|
|
288
|
+
T = T.to(torch.float64)
|
|
289
|
+
lo = lo.to(torch.int64).clamp(min=0)
|
|
290
|
+
hi = hi.to(torch.int64).clamp(max=x_grid.shape[0] - 1)
|
|
291
|
+
Q, N = x_grid.shape
|
|
292
|
+
r = torch.arange(Q, dtype=torch.int64, device=x_grid.device)[:, None]
|
|
293
|
+
mask = (r >= lo[None, :]) & (r <= hi[None, :]) # (Q, N)
|
|
294
|
+
gw = x_grid.masked_fill(~mask, float("nan"))
|
|
295
|
+
tw = T.masked_fill(~mask, float("nan"))
|
|
296
|
+
counts = mask.sum(dim=0).to(torch.float64).clamp(min=1.0)
|
|
297
|
+
gm = torch.nansum(gw, dim=0) / counts
|
|
298
|
+
g0 = gw - gm[None, :]
|
|
299
|
+
tm = torch.nansum(tw, dim=0) / counts
|
|
300
|
+
t0 = tw - tm[None, :]
|
|
301
|
+
num = torch.nansum(g0 * t0, dim=0)
|
|
302
|
+
den = torch.nansum(g0 * g0, dim=0)
|
|
303
|
+
slope = torch.where(den > 0, num / torch.where(den > 0, den,
|
|
304
|
+
torch.ones_like(den)),
|
|
305
|
+
torch.zeros_like(den))
|
|
306
|
+
return torch.where(torch.isfinite(slope), slope, torch.zeros_like(slope))
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
# ---------------------------------------------------------------------------
|
|
310
|
+
# Transfer interpolation (per cell) + tail handling
|
|
311
|
+
# ---------------------------------------------------------------------------
|
|
312
|
+
|
|
313
|
+
def interp_transfer(x_grid, T, k_lo, k_hi, x, tail: str = "linear"):
|
|
314
|
+
"""Evaluate the CDF-t transfer at the target values ``x`` (T, N).
|
|
315
|
+
|
|
316
|
+
Vectorized equivalent of :func:`pycdft.math.interp_transfer`:
|
|
317
|
+
``y(x)`` is the per-cell piecewise-linear interpolation of the transfer
|
|
318
|
+
curve ``(x_grid, T)`` with the same endpoint / duplicate-value
|
|
319
|
+
convention as ``numpy.interp`` (``T[0]`` / ``T[-1]`` as the constant
|
|
320
|
+
clip extrapolation below / above the grid; for duplicated grid values
|
|
321
|
+
the value of the last occurrence is used).
|
|
322
|
+
|
|
323
|
+
Parameters
|
|
324
|
+
----------
|
|
325
|
+
x_grid, T : (Q, N) transfer tensors (``x_grid`` = target quantiles at
|
|
326
|
+
the probability grid, ``T`` = target quantiles at the mapping ``R``).
|
|
327
|
+
k_lo, k_hi : (N,) first / last *unsaturated* ranks of ``R`` per cell.
|
|
328
|
+
x : (T, N) target values (already delta-shifted).
|
|
329
|
+
tail : {"linear", "clip"}: as in the numpy backend ("linear" adds the
|
|
330
|
+
windowed least-squares extrapolation, slope capped at 2x the median
|
|
331
|
+
interior slope).
|
|
332
|
+
|
|
333
|
+
Returns
|
|
334
|
+
-------
|
|
335
|
+
(T, N) mapped values (NaN inputs propagate to NaN outputs).
|
|
336
|
+
"""
|
|
337
|
+
torch = require_torch()
|
|
338
|
+
x_grid = x_grid.to(torch.float64)
|
|
339
|
+
T = T.to(torch.float64)
|
|
340
|
+
x = x.to(torch.float64)
|
|
341
|
+
if not (x_grid.ndim == T.ndim == 2 and x.ndim == 2
|
|
342
|
+
and x_grid.shape == T.shape and x_grid.shape[1] == x.shape[1]):
|
|
343
|
+
raise ValueError(
|
|
344
|
+
f"expected (Q, N) x_grid/T and (T, N) x, got "
|
|
345
|
+
f"{tuple(x_grid.shape)}, {tuple(T.shape)}, {tuple(x.shape)}")
|
|
346
|
+
Q, N = x_grid.shape
|
|
347
|
+
# ---- np.interp equivalent (per cell, vectorized) --------------------
|
|
348
|
+
# torch.searchsorted is not well-defined for NaN; replace with +inf in
|
|
349
|
+
# the sorted grid and the queries (NaN queries are masked to NaN below,
|
|
350
|
+
# matching np.interp). Ties / duplicated grid values follow the numpy
|
|
351
|
+
# convention: right=True lands after the duplicate run, so the value of
|
|
352
|
+
# the last occurrence is used.
|
|
353
|
+
xg_ss = torch.nan_to_num(x_grid, nan=float("inf"), posinf=float("inf"),
|
|
354
|
+
neginf=float("-inf"))
|
|
355
|
+
x_ss = torch.nan_to_num(x, nan=float("inf"), posinf=float("inf"),
|
|
356
|
+
neginf=float("-inf"))
|
|
357
|
+
idx = searchsorted_columns(xg_ss, x_ss, side="right") # (M, N)
|
|
358
|
+
idx_cl = idx.clamp(1, Q - 1)
|
|
359
|
+
x0 = torch.take_along_dim(x_grid, idx_cl - 1, dim=0)
|
|
360
|
+
x1 = torch.take_along_dim(x_grid, idx_cl, dim=0)
|
|
361
|
+
y0 = torch.take_along_dim(T, idx_cl - 1, dim=0)
|
|
362
|
+
y1 = torch.take_along_dim(T, idx_cl, dim=0)
|
|
363
|
+
frac = (y1 - y0) / (x1 - x0)
|
|
364
|
+
y = y0 + frac * (x - x0)
|
|
365
|
+
y = torch.where(idx == 0, T[0][None, :], y) # x < x_grid[0]
|
|
366
|
+
y = torch.where(idx >= Q, T[-1][None, :], y) # x >= x_grid[-1]
|
|
367
|
+
y = y.masked_fill(torch.isnan(x), float("nan")) # NaN queries
|
|
368
|
+
|
|
369
|
+
if tail == "linear":
|
|
370
|
+
# ---- interior median slope + 2x cap (as the numpy backend) ------
|
|
371
|
+
a, b = int(0.2 * (Q - 1)), int(0.8 * (Q - 1)) + 1
|
|
372
|
+
dg = torch.diff(x_grid[a:b], dim=0)
|
|
373
|
+
dT = torch.diff(T[a:b], dim=0)
|
|
374
|
+
ratio = torch.where(dg > 0, dT / torch.where(dg > 0, dg,
|
|
375
|
+
torch.full_like(dg, np.nan)),
|
|
376
|
+
torch.full_like(dg, np.nan))
|
|
377
|
+
s_med = _nanmedian_axis0(ratio)
|
|
378
|
+
s_med = torch.where(torch.isnan(s_med), torch.zeros_like(s_med), s_med)
|
|
379
|
+
cap = torch.where(s_med > 0, 2.0 * s_med, torch.zeros_like(s_med))
|
|
380
|
+
s_lo = windowed_slope(x_grid, T, k_lo, (k_lo + 30).clamp(max=Q - 1))
|
|
381
|
+
s_lo = s_lo.clamp(min=0.0).clamp(max=cap)
|
|
382
|
+
s_hi = windowed_slope(x_grid, T, (k_hi - 30).clamp(min=0), k_hi)
|
|
383
|
+
s_hi = s_hi.clamp(min=0.0).clamp(max=cap)
|
|
384
|
+
# ---- linear tail override ---------------------------------------
|
|
385
|
+
g_lo = torch.take_along_dim(x_grid, k_lo[None, :], dim=0)
|
|
386
|
+
T_lo = torch.take_along_dim(T, k_lo[None, :], dim=0)
|
|
387
|
+
g_hi = torch.take_along_dim(x_grid, k_hi[None, :], dim=0)
|
|
388
|
+
T_hi = torch.take_along_dim(T, k_hi[None, :], dim=0)
|
|
389
|
+
ok_lo = (k_lo > 0) & (k_lo < Q - 1)
|
|
390
|
+
ok_hi = (k_hi > 0) & (k_hi < Q - 1)
|
|
391
|
+
y = torch.where((x < g_lo) & ok_lo[None, :],
|
|
392
|
+
T_lo + s_lo[None, :] * (x - g_lo), y)
|
|
393
|
+
y = torch.where((x > g_hi) & ok_hi[None, :],
|
|
394
|
+
T_hi + s_hi[None, :] * (x - g_hi), y)
|
|
395
|
+
elif tail != "clip":
|
|
396
|
+
raise ValueError(f"tail must be 'linear' or 'clip', got {tail!r}")
|
|
397
|
+
return y
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
# ---------------------------------------------------------------------------
|
|
401
|
+
# Histogram-based ECDF / quantile variants (bounded memory)
|
|
402
|
+
# ---------------------------------------------------------------------------
|
|
403
|
+
|
|
404
|
+
def histogram_accumulate(values, n_bins: int = 2000):
|
|
405
|
+
"""Per-cell histogram with global (field-wide) edges.
|
|
406
|
+
|
|
407
|
+
Same convention as :func:`pycdft.math.histogram_accumulate`: NaNs are
|
|
408
|
+
excluded from the binning (sentinel bin), all-NaN fields give
|
|
409
|
+
``edges = linspace(0, 1, n_bins + 1)`` and zero counts, degenerate
|
|
410
|
+
(constant) fields get a ``+/-1`` padding. Returns
|
|
411
|
+
``(edges (n_bins + 1,), counts (n_bins, N))``.
|
|
412
|
+
"""
|
|
413
|
+
torch = require_torch()
|
|
414
|
+
values = _as_2d(values, "values")
|
|
415
|
+
n_bins = int(n_bins)
|
|
416
|
+
valid = torch.isfinite(values)
|
|
417
|
+
lo = torch.where(valid, values, torch.full_like(values, float("inf"))).min()
|
|
418
|
+
hi = torch.where(valid, values, torch.full_like(values, float("-inf"))).max()
|
|
419
|
+
if not (torch.isfinite(lo) and torch.isfinite(hi)):
|
|
420
|
+
lo, hi = torch.tensor(0.0, dtype=values.dtype,
|
|
421
|
+
device=values.device), \
|
|
422
|
+
torch.tensor(1.0, dtype=values.dtype, device=values.device)
|
|
423
|
+
elif bool(hi <= lo):
|
|
424
|
+
lo, hi = lo - 1.0, hi + 1.0
|
|
425
|
+
edges = (torch.arange(n_bins + 1, dtype=torch.float64,
|
|
426
|
+
device=values.device)
|
|
427
|
+
* ((hi - lo) / n_bins)) + lo
|
|
428
|
+
width = (hi - lo)
|
|
429
|
+
idx = (((values - lo) / width * n_bins)
|
|
430
|
+
.to(torch.int64).clamp(0, n_bins - 1))
|
|
431
|
+
idx = torch.where(valid, idx,
|
|
432
|
+
torch.full_like(idx, n_bins)) # sentinel bin
|
|
433
|
+
N = values.shape[1]
|
|
434
|
+
counts = torch.zeros((n_bins + 1, N), dtype=torch.float64,
|
|
435
|
+
device=values.device)
|
|
436
|
+
counts.scatter_add_(0, idx, torch.ones_like(values, dtype=torch.float64))
|
|
437
|
+
return edges, counts[:-1]
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _hist_quantile(edges, counts, q):
|
|
441
|
+
"""Shared type-7-style within-bin interpolation (see hist_quantile*)."""
|
|
442
|
+
torch = require_torch()
|
|
443
|
+
B, N = counts.shape
|
|
444
|
+
C = torch.cumsum(counts, dim=0)
|
|
445
|
+
total = C[-1]
|
|
446
|
+
pos = q * (total - 1.0)[None, :]
|
|
447
|
+
b = searchsorted_columns(C, pos, side="right") # (Q, N)
|
|
448
|
+
b_safe = b.clamp(1, B - 1)
|
|
449
|
+
lo = torch.take_along_dim(C, b_safe - 1, dim=0)
|
|
450
|
+
hi = torch.take_along_dim(C, b_safe, dim=0)
|
|
451
|
+
frac = torch.where(hi > lo, (pos - lo) / torch.where(hi > lo, hi - lo,
|
|
452
|
+
torch.ones_like(hi)),
|
|
453
|
+
torch.zeros_like(hi))
|
|
454
|
+
return C, total, b, b_safe, frac
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def hist_quantile(edges, counts, q):
|
|
458
|
+
"""Quantile function of per-cell histograms with GLOBAL 1-D edges.
|
|
459
|
+
|
|
460
|
+
Same convention as :func:`pycdft.math.hist_quantile` (type-7-style
|
|
461
|
+
linear within-bin interpolation scaled by ``total - 1``; ``q=0``
|
|
462
|
+
interpolates inside the first non-empty bin instead of extrapolating
|
|
463
|
+
below the global minimum; all-empty cells give NaN).
|
|
464
|
+
"""
|
|
465
|
+
torch = require_torch()
|
|
466
|
+
edges = edges.to(torch.float64)
|
|
467
|
+
counts = counts.to(torch.float64)
|
|
468
|
+
q = q.to(torch.float64)
|
|
469
|
+
B, N = counts.shape
|
|
470
|
+
if edges.ndim != 1 or edges.size(0) != B + 1:
|
|
471
|
+
raise ValueError(f"expected edges of shape ({B + 1},), got "
|
|
472
|
+
f"{tuple(edges.shape)}")
|
|
473
|
+
if q.ndim == 1:
|
|
474
|
+
q = q[:, None].expand(q.size(0), N)
|
|
475
|
+
C, total, b, b_safe, frac = _hist_quantile(edges, counts, q)
|
|
476
|
+
out = edges[b_safe] + frac * (edges[b_safe + 1] - edges[b_safe])
|
|
477
|
+
# q=0 / below the first non-empty bin: interpolate within bin 0
|
|
478
|
+
c0 = C[0].clamp(min=1.0)[None, :]
|
|
479
|
+
frac0 = torch.where(C[0][None, :] > 0, q * (total - 1.0)[None, :] / c0,
|
|
480
|
+
torch.zeros_like(c0))
|
|
481
|
+
out0 = edges[0] + frac0 * (edges[1] - edges[0])
|
|
482
|
+
out = torch.where(b == 0, out0, out)
|
|
483
|
+
return out.masked_fill(total[None, :] <= 0, float("nan"))
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def hist_quantile_pc(edges, counts, q):
|
|
487
|
+
"""Quantile function of per-cell histograms with PER-CELL edges.
|
|
488
|
+
|
|
489
|
+
Same convention as :func:`hist_quantile`, but ``edges`` is ``(B + 1, N)``
|
|
490
|
+
(a different edge vector per column).
|
|
491
|
+
"""
|
|
492
|
+
torch = require_torch()
|
|
493
|
+
edges = edges.to(torch.float64)
|
|
494
|
+
counts = counts.to(torch.float64)
|
|
495
|
+
q = q.to(torch.float64)
|
|
496
|
+
B, N = counts.shape
|
|
497
|
+
if edges.shape != (B + 1, N):
|
|
498
|
+
raise ValueError(f"expected edges of shape ({B + 1}, {N}), "
|
|
499
|
+
f"got {tuple(edges.shape)}")
|
|
500
|
+
if q.ndim == 1:
|
|
501
|
+
q = q[:, None].expand(q.size(0), N)
|
|
502
|
+
C, total, b, b_safe, frac = _hist_quantile(edges, counts, q)
|
|
503
|
+
e_lo = torch.take_along_dim(edges, b_safe, dim=0)
|
|
504
|
+
e_hi = torch.take_along_dim(edges, b_safe + 1, dim=0)
|
|
505
|
+
out = e_lo + frac * (e_hi - e_lo)
|
|
506
|
+
c0 = C[0].clamp(min=1.0)[None, :]
|
|
507
|
+
frac0 = torch.where(C[0][None, :] > 0, q * (total - 1.0)[None, :] / c0,
|
|
508
|
+
torch.zeros_like(c0))
|
|
509
|
+
out0 = edges[0][None, :] + frac0 * (edges[1][None, :] - edges[0][None, :])
|
|
510
|
+
out = torch.where(b == 0, out0, out)
|
|
511
|
+
return out.masked_fill(total[None, :] <= 0, float("nan"))
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def ecdf_hist(edges, counts, points):
|
|
515
|
+
"""Empirical CDF of per-cell histograms at ``points`` (M, N) -> (M, N).
|
|
516
|
+
|
|
517
|
+
Linear interpolation inside a bin (consistent with
|
|
518
|
+
:func:`hist_quantile_pc`): ``F(v) = (C[b-1] + frac * counts[b]) / total``;
|
|
519
|
+
``F = 0`` below the first edge, ``1`` at/above the last edge; all-empty
|
|
520
|
+
cells and NaN points give NaN. ``edges`` may be 1-D (global) or
|
|
521
|
+
``(B + 1, N)`` (per cell).
|
|
522
|
+
"""
|
|
523
|
+
torch = require_torch()
|
|
524
|
+
edges = edges.to(torch.float64)
|
|
525
|
+
counts = counts.to(torch.float64)
|
|
526
|
+
points = points.to(torch.float64)
|
|
527
|
+
B, N = counts.shape
|
|
528
|
+
if edges.ndim == 1:
|
|
529
|
+
if edges.size(0) != B + 1:
|
|
530
|
+
raise ValueError(f"expected edges of shape ({B + 1},), "
|
|
531
|
+
f"got {tuple(edges.shape)}")
|
|
532
|
+
edges = edges[:, None].expand(B + 1, N)
|
|
533
|
+
if edges.shape != (B + 1, N):
|
|
534
|
+
raise ValueError(f"expected edges of shape ({B + 1}, {N}), "
|
|
535
|
+
f"got {tuple(edges.shape)}")
|
|
536
|
+
C = torch.cumsum(counts, dim=0)
|
|
537
|
+
total = C[-1]
|
|
538
|
+
idx = searchsorted_columns(edges, points, side="right") - 1
|
|
539
|
+
idx_safe = idx.clamp(0, B - 1)
|
|
540
|
+
lo = torch.take_along_dim(edges, idx_safe, dim=0)
|
|
541
|
+
hi = torch.take_along_dim(edges, idx_safe + 1, dim=0)
|
|
542
|
+
frac = torch.where(hi > lo, (points - lo) / torch.where(hi > lo, hi - lo,
|
|
543
|
+
torch.ones_like(hi)),
|
|
544
|
+
torch.zeros_like(hi))
|
|
545
|
+
c_below = torch.take_along_dim(C, (idx_safe - 1).clamp(min=0), dim=0)
|
|
546
|
+
c_below = torch.where(idx_safe > 0, c_below, torch.zeros_like(c_below))
|
|
547
|
+
cnt = torch.take_along_dim(counts, idx_safe, dim=0)
|
|
548
|
+
out = (c_below + frac * cnt) / torch.where(total > 0, total,
|
|
549
|
+
torch.ones_like(total))
|
|
550
|
+
out = torch.where(points <= edges[0], torch.zeros_like(out), out)
|
|
551
|
+
out = torch.where(points >= edges[-1], torch.ones_like(out), out)
|
|
552
|
+
out = out.masked_fill(~torch.isfinite(points), float("nan"))
|
|
553
|
+
out = out.clamp(0.0, 1.0)
|
|
554
|
+
return out.masked_fill(total[None, :] <= 0, float("nan"))
|