|
"""Module for RBF interpolation.""" |
|
import warnings |
|
from itertools import combinations_with_replacement |
|
|
|
import numpy as np |
|
from numpy.linalg import LinAlgError |
|
from scipy.spatial import KDTree |
|
from scipy.special import comb |
|
from scipy.linalg.lapack import dgesv |
|
|
|
from ._rbfinterp_pythran import (_build_system, |
|
_build_evaluation_coefficients, |
|
_polynomial_matrix) |
|
|
|
|
|
__all__ = ["RBFInterpolator"] |
|
|
|
|
|
|
|
_AVAILABLE = { |
|
"linear", |
|
"thin_plate_spline", |
|
"cubic", |
|
"quintic", |
|
"multiquadric", |
|
"inverse_multiquadric", |
|
"inverse_quadratic", |
|
"gaussian" |
|
} |
|
|
|
|
|
|
|
_SCALE_INVARIANT = {"linear", "thin_plate_spline", "cubic", "quintic"} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_NAME_TO_MIN_DEGREE = { |
|
"multiquadric": 0, |
|
"linear": 0, |
|
"thin_plate_spline": 1, |
|
"cubic": 1, |
|
"quintic": 2 |
|
} |
|
|
|
|
|
def _monomial_powers(ndim, degree): |
|
"""Return the powers for each monomial in a polynomial. |
|
|
|
Parameters |
|
---------- |
|
ndim : int |
|
Number of variables in the polynomial. |
|
degree : int |
|
Degree of the polynomial. |
|
|
|
Returns |
|
------- |
|
(nmonos, ndim) int ndarray |
|
Array where each row contains the powers for each variable in a |
|
monomial. |
|
|
|
""" |
|
nmonos = comb(degree + ndim, ndim, exact=True) |
|
out = np.zeros((nmonos, ndim), dtype=np.dtype("long")) |
|
count = 0 |
|
for deg in range(degree + 1): |
|
for mono in combinations_with_replacement(range(ndim), deg): |
|
|
|
|
|
for var in mono: |
|
out[count, var] += 1 |
|
|
|
count += 1 |
|
|
|
return out |
|
|
|
|
|
def _build_and_solve_system(y, d, smoothing, kernel, epsilon, powers): |
|
"""Build and solve the RBF interpolation system of equations. |
|
|
|
Parameters |
|
---------- |
|
y : (P, N) float ndarray |
|
Data point coordinates. |
|
d : (P, S) float ndarray |
|
Data values at `y`. |
|
smoothing : (P,) float ndarray |
|
Smoothing parameter for each data point. |
|
kernel : str |
|
Name of the RBF. |
|
epsilon : float |
|
Shape parameter. |
|
powers : (R, N) int ndarray |
|
The exponents for each monomial in the polynomial. |
|
|
|
Returns |
|
------- |
|
coeffs : (P + R, S) float ndarray |
|
Coefficients for each RBF and monomial. |
|
shift : (N,) float ndarray |
|
Domain shift used to create the polynomial matrix. |
|
scale : (N,) float ndarray |
|
Domain scaling used to create the polynomial matrix. |
|
|
|
""" |
|
lhs, rhs, shift, scale = _build_system( |
|
y, d, smoothing, kernel, epsilon, powers |
|
) |
|
_, _, coeffs, info = dgesv(lhs, rhs, overwrite_a=True, overwrite_b=True) |
|
if info < 0: |
|
raise ValueError(f"The {-info}-th argument had an illegal value.") |
|
elif info > 0: |
|
msg = "Singular matrix." |
|
nmonos = powers.shape[0] |
|
if nmonos > 0: |
|
pmat = _polynomial_matrix((y - shift)/scale, powers) |
|
rank = np.linalg.matrix_rank(pmat) |
|
if rank < nmonos: |
|
msg = ( |
|
"Singular matrix. The matrix of monomials evaluated at " |
|
"the data point coordinates does not have full column " |
|
f"rank ({rank}/{nmonos})." |
|
) |
|
|
|
raise LinAlgError(msg) |
|
|
|
return shift, scale, coeffs |
|
|
|
|
|
class RBFInterpolator: |
|
"""Radial basis function (RBF) interpolation in N dimensions. |
|
|
|
Parameters |
|
---------- |
|
y : (npoints, ndims) array_like |
|
2-D array of data point coordinates. |
|
d : (npoints, ...) array_like |
|
N-D array of data values at `y`. The length of `d` along the first |
|
axis must be equal to the length of `y`. Unlike some interpolators, the |
|
interpolation axis cannot be changed. |
|
neighbors : int, optional |
|
If specified, the value of the interpolant at each evaluation point |
|
will be computed using only this many nearest data points. All the data |
|
points are used by default. |
|
smoothing : float or (npoints, ) array_like, optional |
|
Smoothing parameter. The interpolant perfectly fits the data when this |
|
is set to 0. For large values, the interpolant approaches a least |
|
squares fit of a polynomial with the specified degree. Default is 0. |
|
kernel : str, optional |
|
Type of RBF. This should be one of |
|
|
|
- 'linear' : ``-r`` |
|
- 'thin_plate_spline' : ``r**2 * log(r)`` |
|
- 'cubic' : ``r**3`` |
|
- 'quintic' : ``-r**5`` |
|
- 'multiquadric' : ``-sqrt(1 + r**2)`` |
|
- 'inverse_multiquadric' : ``1/sqrt(1 + r**2)`` |
|
- 'inverse_quadratic' : ``1/(1 + r**2)`` |
|
- 'gaussian' : ``exp(-r**2)`` |
|
|
|
Default is 'thin_plate_spline'. |
|
epsilon : float, optional |
|
Shape parameter that scales the input to the RBF. If `kernel` is |
|
'linear', 'thin_plate_spline', 'cubic', or 'quintic', this defaults to |
|
1 and can be ignored because it has the same effect as scaling the |
|
smoothing parameter. Otherwise, this must be specified. |
|
degree : int, optional |
|
Degree of the added polynomial. For some RBFs the interpolant may not |
|
be well-posed if the polynomial degree is too small. Those RBFs and |
|
their corresponding minimum degrees are |
|
|
|
- 'multiquadric' : 0 |
|
- 'linear' : 0 |
|
- 'thin_plate_spline' : 1 |
|
- 'cubic' : 1 |
|
- 'quintic' : 2 |
|
|
|
The default value is the minimum degree for `kernel` or 0 if there is |
|
no minimum degree. Set this to -1 for no added polynomial. |
|
|
|
Notes |
|
----- |
|
An RBF is a scalar valued function in N-dimensional space whose value at |
|
:math:`x` can be expressed in terms of :math:`r=||x - c||`, where :math:`c` |
|
is the center of the RBF. |
|
|
|
An RBF interpolant for the vector of data values :math:`d`, which are from |
|
locations :math:`y`, is a linear combination of RBFs centered at :math:`y` |
|
plus a polynomial with a specified degree. The RBF interpolant is written |
|
as |
|
|
|
.. math:: |
|
f(x) = K(x, y) a + P(x) b, |
|
|
|
where :math:`K(x, y)` is a matrix of RBFs with centers at :math:`y` |
|
evaluated at the points :math:`x`, and :math:`P(x)` is a matrix of |
|
monomials, which span polynomials with the specified degree, evaluated at |
|
:math:`x`. The coefficients :math:`a` and :math:`b` are the solution to the |
|
linear equations |
|
|
|
.. math:: |
|
(K(y, y) + \\lambda I) a + P(y) b = d |
|
|
|
and |
|
|
|
.. math:: |
|
P(y)^T a = 0, |
|
|
|
where :math:`\\lambda` is a non-negative smoothing parameter that controls |
|
how well we want to fit the data. The data are fit exactly when the |
|
smoothing parameter is 0. |
|
|
|
The above system is uniquely solvable if the following requirements are |
|
met: |
|
|
|
- :math:`P(y)` must have full column rank. :math:`P(y)` always has full |
|
column rank when `degree` is -1 or 0. When `degree` is 1, |
|
:math:`P(y)` has full column rank if the data point locations are not |
|
all collinear (N=2), coplanar (N=3), etc. |
|
- If `kernel` is 'multiquadric', 'linear', 'thin_plate_spline', |
|
'cubic', or 'quintic', then `degree` must not be lower than the |
|
minimum value listed above. |
|
- If `smoothing` is 0, then each data point location must be distinct. |
|
|
|
When using an RBF that is not scale invariant ('multiquadric', |
|
'inverse_multiquadric', 'inverse_quadratic', or 'gaussian'), an appropriate |
|
shape parameter must be chosen (e.g., through cross validation). Smaller |
|
values for the shape parameter correspond to wider RBFs. The problem can |
|
become ill-conditioned or singular when the shape parameter is too small. |
|
|
|
The memory required to solve for the RBF interpolation coefficients |
|
increases quadratically with the number of data points, which can become |
|
impractical when interpolating more than about a thousand data points. |
|
To overcome memory limitations for large interpolation problems, the |
|
`neighbors` argument can be specified to compute an RBF interpolant for |
|
each evaluation point using only the nearest data points. |
|
|
|
.. versionadded:: 1.7.0 |
|
|
|
See Also |
|
-------- |
|
NearestNDInterpolator |
|
LinearNDInterpolator |
|
CloughTocher2DInterpolator |
|
|
|
References |
|
---------- |
|
.. [1] Fasshauer, G., 2007. Meshfree Approximation Methods with Matlab. |
|
World Scientific Publishing Co. |
|
|
|
.. [2] http://amadeus.math.iit.edu/~fass/603_ch3.pdf |
|
|
|
.. [3] Wahba, G., 1990. Spline Models for Observational Data. SIAM. |
|
|
|
.. [4] http://pages.stat.wisc.edu/~wahba/stat860public/lect/lect8/lect8.pdf |
|
|
|
Examples |
|
-------- |
|
Demonstrate interpolating scattered data to a grid in 2-D. |
|
|
|
>>> import numpy as np |
|
>>> import matplotlib.pyplot as plt |
|
>>> from scipy.interpolate import RBFInterpolator |
|
>>> from scipy.stats.qmc import Halton |
|
|
|
>>> rng = np.random.default_rng() |
|
>>> xobs = 2*Halton(2, seed=rng).random(100) - 1 |
|
>>> yobs = np.sum(xobs, axis=1)*np.exp(-6*np.sum(xobs**2, axis=1)) |
|
|
|
>>> xgrid = np.mgrid[-1:1:50j, -1:1:50j] |
|
>>> xflat = xgrid.reshape(2, -1).T |
|
>>> yflat = RBFInterpolator(xobs, yobs)(xflat) |
|
>>> ygrid = yflat.reshape(50, 50) |
|
|
|
>>> fig, ax = plt.subplots() |
|
>>> ax.pcolormesh(*xgrid, ygrid, vmin=-0.25, vmax=0.25, shading='gouraud') |
|
>>> p = ax.scatter(*xobs.T, c=yobs, s=50, ec='k', vmin=-0.25, vmax=0.25) |
|
>>> fig.colorbar(p) |
|
>>> plt.show() |
|
|
|
""" |
|
|
|
def __init__(self, y, d, |
|
neighbors=None, |
|
smoothing=0.0, |
|
kernel="thin_plate_spline", |
|
epsilon=None, |
|
degree=None): |
|
y = np.asarray(y, dtype=float, order="C") |
|
if y.ndim != 2: |
|
raise ValueError("`y` must be a 2-dimensional array.") |
|
|
|
ny, ndim = y.shape |
|
|
|
d_dtype = complex if np.iscomplexobj(d) else float |
|
d = np.asarray(d, dtype=d_dtype, order="C") |
|
if d.shape[0] != ny: |
|
raise ValueError( |
|
f"Expected the first axis of `d` to have length {ny}." |
|
) |
|
|
|
d_shape = d.shape[1:] |
|
d = d.reshape((ny, -1)) |
|
|
|
|
|
|
|
d = d.view(float) |
|
|
|
if np.isscalar(smoothing): |
|
smoothing = np.full(ny, smoothing, dtype=float) |
|
else: |
|
smoothing = np.asarray(smoothing, dtype=float, order="C") |
|
if smoothing.shape != (ny,): |
|
raise ValueError( |
|
"Expected `smoothing` to be a scalar or have shape " |
|
f"({ny},)." |
|
) |
|
|
|
kernel = kernel.lower() |
|
if kernel not in _AVAILABLE: |
|
raise ValueError(f"`kernel` must be one of {_AVAILABLE}.") |
|
|
|
if epsilon is None: |
|
if kernel in _SCALE_INVARIANT: |
|
epsilon = 1.0 |
|
else: |
|
raise ValueError( |
|
"`epsilon` must be specified if `kernel` is not one of " |
|
f"{_SCALE_INVARIANT}." |
|
) |
|
else: |
|
epsilon = float(epsilon) |
|
|
|
min_degree = _NAME_TO_MIN_DEGREE.get(kernel, -1) |
|
if degree is None: |
|
degree = max(min_degree, 0) |
|
else: |
|
degree = int(degree) |
|
if degree < -1: |
|
raise ValueError("`degree` must be at least -1.") |
|
elif -1 < degree < min_degree: |
|
warnings.warn( |
|
f"`degree` should not be below {min_degree} except -1 " |
|
f"when `kernel` is '{kernel}'." |
|
f"The interpolant may not be uniquely " |
|
f"solvable, and the smoothing parameter may have an " |
|
f"unintuitive effect.", |
|
UserWarning, stacklevel=2 |
|
) |
|
|
|
if neighbors is None: |
|
nobs = ny |
|
else: |
|
|
|
|
|
neighbors = int(min(neighbors, ny)) |
|
nobs = neighbors |
|
|
|
powers = _monomial_powers(ndim, degree) |
|
|
|
|
|
|
|
if powers.shape[0] > nobs: |
|
raise ValueError( |
|
f"At least {powers.shape[0]} data points are required when " |
|
f"`degree` is {degree} and the number of dimensions is {ndim}." |
|
) |
|
|
|
if neighbors is None: |
|
shift, scale, coeffs = _build_and_solve_system( |
|
y, d, smoothing, kernel, epsilon, powers |
|
) |
|
|
|
|
|
self._shift = shift |
|
self._scale = scale |
|
self._coeffs = coeffs |
|
|
|
else: |
|
self._tree = KDTree(y) |
|
|
|
self.y = y |
|
self.d = d |
|
self.d_shape = d_shape |
|
self.d_dtype = d_dtype |
|
self.neighbors = neighbors |
|
self.smoothing = smoothing |
|
self.kernel = kernel |
|
self.epsilon = epsilon |
|
self.powers = powers |
|
|
|
def _chunk_evaluator( |
|
self, |
|
x, |
|
y, |
|
shift, |
|
scale, |
|
coeffs, |
|
memory_budget=1000000 |
|
): |
|
""" |
|
Evaluate the interpolation while controlling memory consumption. |
|
We chunk the input if we need more memory than specified. |
|
|
|
Parameters |
|
---------- |
|
x : (Q, N) float ndarray |
|
array of points on which to evaluate |
|
y: (P, N) float ndarray |
|
array of points on which we know function values |
|
shift: (N, ) ndarray |
|
Domain shift used to create the polynomial matrix. |
|
scale : (N,) float ndarray |
|
Domain scaling used to create the polynomial matrix. |
|
coeffs: (P+R, S) float ndarray |
|
Coefficients in front of basis functions |
|
memory_budget: int |
|
Total amount of memory (in units of sizeof(float)) we wish |
|
to devote for storing the array of coefficients for |
|
interpolated points. If we need more memory than that, we |
|
chunk the input. |
|
|
|
Returns |
|
------- |
|
(Q, S) float ndarray |
|
Interpolated array |
|
""" |
|
nx, ndim = x.shape |
|
if self.neighbors is None: |
|
nnei = len(y) |
|
else: |
|
nnei = self.neighbors |
|
|
|
chunksize = memory_budget // (self.powers.shape[0] + nnei) + 1 |
|
if chunksize <= nx: |
|
out = np.empty((nx, self.d.shape[1]), dtype=float) |
|
for i in range(0, nx, chunksize): |
|
vec = _build_evaluation_coefficients( |
|
x[i:i + chunksize, :], |
|
y, |
|
self.kernel, |
|
self.epsilon, |
|
self.powers, |
|
shift, |
|
scale) |
|
out[i:i + chunksize, :] = np.dot(vec, coeffs) |
|
else: |
|
vec = _build_evaluation_coefficients( |
|
x, |
|
y, |
|
self.kernel, |
|
self.epsilon, |
|
self.powers, |
|
shift, |
|
scale) |
|
out = np.dot(vec, coeffs) |
|
return out |
|
|
|
def __call__(self, x): |
|
"""Evaluate the interpolant at `x`. |
|
|
|
Parameters |
|
---------- |
|
x : (Q, N) array_like |
|
Evaluation point coordinates. |
|
|
|
Returns |
|
------- |
|
(Q, ...) ndarray |
|
Values of the interpolant at `x`. |
|
|
|
""" |
|
x = np.asarray(x, dtype=float, order="C") |
|
if x.ndim != 2: |
|
raise ValueError("`x` must be a 2-dimensional array.") |
|
|
|
nx, ndim = x.shape |
|
if ndim != self.y.shape[1]: |
|
raise ValueError("Expected the second axis of `x` to have length " |
|
f"{self.y.shape[1]}.") |
|
|
|
|
|
|
|
|
|
|
|
|
|
memory_budget = max(x.size + self.y.size + self.d.size, 1000000) |
|
|
|
if self.neighbors is None: |
|
out = self._chunk_evaluator( |
|
x, |
|
self.y, |
|
self._shift, |
|
self._scale, |
|
self._coeffs, |
|
memory_budget=memory_budget) |
|
else: |
|
|
|
|
|
_, yindices = self._tree.query(x, self.neighbors) |
|
if self.neighbors == 1: |
|
|
|
yindices = yindices[:, None] |
|
|
|
|
|
|
|
|
|
|
|
yindices = np.sort(yindices, axis=1) |
|
yindices, inv = np.unique(yindices, return_inverse=True, axis=0) |
|
inv = np.reshape(inv, (-1,)) |
|
|
|
|
|
|
|
xindices = [[] for _ in range(len(yindices))] |
|
for i, j in enumerate(inv): |
|
xindices[j].append(i) |
|
|
|
out = np.empty((nx, self.d.shape[1]), dtype=float) |
|
for xidx, yidx in zip(xindices, yindices): |
|
|
|
|
|
|
|
xnbr = x[xidx] |
|
ynbr = self.y[yidx] |
|
dnbr = self.d[yidx] |
|
snbr = self.smoothing[yidx] |
|
shift, scale, coeffs = _build_and_solve_system( |
|
ynbr, |
|
dnbr, |
|
snbr, |
|
self.kernel, |
|
self.epsilon, |
|
self.powers, |
|
) |
|
out[xidx] = self._chunk_evaluator( |
|
xnbr, |
|
ynbr, |
|
shift, |
|
scale, |
|
coeffs, |
|
memory_budget=memory_budget) |
|
|
|
out = out.view(self.d_dtype) |
|
out = out.reshape((nx, ) + self.d_shape) |
|
return out |
|
|