Skip to content

Commit 72f6263

Browse files
committed
Add tests for BayesianOptimizerController and fix verbosity
1 parent 17046db commit 72f6263

File tree

2 files changed

+197
-23
lines changed

2 files changed

+197
-23
lines changed

climada/util/calibrate/bayesian_optimizer.py

Lines changed: 31 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
import numpy as np
3030
import matplotlib.pyplot as plt
3131
import matplotlib.axes as maxes
32-
from bayes_opt import BayesianOptimization, Events, UtilityFunction
32+
from bayes_opt import BayesianOptimization, Events, UtilityFunction, ScreenLogger
3333
from bayes_opt.target_space import TargetSpace
3434

3535
from .base import Input, Output, Optimizer, OutputEvaluator
@@ -237,8 +237,8 @@ class BayesianOptimizerController(object):
237237
"""
238238

239239
# Init attributes
240-
init_points: int = 0
241-
n_iter: int = 0
240+
init_points: int
241+
n_iter: int
242242
min_improvement: float = 1e-3
243243
min_improvement_count: int = 2
244244
kappa: float = 2.576
@@ -256,9 +256,13 @@ class BayesianOptimizerController(object):
256256

257257
def __post_init__(self):
258258
"""Set the decay factor for :py:attr:`kappa`."""
259-
self.kappa_decay = np.exp(
260-
(np.log(self.kappa_min) - np.log(self.kappa)) / self.n_iter
261-
)
259+
if self.init_points < 0 or self.n_iter < 0:
260+
raise ValueError("'init_points' and 'n_iter' must be 0 or positive")
261+
self.kappa_decay = self._calc_kappa_decay()
262+
263+
def _calc_kappa_decay(self):
264+
"""Compute the decay factor for :py:attr:`kappa`."""
265+
return np.exp((np.log(self.kappa_min) - np.log(self.kappa)) / self.n_iter)
262266

263267
@classmethod
264268
def from_input(cls, inp: Input, sampling_base: float = 4, **kwargs):
@@ -291,10 +295,6 @@ def _previous_max(self):
291295
return -np.inf
292296
return self._improvements[-1].target
293297

294-
def is_converged(self) -> bool:
295-
"""Check if convergence criteria are met"""
296-
return True
297-
298298
def optimizer_params(self) -> dict[str, Union[int, float, str, UtilityFunction]]:
299299
"""Return parameters for the optimizer"""
300300
return {
@@ -365,7 +365,7 @@ def _maybe_stop_early(self, instance):
365365
instance.dispatch(Events.OPTIMIZATION_END)
366366
raise StopEarly()
367367

368-
def update(self, event, instance):
368+
def update(self, event: str, instance: BayesianOptimization):
369369
"""Update the step tracker of this instance.
370370
371371
For step events, check if the latest guess is the new maximum. Also check if the
@@ -441,7 +441,8 @@ class BayesianOptimizer(Optimizer):
441441
input : Input
442442
The input data for this optimizer. See the Notes below for input requirements.
443443
verbose : int, optional
444-
Verbosity of the optimizer output. Defaults to 1.
444+
Verbosity of the optimizer output. Defaults to 0. The output is *not* affected
445+
by the CLIMADA logging settings.
445446
random_state : int, optional
446447
Seed for initializing the random number generator. Defaults to 1.
447448
allow_duplicate_points : bool, optional
@@ -472,14 +473,12 @@ class BayesianOptimizer(Optimizer):
472473
The optimizer instance of this class.
473474
"""
474475

475-
verbose: InitVar[int] = 1
476+
verbose: int = 0
476477
random_state: InitVar[int] = 1
477478
allow_duplicate_points: InitVar[bool] = True
478479
bayes_opt_kwds: InitVar[Optional[Mapping[str, Any]]] = None
479480

480-
def __post_init__(
481-
self, verbose, random_state, allow_duplicate_points, bayes_opt_kwds
482-
):
481+
def __post_init__(self, random_state, allow_duplicate_points, bayes_opt_kwds):
483482
"""Create optimizer"""
484483
if bayes_opt_kwds is None:
485484
bayes_opt_kwds = {}
@@ -491,7 +490,6 @@ def __post_init__(
491490
f=self._opt_func,
492491
pbounds=self.input.bounds,
493492
constraint=self.input.constraints,
494-
verbose=verbose,
495493
random_state=random_state,
496494
allow_duplicate_points=allow_duplicate_points,
497495
**bayes_opt_kwds,
@@ -501,10 +499,7 @@ def _target_func(self, data: pd.DataFrame, predicted: pd.DataFrame) -> Number:
501499
"""Invert the cost function because BayesianOptimization maximizes the target"""
502500
return -self.input.cost_func(data, predicted)
503501

504-
def run(
505-
self,
506-
controller: BayesianOptimizerController,
507-
) -> BayesianOptimizerOutput:
502+
def run(self, controller: BayesianOptimizerController) -> BayesianOptimizerOutput:
508503
"""Execute the optimization
509504
510505
``BayesianOptimization`` *maximizes* a target function. Therefore, this class
@@ -524,14 +519,29 @@ def run(
524519
Optimization output. :py:attr:`BayesianOptimizerOutput.p_space` stores data
525520
on the sampled parameter space.
526521
"""
522+
# Register the controller
527523
for event in (Events.OPTIMIZATION_STEP, Events.OPTIMIZATION_END):
528524
self.optimizer.subscribe(event, controller)
529525

526+
# Register the logger
527+
if self.verbose > 0:
528+
log = ScreenLogger(
529+
verbose=self.verbose, is_constrained=self.optimizer.is_constrained
530+
)
531+
for event in (
532+
Events.OPTIMIZATION_START,
533+
Events.OPTIMIZATION_STEP,
534+
Events.OPTIMIZATION_END,
535+
):
536+
self.optimizer.subscribe(event, log)
537+
538+
# Run the optimization
530539
while controller.iterations < controller.max_iterations:
531540
try:
532541
LOGGER.info(f"Optimization iteration: {controller.iterations}")
533542
self.optimizer.maximize(**controller.optimizer_params())
534543
except StopEarly:
544+
# Start a new iteration
535545
continue
536546
except StopIteration:
537547
# Exit the loop

climada/util/calibrate/test/test_bayesian_optimizer.py

Lines changed: 166 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,18 @@
2121
import unittest
2222
from unittest.mock import patch, MagicMock
2323

24+
import numpy as np
2425
import numpy.testing as npt
2526
import pandas as pd
27+
from bayes_opt import BayesianOptimization, UtilityFunction, Events
28+
from scipy.optimize import NonlinearConstraint
2629

2730
from climada.util.calibrate import Input, BayesianOptimizer, BayesianOptimizerController
31+
from climada.util.calibrate.bayesian_optimizer import (
32+
Improvement,
33+
StopEarly,
34+
BayesianOptimizerOutput,
35+
)
2836

2937
from .test_base import hazard, exposure
3038

@@ -44,12 +52,26 @@ def input():
4452
class TestBayesianOptimizerController(unittest.TestCase):
4553
"""Tests for the controller of the BayesianOptimizer"""
4654

55+
def setUp(self):
56+
"""Create a optimization instance"""
57+
self.bayes_opt = BayesianOptimization(
58+
f=lambda x: -(x**2),
59+
pbounds={"x": (-10, 10)},
60+
constraint=NonlinearConstraint(fun=lambda x: x, lb=-0.5, ub=np.inf),
61+
verbose=0,
62+
allow_duplicate_points=True,
63+
)
64+
65+
def _make_step(self, x, controller):
66+
self.bayes_opt.probe({"x": x}, lazy=False)
67+
controller.update(Events.OPTIMIZATION_STEP, self.bayes_opt)
68+
4769
def test_kappa_decay(self):
4870
"""Check correct values for kappa_decay"""
49-
contr = BayesianOptimizerController(kappa=3, kappa_min=3, n_iter=10)
71+
contr = BayesianOptimizerController(0, kappa=3, kappa_min=3, n_iter=10)
5072
self.assertAlmostEqual(contr.kappa_decay, 1.0)
5173

52-
contr = BayesianOptimizerController(kappa=3, kappa_min=1, n_iter=10)
74+
contr = BayesianOptimizerController(0, kappa=3, kappa_min=1, n_iter=10)
5375
self.assertAlmostEqual(contr.kappa * (contr.kappa_decay**10), 1.0)
5476

5577
def test_from_input(self):
@@ -62,6 +84,142 @@ def test_from_input(self):
6284
self.assertEqual(contr.init_points, 3**2)
6385
self.assertEqual(contr.n_iter, 3**2)
6486

87+
def test_optimizer_params(self):
88+
"""Test BayesianOptimizerController.optimizer_params"""
89+
contr = BayesianOptimizerController(
90+
1, 2, kappa=3, utility_func_kwargs={"xi": 1.11, "kind": "ei"}
91+
)
92+
result = contr.optimizer_params()
93+
94+
self.assertDictContainsSubset(
95+
{
96+
"init_points": 1,
97+
"n_iter": 2,
98+
},
99+
result,
100+
)
101+
util_func = result["acquisition_function"]
102+
self.assertEqual(util_func.kappa, 3)
103+
self.assertEqual(util_func._kappa_decay, contr._calc_kappa_decay())
104+
self.assertEqual(util_func.xi, 1.11)
105+
self.assertEqual(util_func.kind, "ei")
106+
107+
def test_update_step(self):
108+
"""Test the update for STEP events"""
109+
contr = BayesianOptimizerController(3, 2)
110+
111+
# Regular step
112+
self._make_step(3.0, contr)
113+
self.assertEqual(contr.steps, 1)
114+
best = Improvement(
115+
iteration=0, sample=0, random=True, target=-9.0, improvement=np.inf
116+
)
117+
self.assertEqual(len(contr._improvements), 1)
118+
self.assertTupleEqual(contr._improvements[-1], best)
119+
120+
# Step that has no effect due to constraints
121+
self._make_step(-2.0, contr)
122+
self.assertEqual(contr.steps, 2)
123+
self.assertEqual(len(contr._improvements), 1)
124+
self.assertTupleEqual(contr._improvements[-1], best)
125+
126+
# Step that is not new max
127+
self._make_step(4.0, contr)
128+
self.assertEqual(contr.steps, 3)
129+
self.assertEqual(len(contr._improvements), 1)
130+
self.assertTupleEqual(contr._improvements[-1], best)
131+
132+
# Two minimal increments, therefore we should see a StopEarly
133+
self._make_step(2.999, contr)
134+
self.assertEqual(contr.steps, 4)
135+
self.assertEqual(len(contr._improvements), 2)
136+
137+
with self.assertRaises(StopEarly):
138+
self._make_step(2.998, contr)
139+
self.assertEqual(contr.steps, 5)
140+
self.assertEqual(len(contr._improvements), 3)
141+
142+
def test_update_end(self):
143+
"""Test the update for END events"""
144+
contr = BayesianOptimizerController(1, 1)
145+
146+
# One step with improvement, then stop
147+
self._make_step(3.0, contr)
148+
contr.update(Events.OPTIMIZATION_END, self.bayes_opt)
149+
self.assertEqual(contr._last_it_improved, 0)
150+
self.assertEqual(contr._last_it_end, 1)
151+
152+
# One step with no more improvement
153+
self._make_step(4.0, contr)
154+
with self.assertRaises(StopIteration):
155+
contr.update(Events.OPTIMIZATION_END, self.bayes_opt)
156+
157+
def test_improvements(self):
158+
"""Test ouput of BayesianOptimizerController.improvements"""
159+
contr = BayesianOptimizerController(1, 1)
160+
self._make_step(3.0, contr)
161+
self._make_step(2.0, contr)
162+
contr.update(Events.OPTIMIZATION_END, self.bayes_opt)
163+
self._make_step(2.5, contr) # Not better
164+
self._make_step(1.0, contr)
165+
contr.update(Events.OPTIMIZATION_END, self.bayes_opt)
166+
self._make_step(-0.9, contr) # Constrained
167+
168+
df = contr.improvements()
169+
pd.testing.assert_frame_equal(
170+
df,
171+
pd.DataFrame.from_dict(
172+
data={
173+
"iteration": [0, 0, 1],
174+
"sample": [0, 1, 3],
175+
"random": [True, False, False],
176+
"target": [-9.0, -4.0, -1.0],
177+
"improvement": [np.inf, 9.0 / 4.0 - 1, 3.0],
178+
}
179+
).set_index("sample"),
180+
)
181+
182+
183+
class TestBayesianOptimizerOutput(unittest.TestCase):
184+
"""Tests for the output class of BayesianOptimizer"""
185+
186+
def test_p_space_to_dataframe(self):
187+
""""""
188+
bayes_opt = BayesianOptimization(
189+
f=lambda x: -(x**2),
190+
pbounds={"x": (-10, 10)},
191+
constraint=NonlinearConstraint(fun=lambda x: x, lb=-0.5, ub=np.inf),
192+
verbose=0,
193+
allow_duplicate_points=True,
194+
)
195+
bayes_opt.probe({"x": 2.0}, lazy=False)
196+
bayes_opt.probe({"x": 1.0}, lazy=False)
197+
bayes_opt.probe({"x": -0.9}, lazy=False)
198+
199+
output = BayesianOptimizerOutput(
200+
params=bayes_opt.max["params"],
201+
target=bayes_opt.max["target"],
202+
p_space=bayes_opt.space,
203+
)
204+
self.assertDictEqual(output.params, {"x": 1.0})
205+
self.assertEqual(output.target, -1.0)
206+
207+
idx = pd.MultiIndex.from_tuples(
208+
[
209+
("Parameters", "x"),
210+
("Calibration", "Cost Function"),
211+
("Calibration", "Constraints Function"),
212+
("Calibration", "Allowed"),
213+
]
214+
)
215+
df = pd.DataFrame(data=None, columns=idx)
216+
df["Parameters", "x"] = [2.0, 1.0, -0.9]
217+
df["Calibration", "Cost Function"] = [4.0, 1.0, 0.9**2]
218+
df["Calibration", "Constraints Function"] = df["Parameters", "x"]
219+
df["Calibration", "Allowed"] = [True, True, False]
220+
df.index.rename("Iteration", inplace=True)
221+
pd.testing.assert_frame_equal(output.p_space_to_dataframe(), df)
222+
65223

66224
class TestBayesianOptimizer(unittest.TestCase):
67225
"""Tests for the optimizer based on bayes_opt.BayesianOptimization"""
@@ -121,4 +279,10 @@ def test_target_func(self, _):
121279
# Execute Tests
122280
if __name__ == "__main__":
123281
TESTS = unittest.TestLoader().loadTestsFromTestCase(TestBayesianOptimizer)
282+
TESTS.addTests(
283+
unittest.TestLoader().loadTestsFromTestCase(TestBayesianOptimizerOutput)
284+
)
285+
TESTS.addTests(
286+
unittest.TestLoader().loadTestsFromTestCase(TestBayesianOptimizerController)
287+
)
124288
unittest.TextTestRunner(verbosity=2).run(TESTS)

0 commit comments

Comments
 (0)