spotoptim 0.0.4__py3-none-any.whl

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/SpotOptim.py ADDED
@@ -0,0 +1,382 @@
1
+ import numpy as np
2
+ from typing import Callable, Optional, Tuple, List
3
+ from scipy.optimize import OptimizeResult, differential_evolution
4
+ from scipy.stats.qmc import LatinHypercube
5
+ from sklearn.base import BaseEstimator
6
+ from sklearn.gaussian_process import GaussianProcessRegressor
7
+ from sklearn.gaussian_process.kernels import Matern, ConstantKernel
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: Optional[float] = None,
76
+ seed: Optional[int] = None,
77
+ verbose: bool = False,
78
+ warnings_filter: str = "ignore",
79
+ ):
80
+
81
+ warnings.filterwarnings(warnings_filter)
82
+
83
+ # small value, converted to float
84
+ self.eps = np.sqrt(np.spacing(1))
85
+
86
+ if tolerance_x is None:
87
+ self.tolerance_x = self.eps
88
+ else:
89
+ self.tolerance_x = tolerance_x
90
+
91
+ self.fun = fun
92
+ self.bounds = bounds
93
+ self.max_iter = max_iter
94
+ self.n_initial = n_initial
95
+ self.surrogate = surrogate
96
+ self.acquisition = acquisition
97
+ self.var_type = var_type
98
+ self.seed = seed
99
+ self.verbose = verbose
100
+
101
+ # Derived attributes
102
+ self.n_dim = len(bounds)
103
+ self.lower = np.array([b[0] for b in bounds])
104
+ self.upper = np.array([b[1] for b in bounds])
105
+
106
+ # Default variable types
107
+ if self.var_type is None:
108
+ self.var_type = ["num"] * self.n_dim
109
+
110
+ # Initialize surrogate if not provided
111
+ if self.surrogate is None:
112
+ kernel = ConstantKernel(1.0, (1e-3, 1e3)) * Matern(
113
+ length_scale=1.0, length_scale_bounds=(1e-2, 1e2), nu=2.5
114
+ )
115
+ self.surrogate = GaussianProcessRegressor(
116
+ kernel=kernel,
117
+ n_restarts_optimizer=10,
118
+ normalize_y=True,
119
+ random_state=self.seed,
120
+ )
121
+
122
+ # Design generator
123
+ self.lhs_sampler = LatinHypercube(d=self.n_dim, seed=self.seed)
124
+
125
+ # Storage for results
126
+ self.X_ = None
127
+ self.y_ = None
128
+ self.best_x_ = None
129
+ self.best_y_ = None
130
+ self.n_iter_ = 0
131
+
132
+ def _evaluate_function(self, X: np.ndarray) -> np.ndarray:
133
+ """Evaluate objective function at points X."""
134
+ # Ensure X is 2D
135
+ X = np.atleast_2d(X)
136
+
137
+ # Evaluate function
138
+ y = self.fun(X)
139
+
140
+ # Ensure y is 1D
141
+ if isinstance(y, np.ndarray) and y.ndim > 1:
142
+ y = y.ravel()
143
+ elif not isinstance(y, np.ndarray):
144
+ y = np.array([y])
145
+
146
+ return y
147
+
148
+ def _generate_initial_design(self) -> np.ndarray:
149
+ """Generate initial space-filling design using Latin Hypercube Sampling."""
150
+ # Generate samples in [0, 1]^d
151
+ X0_unit = self.lhs_sampler.random(n=self.n_initial)
152
+
153
+ # Scale to [lower, upper]
154
+ X0 = self.lower + X0_unit * (self.upper - self.lower)
155
+
156
+ return self._repair_non_numeric(X0, self.var_type)
157
+
158
+ def _fit_surrogate(self, X: np.ndarray, y: np.ndarray) -> None:
159
+ """Fit surrogate model to data."""
160
+ self.surrogate.fit(X, y)
161
+
162
+ def _select_new(
163
+ self, A: np.ndarray, X: np.ndarray, tolerance: float = 0
164
+ ) -> Tuple[np.ndarray, np.ndarray]:
165
+ """
166
+ Select rows from A that are not in X.
167
+
168
+ Parameters
169
+ ----------
170
+ A : ndarray
171
+ Array with new values.
172
+ X : ndarray
173
+ Array with known values.
174
+ tolerance : float, default=0
175
+ Tolerance value for comparison.
176
+
177
+ Returns
178
+ -------
179
+ ndarray
180
+ Array with unknown (new) values.
181
+ ndarray
182
+ Array with True if value is new, otherwise False.
183
+ """
184
+ B = np.abs(A[:, None] - X)
185
+ ind = np.any(np.all(B <= tolerance, axis=2), axis=1)
186
+ return A[~ind], ~ind
187
+
188
+ def _repair_non_numeric(self, X: np.ndarray, var_type: List[str]) -> np.ndarray:
189
+ """
190
+ Round non-numeric values to integers.
191
+ This applies to all variables except for "num" and "float".
192
+
193
+ Parameters
194
+ ----------
195
+ X : ndarray
196
+ X array.
197
+ var_type : list of str
198
+ List with type information.
199
+
200
+ Returns
201
+ -------
202
+ ndarray
203
+ X array with non-numeric values rounded to integers.
204
+ """
205
+ mask = np.isin(var_type, ["num", "float"], invert=True)
206
+ X[:, mask] = np.around(X[:, mask])
207
+ return X
208
+
209
+ def _acquisition_function(self, x: np.ndarray) -> float:
210
+ """
211
+ Compute acquisition function value.
212
+
213
+ Parameters
214
+ ----------
215
+ x : ndarray of shape (n_features,)
216
+ Point to evaluate.
217
+
218
+ Returns
219
+ -------
220
+ float
221
+ Acquisition function value (to be minimized).
222
+ """
223
+ x = x.reshape(1, -1)
224
+
225
+ if self.acquisition == "y":
226
+ # Predicted mean
227
+ return self.surrogate.predict(x)[0]
228
+
229
+ elif self.acquisition == "ei":
230
+ # Expected Improvement
231
+ mu, sigma = self.surrogate.predict(x, return_std=True)
232
+ mu = mu[0]
233
+ sigma = sigma[0]
234
+
235
+ if sigma < 1e-10:
236
+ return 0.0
237
+
238
+ y_best = np.min(self.y_)
239
+ improvement = y_best - mu
240
+ Z = improvement / sigma
241
+
242
+ from scipy.stats import norm
243
+
244
+ ei = improvement * norm.cdf(Z) + sigma * norm.pdf(Z)
245
+ return -ei # Minimize negative EI
246
+
247
+ elif self.acquisition == "pi":
248
+ # Probability of Improvement
249
+ mu, sigma = self.surrogate.predict(x, return_std=True)
250
+ mu = mu[0]
251
+ sigma = sigma[0]
252
+
253
+ if sigma < 1e-10:
254
+ return 0.0
255
+
256
+ y_best = np.min(self.y_)
257
+ Z = (y_best - mu) / sigma
258
+
259
+ from scipy.stats import norm
260
+
261
+ pi = norm.cdf(Z)
262
+ return -pi # Minimize negative PI
263
+
264
+ else:
265
+ raise ValueError(f"Unknown acquisition function: {self.acquisition}")
266
+
267
+ def _suggest_next_point(self) -> np.ndarray:
268
+ """
269
+ Suggest next point to evaluate using acquisition function optimization.
270
+
271
+ Returns
272
+ -------
273
+ ndarray of shape (n_features,)
274
+ Next point to evaluate.
275
+ """
276
+ result = differential_evolution(
277
+ func=self._acquisition_function,
278
+ bounds=self.bounds,
279
+ seed=self.seed,
280
+ maxiter=1000,
281
+ )
282
+
283
+ x_next = result.x
284
+
285
+ # Ensure minimum distance to existing points
286
+ x_next_2d = x_next.reshape(1, -1)
287
+ x_new, _ = self._select_new(A=x_next_2d, X=self.X_, tolerance=self.tolerance_x)
288
+
289
+ if x_new.shape[0] == 0:
290
+ # If too close, generate random point
291
+ if self.verbose:
292
+ print("Proposed point too close, generating random point")
293
+ # Generate a random point using LHS
294
+ x_next_unit = self.lhs_sampler.random(n=1)[0]
295
+ x_next = self.lower + x_next_unit * (self.upper - self.lower)
296
+
297
+ return self._repair_non_numeric(x_next.reshape(1, -1), self.var_type)[0]
298
+
299
+ def optimize(self, X0: Optional[np.ndarray] = None) -> OptimizeResult:
300
+ """
301
+ Run the optimization process.
302
+
303
+ Parameters
304
+ ----------
305
+ X0 : ndarray of shape (n_initial, n_features), optional
306
+ Initial design points. If None, generates space-filling design.
307
+
308
+ Returns
309
+ -------
310
+ OptimizeResult
311
+ Optimization result with fields:
312
+ - x : best point found
313
+ - fun : best function value
314
+ - nfev : number of function evaluations
315
+ - success : whether optimization succeeded
316
+ - message : termination message
317
+ - X : all evaluated points
318
+ - y : all function values
319
+ """
320
+ # Generate or use provided initial design
321
+ if X0 is None:
322
+ X0 = self._generate_initial_design()
323
+ else:
324
+ X0 = np.atleast_2d(X0)
325
+ X0 = self._repair_non_numeric(X0, self.var_type)
326
+
327
+ # Evaluate initial design
328
+ y0 = self._evaluate_function(X0)
329
+
330
+ # Initialize storage
331
+ self.X_ = X0.copy()
332
+ self.y_ = y0.copy()
333
+ self.n_iter_ = 0
334
+
335
+ # Initial best
336
+ best_idx = np.argmin(self.y_)
337
+ self.best_x_ = self.X_[best_idx].copy()
338
+ self.best_y_ = self.y_[best_idx]
339
+
340
+ if self.verbose:
341
+ print(f"Initial best: f(x) = {self.best_y_:.6f}")
342
+
343
+ # Main optimization loop
344
+ for iteration in range(self.max_iter):
345
+ self.n_iter_ = iteration + 1
346
+
347
+ # Fit surrogate
348
+ self._fit_surrogate(self.X_, self.y_)
349
+
350
+ # Suggest next point
351
+ x_next = self._suggest_next_point()
352
+
353
+ # Evaluate next point
354
+ y_next = self._evaluate_function(x_next.reshape(1, -1))
355
+
356
+ # Update storage
357
+ self.X_ = np.vstack([self.X_, x_next])
358
+ self.y_ = np.append(self.y_, y_next)
359
+
360
+ # Update best
361
+ if y_next[0] < self.best_y_:
362
+ self.best_x_ = x_next.copy()
363
+ self.best_y_ = y_next[0]
364
+
365
+ if self.verbose:
366
+ print(
367
+ f"Iteration {iteration+1}: New best f(x) = {self.best_y_:.6f}"
368
+ )
369
+ elif self.verbose:
370
+ print(f"Iteration {iteration+1}: f(x) = {y_next[0]:.6f}")
371
+
372
+ # Return scipy-style result
373
+ return OptimizeResult(
374
+ x=self.best_x_,
375
+ fun=self.best_y_,
376
+ nfev=len(self.y_),
377
+ nit=self.n_iter_,
378
+ success=True,
379
+ message="Optimization finished successfully",
380
+ X=self.X_,
381
+ y=self.y_,
382
+ )
spotoptim/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ def hello() -> str:
2
+ return "Hello from spotoptim!"
spotoptim/py.typed ADDED
File without changes
@@ -0,0 +1,12 @@
1
+ Metadata-Version: 2.3
2
+ Name: spotoptim
3
+ Version: 0.0.4
4
+ Summary: Add your description here
5
+ Author: bartzbeielstein
6
+ Author-email: bartzbeielstein <32470350+bartzbeielstein@users.noreply.github.com>
7
+ Requires-Dist: numpy>=1.24.3
8
+ Requires-Dist: scipy>=1.10.1
9
+ Requires-Dist: scikit-learn>=1.3.0
10
+ Requires-Python: >=3.10
11
+ Description-Content-Type: text/markdown
12
+
@@ -0,0 +1,6 @@
1
+ spotoptim/SpotOptim.py,sha256=eExPcSq2rLolokE02Yb_U2ShCCjnUrQ3mbT221yWc_A,11890
2
+ spotoptim/__init__.py,sha256=d0H44GlTwjxbCV7oWR91CGQfwocBwqouV3U1w88IV7I,55
3
+ spotoptim/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ spotoptim-0.0.4.dist-info/WHEEL,sha256=DpNsHFUm_gffZe1FgzmqwuqiuPC6Y-uBCzibcJcdupM,78
5
+ spotoptim-0.0.4.dist-info/METADATA,sha256=2wK_MA4u6CUsNzeT6VsHgNjDUW8C4WSz7L3G304DWkk,352
6
+ spotoptim-0.0.4.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.9.8
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any