Skip to content

Commit 0ef608f

Browse files
till-mphi-friday
andauthored
Typed Optimization (#531)
* WIP * Add ML example * Save for merge * Update * Parameter types more (#13) * fix: import error from exception module (#525) * fix: replace list with sequence (#524) * Fix min window type check (#523) * fix: replace dict with Mapping * fix: replace list with Sequence * fix: add type hint * fix: does not accept None * Change docs badge (#527) * fix: parameter, target_space * fix: constraint, bayesian_optimization * fix: ParamsType --------- Co-authored-by: till-m <[email protected]> * Use `.masks` not `._masks` * User `super` to call kernel * Update logging for parameters * Disable SDR when non-float parameters are present * Add demo script for typed optimization * Update parameters, testing * Remove sorting, gradient optimize only continuous params * Go back to `wrap_kernel` * Update code * Remove `tqdm` dependency, use EI acq * Add more text to typed optimization notebook. * Save files while moving device * Update with custom parameter type example * Mention that parameters are not sorted * Change array reg warning * Update Citations, parameter notebook --------- Co-authored-by: phi-friday <[email protected]>
1 parent e487e5f commit 0ef608f

24 files changed

+2082
-425
lines changed

README.md

+13
Original file line numberDiff line numberDiff line change
@@ -185,3 +185,16 @@ For constrained optimization:
185185
year={2014}
186186
}
187187
```
188+
189+
For optimization over non-float parameters:
190+
```
191+
@article{garrido2020dealing,
192+
title={Dealing with categorical and integer-valued variables in bayesian optimization with gaussian processes},
193+
author={Garrido-Merch{\'a}n, Eduardo C and Hern{\'a}ndez-Lobato, Daniel},
194+
journal={Neurocomputing},
195+
volume={380},
196+
pages={20--35},
197+
year={2020},
198+
publisher={Elsevier}
199+
}
200+
```

bayes_opt/acquisition.py

+56-34
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ def suggest(
127127
self._fit_gp(gp=gp, target_space=target_space)
128128

129129
acq = self._get_acq(gp=gp, constraint=target_space.constraint)
130-
return self._acq_min(acq, target_space.bounds, n_random=n_random, n_l_bfgs_b=n_l_bfgs_b)
130+
return self._acq_min(acq, target_space, n_random=n_random, n_l_bfgs_b=n_l_bfgs_b)
131131

132132
def _get_acq(
133133
self, gp: GaussianProcessRegressor, constraint: ConstraintModel | None = None
@@ -182,7 +182,7 @@ def acq(x: NDArray[Float]) -> NDArray[Float]:
182182
def _acq_min(
183183
self,
184184
acq: Callable[[NDArray[Float]], NDArray[Float]],
185-
bounds: NDArray[Float],
185+
space: TargetSpace,
186186
n_random: int = 10_000,
187187
n_l_bfgs_b: int = 10,
188188
) -> NDArray[Float]:
@@ -197,10 +197,8 @@ def _acq_min(
197197
acq : Callable
198198
Acquisition function to use. Should accept an array of parameters `x`.
199199
200-
bounds : np.ndarray
201-
Bounds of the search space. For `N` parameters this has shape
202-
`(N, 2)` with `[i, 0]` the lower bound of parameter `i` and
203-
`[i, 1]` the upper bound.
200+
space : TargetSpace
201+
The target space over which to optimize.
204202
205203
n_random : int
206204
Number of random samples to use.
@@ -217,15 +215,22 @@ def _acq_min(
217215
if n_random == 0 and n_l_bfgs_b == 0:
218216
error_msg = "Either n_random or n_l_bfgs_b needs to be greater than 0."
219217
raise ValueError(error_msg)
220-
x_min_r, min_acq_r = self._random_sample_minimize(acq, bounds, n_random=n_random)
221-
x_min_l, min_acq_l = self._l_bfgs_b_minimize(acq, bounds, n_x_seeds=n_l_bfgs_b)
222-
# Either n_random or n_l_bfgs_b is not 0 => at least one of x_min_r and x_min_l is not None
223-
if min_acq_r < min_acq_l:
224-
return x_min_r
225-
return x_min_l
218+
x_min_r, min_acq_r, x_seeds = self._random_sample_minimize(
219+
acq, space, n_random=max(n_random, n_l_bfgs_b), n_x_seeds=n_l_bfgs_b
220+
)
221+
if n_l_bfgs_b:
222+
x_min_l, min_acq_l = self._l_bfgs_b_minimize(acq, space, x_seeds=x_seeds)
223+
# Either n_random or n_l_bfgs_b is not 0 => at least one of x_min_r and x_min_l is not None
224+
if min_acq_r > min_acq_l:
225+
return x_min_l
226+
return x_min_r
226227

227228
def _random_sample_minimize(
228-
self, acq: Callable[[NDArray[Float]], NDArray[Float]], bounds: NDArray[Float], n_random: int
229+
self,
230+
acq: Callable[[NDArray[Float]], NDArray[Float]],
231+
space: TargetSpace,
232+
n_random: int,
233+
n_x_seeds: int = 0,
229234
) -> tuple[NDArray[Float] | None, float]:
230235
"""Random search to find the minimum of `acq` function.
231236
@@ -234,14 +239,14 @@ def _random_sample_minimize(
234239
acq : Callable
235240
Acquisition function to use. Should accept an array of parameters `x`.
236241
237-
bounds : np.ndarray
238-
Bounds of the search space. For `N` parameters this has shape
239-
`(N, 2)` with `[i, 0]` the lower bound of parameter `i` and
240-
`[i, 1]` the upper bound.
242+
space : TargetSpace
243+
The target space over which to optimize.
241244
242245
n_random : int
243246
Number of random samples to use.
244247
248+
n_x_seeds : int
249+
Number of top points to return, for use as starting points for L-BFGS-B.
245250
Returns
246251
-------
247252
x_min : np.ndarray
@@ -252,14 +257,22 @@ def _random_sample_minimize(
252257
"""
253258
if n_random == 0:
254259
return None, np.inf
255-
x_tries = self.random_state.uniform(bounds[:, 0], bounds[:, 1], size=(n_random, bounds.shape[0]))
260+
x_tries = space.random_sample(n_random, random_state=self.random_state)
256261
ys = acq(x_tries)
257262
x_min = x_tries[ys.argmin()]
258263
min_acq = ys.min()
259-
return x_min, min_acq
264+
if n_x_seeds != 0:
265+
idxs = np.argsort(ys)[-n_x_seeds:]
266+
x_seeds = x_tries[idxs]
267+
else:
268+
x_seeds = []
269+
return x_min, min_acq, x_seeds
260270

261271
def _l_bfgs_b_minimize(
262-
self, acq: Callable[[NDArray[Float]], NDArray[Float]], bounds: NDArray[Float], n_x_seeds: int = 10
272+
self,
273+
acq: Callable[[NDArray[Float]], NDArray[Float]],
274+
space: TargetSpace,
275+
x_seeds: NDArray[Float] | None = None,
263276
) -> tuple[NDArray[Float] | None, float]:
264277
"""Random search to find the minimum of `acq` function.
265278
@@ -268,13 +281,11 @@ def _l_bfgs_b_minimize(
268281
acq : Callable
269282
Acquisition function to use. Should accept an array of parameters `x`.
270283
271-
bounds : np.ndarray
272-
Bounds of the search space. For `N` parameters this has shape
273-
`(N, 2)` with `[i, 0]` the lower bound of parameter `i` and
274-
`[i, 1]` the upper bound.
284+
space : TargetSpace
285+
The target space over which to optimize.
275286
276-
n_x_seeds : int
277-
Number of starting points for the L-BFGS-B optimizer.
287+
x_seeds : int
288+
Starting points for the L-BFGS-B optimizer.
278289
279290
Returns
280291
-------
@@ -284,33 +295,44 @@ def _l_bfgs_b_minimize(
284295
min_acq : float
285296
Acquisition function value at `x_min`
286297
"""
287-
if n_x_seeds == 0:
288-
return None, np.inf
289-
x_seeds = self.random_state.uniform(bounds[:, 0], bounds[:, 1], size=(n_x_seeds, bounds.shape[0]))
298+
continuous_dimensions = space.continuous_dimensions
299+
continuous_bounds = space.bounds[continuous_dimensions]
300+
301+
if not continuous_dimensions.any():
302+
min_acq = np.inf
303+
x_min = np.array([np.nan] * space.bounds.shape[0])
304+
return x_min, min_acq
290305

291306
min_acq: float | None = None
292307
x_try: NDArray[Float]
293308
x_min: NDArray[Float]
294309
for x_try in x_seeds:
295-
# Find the minimum of minus the acquisition function
296-
res: OptimizeResult = minimize(acq, x_try, bounds=bounds, method="L-BFGS-B")
297310

311+
def continuous_acq(x: NDArray[Float], x_try=x_try) -> NDArray[Float]:
312+
x_try[continuous_dimensions] = x
313+
return acq(x_try)
314+
315+
# Find the minimum of minus the acquisition function
316+
res: OptimizeResult = minimize(
317+
continuous_acq, x_try[continuous_dimensions], bounds=continuous_bounds, method="L-BFGS-B"
318+
)
298319
# See if success
299320
if not res.success:
300321
continue
301322

302323
# Store it if better than previous minimum(maximum).
303324
if min_acq is None or np.squeeze(res.fun) >= min_acq:
304-
x_min = res.x
325+
x_try[continuous_dimensions] = res.x
326+
x_min = x_try
305327
min_acq = np.squeeze(res.fun)
306328

307329
if min_acq is None:
308330
min_acq = np.inf
309-
x_min = np.array([np.nan] * bounds.shape[0])
331+
x_min = np.array([np.nan] * space.bounds.shape[0])
310332

311333
# Clip output to make sure it lies within the bounds. Due to floating
312334
# point technicalities this is not always the case.
313-
return np.clip(x_min, bounds[:, 0], bounds[:, 1]), min_acq
335+
return np.clip(x_min, space.bounds[:, 0], space.bounds[:, 1]), min_acq
314336

315337

316338
class UpperConfidenceBound(AcquisitionFunction):

bayes_opt/bayesian_optimization.py

+34-35
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,15 @@
1616

1717
from bayes_opt import acquisition
1818
from bayes_opt.constraint import ConstraintModel
19+
from bayes_opt.domain_reduction import DomainTransformer
1920
from bayes_opt.event import DEFAULT_EVENTS, Events
2021
from bayes_opt.logger import _get_default_logger
22+
from bayes_opt.parameter import wrap_kernel
2123
from bayes_opt.target_space import TargetSpace
2224
from bayes_opt.util import ensure_rng
2325

2426
if TYPE_CHECKING:
25-
from collections.abc import Callable, Iterable, Mapping, Sequence
27+
from collections.abc import Callable, Iterable, Mapping
2628

2729
from numpy.random import RandomState
2830
from numpy.typing import NDArray
@@ -31,6 +33,7 @@
3133
from bayes_opt.acquisition import AcquisitionFunction
3234
from bayes_opt.constraint import ConstraintModel
3335
from bayes_opt.domain_reduction import DomainTransformer
36+
from bayes_opt.parameter import BoundsMapping, ParamsType
3437

3538
Float = np.floating[Any]
3639

@@ -114,7 +117,7 @@ def __init__(
114117
):
115118
self._random_state = ensure_rng(random_state)
116119
self._allow_duplicate_points = allow_duplicate_points
117-
self._queue: deque[Mapping[str, float] | Sequence[float] | NDArray[Float]] = deque()
120+
self._queue: deque[ParamsType] = deque()
118121

119122
if acquisition_function is None:
120123
if constraint is None:
@@ -128,15 +131,6 @@ def __init__(
128131
else:
129132
self._acquisition_function = acquisition_function
130133

131-
# Internal GP regressor
132-
self._gp = GaussianProcessRegressor(
133-
kernel=Matern(nu=2.5),
134-
alpha=1e-6,
135-
normalize_y=True,
136-
n_restarts_optimizer=5,
137-
random_state=self._random_state,
138-
)
139-
140134
if constraint is None:
141135
# Data structure containing the function to be optimized, the
142136
# bounds of its domain, and a record of the evaluations we have
@@ -158,14 +152,22 @@ def __init__(
158152
)
159153
self.is_constrained = True
160154

155+
# Internal GP regressor
156+
self._gp = GaussianProcessRegressor(
157+
kernel=wrap_kernel(Matern(nu=2.5), transform=self._space.kernel_transform),
158+
alpha=1e-6,
159+
normalize_y=True,
160+
n_restarts_optimizer=5,
161+
random_state=self._random_state,
162+
)
163+
161164
self._verbose = verbose
162165
self._bounds_transformer = bounds_transformer
163166
if self._bounds_transformer:
164-
try:
165-
self._bounds_transformer.initialize(self._space)
166-
except (AttributeError, TypeError) as exc:
167-
error_msg = "The transformer must be an instance of DomainTransformer"
168-
raise TypeError(error_msg) from exc
167+
if not isinstance(self._bounds_transformer, DomainTransformer):
168+
msg = "The transformer must be an instance of DomainTransformer"
169+
raise TypeError(msg)
170+
self._bounds_transformer.initialize(self._space)
169171

170172
self._sorting_warning_already_shown = False # TODO: remove in future version
171173
super().__init__(events=DEFAULT_EVENTS)
@@ -204,10 +206,7 @@ def res(self) -> list[dict[str, Any]]:
204206
return self._space.res()
205207

206208
def register(
207-
self,
208-
params: Mapping[str, float] | Sequence[float] | NDArray[Float],
209-
target: float,
210-
constraint_value: float | NDArray[Float] | None = None,
209+
self, params: ParamsType, target: float, constraint_value: float | NDArray[Float] | None = None
211210
) -> None:
212211
"""Register an observation with known target.
213212
@@ -225,20 +224,18 @@ def register(
225224
# TODO: remove in future version
226225
if isinstance(params, np.ndarray) and not self._sorting_warning_already_shown:
227226
msg = (
228-
"You're attempting to register an np.ndarray. Currently, the optimizer internally sorts"
229-
" parameters by key and expects any registered array to respect this order. In future"
230-
" versions this behaviour will change and the order as given by the pbounds dictionary"
231-
" will be used. If you wish to retain sorted parameters, please manually sort your pbounds"
227+
"You're attempting to register an np.ndarray. In previous versions, the optimizer internally"
228+
" sorted parameters by key and expected any registered array to respect this order."
229+
" In the current and any future version the order as given by the pbounds dictionary will be"
230+
" used. If you wish to retain sorted parameters, please manually sort your pbounds"
232231
" dictionary before constructing the optimizer."
233232
)
234233
warn(msg, stacklevel=1)
235234
self._sorting_warning_already_shown = True
236235
self._space.register(params, target, constraint_value)
237236
self.dispatch(Events.OPTIMIZATION_STEP)
238237

239-
def probe(
240-
self, params: Mapping[str, float] | Sequence[float] | NDArray[Float], lazy: bool = True
241-
) -> None:
238+
def probe(self, params: ParamsType, lazy: bool = True) -> None:
242239
"""Evaluate the function at the given points.
243240
244241
Useful to guide the optimizer.
@@ -255,10 +252,10 @@ def probe(
255252
# TODO: remove in future version
256253
if isinstance(params, np.ndarray) and not self._sorting_warning_already_shown:
257254
msg = (
258-
"You're attempting to register an np.ndarray. Currently, the optimizer internally sorts"
259-
" parameters by key and expects any registered array to respect this order. In future"
260-
" versions this behaviour will change and the order as given by the pbounds dictionary"
261-
" will be used. If you wish to retain sorted parameters, please manually sort your pbounds"
255+
"You're attempting to register an np.ndarray. In previous versions, the optimizer internally"
256+
" sorted parameters by key and expected any registered array to respect this order."
257+
" In the current and any future version the order as given by the pbounds dictionary will be"
258+
" used. If you wish to retain sorted parameters, please manually sort your pbounds"
262259
" dictionary before constructing the optimizer."
263260
)
264261
warn(msg, stacklevel=1)
@@ -270,10 +267,10 @@ def probe(
270267
self._space.probe(params)
271268
self.dispatch(Events.OPTIMIZATION_STEP)
272269

273-
def suggest(self) -> dict[str, float]:
270+
def suggest(self) -> dict[str, float | NDArray[Float]]:
274271
"""Suggest a promising point to probe next."""
275272
if len(self._space) == 0:
276-
return self._space.array_to_params(self._space.random_sample())
273+
return self._space.array_to_params(self._space.random_sample(random_state=self._random_state))
277274

278275
# Finding argmax of the acquisition function.
279276
suggestion = self._acquisition_function.suggest(gp=self._gp, target_space=self._space, fit_gp=True)
@@ -292,7 +289,7 @@ def _prime_queue(self, init_points: int) -> None:
292289
init_points = max(init_points, 1)
293290

294291
for _ in range(init_points):
295-
sample = self._space.random_sample()
292+
sample = self._space.random_sample(random_state=self._random_state)
296293
self._queue.append(self._space.array_to_params(sample))
297294

298295
def _prime_subscriptions(self) -> None:
@@ -344,7 +341,7 @@ def maximize(self, init_points: int = 5, n_iter: int = 25) -> None:
344341

345342
self.dispatch(Events.OPTIMIZATION_END)
346343

347-
def set_bounds(self, new_bounds: Mapping[str, NDArray[Float] | Sequence[float]]) -> None:
344+
def set_bounds(self, new_bounds: BoundsMapping) -> None:
348345
"""Modify the bounds of the search space.
349346
350347
Parameters
@@ -356,4 +353,6 @@ def set_bounds(self, new_bounds: Mapping[str, NDArray[Float] | Sequence[float]])
356353

357354
def set_gp_params(self, **params: Any) -> None:
358355
"""Set parameters of the internal Gaussian Process Regressor."""
356+
if "kernel" in params:
357+
params["kernel"] = wrap_kernel(kernel=params["kernel"], transform=self._space.kernel_transform)
359358
self._gp.set_params(**params)

bayes_opt/constraint.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
from sklearn.gaussian_process import GaussianProcessRegressor
1010
from sklearn.gaussian_process.kernels import Matern
1111

12+
from bayes_opt.parameter import wrap_kernel
13+
1214
if TYPE_CHECKING:
1315
from collections.abc import Callable
1416

@@ -55,6 +57,7 @@ def __init__(
5557
fun: Callable[..., float] | Callable[..., NDArray[Float]] | None,
5658
lb: float | NDArray[Float],
5759
ub: float | NDArray[Float],
60+
transform: Callable[[Any], Any] | None = None,
5861
random_state: int | RandomState | None = None,
5962
) -> None:
6063
self.fun = fun
@@ -68,7 +71,7 @@ def __init__(
6871

6972
self._model = [
7073
GaussianProcessRegressor(
71-
kernel=Matern(nu=2.5),
74+
kernel=wrap_kernel(Matern(nu=2.5), transform) if transform is not None else Matern(nu=2.5),
7275
alpha=1e-6,
7376
normalize_y=True,
7477
n_restarts_optimizer=5,

0 commit comments

Comments
 (0)