Skip to content

Commit fd76822

Browse files
v0.29.12
vectorized kernel
1 parent 5ecc179 commit fd76822

6 files changed

Lines changed: 231 additions & 9 deletions

File tree

.vscode/settings.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,5 +43,10 @@
4343
],
4444
"cSpell.enableFiletypes": [
4545
"quarto"
46-
]
46+
],
47+
"python.testing.pytestArgs": [
48+
"test"
49+
],
50+
"python.testing.unittestEnabled": false,
51+
"python.testing.pytestEnabled": true
4752
}

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
77

88
[project]
99
name = "spotpython"
10-
version = "0.29.11"
10+
version = "0.29.12"
1111
authors = [
1212
{ name="T. Bartz-Beielstein", email="tbb@bartzundbartz.de" }
1313
]

src/spotpython/surrogate/kriging.py

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,34 @@ def predict(self, X: np.ndarray, return_std=False, return_val: str = "y") -> np.
316316
predictions = [self._pred(x_i)[0] for x_i in X]
317317
return np.array(predictions)
318318

319+
def _kernel(self, X: np.ndarray, theta: np.ndarray, p: float) -> np.ndarray:
320+
"""
321+
Computes the correlation matrix Psi using vectorized operations.
322+
323+
Args:
324+
X (np.ndarray): Input data of shape (n_samples, n_features).
325+
theta (np.ndarray): Theta parameters of shape (n_features,).
326+
p (float): Power exponent.
327+
328+
Returns:
329+
np.ndarray: The upper triangle of the correlation matrix Psi.
330+
"""
331+
n_samples, n_features = X.shape
332+
Psi = np.zeros((n_samples, n_samples), dtype=float)
333+
# Calculate all pairwise differences:
334+
# X_expanded_rows will have shape (n_samples, 1, n_features)
335+
# X_expanded_cols will have shape (1, n_samples, n_features)
336+
# diff will have shape (n_samples, n_samples, n_features)
337+
diff = np.abs(X[:, np.newaxis, :] - X[np.newaxis, :, :]) ** p
338+
# Apply theta and sum over features
339+
# dist_matrix will have shape (n_samples, n_samples)
340+
dist_matrix = np.sum(theta * diff, axis=2)
341+
# Compute Psi using the exponential kernel
342+
Psi = np.exp(-dist_matrix)
343+
# Return only the upper triangle, as the matrix is symmetric
344+
# and the diagonal will be handled later.
345+
return np.triu(Psi, k=1)
346+
319347
def likelihood(self, x: np.ndarray) -> Tuple[float, np.ndarray, np.ndarray]:
320348
"""
321349
Computes the negative of the concentrated log-likelihood for a given set
@@ -359,13 +387,8 @@ def likelihood(self, x: np.ndarray) -> Tuple[float, np.ndarray, np.ndarray]:
359387
one = np.ones(n)
360388

361389
# Build correlation matrix
362-
Psi = np.zeros((n, n), dtype=float)
363-
for i in range(n):
364-
for j in range(i + 1, n):
365-
dist_vec = np.abs(X[i, :] - X[j, :]) ** p
366-
Psi[i, j] = np.exp(-np.sum(theta * dist_vec))
367-
368-
Psi = Psi + Psi.T + np.eye(n) + np.eye(n) * lambda_
390+
Psi_upper_triangle = self._kernel(X, theta, p)
391+
Psi = Psi_upper_triangle + Psi_upper_triangle.T + np.eye(n) + np.eye(n) * lambda_
369392

370393
try:
371394
U = np.linalg.cholesky(Psi)

test/test_kernel.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import numpy as np
2+
import pytest
3+
from spotpython.surrogate.kriging import Kriging # Assuming Kriging class is in this path
4+
5+
# Create a dummy Kriging instance for testing, as _kernel is a method of the class
6+
# We can use default parameters for the Kriging constructor as they don't affect _kernel
7+
@pytest.fixture
8+
def kriging_model():
9+
"""Fixture to create a Kriging model instance."""
10+
return Kriging()
11+
12+
def test_kernel_simple_1d(kriging_model):
13+
"""Test _kernel with 2 samples, 1 feature."""
14+
X = np.array([[0.0], [1.0]])
15+
theta = np.array([1.0])
16+
p = 2.0
17+
18+
expected_psi_upper_triangle = np.array([
19+
[0.0, np.exp(-1.0)],
20+
[0.0, 0.0]
21+
])
22+
23+
result = kriging_model._kernel(X, theta, p)
24+
np.testing.assert_array_almost_equal(result, expected_psi_upper_triangle)
25+
26+
def test_kernel_simple_2d(kriging_model):
27+
"""Test _kernel with 2 samples, 2 features."""
28+
X = np.array([[0.0, 0.0], [1.0, 1.0]])
29+
theta = np.array([1.0, 1.0])
30+
p = 2.0
31+
32+
# diff for [0,1]: [abs(0-1)^2, abs(0-1)^2] = [1,1]
33+
# dist_matrix[0,1] = sum(theta * diff) = 1*1 + 1*1 = 2
34+
# Psi[0,1] = exp(-2)
35+
expected_psi_upper_triangle = np.array([
36+
[0.0, np.exp(-2.0)],
37+
[0.0, 0.0]
38+
])
39+
40+
result = kriging_model._kernel(X, theta, p)
41+
np.testing.assert_array_almost_equal(result, expected_psi_upper_triangle)
42+
43+
def test_kernel_three_samples_1d(kriging_model):
44+
"""Test _kernel with 3 samples, 1 feature."""
45+
X = np.array([[0.0], [1.0], [2.0]])
46+
theta = np.array([0.5])
47+
p = 2.0
48+
49+
# For X[0] and X[1]: diff = abs(0-1)^2 = 1. dist = 0.5 * 1 = 0.5. val = exp(-0.5)
50+
# For X[0] and X[2]: diff = abs(0-2)^2 = 4. dist = 0.5 * 4 = 2.0. val = exp(-2.0)
51+
# For X[1] and X[2]: diff = abs(1-2)^2 = 1. dist = 0.5 * 1 = 0.5. val = exp(-0.5)
52+
expected_psi_upper_triangle = np.array([
53+
[0.0, np.exp(-0.5), np.exp(-2.0)],
54+
[0.0, 0.0, np.exp(-0.5)],
55+
[0.0, 0.0, 0.0]
56+
])
57+
58+
result = kriging_model._kernel(X, theta, p)
59+
np.testing.assert_array_almost_equal(result, expected_psi_upper_triangle)
60+
61+
def test_kernel_different_p_value(kriging_model):
62+
"""Test _kernel with a different p value."""
63+
X = np.array([[0.0], [1.0]])
64+
theta = np.array([1.0])
65+
p = 1.0 # Changed p value
66+
67+
# diff for [0,1]: abs(0-1)^1 = 1
68+
# dist_matrix[0,1] = sum(theta * diff) = 1*1 = 1
69+
# Psi[0,1] = exp(-1)
70+
expected_psi_upper_triangle = np.array([
71+
[0.0, np.exp(-1.0)],
72+
[0.0, 0.0]
73+
])
74+
75+
result = kriging_model._kernel(X, theta, p)
76+
np.testing.assert_array_almost_equal(result, expected_psi_upper_triangle)
77+
78+
def test_kernel_theta_zeros(kriging_model):
79+
"""Test _kernel when a theta value is zero."""
80+
X = np.array([[0.0, 0.0], [1.0, 1.0]])
81+
theta = np.array([1.0, 0.0]) # Second feature's theta is 0
82+
p = 2.0
83+
84+
# diff for [0,1]: [abs(0-1)^2, abs(0-1)^2] = [1,1]
85+
# dist_matrix[0,1] = sum(theta * diff) = 1*1 + 0*1 = 1
86+
# Psi[0,1] = exp(-1)
87+
expected_psi_upper_triangle = np.array([
88+
[0.0, np.exp(-1.0)],
89+
[0.0, 0.0]
90+
])
91+
92+
result = kriging_model._kernel(X, theta, p)
93+
np.testing.assert_array_almost_equal(result, expected_psi_upper_triangle)
94+
95+
def test_kernel_no_samples(kriging_model):
96+
"""Test _kernel with no samples. Should produce an empty array or handle gracefully."""
97+
X = np.empty((0, 2)) # No samples, 2 features
98+
theta = np.array([1.0, 1.0])
99+
p = 2.0
100+
101+
# Expected: an empty (0,0) array for the upper triangle
102+
expected_psi_upper_triangle = np.empty((0,0))
103+
104+
result = kriging_model._kernel(X, theta, p)
105+
# The current implementation of _kernel will likely raise an error or behave unexpectedly
106+
# with X.shape[0] == 0 before np.triu.
107+
# np.zeros((0,0)) is valid.
108+
# np.abs(X[:, np.newaxis, :] - X[np.newaxis, :, :]) will result in (0,0,2) shape
109+
# np.sum on axis 2 will result in (0,0) shape
110+
# np.exp will result in (0,0) shape
111+
# np.triu will result in (0,0) shape
112+
np.testing.assert_array_almost_equal(result, expected_psi_upper_triangle)
113+
114+
def test_kernel_one_sample(kriging_model):
115+
"""Test _kernel with only one sample."""
116+
X = np.array([[1.0, 2.0]]) # One sample, 2 features
117+
theta = np.array([1.0, 1.0])
118+
p = 2.0
119+
120+
# Expected: a (1,1) array with 0 in the upper triangle (as k=1)
121+
expected_psi_upper_triangle = np.array([[0.0]])
122+
123+
result = kriging_model._kernel(X, theta, p)
124+
np.testing.assert_array_almost_equal(result, expected_psi_upper_triangle)

test/test_likelihood_surrogate.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import numpy as np
2+
import pytest
3+
from spotpython.surrogate.kriging import Kriging
4+
5+
6+
@pytest.mark.parametrize("method, x_shape", [
7+
("interpolation", (1,)),
8+
("regression", (2,)),
9+
("reinterpolation", (2,))
10+
])
11+
def test_likelihood_basic(method, x_shape):
12+
# Small toy data: 3 points, 1D
13+
X = np.array([[0.0], [0.5], [1.0]])
14+
y = np.array([1.0, 2.0, 3.0])
15+
model = Kriging(method=method, penalty=12345.0)
16+
model.X_ = X
17+
model.y_ = y
18+
model.eps = 1e-6
19+
model.penalty = 12345.0
20+
21+
# log10(theta) = 0, log10(lambda) = -3 for regression/reinterpolation
22+
if method == "interpolation":
23+
x = np.zeros(x_shape)
24+
else:
25+
x = np.zeros(x_shape)
26+
x[-1] = -3 # log10(lambda)
27+
28+
negLnLike, Psi, U = model.likelihood(x)
29+
30+
# Check types and shapes
31+
assert isinstance(negLnLike, float)
32+
assert Psi.shape == (3, 3)
33+
assert np.allclose(Psi, Psi.T)
34+
if U is not None:
35+
assert U.shape == (3, 3)
36+
# U should be upper-triangular (Cholesky returns lower, but code uses U as upper)
37+
# Actually, np.linalg.cholesky returns lower-triangular, so U @ U.T == Psi
38+
assert np.allclose(U @ U.T, Psi, atol=1e-8)
39+
else:
40+
# If U is None, penalty should be returned
41+
assert negLnLike == model.penalty
42+
43+
# negLnLike should be finite unless penalty
44+
if U is not None:
45+
assert np.isfinite(negLnLike)
46+
47+
def test_likelihood_invalid_method():
48+
X = np.array([[0.0], [1.0]])
49+
y = np.array([1.0, 2.0])
50+
model = Kriging(method="regression")
51+
model.X_ = X
52+
model.y_ = y
53+
# Set an invalid method
54+
model.method = "invalid"
55+
with pytest.raises(ValueError):
56+
model.likelihood(np.zeros(1))
57+
58+
def test_likelihood_ill_conditioned_returns_penalty():
59+
# Use two identical points to force singular Psi
60+
X = np.array([[0.0], [0.0]])
61+
y = np.array([1.0, 1.0])
62+
model = Kriging(method="interpolation", penalty=99999.0)
63+
model.X_ = X
64+
model.y_ = y
65+
model.eps = 0.0 # No regularization
66+
x = np.zeros(1)
67+
negLnLike, Psi, U = model.likelihood(x)
68+
assert negLnLike == 99999.0
69+
assert U is None
70+
assert Psi.shape == (2, 2)

0 commit comments

Comments
 (0)