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/__init__.py +77 -0
- rbfgpu/__version__.py +32 -0
- rbfgpu/_backend.py +121 -0
- rbfgpu/_distance.py +138 -0
- rbfgpu/_kernels.py +220 -0
- rbfgpu/_polynomial.py +104 -0
- rbfgpu/interpolator.py +497 -0
- rbfgpu/py.typed +0 -0
- rbfgpu-1.0.0.dist-info/METADATA +336 -0
- rbfgpu-1.0.0.dist-info/RECORD +13 -0
- rbfgpu-1.0.0.dist-info/WHEEL +5 -0
- rbfgpu-1.0.0.dist-info/licenses/LICENSE +21 -0
- rbfgpu-1.0.0.dist-info/top_level.txt +1 -0
rbfgpu/__init__.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""
|
|
2
|
+
rbfGPU: GPU/CPU agnostic Radial Basis Function interpolation.
|
|
3
|
+
|
|
4
|
+
``rbfgpu`` interpolates scattered data with radial basis functions (thin plate
|
|
5
|
+
splines by default) using the `XuPy <https://pypi.org/project/xupy/>`_ array
|
|
6
|
+
namespace, so the very same code runs on CuPy arrays when an NVIDIA GPU is
|
|
7
|
+
available and on NumPy arrays otherwise. It is the drop-in replacement for
|
|
8
|
+
``scipy.interpolate.RBFInterpolator`` in the regime where the O(n^2) Gram matrix
|
|
9
|
+
and the O(n^3) solve become the bottleneck, i.e. from a few thousand constraint
|
|
10
|
+
points upwards.
|
|
11
|
+
|
|
12
|
+
Examples
|
|
13
|
+
--------
|
|
14
|
+
>>> import numpy as np
|
|
15
|
+
>>> from rbfgpu import RBFInterpolator
|
|
16
|
+
>>> rng = np.random.default_rng(0)
|
|
17
|
+
>>> y = rng.random((1000, 2))
|
|
18
|
+
>>> d = np.sin(3 * y[:, 0]) * np.cos(3 * y[:, 1])
|
|
19
|
+
>>> rbf = RBFInterpolator(y, d) # thin plate spline, degree=1
|
|
20
|
+
>>> values = rbf(rng.random((10000, 2))) # chunked, bounded memory
|
|
21
|
+
>>> values.shape
|
|
22
|
+
(10000,)
|
|
23
|
+
|
|
24
|
+
The backend can be inspected (and, through XuPy, switched) at any time:
|
|
25
|
+
|
|
26
|
+
>>> import rbfgpu
|
|
27
|
+
>>> rbfgpu.backend_info()["backend"] in ("numpy", "cupy")
|
|
28
|
+
True
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from . import __version__ as _version_module
|
|
32
|
+
from ._backend import as_numpy, backend_info, is_on_gpu, require_gpu
|
|
33
|
+
from ._distance import default_epsilon, euclidean_distance
|
|
34
|
+
from ._kernels import (
|
|
35
|
+
KERNELS,
|
|
36
|
+
KERNEL_ALIASES,
|
|
37
|
+
Kernel,
|
|
38
|
+
available_kernels,
|
|
39
|
+
resolve_kernel,
|
|
40
|
+
)
|
|
41
|
+
from ._polynomial import monomial_exponents, polynomial_matrix
|
|
42
|
+
from .__version__ import __version__
|
|
43
|
+
from .interpolator import (
|
|
44
|
+
DEFAULT_MAX_CHUNK_BYTES,
|
|
45
|
+
DEFAULT_MIN_CHUNK_SIZE,
|
|
46
|
+
RBFInterpolator,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
__title__ = _version_module.__title__
|
|
50
|
+
__description__ = _version_module.__description__
|
|
51
|
+
__author__ = _version_module.__author__
|
|
52
|
+
__author_email__ = _version_module.__author_email__
|
|
53
|
+
__license__ = _version_module.__license__
|
|
54
|
+
__url__ = _version_module.__url__
|
|
55
|
+
|
|
56
|
+
__all__ = [
|
|
57
|
+
"__version__",
|
|
58
|
+
# main API
|
|
59
|
+
"RBFInterpolator",
|
|
60
|
+
"available_kernels",
|
|
61
|
+
"KERNELS",
|
|
62
|
+
"KERNEL_ALIASES",
|
|
63
|
+
"Kernel",
|
|
64
|
+
"resolve_kernel",
|
|
65
|
+
# backend
|
|
66
|
+
"backend_info",
|
|
67
|
+
"is_on_gpu",
|
|
68
|
+
"require_gpu",
|
|
69
|
+
"as_numpy",
|
|
70
|
+
# building blocks (handy for custom interpolants and for testing)
|
|
71
|
+
"euclidean_distance",
|
|
72
|
+
"default_epsilon",
|
|
73
|
+
"monomial_exponents",
|
|
74
|
+
"polynomial_matrix",
|
|
75
|
+
"DEFAULT_MAX_CHUNK_BYTES",
|
|
76
|
+
"DEFAULT_MIN_CHUNK_SIZE",
|
|
77
|
+
]
|
rbfgpu/__version__.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Created on October 2026
|
|
3
|
+
|
|
4
|
+
Author
|
|
5
|
+
------
|
|
6
|
+
Pietro Ferraiuolo
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
__title__ = "rbfgpu"
|
|
10
|
+
__long_title__ = "rbfGPU : GPU/CPU agnostic Radial Basis Function interpolation"
|
|
11
|
+
__description__ = (
|
|
12
|
+
"Thin plate spline and general RBF interpolation that runs on the GPU through "
|
|
13
|
+
"XuPy (CuPy) and transparently falls back to NumPy on the CPU."
|
|
14
|
+
)
|
|
15
|
+
__version__ = "1.0.0"
|
|
16
|
+
__author__ = "Pietro Ferraiuolo"
|
|
17
|
+
__maintainer__ = "Pietro Ferraiuolo"
|
|
18
|
+
__author_email__ = "pietro.ferraiuolo@inaf.it"
|
|
19
|
+
__license__ = "MIT License"
|
|
20
|
+
__url__ = "https://github.com/pietroferraiuolo/rbfgpu"
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"__title__",
|
|
24
|
+
"__long_title__",
|
|
25
|
+
"__description__",
|
|
26
|
+
"__version__",
|
|
27
|
+
"__author__",
|
|
28
|
+
"__maintainer__",
|
|
29
|
+
"__author_email__",
|
|
30
|
+
"__license__",
|
|
31
|
+
"__url__",
|
|
32
|
+
]
|
rbfgpu/_backend.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Backend adapter over the XuPy array namespace.
|
|
3
|
+
|
|
4
|
+
rbfGPU never talks to NumPy or CuPy directly: every array operation goes through
|
|
5
|
+
``xupy``, whose namespace is resolved dynamically against the active backend
|
|
6
|
+
(CuPy when a usable NVIDIA GPU is present, NumPy otherwise). This module is the
|
|
7
|
+
only place where that duality is dealt with.
|
|
8
|
+
|
|
9
|
+
Note that, unlike the module this package started from, there is no
|
|
10
|
+
``if xp.on_gpu:`` branch: XuPy already provides the CPU fallback, so the very
|
|
11
|
+
same code path runs (and is tested) on both backends.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
import xupy as xp
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"LINALG_ERRORS",
|
|
21
|
+
"as_numpy",
|
|
22
|
+
"backend_info",
|
|
23
|
+
"is_on_gpu",
|
|
24
|
+
"require_gpu",
|
|
25
|
+
"xp",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _linalg_errors() -> tuple:
|
|
30
|
+
"""
|
|
31
|
+
Exception types raised by ``xp.linalg.solve`` on a singular matrix.
|
|
32
|
+
|
|
33
|
+
NumPy's and CuPy's ``LinAlgError`` are distinct classes, so both are
|
|
34
|
+
collected: whichever backend is active, the solve step can catch the right
|
|
35
|
+
one.
|
|
36
|
+
"""
|
|
37
|
+
errors = [np.linalg.LinAlgError]
|
|
38
|
+
try: # pragma: no cover - depends on the machine running the tests
|
|
39
|
+
import cupy
|
|
40
|
+
|
|
41
|
+
errors.append(cupy.linalg.LinAlgError)
|
|
42
|
+
except Exception: # CuPy missing, or importable but unusable
|
|
43
|
+
pass
|
|
44
|
+
return tuple(errors)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
LINALG_ERRORS = _linalg_errors()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def is_on_gpu() -> bool:
|
|
51
|
+
"""True when XuPy is serving arrays from CuPy (GPU), False for NumPy (CPU)."""
|
|
52
|
+
return bool(xp.on_gpu)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def require_gpu(hint: str = "") -> None:
|
|
56
|
+
"""
|
|
57
|
+
Raise ``RuntimeError`` unless XuPy is running on the GPU backend.
|
|
58
|
+
|
|
59
|
+
Useful at the top of a script that is only worth running on a GPU, so that
|
|
60
|
+
it fails loudly instead of silently computing on the CPU.
|
|
61
|
+
|
|
62
|
+
Parameters
|
|
63
|
+
----------
|
|
64
|
+
hint : str, optional
|
|
65
|
+
Extra text appended to the error message.
|
|
66
|
+
"""
|
|
67
|
+
if not is_on_gpu():
|
|
68
|
+
message = (
|
|
69
|
+
"rbfGPU is running on the NumPy (CPU) backend: no usable NVIDIA GPU "
|
|
70
|
+
"or CuPy installation was found. Install a matching CuPy build with "
|
|
71
|
+
"`pip install 'rbfgpu[cuda12]'` (or `[cuda13]`) and retry."
|
|
72
|
+
)
|
|
73
|
+
if hint:
|
|
74
|
+
message = f"{message} {hint}"
|
|
75
|
+
raise RuntimeError(message)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def as_numpy(array):
|
|
79
|
+
"""
|
|
80
|
+
Move ``array`` to host memory and return it as a NumPy array.
|
|
81
|
+
|
|
82
|
+
A no-op when XuPy is on the CPU backend (the array is returned unchanged if
|
|
83
|
+
it already is an ``ndarray``).
|
|
84
|
+
"""
|
|
85
|
+
return xp.asnumpy(array)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def backend_info() -> dict:
|
|
89
|
+
"""
|
|
90
|
+
Describe the array backend rbfGPU is currently running on.
|
|
91
|
+
|
|
92
|
+
Returns
|
|
93
|
+
-------
|
|
94
|
+
info : dict
|
|
95
|
+
With keys ``on_gpu``, ``backend`` ("cupy" or "numpy"), ``device``,
|
|
96
|
+
``numpy_version``, ``xupy_version`` and ``cupy_version`` (None when
|
|
97
|
+
CuPy is not importable).
|
|
98
|
+
"""
|
|
99
|
+
on_gpu = is_on_gpu()
|
|
100
|
+
info = {
|
|
101
|
+
"on_gpu": on_gpu,
|
|
102
|
+
"backend": "cupy" if on_gpu else "numpy",
|
|
103
|
+
"device": "cpu",
|
|
104
|
+
"numpy_version": np.__version__,
|
|
105
|
+
"xupy_version": xp.__version__,
|
|
106
|
+
"cupy_version": None,
|
|
107
|
+
}
|
|
108
|
+
if on_gpu: # pragma: no cover - needs a GPU
|
|
109
|
+
try:
|
|
110
|
+
import cupy
|
|
111
|
+
|
|
112
|
+
info["cupy_version"] = cupy.__version__
|
|
113
|
+
device_id = cupy.cuda.runtime.getDevice()
|
|
114
|
+
props = cupy.cuda.runtime.getDeviceProperties(device_id)
|
|
115
|
+
name = props.get("name", b"")
|
|
116
|
+
info["device"] = f"cuda:{device_id} " + (
|
|
117
|
+
name.decode() if isinstance(name, bytes) else str(name)
|
|
118
|
+
)
|
|
119
|
+
except Exception as error: # never let an informational call fail
|
|
120
|
+
info["device"] = f"<unknown: {error}>"
|
|
121
|
+
return info
|
rbfgpu/_distance.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pairwise Euclidean distances on the active XuPy backend.
|
|
3
|
+
|
|
4
|
+
Distances are the only O(n*m) quantity in an RBF interpolant, so they are
|
|
5
|
+
computed with the dot-product identity instead of an explicit difference: it
|
|
6
|
+
avoids materialising the (n, m, d) broadcast temporary, which on a GPU is what
|
|
7
|
+
actually limits the problem size.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import math
|
|
13
|
+
|
|
14
|
+
from ._backend import xp
|
|
15
|
+
|
|
16
|
+
__all__ = ["default_epsilon", "euclidean_distance"]
|
|
17
|
+
|
|
18
|
+
#: Memory budget (in floats) for the (block, npoints, ndim) temporary that
|
|
19
|
+
#: :func:`default_epsilon` allocates: 2**22 floats is 32 MiB of float64.
|
|
20
|
+
_EPSILON_BLOCK_FLOATS = 2**22
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def euclidean_distance(x, y, epsilon=None):
|
|
24
|
+
"""
|
|
25
|
+
Compute the (optionally scaled) Euclidean distance matrix between two clouds.
|
|
26
|
+
|
|
27
|
+
Parameters
|
|
28
|
+
----------
|
|
29
|
+
x : array, shape (n, d)
|
|
30
|
+
First set of points.
|
|
31
|
+
y : array, shape (m, d)
|
|
32
|
+
Second set of points.
|
|
33
|
+
epsilon : float, optional
|
|
34
|
+
Shape parameter the distances are multiplied by before being fed to the
|
|
35
|
+
kernel. ``None`` (default) leaves the distances unscaled.
|
|
36
|
+
|
|
37
|
+
Returns
|
|
38
|
+
-------
|
|
39
|
+
dist : array, shape (n, m)
|
|
40
|
+
Pairwise distances, on the active backend (NumPy or CuPy).
|
|
41
|
+
|
|
42
|
+
Notes
|
|
43
|
+
-----
|
|
44
|
+
The dot-product identity costs one (n, m) temporary instead of the (n, m, d)
|
|
45
|
+
one an explicit difference would need, which is what makes large problems fit
|
|
46
|
+
in device memory. The price is that a distance that is exactly zero (the
|
|
47
|
+
diagonal of a self-distance matrix, or duplicated points) comes out as
|
|
48
|
+
round-off instead: up to about ``sqrt(machine epsilon) * ||x||``, i.e. ~1e-8
|
|
49
|
+
for unit-scale coordinates. That is harmless for every kernel here (the
|
|
50
|
+
smallest kernel value it can produce is ``r**2 * log(r)`` ~ 1e-15), but
|
|
51
|
+
anything that needs *exact* zeros, such as :func:`default_epsilon`, must use
|
|
52
|
+
the explicit difference instead.
|
|
53
|
+
"""
|
|
54
|
+
x = xp.asarray(x, dtype=xp.float64)
|
|
55
|
+
y = xp.asarray(y, dtype=xp.float64)
|
|
56
|
+
|
|
57
|
+
# ||x - y||^2 = ||x||^2 + ||y||^2 - 2 x.y
|
|
58
|
+
x_sqnorm = (x**2).sum(axis=1, keepdims=True) # (n, 1)
|
|
59
|
+
y_sqnorm = (y**2).sum(axis=1, keepdims=True) # (m, 1)
|
|
60
|
+
|
|
61
|
+
dist_sq = x_sqnorm + y_sqnorm.T - 2 * (x @ y.T) # (n, m)
|
|
62
|
+
# Round-off can make the above slightly negative for coincident points.
|
|
63
|
+
dist_sq = xp.clip(dist_sq, 0, None)
|
|
64
|
+
dist = xp.sqrt(dist_sq)
|
|
65
|
+
|
|
66
|
+
if epsilon is not None:
|
|
67
|
+
dist = dist * epsilon
|
|
68
|
+
|
|
69
|
+
return dist
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def default_epsilon(y) -> float:
|
|
73
|
+
"""
|
|
74
|
+
Data-driven default shape parameter, for the kernels that need one.
|
|
75
|
+
|
|
76
|
+
The classic nearest neighbour heuristic: the square root of the mean distance
|
|
77
|
+
between each point and its closest distinct neighbour. Small ``epsilon``
|
|
78
|
+
means wide kernels, large ``epsilon`` means narrow ones.
|
|
79
|
+
|
|
80
|
+
Parameters
|
|
81
|
+
----------
|
|
82
|
+
y : array, shape (n, d)
|
|
83
|
+
Data point coordinates, with ``n >= 2``.
|
|
84
|
+
|
|
85
|
+
Returns
|
|
86
|
+
-------
|
|
87
|
+
epsilon : float
|
|
88
|
+
Positive shape parameter.
|
|
89
|
+
|
|
90
|
+
Raises
|
|
91
|
+
------
|
|
92
|
+
ValueError
|
|
93
|
+
If there are fewer than two points, or if the estimate comes out
|
|
94
|
+
non-finite or non-positive (every point coincident with another).
|
|
95
|
+
|
|
96
|
+
Notes
|
|
97
|
+
-----
|
|
98
|
+
Distances are computed with the explicit difference, block by block, so that
|
|
99
|
+
the self-distances are *exactly* zero and can be discarded: the round-off of
|
|
100
|
+
:func:`euclidean_distance` (~1e-8) would otherwise be mistaken for the
|
|
101
|
+
nearest neighbour distance. The blocks bound the temporary memory to a few
|
|
102
|
+
tens of MiB whatever ``n`` is. The final reduction to a Python float forces
|
|
103
|
+
one host synchronisation, paid once at build time.
|
|
104
|
+
"""
|
|
105
|
+
y = xp.asarray(y, dtype=xp.float64)
|
|
106
|
+
if y.ndim == 1:
|
|
107
|
+
y = xp.reshape(y, (y.shape[0], 1))
|
|
108
|
+
npoints, ndim = y.shape
|
|
109
|
+
if npoints < 2:
|
|
110
|
+
raise ValueError(
|
|
111
|
+
f"`default_epsilon` needs at least two points, got {npoints}: pass "
|
|
112
|
+
"`epsilon` explicitly."
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
block = max(1, int(_EPSILON_BLOCK_FLOATS // max(npoints * ndim, 1)))
|
|
116
|
+
nearest_sum = xp.zeros((), dtype=xp.float64)
|
|
117
|
+
|
|
118
|
+
for start in range(0, npoints, block):
|
|
119
|
+
stop = min(start + block, npoints)
|
|
120
|
+
chunk = y[start:stop]
|
|
121
|
+
|
|
122
|
+
difference = chunk[:, None, :] - y[None, :, :] # (block, npoints, ndim)
|
|
123
|
+
dist = xp.sqrt((difference**2).sum(axis=-1))
|
|
124
|
+
del difference
|
|
125
|
+
|
|
126
|
+
index = xp.arange(stop - start)
|
|
127
|
+
dist[index, index + start] = xp.inf # a point is not its own neighbour
|
|
128
|
+
dist = xp.where(dist == 0, xp.inf, dist) # ...nor are its exact copies
|
|
129
|
+
nearest_sum = nearest_sum + dist.min(axis=1).sum()
|
|
130
|
+
del dist
|
|
131
|
+
|
|
132
|
+
epsilon = float(xp.sqrt(nearest_sum / npoints))
|
|
133
|
+
if not math.isfinite(epsilon) or epsilon <= 0:
|
|
134
|
+
raise ValueError(
|
|
135
|
+
"Cannot estimate a default shape parameter from `y` (all the points "
|
|
136
|
+
"are coincident). Pass `epsilon` explicitly."
|
|
137
|
+
)
|
|
138
|
+
return epsilon
|
rbfgpu/_kernels.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Radial basis function kernels.
|
|
3
|
+
|
|
4
|
+
Every kernel maps the (already ``epsilon``-scaled) distance ``r`` onto a scalar
|
|
5
|
+
weight; an RBF interpolant is a linear combination of those weights centred on
|
|
6
|
+
the data points, plus a polynomial tail.
|
|
7
|
+
|
|
8
|
+
Names, signs and defaults follow ``scipy.interpolate.RBFInterpolator`` so that
|
|
9
|
+
an interpolant built here and one built by SciPy agree to round-off (see
|
|
10
|
+
``test/test_scipy_parity.py``).
|
|
11
|
+
|
|
12
|
+
References
|
|
13
|
+
----------
|
|
14
|
+
[1] Fasshauer, G., 2007. Meshfree Approximation Methods with Matlab.
|
|
15
|
+
[2] Wahba, G., 1990. Spline Models for Observational Data.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import math
|
|
21
|
+
from typing import Any, Callable, NamedTuple, Optional
|
|
22
|
+
|
|
23
|
+
from ._backend import xp
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"KERNELS",
|
|
27
|
+
"KERNEL_ALIASES",
|
|
28
|
+
"Kernel",
|
|
29
|
+
"available_kernels",
|
|
30
|
+
"resolve_kernel",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Kernel(NamedTuple):
|
|
35
|
+
"""
|
|
36
|
+
A radial basis function and the polynomial degree it needs.
|
|
37
|
+
|
|
38
|
+
Attributes
|
|
39
|
+
----------
|
|
40
|
+
name : str
|
|
41
|
+
Canonical kernel name (also the key in :data:`KERNELS`).
|
|
42
|
+
function : callable
|
|
43
|
+
Maps an array of distances ``r`` to an array of weights.
|
|
44
|
+
scale_invariant : bool
|
|
45
|
+
True when ``epsilon`` only rescales the interpolant, so it can safely be
|
|
46
|
+
left at 1 (``linear``, ``thin_plate_spline``, ``cubic``, ``quintic``).
|
|
47
|
+
min_degree : int or None
|
|
48
|
+
Minimum polynomial degree for a well-posed interpolant, or ``None`` for a
|
|
49
|
+
user supplied callable (then both ``epsilon`` and ``degree`` must be
|
|
50
|
+
given explicitly). ``-1`` means "no constraint" (positive definite
|
|
51
|
+
kernels).
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
name: str
|
|
55
|
+
function: Callable[[Any], Any]
|
|
56
|
+
scale_invariant: bool
|
|
57
|
+
min_degree: Optional[int]
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def default_degree(self) -> Optional[int]:
|
|
61
|
+
"""
|
|
62
|
+
Polynomial degree used when the caller does not pass one.
|
|
63
|
+
|
|
64
|
+
The minimum degree required by the kernel, or 0 when the kernel needs no
|
|
65
|
+
polynomial at all; ``None`` for custom callables (no default).
|
|
66
|
+
"""
|
|
67
|
+
if self.min_degree is None:
|
|
68
|
+
return None
|
|
69
|
+
return max(self.min_degree, 0)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _thin_plate_spline(r):
|
|
73
|
+
"""``r**2 * log(r)``, with the r -> 0 limit (0) applied elementwise."""
|
|
74
|
+
# log(0) = -inf and 0 * -inf = nan: both are cleaned up to the limit value.
|
|
75
|
+
with xp.errstate(divide="ignore", invalid="ignore"):
|
|
76
|
+
result = r**2 * xp.log(r)
|
|
77
|
+
return xp.nan_to_num(result, nan=0.0, posinf=0.0, neginf=0.0)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _linear(r):
|
|
81
|
+
"""``-r``."""
|
|
82
|
+
return -r
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _cubic(r):
|
|
86
|
+
"""``r**3``."""
|
|
87
|
+
return r**3
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _quintic(r):
|
|
91
|
+
"""``-r**5``."""
|
|
92
|
+
return -(r**5)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _multiquadric(r):
|
|
96
|
+
"""``-sqrt(1 + r**2)``."""
|
|
97
|
+
return -xp.sqrt(r**2 + 1)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _inverse_multiquadric(r):
|
|
101
|
+
"""``1/sqrt(1 + r**2)``."""
|
|
102
|
+
return 1 / xp.sqrt(r**2 + 1)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _inverse_quadratic(r):
|
|
106
|
+
"""``1/(1 + r**2)``."""
|
|
107
|
+
return 1 / (r**2 + 1)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _gaussian(r):
|
|
111
|
+
"""``exp(-r**2)``."""
|
|
112
|
+
return xp.exp(-(r**2))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _matern1_2(r):
|
|
116
|
+
"""``exp(-r)``."""
|
|
117
|
+
return xp.exp(-r)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _matern3_2(r):
|
|
121
|
+
"""``(1 + sqrt(3) r) exp(-sqrt(3) r)``."""
|
|
122
|
+
s = math.sqrt(3.0) * r
|
|
123
|
+
return (1 + s) * xp.exp(-s)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _matern5_2(r):
|
|
127
|
+
"""``(1 + sqrt(5) r + 5/3 r**2) exp(-sqrt(5) r)``."""
|
|
128
|
+
s = math.sqrt(5.0) * r
|
|
129
|
+
return (1 + s + s**2 / 3) * xp.exp(-s)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# name, function, scale invariant, minimum polynomial degree
|
|
133
|
+
_KERNEL_DEFS = (
|
|
134
|
+
("linear", _linear, True, 0),
|
|
135
|
+
("thin_plate_spline", _thin_plate_spline, True, 1),
|
|
136
|
+
("cubic", _cubic, True, 1),
|
|
137
|
+
("quintic", _quintic, True, 2),
|
|
138
|
+
("multiquadric", _multiquadric, False, 0),
|
|
139
|
+
("inverse_multiquadric", _inverse_multiquadric, False, -1),
|
|
140
|
+
("inverse_quadratic", _inverse_quadratic, False, -1),
|
|
141
|
+
("gaussian", _gaussian, False, -1),
|
|
142
|
+
("matern1_2", _matern1_2, False, -1),
|
|
143
|
+
("matern3_2", _matern3_2, False, -1),
|
|
144
|
+
("matern5_2", _matern5_2, False, -1),
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
KERNELS: dict = {
|
|
148
|
+
name: Kernel(name, function, scale_invariant, min_degree)
|
|
149
|
+
for name, function, scale_invariant, min_degree in _KERNEL_DEFS
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
#: Accepted spellings that are not canonical names.
|
|
153
|
+
KERNEL_ALIASES: dict = {
|
|
154
|
+
"inverse_quartic": "inverse_quadratic", # SciPy < 1.15 spelling
|
|
155
|
+
"tps": "thin_plate_spline",
|
|
156
|
+
"thin_plate": "thin_plate_spline",
|
|
157
|
+
"imq": "inverse_multiquadric",
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def available_kernels() -> tuple:
|
|
162
|
+
"""
|
|
163
|
+
Canonical names of every built-in kernel.
|
|
164
|
+
|
|
165
|
+
Returns
|
|
166
|
+
-------
|
|
167
|
+
names : tuple of str
|
|
168
|
+
Sorted kernel names, as accepted by ``RBFInterpolator(kernel=...)``.
|
|
169
|
+
"""
|
|
170
|
+
return tuple(sorted(KERNELS))
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def resolve_kernel(kernel) -> Kernel:
|
|
174
|
+
"""
|
|
175
|
+
Turn a kernel specification into a :class:`Kernel`.
|
|
176
|
+
|
|
177
|
+
Parameters
|
|
178
|
+
----------
|
|
179
|
+
kernel : str or callable
|
|
180
|
+
A kernel name (aliases in :data:`KERNEL_ALIASES` are accepted, case
|
|
181
|
+
insensitive) or a callable mapping an array of distances to an array of
|
|
182
|
+
weights.
|
|
183
|
+
|
|
184
|
+
Returns
|
|
185
|
+
-------
|
|
186
|
+
Kernel
|
|
187
|
+
The resolved kernel. A callable is wrapped in a ``Kernel`` whose
|
|
188
|
+
``min_degree`` is ``None``, i.e. one that requires ``epsilon`` and
|
|
189
|
+
``degree`` to be given explicitly.
|
|
190
|
+
|
|
191
|
+
Raises
|
|
192
|
+
------
|
|
193
|
+
TypeError
|
|
194
|
+
If ``kernel`` is neither a string nor a callable.
|
|
195
|
+
ValueError
|
|
196
|
+
If the name is unknown.
|
|
197
|
+
"""
|
|
198
|
+
if isinstance(kernel, str):
|
|
199
|
+
name = kernel.strip().lower()
|
|
200
|
+
name = KERNEL_ALIASES.get(name, name)
|
|
201
|
+
try:
|
|
202
|
+
return KERNELS[name]
|
|
203
|
+
except KeyError:
|
|
204
|
+
raise ValueError(
|
|
205
|
+
f"Unknown kernel {kernel!r}. Available kernels: "
|
|
206
|
+
f"{', '.join(available_kernels())}."
|
|
207
|
+
) from None
|
|
208
|
+
|
|
209
|
+
if callable(kernel):
|
|
210
|
+
return Kernel(
|
|
211
|
+
name=getattr(kernel, "__name__", "<callable>"),
|
|
212
|
+
function=kernel,
|
|
213
|
+
scale_invariant=False,
|
|
214
|
+
min_degree=None,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
raise TypeError(
|
|
218
|
+
"`kernel` must be a kernel name (str) or a callable taking the distance "
|
|
219
|
+
f"array, got {type(kernel).__name__}."
|
|
220
|
+
)
|
rbfgpu/_polynomial.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Polynomial tail of the RBF interpolant.
|
|
3
|
+
|
|
4
|
+
The RBF part alone cannot extrapolate and, for conditionally positive definite
|
|
5
|
+
kernels, is not even well posed: a polynomial of degree ``degree`` is added to
|
|
6
|
+
the interpolant and constrained to be orthogonal to the RBF coefficients
|
|
7
|
+
(``P(y)^T a = 0``). The basis used here is the plain monomial basis
|
|
8
|
+
``x**p`` with ``|p| <= degree``.
|
|
9
|
+
|
|
10
|
+
Any basis spanning the same polynomial space gives the same interpolant, so the
|
|
11
|
+
column order below is arbitrary (it differs from SciPy's, which also shifts and
|
|
12
|
+
scales the coordinates): the coefficients are consistent because the very same
|
|
13
|
+
``powers`` are used to build and to evaluate the system.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import itertools
|
|
19
|
+
|
|
20
|
+
from ._backend import xp
|
|
21
|
+
|
|
22
|
+
__all__ = ["monomial_exponents", "polynomial_matrix"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def monomial_exponents(ndim: int, degree: int) -> tuple:
|
|
26
|
+
"""
|
|
27
|
+
Exponent tuples of every monomial of total degree <= ``degree``.
|
|
28
|
+
|
|
29
|
+
Parameters
|
|
30
|
+
----------
|
|
31
|
+
ndim : int
|
|
32
|
+
Number of coordinates of the points.
|
|
33
|
+
degree : int
|
|
34
|
+
Polynomial degree. ``-1`` means "no polynomial terms at all", ``0``
|
|
35
|
+
only the constant term.
|
|
36
|
+
|
|
37
|
+
Returns
|
|
38
|
+
-------
|
|
39
|
+
powers : tuple of tuple of int
|
|
40
|
+
``len(powers)`` is the number of polynomial terms, i.e.
|
|
41
|
+
``C(ndim + degree, degree)``. Empty for ``degree == -1``.
|
|
42
|
+
|
|
43
|
+
Raises
|
|
44
|
+
------
|
|
45
|
+
ValueError
|
|
46
|
+
If ``degree < -1``, or ``ndim < 1``.
|
|
47
|
+
"""
|
|
48
|
+
if not isinstance(degree, (int,)) or isinstance(degree, bool):
|
|
49
|
+
degree = int(degree)
|
|
50
|
+
if degree < -1:
|
|
51
|
+
raise ValueError(f"`degree` must be >= -1 (-1 means no polynomial), got {degree}.")
|
|
52
|
+
if ndim < 1:
|
|
53
|
+
raise ValueError(f"`ndim` must be >= 1, got {ndim}.")
|
|
54
|
+
if degree == -1:
|
|
55
|
+
return ()
|
|
56
|
+
|
|
57
|
+
return tuple(
|
|
58
|
+
powers
|
|
59
|
+
for powers in itertools.product(range(degree + 1), repeat=ndim)
|
|
60
|
+
if sum(powers) <= degree
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def polynomial_matrix(x, powers) -> object:
|
|
65
|
+
"""
|
|
66
|
+
Evaluate a monomial basis at the points ``x``.
|
|
67
|
+
|
|
68
|
+
Parameters
|
|
69
|
+
----------
|
|
70
|
+
x : array, shape (n, d)
|
|
71
|
+
Point coordinates.
|
|
72
|
+
powers : sequence of tuple of int
|
|
73
|
+
Exponents of the monomials, as returned by :func:`monomial_exponents`.
|
|
74
|
+
|
|
75
|
+
Returns
|
|
76
|
+
-------
|
|
77
|
+
P : array, shape (n, len(powers))
|
|
78
|
+
``P[i, j]`` is ``prod_k x[i, k] ** powers[j][k]``. For an empty
|
|
79
|
+
``powers`` an ``(n, 0)`` array is returned.
|
|
80
|
+
"""
|
|
81
|
+
x = xp.asarray(x)
|
|
82
|
+
n, ndim = x.shape
|
|
83
|
+
|
|
84
|
+
if len(powers) == 0:
|
|
85
|
+
return xp.empty((n, 0), dtype=x.dtype)
|
|
86
|
+
|
|
87
|
+
columns = []
|
|
88
|
+
for exponents in powers:
|
|
89
|
+
if len(exponents) != ndim:
|
|
90
|
+
raise ValueError(
|
|
91
|
+
f"`powers` holds {len(exponents)}-dimensional exponents but `x` "
|
|
92
|
+
f"has {ndim} columns."
|
|
93
|
+
)
|
|
94
|
+
column = xp.ones(n, dtype=x.dtype)
|
|
95
|
+
for axis, exponent in enumerate(exponents):
|
|
96
|
+
if exponent == 0:
|
|
97
|
+
continue
|
|
98
|
+
if exponent == 1:
|
|
99
|
+
column = column * x[:, axis]
|
|
100
|
+
else:
|
|
101
|
+
column = column * x[:, axis] ** exponent
|
|
102
|
+
columns.append(column)
|
|
103
|
+
|
|
104
|
+
return xp.column_stack(columns)
|