rbfgpu 1.0.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.
rbfgpu/interpolator.py ADDED
@@ -0,0 +1,497 @@
1
+ """
2
+ Radial Basis Function interpolation on the GPU (with a transparent CPU fallback).
3
+
4
+ This is a GPU-optimised alternative to ``scipy.interpolate.RBFInterpolator`` for
5
+ the regime where SciPy runs out of breath: a few thousand constraint points, for
6
+ which the O(n^2) Gram matrix and the O(n^3) solve dominate the runtime and fit
7
+ comfortably in device memory.
8
+
9
+ Theory
10
+ ------
11
+ An RBF interpolant is a linear combination of radial basis functions centred at
12
+ the data points ``y`` plus a polynomial:
13
+
14
+ f(x) = sum_i a_i phi(||x - y_i||) + sum_j b_j p_j(x)
15
+
16
+ where ``phi`` is the RBF kernel (e.g. the thin plate spline ``r^2 log r``) and
17
+ ``p_j`` are monomials of total degree <= ``degree``. The coefficients ``(a, b)``
18
+ solve the block system
19
+
20
+ [K + lambda I P] [a] = [d]
21
+ [P^T 0] [b] [0]
22
+
23
+ with ``K`` the RBF Gram matrix and ``lambda`` the smoothing parameter. The
24
+ polynomial side conditions ``P^T a = 0`` are what makes the system uniquely
25
+ solvable for conditionally positive definite kernels, and what allows the
26
+ interpolant to extrapolate.
27
+
28
+ Everything runs through XuPy, so the same object is built on CuPy arrays when a
29
+ GPU is available and on NumPy arrays otherwise.
30
+
31
+ References
32
+ ----------
33
+ [1] Fasshauer, G., 2007. Meshfree Approximation Methods with Matlab.
34
+ [2] Wahba, G., 1990. Spline Models for Observational Data.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import math
40
+ import warnings
41
+
42
+ import numpy as np
43
+ from xupy import typings as _ot
44
+
45
+ from ._backend import LINALG_ERRORS, as_numpy, xp
46
+ from ._distance import default_epsilon, euclidean_distance
47
+ from ._kernels import Kernel, resolve_kernel
48
+ from ._polynomial import monomial_exponents, polynomial_matrix
49
+
50
+ __all__ = ["DEFAULT_MAX_CHUNK_BYTES", "DEFAULT_MIN_CHUNK_SIZE", "RBFInterpolator"]
51
+
52
+ #: Memory budget used to pick the evaluation chunk size (1 GiB).
53
+ DEFAULT_MAX_CHUNK_BYTES = 1024 * 1024**2
54
+ #: Floor for the estimated chunk size, so that tiny budgets still batch usefully.
55
+ DEFAULT_MIN_CHUNK_SIZE = 4096
56
+
57
+ #: Message for a system that cannot be solved, whatever the backend reported.
58
+ _SINGULAR_SYSTEM_HINT = (
59
+ "Failed to solve the RBF system: the matrix is singular. The constraint "
60
+ "points may be duplicated or collinear, or the kernel may be too wide or too "
61
+ "narrow for the data. Try removing duplicated points, increasing `smoothing`, "
62
+ "or tuning `epsilon`."
63
+ )
64
+
65
+
66
+ class RBFInterpolator:
67
+ """
68
+ Radial Basis Function interpolator, GPU accelerated through XuPy.
69
+
70
+ The API mirrors ``scipy.interpolate.RBFInterpolator`` for the parts that are
71
+ implemented here (there is no ``neighbors`` support: every evaluation point
72
+ always uses all the constraint points). Kernels, ``epsilon`` and ``degree``
73
+ defaults follow SciPy, so the two agree to round-off.
74
+
75
+ Parameters
76
+ ----------
77
+ y : array, shape (npoints, ndims)
78
+ Constraint point coordinates. A 1-D array is accepted and read as
79
+ ``npoints`` points in one dimension. Host arrays are moved onto the
80
+ active backend automatically.
81
+ d : array, shape (npoints, ...)
82
+ Data values at ``y``. The first axis must match ``y``; the trailing
83
+ shape is preserved in the output. Several outputs are solved for at
84
+ once, in a single factorisation.
85
+ kernel : str or callable, optional
86
+ RBF kernel: one of ``rbfgpu.available_kernels()``, or a callable mapping
87
+ an array of distances to an array of weights (then ``epsilon`` and
88
+ ``degree`` must be given explicitly). Default: 'thin_plate_spline'.
89
+ epsilon : float, optional
90
+ Shape parameter: distances are multiplied by it before the kernel is
91
+ applied. Defaults to 1 for the scale invariant kernels ('linear',
92
+ 'thin_plate_spline', 'cubic', 'quintic') and, for the others, to
93
+ :func:`rbfgpu.default_epsilon` (the square root of the mean nearest
94
+ neighbour distance of ``y``). SciPy refuses to guess and requires
95
+ ``epsilon`` for those kernels; rbfgpu estimates it instead, so pass your
96
+ own value whenever the interpolation quality matters.
97
+ smoothing : float or array, shape (npoints,), optional
98
+ Tikhonov regularization. 0 (default) interpolates the data exactly;
99
+ large values approach a least squares polynomial fit of the given
100
+ ``degree``.
101
+ degree : int, optional
102
+ Degree of the added polynomial. ``-1`` adds none. Defaults to the
103
+ minimum degree the kernel needs ('thin_plate_spline' and 'cubic': 1,
104
+ 'quintic': 2, 'linear' and 'multiquadric': 0), or 0 for positive
105
+ definite kernels. A degree strictly between -1 and that minimum warns,
106
+ as in SciPy: the interpolant may not be uniquely solvable.
107
+ memory_efficient : bool, optional
108
+ If True (default), evaluation is chunked to keep the peak device memory
109
+ below ``max_chunk_bytes``.
110
+ max_chunk_bytes : int, optional
111
+ Memory budget, in bytes, for the temporaries allocated while evaluating
112
+ one chunk. Default: 1 GiB.
113
+ min_chunk_size : int, optional
114
+ Smallest chunk the automatic estimate may return. Default: 4096.
115
+
116
+ Examples
117
+ --------
118
+ >>> import numpy as np
119
+ >>> import rbfgpu
120
+ >>> from rbfgpu import RBFInterpolator
121
+ >>> rng = np.random.default_rng(0)
122
+ >>> y = rng.random((1000, 2)) # 1000 constraint points in 2-D
123
+ >>> d = np.sin(y[:, 0]) * np.cos(y[:, 1])
124
+ >>> rbf = RBFInterpolator(y, d) # thin plate spline, degree=1
125
+ >>> x = rng.random((10000, 2)) # 10000 evaluation points
126
+ >>> values = rbf(x)
127
+ >>> values.shape
128
+ (10000,)
129
+
130
+ The result lives on the interpolant's backend (CuPy on a GPU), so bring it to
131
+ the host before mixing it with NumPy arrays:
132
+
133
+ >>> residual = rbfgpu.as_numpy(rbf(y)) - d
134
+ >>> bool(np.max(np.abs(residual)) < 1e-9)
135
+ True
136
+
137
+ Notes
138
+ -----
139
+ Building the interpolant costs one O(npoints^2) distance/kernel evaluation
140
+ and one O(npoints^3) solve; evaluating it costs O(n_eval * npoints). With
141
+ ``memory_efficient=True`` the evaluation is split into chunks, trading a
142
+ little speed for a bounded peak memory.
143
+
144
+ The ``epsilon`` default and the ``smoothing`` validation reduce device values
145
+ to Python floats, i.e. they synchronise the GPU once at build time. The
146
+ solve synchronises anyway, so no time is lost.
147
+ """
148
+
149
+ def __init__(
150
+ self,
151
+ y: _ot.ArrayLike,
152
+ d: _ot.ArrayLike,
153
+ kernel: _ot.Union[str, _ot.Callable] = "thin_plate_spline",
154
+ epsilon: _ot.Optional[float] = None,
155
+ smoothing: _ot.Union[float, _ot.ArrayLike] = 0.0,
156
+ degree: _ot.Optional[int] = None,
157
+ memory_efficient: bool = True,
158
+ max_chunk_bytes: int = DEFAULT_MAX_CHUNK_BYTES,
159
+ min_chunk_size: int = DEFAULT_MIN_CHUNK_SIZE,
160
+ ):
161
+ self.y, npoints, ndim = self._as_points(y)
162
+ self.d, self.d_shape = self._as_values(d, npoints)
163
+
164
+ self._kernel: Kernel = resolve_kernel(kernel)
165
+ self.kernel = self._kernel.name
166
+ self.epsilon = self._resolve_epsilon(epsilon)
167
+ self.degree = self._resolve_degree(degree)
168
+ self._powers = monomial_exponents(ndim, self.degree)
169
+ self.n_poly = len(self._powers)
170
+
171
+ self.smoothing = self._as_smoothing(smoothing, npoints)
172
+
173
+ self.memory_efficient = bool(memory_efficient)
174
+ self.max_chunk_bytes = int(max_chunk_bytes)
175
+ self.min_chunk_size = int(min_chunk_size)
176
+ if self.max_chunk_bytes <= 0:
177
+ raise ValueError(
178
+ f"`max_chunk_bytes` must be positive, got {max_chunk_bytes}."
179
+ )
180
+ if self.min_chunk_size <= 0:
181
+ raise ValueError(
182
+ f"`min_chunk_size` must be positive, got {min_chunk_size}."
183
+ )
184
+
185
+ self.npoints = npoints
186
+ self.ndim = ndim
187
+ self.output_dim = self.d.shape[1]
188
+
189
+ self._build_system()
190
+
191
+ # ------------------------------------------------------------------
192
+ # Input validation
193
+ # ------------------------------------------------------------------
194
+ @staticmethod
195
+ def _as_points(y: _ot.ArrayLike):
196
+ """Convert ``y`` to a float64 (npoints, ndims) array on the backend."""
197
+ y = xp.asarray(y, dtype=xp.float64)
198
+ if y.ndim == 1:
199
+ y = xp.reshape(y, (y.shape[0], 1))
200
+ if y.ndim != 2:
201
+ raise ValueError(
202
+ "`y` must be a 1-D or 2-D array of point coordinates, got an "
203
+ f"array with {y.ndim} dimensions."
204
+ )
205
+ npoints, ndim = y.shape
206
+ if npoints == 0:
207
+ raise ValueError("`y` must contain at least one point.")
208
+ return y, int(npoints), int(ndim)
209
+
210
+ @staticmethod
211
+ def _as_values(d: _ot.ArrayLike, npoints: int):
212
+ """
213
+ Convert ``d`` to a float64 (npoints, output_dim) array on the backend.
214
+
215
+ Returns the flattened values and the trailing shape of the input, so
216
+ that ``__call__`` can restore it.
217
+ """
218
+ d = xp.asarray(d, dtype=xp.float64)
219
+ if d.ndim == 0:
220
+ d = xp.reshape(d, (1,))
221
+ if d.shape[0] != npoints:
222
+ raise ValueError(
223
+ "`d` must have the same length along its first axis as `y` "
224
+ f"({npoints}), got {d.shape[0]}."
225
+ )
226
+ d_shape = tuple(int(size) for size in d.shape[1:])
227
+ return xp.reshape(d, (npoints, -1)), d_shape
228
+
229
+ def _resolve_epsilon(self, epsilon: _ot.Optional[float]) -> float:
230
+ """Pick the shape parameter, following SciPy's defaulting rules."""
231
+ if self._kernel.min_degree is None and epsilon is None:
232
+ raise ValueError(
233
+ "`epsilon` must be given explicitly when `kernel` is a callable."
234
+ )
235
+ if epsilon is None:
236
+ # Scale invariant kernels only use epsilon to rescale the data, so
237
+ # any value works; the others need a data-driven default.
238
+ epsilon = 1.0 if self._kernel.scale_invariant else default_epsilon(self.y)
239
+ epsilon = float(epsilon)
240
+ if not math.isfinite(epsilon) or epsilon <= 0:
241
+ raise ValueError(f"`epsilon` must be a positive finite number, got {epsilon}.")
242
+ return epsilon
243
+
244
+ def _resolve_degree(self, degree: _ot.Optional[int]) -> int:
245
+ """Pick the polynomial degree, following SciPy's defaulting rules."""
246
+ if self._kernel.min_degree is None and degree is None:
247
+ raise ValueError(
248
+ "`degree` must be given explicitly when `kernel` is a callable."
249
+ )
250
+ if degree is None:
251
+ return int(self._kernel.default_degree)
252
+
253
+ degree = int(degree)
254
+ if degree < -1:
255
+ raise ValueError(f"`degree` must be >= -1 (-1 means no polynomial), got {degree}.")
256
+ min_degree = self._kernel.min_degree
257
+ if min_degree is not None and -1 < degree < min_degree:
258
+ # Same rule as SciPy: -1 ("no polynomial at all") is an explicit,
259
+ # legitimate choice and must not warn; a degree in between is not.
260
+ warnings.warn(
261
+ f"`degree` should not be below {min_degree} except -1 when "
262
+ f"`kernel` is '{self._kernel.name}'. The interpolant may not be "
263
+ "uniquely solvable, and the smoothing parameter may have an "
264
+ "unintuitive effect.",
265
+ UserWarning,
266
+ stacklevel=3,
267
+ )
268
+ return degree
269
+
270
+ @staticmethod
271
+ def _as_smoothing(smoothing: _ot.Union[float, _ot.ArrayLike], npoints: int):
272
+ """Broadcast a scalar ``smoothing`` or validate an array one."""
273
+ if np.ndim(smoothing) == 0:
274
+ smoothing = xp.full(npoints, float(smoothing), dtype=xp.float64)
275
+ else:
276
+ smoothing = xp.asarray(smoothing, dtype=xp.float64)
277
+ if smoothing.shape != (npoints,):
278
+ raise ValueError(
279
+ "`smoothing` must be a scalar or an array of shape "
280
+ f"({npoints},), got shape {tuple(smoothing.shape)}."
281
+ )
282
+ if bool(xp.any(smoothing < 0)):
283
+ raise ValueError("`smoothing` must be non-negative.")
284
+ return smoothing
285
+
286
+ # ------------------------------------------------------------------
287
+ # Build
288
+ # ------------------------------------------------------------------
289
+ def _build_system(self) -> None:
290
+ """
291
+ Build and solve the RBF linear system.
292
+
293
+ Constructs the block matrix
294
+
295
+ [K + lambda I P] [a] = [d]
296
+ [P^T 0] [b] [0]
297
+
298
+ with ``K`` the RBF Gram matrix, ``P`` the polynomial matrix and
299
+ ``lambda`` the smoothing, and stores the solution as ``coeffs_rbf``
300
+ (``a``) and ``coeffs_poly`` (``b``, or None when ``degree == -1``).
301
+ """
302
+ n = self.npoints
303
+ n_poly = self.n_poly
304
+ n_sys = n + n_poly
305
+
306
+ A = xp.zeros((n_sys, n_sys), dtype=xp.float64)
307
+
308
+ # The kernel block is written straight into the system matrix, so that
309
+ # the (n, n) Gram matrix and the (n_sys, n_sys) block matrix are never
310
+ # resident on the device at the same time.
311
+ dist = euclidean_distance(self.y, self.y, self.epsilon)
312
+ A[:n, :n] = self._kernel.function(dist)
313
+ del dist
314
+
315
+ A[:n, :n] += xp.diag(self.smoothing)
316
+
317
+ if n_poly > 0:
318
+ P = polynomial_matrix(self.y, self._powers)
319
+ A[:n, n:] = P
320
+ A[n:, :n] = P.T
321
+ del P
322
+
323
+ b = xp.zeros((n_sys, self.output_dim), dtype=xp.float64)
324
+ b[:n] = self.d
325
+
326
+ try:
327
+ coeffs = xp.linalg.solve(A, b)
328
+ except LINALG_ERRORS as error:
329
+ raise RuntimeError(f"{_SINGULAR_SYSTEM_HINT} ({error})") from error
330
+ del A, b
331
+
332
+ # LAPACK reports a singular factorisation by raising LinAlgError; cuSOLVER
333
+ # quietly returns NaNs instead. The solution is therefore checked
334
+ # explicitly: handing back NaN coefficients would corrupt every later
335
+ # evaluation without any visible cause. Costs one host synchronisation,
336
+ # which the solve has already paid for.
337
+ if not bool(xp.isfinite(coeffs).all()):
338
+ raise RuntimeError(
339
+ f"{_SINGULAR_SYSTEM_HINT} (the solver returned non-finite "
340
+ "coefficients)"
341
+ )
342
+
343
+ self.coeffs_rbf = coeffs[:n]
344
+ self.coeffs_poly = coeffs[n:] if n_poly > 0 else None
345
+
346
+ # ------------------------------------------------------------------
347
+ # Evaluation
348
+ # ------------------------------------------------------------------
349
+ def _resolve_chunk_size(self, n_eval: int, chunk_size: _ot.Optional[int]) -> int:
350
+ """
351
+ Resolve the evaluation chunk size.
352
+
353
+ Parameters
354
+ ----------
355
+ n_eval : int
356
+ Number of evaluation points.
357
+ chunk_size : int or None
358
+ Explicit chunk size. If None, a size is estimated from the
359
+ configured memory budget (or the whole evaluation is done at once
360
+ when ``memory_efficient`` is False).
361
+
362
+ Returns
363
+ -------
364
+ int
365
+ Effective chunk size, always in [1, n_eval].
366
+ """
367
+ if chunk_size is not None:
368
+ return max(1, min(n_eval, int(chunk_size)))
369
+
370
+ if not self.memory_efficient:
371
+ return n_eval
372
+
373
+ bytes_per_float = np.dtype(np.float64).itemsize
374
+ # Per evaluation point: the (npoints,) distance and kernel arrays and
375
+ # the matmul result (4 arrays of npoints floats, conservatively), plus
376
+ # the polynomial matrix and the output.
377
+ floats_per_eval = 4 * self.npoints + self.output_dim + self.n_poly + self.ndim
378
+ bytes_per_eval = bytes_per_float * floats_per_eval
379
+ estimated_chunk = self.max_chunk_bytes // max(bytes_per_eval, 1)
380
+ estimated_chunk = max(self.min_chunk_size, int(estimated_chunk))
381
+ return max(1, min(n_eval, estimated_chunk))
382
+
383
+ def __call__(self, x: _ot.ArrayLike, chunk_size: _ot.Optional[int] = None):
384
+ """
385
+ Evaluate the interpolant at the points ``x``.
386
+
387
+ Parameters
388
+ ----------
389
+ x : array, shape (npts, ndims)
390
+ Evaluation points. For a 1-D interpolation problem (``ndims == 1``)
391
+ a flat array of ``npts`` coordinates is accepted; for ``ndims > 1``
392
+ a flat array is read as a single point.
393
+ chunk_size : int, optional
394
+ Number of evaluation points per batch. Overrides the automatic
395
+ estimate; useful to bound the device memory on very large grids.
396
+
397
+ Returns
398
+ -------
399
+ f : array
400
+ Interpolant values at ``x``, with shape ``(npts,) + d.shape[1:]``,
401
+ on the same backend as the interpolant.
402
+ """
403
+ x = self._as_evaluation_points(x)
404
+ n_eval = x.shape[0]
405
+
406
+ resolved_chunk_size = self._resolve_chunk_size(n_eval, chunk_size)
407
+ if resolved_chunk_size >= n_eval:
408
+ out = self._evaluate_batch(x)
409
+ else:
410
+ out = xp.empty((n_eval, self.output_dim), dtype=xp.float64)
411
+ for start in range(0, n_eval, resolved_chunk_size):
412
+ stop = min(start + resolved_chunk_size, n_eval)
413
+ out[start:stop] = self._evaluate_batch(x[start:stop])
414
+
415
+ if self.d_shape:
416
+ return xp.reshape(out, (n_eval,) + self.d_shape)
417
+ return out[:, 0]
418
+
419
+ def _as_evaluation_points(self, x: _ot.ArrayLike):
420
+ """Convert ``x`` to a float64 (npts, ndims) array and check its shape."""
421
+ x = xp.asarray(x, dtype=xp.float64)
422
+ if x.ndim == 1:
423
+ if self.ndim == 1:
424
+ x = xp.reshape(x, (-1, 1))
425
+ else:
426
+ x = xp.reshape(x, (1, -1))
427
+ if x.ndim != 2:
428
+ raise ValueError(
429
+ "`x` must be a 2-D array of shape (npts, ndims), got an array "
430
+ f"with {x.ndim} dimensions."
431
+ )
432
+ if x.shape[1] != self.ndim:
433
+ raise ValueError(
434
+ f"Expected the second axis of `x` to have length {self.ndim}, "
435
+ f"got {x.shape[1]}."
436
+ )
437
+ return x
438
+
439
+ def _evaluate_batch(self, x):
440
+ """Evaluate the interpolant on one batch of points."""
441
+ # RBF contribution: sum_i a_i phi(||x - y_i||)
442
+ dist = euclidean_distance(x, self.y, self.epsilon)
443
+ K = self._kernel.function(dist)
444
+ del dist
445
+ out = K @ self.coeffs_rbf
446
+ del K
447
+
448
+ # Polynomial contribution: sum_j b_j p_j(x)
449
+ if self.coeffs_poly is not None:
450
+ P = polynomial_matrix(x, self._powers)
451
+ out = out + P @ self.coeffs_poly
452
+ del P
453
+
454
+ return out
455
+
456
+ # ------------------------------------------------------------------
457
+ # Inspection / export
458
+ # ------------------------------------------------------------------
459
+ @property
460
+ def on_gpu(self) -> bool:
461
+ """True when this interpolant's arrays live in GPU memory."""
462
+ return not isinstance(self.y, np.ndarray)
463
+
464
+ def to_cpu(self) -> dict:
465
+ """
466
+ Export the solved interpolant as host (NumPy) arrays.
467
+
468
+ The returned state is everything needed to evaluate the interpolant
469
+ without this package, for instance to hand it over to a CPU-only process
470
+ or to store it on disk.
471
+
472
+ Returns
473
+ -------
474
+ state : dict
475
+ With keys ``y``, ``coeffs_rbf``, ``coeffs_poly`` (None when
476
+ ``degree == -1``), ``powers``, ``degree``, ``kernel`` and
477
+ ``epsilon``.
478
+ """
479
+ return {
480
+ "y": as_numpy(self.y),
481
+ "coeffs_rbf": as_numpy(self.coeffs_rbf),
482
+ "coeffs_poly": (
483
+ None if self.coeffs_poly is None else as_numpy(self.coeffs_poly)
484
+ ),
485
+ "powers": self._powers,
486
+ "degree": self.degree,
487
+ "kernel": self.kernel,
488
+ "epsilon": self.epsilon,
489
+ }
490
+
491
+ def __repr__(self) -> str:
492
+ return (
493
+ f"{type(self).__name__}(kernel={self.kernel!r}, npoints={self.npoints}, "
494
+ f"ndim={self.ndim}, output_dim={self.output_dim}, degree={self.degree}, "
495
+ f"epsilon={self.epsilon!r}, "
496
+ f"backend={'gpu' if self.on_gpu else 'cpu'!r})"
497
+ )
rbfgpu/py.typed ADDED
File without changes