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