11import numpy as np
22from numpy .linalg import LinAlgError , cond
3- from typing import Dict , Tuple , List , Optional
3+ from typing import Dict , Tuple , List , Optional , Callable , Union
44from scipy .optimize import differential_evolution
55from sklearn .base import BaseEstimator , RegressorMixin
66from scipy .special import erf
99from scipy .spatial .distance import cdist , pdist , squareform
1010from spotpython .surrogate .plot import plotkd
1111
12+ # --- Kernel functions ---
13+
14+
15+ def gauss_kernel (D ):
16+ """Gaussian (RBF) kernel: exp(-D)"""
17+ return np .exp (- D )
18+
19+
20+ def matern_kernel (D , nu = 2.5 ):
21+ """Matern kernel (default nu=2.5, smooth)."""
22+ if nu == 0.5 :
23+ return np .exp (- np .sqrt (D ))
24+ elif nu == 1.5 :
25+ sqrt3D = np .sqrt (3.0 * D )
26+ return (1.0 + sqrt3D ) * np .exp (- sqrt3D )
27+ elif nu == 2.5 :
28+ sqrt5D = np .sqrt (5.0 * D )
29+ return (1.0 + sqrt5D + (5.0 / 3.0 ) * D ) * np .exp (- sqrt5D )
30+ else :
31+ # Fallback to Gaussian for unsupported nu
32+ return np .exp (- D )
33+
34+
35+ def exponential_kernel (D ):
36+ """Exponential kernel: exp(-sqrt(D))"""
37+ return np .exp (- np .sqrt (D ))
38+
39+
40+ def cubic_kernel (D ):
41+ """Cubic kernel: 1 - D^3"""
42+ return 1.0 - D ** 3
43+
44+
45+ def linear_kernel (D ):
46+ """Linear kernel: 1 - D"""
47+ return 1.0 - D
48+
49+
50+ def rational_quadratic_kernel (D , alpha = 1.0 ):
51+ """Rational Quadratic kernel: (1 + D/(2*alpha))^(-alpha)"""
52+ return (1.0 + D / (2.0 * alpha )) ** (- alpha )
53+
54+
55+ def poly_kernel (D , degree = 2 ):
56+ """Polynomial kernel: (1 + D)^degree"""
57+ return (1.0 + D ) ** degree
58+
59+
1260# --- The New Kriging Class with Nyström Approximation as introduced in v0.34.0 ---
1361
1462
1563class Kriging (BaseEstimator , RegressorMixin ):
1664 """
1765 A scikit-learn compatible Kriging model class for regression tasks,
18- extended with an optional Nyström approximation for scaling.
66+ extended with an optional Nyström approximation for scaling and explicit kernel selection .
1967
2068 Public API and core behavior match src/spotpython/surrogate/kriging.py.
2169 The same basis function/correlation definitions are used. When use_nystrom=True,
2270 training and prediction use low-rank Woodbury solves based on m inducing points.
2371
72+ Kernel selection is explicit via the `kernel` and `kernel_params` arguments.
73+
2474 Attributes:
2575 eps (float): A small regularization term to reduce ill-conditioning.
2676 penalty (float): The penalty value used if the correlation matrix is ill-conditioned.
@@ -35,11 +85,28 @@ class Kriging(BaseEstimator, RegressorMixin):
3585 use_nystrom (bool): If True, use Nyström low-rank solves.
3686 nystrom_m (int): Number of inducing points (landmarks).
3787 nystrom_seed (int): RNG seed for landmark selection.
88+ kernel (str or callable): Kernel type ("gauss", "matern", "exp", "cubic", "linear", "rq", "poly") or a custom callable.
89+ kernel_params (dict): Parameters for the kernel (e.g., nu for Matern, alpha for rational quadratic, degree for poly).
3890
3991 Notes (Forrester et al., Engineering Design via Surrogate Modelling, Ch. 2/3/6):
40- - Correlation: R = exp(- D), with D weighted distances (Ch. 2).
92+ - Correlation: R = kernel( D), with D weighted distances (Ch. 2).
4193 - Ordinary Kriging μ, σ^2, concentrated likelihood (Ch. 3).
4294 - Prediction f̂, variance s^2, and Expected Improvement (Ch. 3 & 6).
95+
96+ Examples:
97+ >>> import numpy as np
98+ >>> from spotpython.surrogate.kriging import Kriging
99+ >>> X_train = np.array([[0.0, 0.0], [0.5, 0.5], [1.0, 1.0]])
100+ >>> y_train = np.array([0.1, 0.2, 0.3])
101+ >>> # Gaussian kernel (default)
102+ >>> model = Kriging(kernel="gauss").fit(X_train, y_train)
103+ >>> # Matern kernel (nu=1.5)
104+ >>> model_matern = Kriging(kernel="matern", kernel_params={"nu": 1.5}).fit(X_train, y_train)
105+ >>> # Rational Quadratic kernel
106+ >>> model_rq = Kriging(kernel="rq", kernel_params={"alpha": 2.0}).fit(X_train, y_train)
107+ >>> # Custom kernel
108+ >>> def custom_kernel(D): return np.exp(-D**2)
109+ >>> model_custom = Kriging(kernel=custom_kernel).fit(X_train, y_train)
43110 """
44111
45112 def __init__ (
@@ -74,9 +141,13 @@ def __init__(
74141 use_nystrom : bool = False ,
75142 nystrom_m : Optional [int ] = None ,
76143 nystrom_seed : int = 1234 ,
144+ # Kernel options
145+ kernel : Union [str , Callable ] = "gauss" ,
146+ kernel_params : Optional [dict ] = None ,
77147 ** kwargs ,
78148 ):
79- """Initialize the Kriging model.
149+ """
150+ Initialize the Kriging model.
80151
81152 Args:
82153 eps (float): Small regularization term for numerical stability.
@@ -108,6 +179,8 @@ def __init__(
108179 use_nystrom (bool): If True, use Nyström approximation.
109180 nystrom_m (Optional[int]): Number of landmarks for Nyström.
110181 nystrom_seed (int): Seed for landmark selection in Nyström.
182+ kernel (str or callable): Kernel type ("gauss", "matern", "exp", "cubic", "linear", "rq", "poly") or a custom callable.
183+ kernel_params (dict): Parameters for the kernel (e.g., nu for Matern, alpha for rational quadratic, degree for poly).
111184 """
112185 if eps is None :
113186 self .eps = self ._get_eps ()
@@ -140,6 +213,10 @@ def __init__(
140213 self .model_optimizer = model_optimizer or differential_evolution
141214 self .model_fun_evals = model_fun_evals or 100
142215
216+ # Kernel selection
217+ self .kernel = kernel
218+ self .kernel_params = kernel_params or {}
219+
143220 # Logging information
144221 self .log = {}
145222 self .log ["negLnLike" ] = []
@@ -180,6 +257,42 @@ def __init__(
180257 self .Rinv_one_ = None # R^{-1} 1 (n,)
181258 self .Rinv_r_ = None # R^{-1} r (n,)
182259
260+ # --- Kernel dispatch ---
261+
262+ def _correlation (self , D ):
263+ """
264+ Dispatches to the selected kernel function.
265+
266+ Args:
267+ D (np.ndarray): Distance matrix.
268+
269+ Returns:
270+ np.ndarray: Correlation matrix.
271+ """
272+ if callable (self .kernel ):
273+ return self .kernel (D , ** self .kernel_params )
274+ elif self .kernel == "gauss" :
275+ return gauss_kernel (D )
276+ elif self .kernel == "matern" :
277+ nu = self .kernel_params .get ("nu" , 2.5 )
278+ return matern_kernel (D , nu = nu )
279+ elif self .kernel == "exp" :
280+ return exponential_kernel (D )
281+ elif self .kernel == "cubic" :
282+ return cubic_kernel (D )
283+ elif self .kernel == "linear" :
284+ return linear_kernel (D )
285+ elif self .kernel == "rq" :
286+ alpha = self .kernel_params .get ("alpha" , 1.0 )
287+ return rational_quadratic_kernel (D , alpha = alpha )
288+ elif self .kernel == "poly" :
289+ degree = self .kernel_params .get ("degree" , 2 )
290+ return poly_kernel (D , degree = degree )
291+ else :
292+ raise ValueError (f"Unknown kernel: { self .kernel } " )
293+
294+ # -------- Basis correlation construction (identical to kriging.py) --------
295+
183296 def _get_eps (self ) -> float :
184297 """
185298 Computes a small epsilon value for numerical stability.
@@ -325,14 +438,13 @@ def build_Psi(self) -> np.ndarray:
325438 Constructs a new (n x n) correlation matrix Psi to reflect new data
326439 or a change in hyperparameters.
327440 This method uses `theta`, `p`, and coded `X` values to construct the
328- correlation matrix as described in [Forr08a , p.57].
441+ correlation matrix as described in [Forrester et al. , p.57].
329442
330443 Notes:
331- - Correlation follows the stationary Gaussian kernel used in Kriging:
332- R = exp(-D), with D a weighted distance. See Forrester et al. (2008),
333- Ch. 2, correlation modelling.
444+ - Correlation follows the selected kernel:
445+ R = kernel(D), with D a weighted distance.
334446 - The code builds D as a sum of per-dimension distance contributions
335- scaled by 10**theta (theta is stored in log10), then applies exp(-D) .
447+ scaled by 10**theta (theta is stored in log10), then applies the kernel .
336448 - Returns only the upper triangle; the symmetric and diagonal parts
337449 are handled by the caller.
338450
@@ -350,9 +462,8 @@ def build_Psi(self) -> np.ndarray:
350462 >>> # Training data
351463 >>> X_train = np.array([[0.0, 0.0], [0.5, 0.5], [1.0, 1.0]])
352464 >>> y_train = np.array([0.1, 0.2, 0.3])
353- >>> # Fit the Kriging model
354- >>> model = Kriging().fit(X_train, y_train)
355- >>> # Build the correlation matrix Psi
465+ >>> # Fit the Kriging model with a Matern kernel
466+ >>> model = Kriging(kernel="matern", kernel_params={"nu": 1.5}).fit(X_train, y_train)
356467 >>> Psi = model.build_Psi()
357468 >>> print("Correlation matrix Psi:\n ", Psi)
358469 """
@@ -372,7 +483,7 @@ def build_Psi(self) -> np.ndarray:
372483 D_factor = squareform (pdist (X_factor , metric = self .metric_factorial , w = theta10 [self .factor_mask ]))
373484 Psi += D_factor
374485
375- Psi = np . exp ( - Psi )
486+ Psi = self . _correlation ( Psi )
376487
377488 self .inf_Psi = np .isinf (Psi ).any ()
378489 self .cnd_Psi = cond (Psi )
@@ -385,7 +496,7 @@ def build_Psi(self) -> np.ndarray:
385496 def _kernel_cross (self , A : np .ndarray , B : np .ndarray ) -> np .ndarray :
386497 """
387498 Cross-correlation matrix K(A,B) using the same distance definition as build_Psi/build_psi_vec.
388- Returns exp(- D) with D composed from ordered and factor contributions.
499+ Returns kernel( D) with D composed from ordered and factor contributions.
389500
390501 Args:
391502 A (np.ndarray): First set of points (m x k).
@@ -396,17 +507,14 @@ def _kernel_cross(self, A: np.ndarray, B: np.ndarray) -> np.ndarray:
396507
397508 Examples:
398509 >>> import numpy as np
399- from spotpython.surrogate.kriging import Kriging
400- X_train = np.array([[0.0, 0.0], [0.5, 0.5], [1.0, 1.0]])
401- y_train = np.array([0.1, 0.2, 0.3])
402- # Fit the Kriging model
403- model = Kriging().fit(X_train, y_train)
404- # Create two sets of points A and B
405- A = np.array([[0.0, 0.0], [1.0, 1.0]])
406- B = np.array([[0.5, 0.5], [1.5, 1.5]])
407- # Compute the cross-correlation matrix K(A, B)
408- K_AB = model._kernel_cross(A, B)
409- print("Cross-correlation matrix K(A, B):\n ", K_AB)
510+ >>> from spotpython.surrogate.kriging import Kriging
511+ >>> X_train = np.array([[0.0, 0.0], [0.5, 0.5], [1.0, 1.0]])
512+ >>> y_train = np.array([0.1, 0.2, 0.3])
513+ >>> model = Kriging(kernel="poly", kernel_params={"degree": 3}).fit(X_train, y_train)
514+ >>> A = np.array([[0.0, 0.0], [1.0, 1.0]])
515+ >>> B = np.array([[0.5, 0.5], [1.5, 1.5]])
516+ >>> K_AB = model._kernel_cross(A, B)
517+ >>> print("Cross-correlation matrix K(A, B):\n ", K_AB)
410518 """
411519 A = np .asarray (A )
412520 B = np .asarray (B )
@@ -426,34 +534,29 @@ def _kernel_cross(self, A: np.ndarray, B: np.ndarray) -> np.ndarray:
426534 Bf = B [:, self .factor_mask ]
427535 D += cdist (Af , Bf , metric = self .metric_factorial , w = theta10 [self .factor_mask ])
428536
429- return np . exp ( - D )
537+ return self . _correlation ( D )
430538
431539 def build_psi_vec (self , x : np .ndarray ) -> np .ndarray :
432540 """
433541 Build the psi vector required for predictive methods.
434- ψ(x) := [exp(- D(x, x_i))]_{i=1..n}, i.e., correlation between a new x and the training sites
435- using the same D as for R (Forrester, Ch. 2) .
542+ ψ(x) := [kernel( D(x, x_i))]_{i=1..n}, i.e., correlation between a new x and the training sites
543+ using the same D as for R.
436544
437545 Args:
438546 x (ndarray): Point to calculate the psi vector for.
439547
440548 Returns:
441- None
442-
443- Modifies:
444- self.psi (np.ndarray): Updates the psi vector.
549+ np.ndarray: The psi vector.
445550
446551 Examples:
447552 >>> import numpy as np
448- from spotpython.surrogate.kriging import Kriging
449- # Training data
450- X_train = np.array([[0.0, 0.0], [0.5, 0.5], [1.0, 1.0]])
451- y_train = np.array([0.1, 0.2, 0.3])
452- # Fit the Kriging model
453- model = Kriging().fit(X_train, y_train)
454- x_new = np.array([0.25, 0.25])
455- psi_vector = model.build_psi_vec(x_new)
456- print("Psi vector for new point:\n ", psi_vector)
553+ >>> from spotpython.surrogate.kriging import Kriging
554+ >>> X_train = np.array([[0.0, 0.0], [0.5, 0.5], [1.0, 1.0]])
555+ >>> y_train = np.array([0.1, 0.2, 0.3])
556+ >>> model = Kriging(kernel="rq", kernel_params={"alpha": 2.0}).fit(X_train, y_train)
557+ >>> x_new = np.array([0.25, 0.25])
558+ >>> psi_vector = model.build_psi_vec(x_new)
559+ >>> print("Psi vector for new point:\n ", psi_vector)
457560 """
458561 try :
459562 psi = self ._kernel_cross (np .asarray (x ), self .X_ ).ravel ()
0 commit comments