Coverage for /usr/lib/python3/dist-packages/scipy/interpolate/_rbfinterp.py: 13%

136 statements  

« prev     ^ index     » next       coverage.py v7.9.1, created at 2025-06-14 15:55 +0200

1"""Module for RBF interpolation.""" 

2import warnings 

3from itertools import combinations_with_replacement 

4 

5import numpy as np 

6from numpy.linalg import LinAlgError 

7from scipy.spatial import KDTree 

8from scipy.special import comb 

9from scipy.linalg.lapack import dgesv # type: ignore[attr-defined] 

10 

11from ._rbfinterp_pythran import (_build_system, 

12 _build_evaluation_coefficients, 

13 _polynomial_matrix) 

14 

15 

16__all__ = ["RBFInterpolator"] 

17 

18 

19# These RBFs are implemented. 

20_AVAILABLE = { 

21 "linear", 

22 "thin_plate_spline", 

23 "cubic", 

24 "quintic", 

25 "multiquadric", 

26 "inverse_multiquadric", 

27 "inverse_quadratic", 

28 "gaussian" 

29 } 

30 

31 

32# The shape parameter does not need to be specified when using these RBFs. 

33_SCALE_INVARIANT = {"linear", "thin_plate_spline", "cubic", "quintic"} 

34 

35 

36# For RBFs that are conditionally positive definite of order m, the interpolant 

37# should include polynomial terms with degree >= m - 1. Define the minimum 

38# degrees here. These values are from Chapter 8 of Fasshauer's "Meshfree 

39# Approximation Methods with MATLAB". The RBFs that are not in this dictionary 

40# are positive definite and do not need polynomial terms. 

41_NAME_TO_MIN_DEGREE = { 

42 "multiquadric": 0, 

43 "linear": 0, 

44 "thin_plate_spline": 1, 

45 "cubic": 1, 

46 "quintic": 2 

47 } 

48 

49 

50def _monomial_powers(ndim, degree): 

51 """Return the powers for each monomial in a polynomial. 

52 

53 Parameters 

54 ---------- 

55 ndim : int 

56 Number of variables in the polynomial. 

57 degree : int 

58 Degree of the polynomial. 

59 

60 Returns 

61 ------- 

62 (nmonos, ndim) int ndarray 

63 Array where each row contains the powers for each variable in a 

64 monomial. 

65 

66 """ 

67 nmonos = comb(degree + ndim, ndim, exact=True) 

68 out = np.zeros((nmonos, ndim), dtype=int) 

69 count = 0 

70 for deg in range(degree + 1): 

71 for mono in combinations_with_replacement(range(ndim), deg): 

72 # `mono` is a tuple of variables in the current monomial with 

73 # multiplicity indicating power (e.g., (0, 1, 1) represents x*y**2) 

74 for var in mono: 

75 out[count, var] += 1 

76 

77 count += 1 

78 

79 return out 

80 

81 

82def _build_and_solve_system(y, d, smoothing, kernel, epsilon, powers): 

83 """Build and solve the RBF interpolation system of equations. 

84 

85 Parameters 

86 ---------- 

87 y : (P, N) float ndarray 

88 Data point coordinates. 

89 d : (P, S) float ndarray 

90 Data values at `y`. 

91 smoothing : (P,) float ndarray 

92 Smoothing parameter for each data point. 

93 kernel : str 

94 Name of the RBF. 

95 epsilon : float 

96 Shape parameter. 

97 powers : (R, N) int ndarray 

98 The exponents for each monomial in the polynomial. 

99 

100 Returns 

101 ------- 

102 coeffs : (P + R, S) float ndarray 

103 Coefficients for each RBF and monomial. 

104 shift : (N,) float ndarray 

105 Domain shift used to create the polynomial matrix. 

106 scale : (N,) float ndarray 

107 Domain scaling used to create the polynomial matrix. 

108 

109 """ 

110 lhs, rhs, shift, scale = _build_system( 

111 y, d, smoothing, kernel, epsilon, powers 

112 ) 

113 _, _, coeffs, info = dgesv(lhs, rhs, overwrite_a=True, overwrite_b=True) 

114 if info < 0: 

115 raise ValueError(f"The {-info}-th argument had an illegal value.") 

116 elif info > 0: 

117 msg = "Singular matrix." 

118 nmonos = powers.shape[0] 

119 if nmonos > 0: 

120 pmat = _polynomial_matrix((y - shift)/scale, powers) 

121 rank = np.linalg.matrix_rank(pmat) 

122 if rank < nmonos: 

123 msg = ( 

124 "Singular matrix. The matrix of monomials evaluated at " 

125 "the data point coordinates does not have full column " 

126 f"rank ({rank}/{nmonos})." 

127 ) 

128 

129 raise LinAlgError(msg) 

130 

131 return shift, scale, coeffs 

132 

133 

134class RBFInterpolator: 

135 """Radial basis function (RBF) interpolation in N dimensions. 

136 

137 Parameters 

138 ---------- 

139 y : (npoints, ndims) array_like 

140 2-D array of data point coordinates. 

141 d : (npoints, ...) array_like 

142 N-D array of data values at `y`. The length of `d` along the first 

143 axis must be equal to the length of `y`. Unlike some interpolators, the 

144 interpolation axis cannot be changed. 

145 neighbors : int, optional 

146 If specified, the value of the interpolant at each evaluation point 

147 will be computed using only this many nearest data points. All the data 

148 points are used by default. 

149 smoothing : float or (npoints, ) array_like, optional 

150 Smoothing parameter. The interpolant perfectly fits the data when this 

151 is set to 0. For large values, the interpolant approaches a least 

152 squares fit of a polynomial with the specified degree. Default is 0. 

153 kernel : str, optional 

154 Type of RBF. This should be one of 

155 

156 - 'linear' : ``-r`` 

157 - 'thin_plate_spline' : ``r**2 * log(r)`` 

158 - 'cubic' : ``r**3`` 

159 - 'quintic' : ``-r**5`` 

160 - 'multiquadric' : ``-sqrt(1 + r**2)`` 

161 - 'inverse_multiquadric' : ``1/sqrt(1 + r**2)`` 

162 - 'inverse_quadratic' : ``1/(1 + r**2)`` 

163 - 'gaussian' : ``exp(-r**2)`` 

164 

165 Default is 'thin_plate_spline'. 

166 epsilon : float, optional 

167 Shape parameter that scales the input to the RBF. If `kernel` is 

168 'linear', 'thin_plate_spline', 'cubic', or 'quintic', this defaults to 

169 1 and can be ignored because it has the same effect as scaling the 

170 smoothing parameter. Otherwise, this must be specified. 

171 degree : int, optional 

172 Degree of the added polynomial. For some RBFs the interpolant may not 

173 be well-posed if the polynomial degree is too small. Those RBFs and 

174 their corresponding minimum degrees are 

175 

176 - 'multiquadric' : 0 

177 - 'linear' : 0 

178 - 'thin_plate_spline' : 1 

179 - 'cubic' : 1 

180 - 'quintic' : 2 

181 

182 The default value is the minimum degree for `kernel` or 0 if there is 

183 no minimum degree. Set this to -1 for no added polynomial. 

184 

185 Notes 

186 ----- 

187 An RBF is a scalar valued function in N-dimensional space whose value at 

188 :math:`x` can be expressed in terms of :math:`r=||x - c||`, where :math:`c` 

189 is the center of the RBF. 

190 

191 An RBF interpolant for the vector of data values :math:`d`, which are from 

192 locations :math:`y`, is a linear combination of RBFs centered at :math:`y` 

193 plus a polynomial with a specified degree. The RBF interpolant is written 

194 as 

195 

196 .. math:: 

197 f(x) = K(x, y) a + P(x) b, 

198 

199 where :math:`K(x, y)` is a matrix of RBFs with centers at :math:`y` 

200 evaluated at the points :math:`x`, and :math:`P(x)` is a matrix of 

201 monomials, which span polynomials with the specified degree, evaluated at 

202 :math:`x`. The coefficients :math:`a` and :math:`b` are the solution to the 

203 linear equations 

204 

205 .. math:: 

206 (K(y, y) + \\lambda I) a + P(y) b = d 

207 

208 and 

209 

210 .. math:: 

211 P(y)^T a = 0, 

212 

213 where :math:`\\lambda` is a non-negative smoothing parameter that controls 

214 how well we want to fit the data. The data are fit exactly when the 

215 smoothing parameter is 0. 

216 

217 The above system is uniquely solvable if the following requirements are 

218 met: 

219 

220 - :math:`P(y)` must have full column rank. :math:`P(y)` always has full 

221 column rank when `degree` is -1 or 0. When `degree` is 1, 

222 :math:`P(y)` has full column rank if the data point locations are not 

223 all collinear (N=2), coplanar (N=3), etc. 

224 - If `kernel` is 'multiquadric', 'linear', 'thin_plate_spline', 

225 'cubic', or 'quintic', then `degree` must not be lower than the 

226 minimum value listed above. 

227 - If `smoothing` is 0, then each data point location must be distinct. 

228 

229 When using an RBF that is not scale invariant ('multiquadric', 

230 'inverse_multiquadric', 'inverse_quadratic', or 'gaussian'), an appropriate 

231 shape parameter must be chosen (e.g., through cross validation). Smaller 

232 values for the shape parameter correspond to wider RBFs. The problem can 

233 become ill-conditioned or singular when the shape parameter is too small. 

234 

235 The memory required to solve for the RBF interpolation coefficients 

236 increases quadratically with the number of data points, which can become 

237 impractical when interpolating more than about a thousand data points. 

238 To overcome memory limitations for large interpolation problems, the 

239 `neighbors` argument can be specified to compute an RBF interpolant for 

240 each evaluation point using only the nearest data points. 

241 

242 .. versionadded:: 1.7.0 

243 

244 See Also 

245 -------- 

246 NearestNDInterpolator 

247 LinearNDInterpolator 

248 CloughTocher2DInterpolator 

249 

250 References 

251 ---------- 

252 .. [1] Fasshauer, G., 2007. Meshfree Approximation Methods with Matlab. 

253 World Scientific Publishing Co. 

254 

255 .. [2] http://amadeus.math.iit.edu/~fass/603_ch3.pdf 

256 

257 .. [3] Wahba, G., 1990. Spline Models for Observational Data. SIAM. 

258 

259 .. [4] http://pages.stat.wisc.edu/~wahba/stat860public/lect/lect8/lect8.pdf 

260 

261 Examples 

262 -------- 

263 Demonstrate interpolating scattered data to a grid in 2-D. 

264 

265 >>> import numpy as np 

266 >>> import matplotlib.pyplot as plt 

267 >>> from scipy.interpolate import RBFInterpolator 

268 >>> from scipy.stats.qmc import Halton 

269 

270 >>> rng = np.random.default_rng() 

271 >>> xobs = 2*Halton(2, seed=rng).random(100) - 1 

272 >>> yobs = np.sum(xobs, axis=1)*np.exp(-6*np.sum(xobs**2, axis=1)) 

273 

274 >>> xgrid = np.mgrid[-1:1:50j, -1:1:50j] 

275 >>> xflat = xgrid.reshape(2, -1).T 

276 >>> yflat = RBFInterpolator(xobs, yobs)(xflat) 

277 >>> ygrid = yflat.reshape(50, 50) 

278 

279 >>> fig, ax = plt.subplots() 

280 >>> ax.pcolormesh(*xgrid, ygrid, vmin=-0.25, vmax=0.25, shading='gouraud') 

281 >>> p = ax.scatter(*xobs.T, c=yobs, s=50, ec='k', vmin=-0.25, vmax=0.25) 

282 >>> fig.colorbar(p) 

283 >>> plt.show() 

284 

285 """ 

286 

287 def __init__(self, y, d, 

288 neighbors=None, 

289 smoothing=0.0, 

290 kernel="thin_plate_spline", 

291 epsilon=None, 

292 degree=None): 

293 y = np.asarray(y, dtype=float, order="C") 

294 if y.ndim != 2: 

295 raise ValueError("`y` must be a 2-dimensional array.") 

296 

297 ny, ndim = y.shape 

298 

299 d_dtype = complex if np.iscomplexobj(d) else float 

300 d = np.asarray(d, dtype=d_dtype, order="C") 

301 if d.shape[0] != ny: 

302 raise ValueError( 

303 f"Expected the first axis of `d` to have length {ny}." 

304 ) 

305 

306 d_shape = d.shape[1:] 

307 d = d.reshape((ny, -1)) 

308 # If `d` is complex, convert it to a float array with twice as many 

309 # columns. Otherwise, the LHS matrix would need to be converted to 

310 # complex and take up 2x more memory than necessary. 

311 d = d.view(float) 

312 

313 if np.isscalar(smoothing): 

314 smoothing = np.full(ny, smoothing, dtype=float) 

315 else: 

316 smoothing = np.asarray(smoothing, dtype=float, order="C") 

317 if smoothing.shape != (ny,): 

318 raise ValueError( 

319 "Expected `smoothing` to be a scalar or have shape " 

320 f"({ny},)." 

321 ) 

322 

323 kernel = kernel.lower() 

324 if kernel not in _AVAILABLE: 

325 raise ValueError(f"`kernel` must be one of {_AVAILABLE}.") 

326 

327 if epsilon is None: 

328 if kernel in _SCALE_INVARIANT: 

329 epsilon = 1.0 

330 else: 

331 raise ValueError( 

332 "`epsilon` must be specified if `kernel` is not one of " 

333 f"{_SCALE_INVARIANT}." 

334 ) 

335 else: 

336 epsilon = float(epsilon) 

337 

338 min_degree = _NAME_TO_MIN_DEGREE.get(kernel, -1) 

339 if degree is None: 

340 degree = max(min_degree, 0) 

341 else: 

342 degree = int(degree) 

343 if degree < -1: 

344 raise ValueError("`degree` must be at least -1.") 

345 elif degree < min_degree: 

346 warnings.warn( 

347 f"`degree` should not be below {min_degree} when `kernel` " 

348 f"is '{kernel}'. The interpolant may not be uniquely " 

349 "solvable, and the smoothing parameter may have an " 

350 "unintuitive effect.", 

351 UserWarning 

352 ) 

353 

354 if neighbors is None: 

355 nobs = ny 

356 else: 

357 # Make sure the number of nearest neighbors used for interpolation 

358 # does not exceed the number of observations. 

359 neighbors = int(min(neighbors, ny)) 

360 nobs = neighbors 

361 

362 powers = _monomial_powers(ndim, degree) 

363 # The polynomial matrix must have full column rank in order for the 

364 # interpolant to be well-posed, which is not possible if there are 

365 # fewer observations than monomials. 

366 if powers.shape[0] > nobs: 

367 raise ValueError( 

368 f"At least {powers.shape[0]} data points are required when " 

369 f"`degree` is {degree} and the number of dimensions is {ndim}." 

370 ) 

371 

372 if neighbors is None: 

373 shift, scale, coeffs = _build_and_solve_system( 

374 y, d, smoothing, kernel, epsilon, powers 

375 ) 

376 

377 # Make these attributes private since they do not always exist. 

378 self._shift = shift 

379 self._scale = scale 

380 self._coeffs = coeffs 

381 

382 else: 

383 self._tree = KDTree(y) 

384 

385 self.y = y 

386 self.d = d 

387 self.d_shape = d_shape 

388 self.d_dtype = d_dtype 

389 self.neighbors = neighbors 

390 self.smoothing = smoothing 

391 self.kernel = kernel 

392 self.epsilon = epsilon 

393 self.powers = powers 

394 

395 def _chunk_evaluator( 

396 self, 

397 x, 

398 y, 

399 shift, 

400 scale, 

401 coeffs, 

402 memory_budget=1000000 

403 ): 

404 """ 

405 Evaluate the interpolation while controlling memory consumption. 

406 We chunk the input if we need more memory than specified. 

407 

408 Parameters 

409 ---------- 

410 x : (Q, N) float ndarray 

411 array of points on which to evaluate 

412 y: (P, N) float ndarray 

413 array of points on which we know function values 

414 shift: (N, ) ndarray 

415 Domain shift used to create the polynomial matrix. 

416 scale : (N,) float ndarray 

417 Domain scaling used to create the polynomial matrix. 

418 coeffs: (P+R, S) float ndarray 

419 Coefficients in front of basis functions 

420 memory_budget: int 

421 Total amount of memory (in units of sizeof(float)) we wish 

422 to devote for storing the array of coefficients for 

423 interpolated points. If we need more memory than that, we 

424 chunk the input. 

425 

426 Returns 

427 ------- 

428 (Q, S) float ndarray 

429 Interpolated array 

430 """ 

431 nx, ndim = x.shape 

432 if self.neighbors is None: 

433 nnei = len(y) 

434 else: 

435 nnei = self.neighbors 

436 # in each chunk we consume the same space we already occupy 

437 chunksize = memory_budget // (self.powers.shape[0] + nnei) + 1 

438 if chunksize <= nx: 

439 out = np.empty((nx, self.d.shape[1]), dtype=float) 

440 for i in range(0, nx, chunksize): 

441 vec = _build_evaluation_coefficients( 

442 x[i:i + chunksize, :], 

443 y, 

444 self.kernel, 

445 self.epsilon, 

446 self.powers, 

447 shift, 

448 scale) 

449 out[i:i + chunksize, :] = np.dot(vec, coeffs) 

450 else: 

451 vec = _build_evaluation_coefficients( 

452 x, 

453 y, 

454 self.kernel, 

455 self.epsilon, 

456 self.powers, 

457 shift, 

458 scale) 

459 out = np.dot(vec, coeffs) 

460 return out 

461 

462 def __call__(self, x): 

463 """Evaluate the interpolant at `x`. 

464 

465 Parameters 

466 ---------- 

467 x : (Q, N) array_like 

468 Evaluation point coordinates. 

469 

470 Returns 

471 ------- 

472 (Q, ...) ndarray 

473 Values of the interpolant at `x`. 

474 

475 """ 

476 x = np.asarray(x, dtype=float, order="C") 

477 if x.ndim != 2: 

478 raise ValueError("`x` must be a 2-dimensional array.") 

479 

480 nx, ndim = x.shape 

481 if ndim != self.y.shape[1]: 

482 raise ValueError("Expected the second axis of `x` to have length " 

483 f"{self.y.shape[1]}.") 

484 

485 # Our memory budget for storing RBF coefficients is 

486 # based on how many floats in memory we already occupy 

487 # If this number is below 1e6 we just use 1e6 

488 # This memory budget is used to decide how we chunk 

489 # the inputs 

490 memory_budget = max(x.size + self.y.size + self.d.size, 1000000) 

491 

492 if self.neighbors is None: 

493 out = self._chunk_evaluator( 

494 x, 

495 self.y, 

496 self._shift, 

497 self._scale, 

498 self._coeffs, 

499 memory_budget=memory_budget) 

500 else: 

501 # Get the indices of the k nearest observation points to each 

502 # evaluation point. 

503 _, yindices = self._tree.query(x, self.neighbors) 

504 if self.neighbors == 1: 

505 # `KDTree` squeezes the output when neighbors=1. 

506 yindices = yindices[:, None] 

507 

508 # Multiple evaluation points may have the same neighborhood of 

509 # observation points. Make the neighborhoods unique so that we only 

510 # compute the interpolation coefficients once for each 

511 # neighborhood. 

512 yindices = np.sort(yindices, axis=1) 

513 yindices, inv = np.unique(yindices, return_inverse=True, axis=0) 

514 # `inv` tells us which neighborhood will be used by each evaluation 

515 # point. Now we find which evaluation points will be using each 

516 # neighborhood. 

517 xindices = [[] for _ in range(len(yindices))] 

518 for i, j in enumerate(inv): 

519 xindices[j].append(i) 

520 

521 out = np.empty((nx, self.d.shape[1]), dtype=float) 

522 for xidx, yidx in zip(xindices, yindices): 

523 # `yidx` are the indices of the observations in this 

524 # neighborhood. `xidx` are the indices of the evaluation points 

525 # that are using this neighborhood. 

526 xnbr = x[xidx] 

527 ynbr = self.y[yidx] 

528 dnbr = self.d[yidx] 

529 snbr = self.smoothing[yidx] 

530 shift, scale, coeffs = _build_and_solve_system( 

531 ynbr, 

532 dnbr, 

533 snbr, 

534 self.kernel, 

535 self.epsilon, 

536 self.powers, 

537 ) 

538 out[xidx] = self._chunk_evaluator( 

539 xnbr, 

540 ynbr, 

541 shift, 

542 scale, 

543 coeffs, 

544 memory_budget=memory_budget) 

545 

546 out = out.view(self.d_dtype) 

547 out = out.reshape((nx, ) + self.d_shape) 

548 return out