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.
@@ -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
+ )
@@ -0,0 +1,2 @@
1
+ def hello() -> str:
2
+ return "Hello from spotoptim!"
File without changes