spotoptim 0.0.3__tar.gz
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.
spotoptim-0.0.3/PKG-INFO
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: spotoptim
|
|
3
|
+
Version: 0.0.3
|
|
4
|
+
Summary: Add your description here
|
|
5
|
+
Author: bartzbeielstein
|
|
6
|
+
Author-email: bartzbeielstein <32470350+bartzbeielstein@users.noreply.github.com>
|
|
7
|
+
Requires-Dist: fastapi>=0.121.1
|
|
8
|
+
Requires-Dist: numpy>=1.24.3
|
|
9
|
+
Requires-Dist: scipy>=1.10.1
|
|
10
|
+
Requires-Dist: uvicorn>=0.22.0
|
|
11
|
+
Requires-Dist: scikit-learn
|
|
12
|
+
Requires-Dist: spotpython
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
File without changes
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "spotoptim"
|
|
3
|
+
version = "0.0.3"
|
|
4
|
+
description = "Add your description here"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "bartzbeielstein", email = "32470350+bartzbeielstein@users.noreply.github.com" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"fastapi>=0.121.1",
|
|
12
|
+
"numpy>=1.24.3",
|
|
13
|
+
"scipy>=1.10.1",
|
|
14
|
+
"uvicorn>=0.22.0",
|
|
15
|
+
"scikit-learn",
|
|
16
|
+
"spotpython"
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[dependency-groups]
|
|
20
|
+
dev = [
|
|
21
|
+
"pytest>=7.4.0",
|
|
22
|
+
"pytest-cov>=4.1.0",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[build-system]
|
|
26
|
+
requires = ["uv_build>=0.9.8,<0.10.0"]
|
|
27
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from typing import Callable, Optional, Tuple, List
|
|
3
|
+
from scipy.optimize import OptimizeResult, differential_evolution
|
|
4
|
+
from sklearn.base import BaseEstimator
|
|
5
|
+
from sklearn.gaussian_process import GaussianProcessRegressor
|
|
6
|
+
from sklearn.gaussian_process.kernels import Matern, ConstantKernel
|
|
7
|
+
from spotpython.design.spacefilling import SpaceFilling
|
|
8
|
+
import warnings
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SpotOptim(BaseEstimator):
|
|
12
|
+
"""
|
|
13
|
+
SPOT optimizer compatible with scipy.optimize interface.
|
|
14
|
+
|
|
15
|
+
Parameters
|
|
16
|
+
----------
|
|
17
|
+
fun : callable
|
|
18
|
+
Objective function to minimize. Should accept array of shape (n_samples, n_features).
|
|
19
|
+
bounds : list of tuple
|
|
20
|
+
Bounds for each dimension as [(low, high), ...].
|
|
21
|
+
max_iter : int, default=20
|
|
22
|
+
Maximum number of optimization iterations.
|
|
23
|
+
n_initial : int, default=10
|
|
24
|
+
Number of initial design points.
|
|
25
|
+
surrogate : object, optional
|
|
26
|
+
Surrogate model (default: Gaussian Process with Matern kernel).
|
|
27
|
+
acquisition : str, default='ei'
|
|
28
|
+
Acquisition function ('ei', 'y', 'pi').
|
|
29
|
+
var_type : list of str, optional
|
|
30
|
+
Variable types for each dimension ('num', 'int', 'float', 'factor').
|
|
31
|
+
tolerance_x : float, default=1e-6
|
|
32
|
+
Minimum distance between points.
|
|
33
|
+
seed : int, optional
|
|
34
|
+
Random seed for reproducibility.
|
|
35
|
+
verbose : bool, default=False
|
|
36
|
+
Print progress information.
|
|
37
|
+
warnings_filter : str, default="ignore". Filter for warnings. One of "error", "ignore", "always", "all", "default", "module", or "once".
|
|
38
|
+
|
|
39
|
+
Attributes
|
|
40
|
+
----------
|
|
41
|
+
X_ : ndarray of shape (n_samples, n_features)
|
|
42
|
+
All evaluated points.
|
|
43
|
+
y_ : ndarray of shape (n_samples,)
|
|
44
|
+
Function values at X_.
|
|
45
|
+
best_x_ : ndarray of shape (n_features,)
|
|
46
|
+
Best point found.
|
|
47
|
+
best_y_ : float
|
|
48
|
+
Best function value found.
|
|
49
|
+
n_iter_ : int
|
|
50
|
+
Number of iterations performed.
|
|
51
|
+
warnings_filter : str
|
|
52
|
+
Filter for warnings during optimization.
|
|
53
|
+
|
|
54
|
+
Examples
|
|
55
|
+
--------
|
|
56
|
+
>>> def objective(X):
|
|
57
|
+
... return np.sum(X**2, axis=1)
|
|
58
|
+
...
|
|
59
|
+
>>> bounds = [(-5, 5), (-5, 5)]
|
|
60
|
+
>>> optimizer = SpotOptim(fun=objective, bounds=bounds, max_iter=10, n_initial=5, verbose=True)
|
|
61
|
+
>>> result = optimizer.optimize()
|
|
62
|
+
>>> print("Best x:", result.x)
|
|
63
|
+
>>> print("Best f(x):", result.fun)
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
fun: Callable,
|
|
69
|
+
bounds: list,
|
|
70
|
+
max_iter: int = 20,
|
|
71
|
+
n_initial: int = 10,
|
|
72
|
+
surrogate: Optional[object] = None,
|
|
73
|
+
acquisition: str = "ei",
|
|
74
|
+
var_type: Optional[list] = None,
|
|
75
|
+
tolerance_x: float = 1e-6,
|
|
76
|
+
seed: Optional[int] = None,
|
|
77
|
+
verbose: bool = False,
|
|
78
|
+
warnings_filter: str = "ignore",
|
|
79
|
+
):
|
|
80
|
+
|
|
81
|
+
warnings.filterwarnings(warnings_filter)
|
|
82
|
+
|
|
83
|
+
self.fun = fun
|
|
84
|
+
self.bounds = bounds
|
|
85
|
+
self.max_iter = max_iter
|
|
86
|
+
self.n_initial = n_initial
|
|
87
|
+
self.surrogate = surrogate
|
|
88
|
+
self.acquisition = acquisition
|
|
89
|
+
self.var_type = var_type
|
|
90
|
+
self.tolerance_x = tolerance_x
|
|
91
|
+
self.seed = seed
|
|
92
|
+
self.verbose = verbose
|
|
93
|
+
|
|
94
|
+
# Derived attributes
|
|
95
|
+
self.n_dim = len(bounds)
|
|
96
|
+
self.lower = np.array([b[0] for b in bounds])
|
|
97
|
+
self.upper = np.array([b[1] for b in bounds])
|
|
98
|
+
|
|
99
|
+
# Default variable types
|
|
100
|
+
if self.var_type is None:
|
|
101
|
+
self.var_type = ["num"] * self.n_dim
|
|
102
|
+
|
|
103
|
+
# Initialize surrogate if not provided
|
|
104
|
+
if self.surrogate is None:
|
|
105
|
+
kernel = ConstantKernel(1.0, (1e-3, 1e3)) * Matern(
|
|
106
|
+
length_scale=1.0, length_scale_bounds=(1e-2, 1e2), nu=2.5
|
|
107
|
+
)
|
|
108
|
+
self.surrogate = GaussianProcessRegressor(
|
|
109
|
+
kernel=kernel,
|
|
110
|
+
n_restarts_optimizer=10,
|
|
111
|
+
normalize_y=True,
|
|
112
|
+
random_state=self.seed,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# Design generator
|
|
116
|
+
self.design = SpaceFilling(k=self.n_dim, seed=self.seed)
|
|
117
|
+
|
|
118
|
+
# Storage for results
|
|
119
|
+
self.X_ = None
|
|
120
|
+
self.y_ = None
|
|
121
|
+
self.best_x_ = None
|
|
122
|
+
self.best_y_ = None
|
|
123
|
+
self.n_iter_ = 0
|
|
124
|
+
|
|
125
|
+
def _evaluate_function(self, X: np.ndarray) -> np.ndarray:
|
|
126
|
+
"""Evaluate objective function at points X."""
|
|
127
|
+
# Ensure X is 2D
|
|
128
|
+
X = np.atleast_2d(X)
|
|
129
|
+
|
|
130
|
+
# Evaluate function
|
|
131
|
+
y = self.fun(X)
|
|
132
|
+
|
|
133
|
+
# Ensure y is 1D
|
|
134
|
+
if isinstance(y, np.ndarray) and y.ndim > 1:
|
|
135
|
+
y = y.ravel()
|
|
136
|
+
elif not isinstance(y, np.ndarray):
|
|
137
|
+
y = np.array([y])
|
|
138
|
+
|
|
139
|
+
return y
|
|
140
|
+
|
|
141
|
+
def _generate_initial_design(self) -> np.ndarray:
|
|
142
|
+
"""Generate initial space-filling design."""
|
|
143
|
+
X0 = self.design.scipy_lhd(
|
|
144
|
+
n=self.n_initial, repeats=1, lower=self.lower, upper=self.upper
|
|
145
|
+
)
|
|
146
|
+
return self._repair_non_numeric(X0, self.var_type)
|
|
147
|
+
|
|
148
|
+
def _fit_surrogate(self, X: np.ndarray, y: np.ndarray) -> None:
|
|
149
|
+
"""Fit surrogate model to data."""
|
|
150
|
+
self.surrogate.fit(X, y)
|
|
151
|
+
|
|
152
|
+
def _select_new(self, A: np.ndarray, X: np.ndarray, tolerance: float = 0) -> Tuple[np.ndarray, np.ndarray]:
|
|
153
|
+
"""
|
|
154
|
+
Select rows from A that are not in X.
|
|
155
|
+
|
|
156
|
+
Parameters
|
|
157
|
+
----------
|
|
158
|
+
A : ndarray
|
|
159
|
+
Array with new values.
|
|
160
|
+
X : ndarray
|
|
161
|
+
Array with known values.
|
|
162
|
+
tolerance : float, default=0
|
|
163
|
+
Tolerance value for comparison.
|
|
164
|
+
|
|
165
|
+
Returns
|
|
166
|
+
-------
|
|
167
|
+
ndarray
|
|
168
|
+
Array with unknown (new) values.
|
|
169
|
+
ndarray
|
|
170
|
+
Array with True if value is new, otherwise False.
|
|
171
|
+
"""
|
|
172
|
+
B = np.abs(A[:, None] - X)
|
|
173
|
+
ind = np.any(np.all(B <= tolerance, axis=2), axis=1)
|
|
174
|
+
return A[~ind], ~ind
|
|
175
|
+
|
|
176
|
+
def _repair_non_numeric(self, X: np.ndarray, var_type: List[str]) -> np.ndarray:
|
|
177
|
+
"""
|
|
178
|
+
Round non-numeric values to integers.
|
|
179
|
+
This applies to all variables except for "num" and "float".
|
|
180
|
+
|
|
181
|
+
Parameters
|
|
182
|
+
----------
|
|
183
|
+
X : ndarray
|
|
184
|
+
X array.
|
|
185
|
+
var_type : list of str
|
|
186
|
+
List with type information.
|
|
187
|
+
|
|
188
|
+
Returns
|
|
189
|
+
-------
|
|
190
|
+
ndarray
|
|
191
|
+
X array with non-numeric values rounded to integers.
|
|
192
|
+
"""
|
|
193
|
+
mask = np.isin(var_type, ["num", "float"], invert=True)
|
|
194
|
+
X[:, mask] = np.around(X[:, mask])
|
|
195
|
+
return X
|
|
196
|
+
|
|
197
|
+
def _acquisition_function(self, x: np.ndarray) -> float:
|
|
198
|
+
"""
|
|
199
|
+
Compute acquisition function value.
|
|
200
|
+
|
|
201
|
+
Parameters
|
|
202
|
+
----------
|
|
203
|
+
x : ndarray of shape (n_features,)
|
|
204
|
+
Point to evaluate.
|
|
205
|
+
|
|
206
|
+
Returns
|
|
207
|
+
-------
|
|
208
|
+
float
|
|
209
|
+
Acquisition function value (to be minimized).
|
|
210
|
+
"""
|
|
211
|
+
x = x.reshape(1, -1)
|
|
212
|
+
|
|
213
|
+
if self.acquisition == "y":
|
|
214
|
+
# Predicted mean
|
|
215
|
+
return self.surrogate.predict(x)[0]
|
|
216
|
+
|
|
217
|
+
elif self.acquisition == "ei":
|
|
218
|
+
# Expected Improvement
|
|
219
|
+
mu, sigma = self.surrogate.predict(x, return_std=True)
|
|
220
|
+
mu = mu[0]
|
|
221
|
+
sigma = sigma[0]
|
|
222
|
+
|
|
223
|
+
if sigma < 1e-10:
|
|
224
|
+
return 0.0
|
|
225
|
+
|
|
226
|
+
y_best = np.min(self.y_)
|
|
227
|
+
improvement = y_best - mu
|
|
228
|
+
Z = improvement / sigma
|
|
229
|
+
|
|
230
|
+
from scipy.stats import norm
|
|
231
|
+
|
|
232
|
+
ei = improvement * norm.cdf(Z) + sigma * norm.pdf(Z)
|
|
233
|
+
return -ei # Minimize negative EI
|
|
234
|
+
|
|
235
|
+
elif self.acquisition == "pi":
|
|
236
|
+
# Probability of Improvement
|
|
237
|
+
mu, sigma = self.surrogate.predict(x, return_std=True)
|
|
238
|
+
mu = mu[0]
|
|
239
|
+
sigma = sigma[0]
|
|
240
|
+
|
|
241
|
+
if sigma < 1e-10:
|
|
242
|
+
return 0.0
|
|
243
|
+
|
|
244
|
+
y_best = np.min(self.y_)
|
|
245
|
+
Z = (y_best - mu) / sigma
|
|
246
|
+
|
|
247
|
+
from scipy.stats import norm
|
|
248
|
+
|
|
249
|
+
pi = norm.cdf(Z)
|
|
250
|
+
return -pi # Minimize negative PI
|
|
251
|
+
|
|
252
|
+
else:
|
|
253
|
+
raise ValueError(f"Unknown acquisition function: {self.acquisition}")
|
|
254
|
+
|
|
255
|
+
def _suggest_next_point(self) -> np.ndarray:
|
|
256
|
+
"""
|
|
257
|
+
Suggest next point to evaluate using acquisition function optimization.
|
|
258
|
+
|
|
259
|
+
Returns
|
|
260
|
+
-------
|
|
261
|
+
ndarray of shape (n_features,)
|
|
262
|
+
Next point to evaluate.
|
|
263
|
+
"""
|
|
264
|
+
result = differential_evolution(
|
|
265
|
+
func=self._acquisition_function,
|
|
266
|
+
bounds=self.bounds,
|
|
267
|
+
seed=self.seed,
|
|
268
|
+
maxiter=1000,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
x_next = result.x
|
|
272
|
+
|
|
273
|
+
# Ensure minimum distance to existing points
|
|
274
|
+
x_next_2d = x_next.reshape(1, -1)
|
|
275
|
+
x_new, _ = self._select_new(A=x_next_2d, X=self.X_, tolerance=self.tolerance_x)
|
|
276
|
+
|
|
277
|
+
if x_new.shape[0] == 0:
|
|
278
|
+
# If too close, generate random point
|
|
279
|
+
if self.verbose:
|
|
280
|
+
print("Proposed point too close, generating random point")
|
|
281
|
+
x_next = self.design.scipy_lhd(
|
|
282
|
+
n=1, repeats=1, lower=self.lower, upper=self.upper
|
|
283
|
+
)[0]
|
|
284
|
+
|
|
285
|
+
return self._repair_non_numeric(x_next.reshape(1, -1), self.var_type)[0]
|
|
286
|
+
|
|
287
|
+
def optimize(self, X0: Optional[np.ndarray] = None) -> OptimizeResult:
|
|
288
|
+
"""
|
|
289
|
+
Run the optimization process.
|
|
290
|
+
|
|
291
|
+
Parameters
|
|
292
|
+
----------
|
|
293
|
+
X0 : ndarray of shape (n_initial, n_features), optional
|
|
294
|
+
Initial design points. If None, generates space-filling design.
|
|
295
|
+
|
|
296
|
+
Returns
|
|
297
|
+
-------
|
|
298
|
+
OptimizeResult
|
|
299
|
+
Optimization result with fields:
|
|
300
|
+
- x : best point found
|
|
301
|
+
- fun : best function value
|
|
302
|
+
- nfev : number of function evaluations
|
|
303
|
+
- success : whether optimization succeeded
|
|
304
|
+
- message : termination message
|
|
305
|
+
- X : all evaluated points
|
|
306
|
+
- y : all function values
|
|
307
|
+
"""
|
|
308
|
+
# Generate or use provided initial design
|
|
309
|
+
if X0 is None:
|
|
310
|
+
X0 = self._generate_initial_design()
|
|
311
|
+
else:
|
|
312
|
+
X0 = np.atleast_2d(X0)
|
|
313
|
+
X0 = self._repair_non_numeric(X0, self.var_type)
|
|
314
|
+
|
|
315
|
+
# Evaluate initial design
|
|
316
|
+
y0 = self._evaluate_function(X0)
|
|
317
|
+
|
|
318
|
+
# Initialize storage
|
|
319
|
+
self.X_ = X0.copy()
|
|
320
|
+
self.y_ = y0.copy()
|
|
321
|
+
self.n_iter_ = 0
|
|
322
|
+
|
|
323
|
+
# Initial best
|
|
324
|
+
best_idx = np.argmin(self.y_)
|
|
325
|
+
self.best_x_ = self.X_[best_idx].copy()
|
|
326
|
+
self.best_y_ = self.y_[best_idx]
|
|
327
|
+
|
|
328
|
+
if self.verbose:
|
|
329
|
+
print(f"Initial best: f(x) = {self.best_y_:.6f}")
|
|
330
|
+
|
|
331
|
+
# Main optimization loop
|
|
332
|
+
for iteration in range(self.max_iter):
|
|
333
|
+
self.n_iter_ = iteration + 1
|
|
334
|
+
|
|
335
|
+
# Fit surrogate
|
|
336
|
+
self._fit_surrogate(self.X_, self.y_)
|
|
337
|
+
|
|
338
|
+
# Suggest next point
|
|
339
|
+
x_next = self._suggest_next_point()
|
|
340
|
+
|
|
341
|
+
# Evaluate next point
|
|
342
|
+
y_next = self._evaluate_function(x_next.reshape(1, -1))
|
|
343
|
+
|
|
344
|
+
# Update storage
|
|
345
|
+
self.X_ = np.vstack([self.X_, x_next])
|
|
346
|
+
self.y_ = np.append(self.y_, y_next)
|
|
347
|
+
|
|
348
|
+
# Update best
|
|
349
|
+
if y_next[0] < self.best_y_:
|
|
350
|
+
self.best_x_ = x_next.copy()
|
|
351
|
+
self.best_y_ = y_next[0]
|
|
352
|
+
|
|
353
|
+
if self.verbose:
|
|
354
|
+
print(
|
|
355
|
+
f"Iteration {iteration+1}: New best f(x) = {self.best_y_:.6f}"
|
|
356
|
+
)
|
|
357
|
+
elif self.verbose:
|
|
358
|
+
print(f"Iteration {iteration+1}: f(x) = {y_next[0]:.6f}")
|
|
359
|
+
|
|
360
|
+
# Return scipy-style result
|
|
361
|
+
return OptimizeResult(
|
|
362
|
+
x=self.best_x_,
|
|
363
|
+
fun=self.best_y_,
|
|
364
|
+
nfev=len(self.y_),
|
|
365
|
+
nit=self.n_iter_,
|
|
366
|
+
success=True,
|
|
367
|
+
message="Optimization finished successfully",
|
|
368
|
+
X=self.X_,
|
|
369
|
+
y=self.y_,
|
|
370
|
+
)
|
|
File without changes
|