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