Skip to content

Commit 9538bb5

Browse files
peanutfunemanuel-schmidtovogttimschmi95Schmid  Timo
authored
Add module for calibrating impact functions (#692)
* Add climada.util.calibrate module with tests * Add calibration tutorial * Add JOSS publication * Add 'bayesian-optimization' and 'seaborn' as dependencies * Update CHANGELOG.md --------- Co-authored-by: emanuel-schmid <[email protected]> Co-authored-by: Thomas Vogt <[email protected]> Co-authored-by: Timo Schmid <[email protected]> Co-authored-by: Schmid Timo <[email protected]> Co-authored-by: Chahan M. Kropf <[email protected]>
1 parent b93842e commit 9538bb5

22 files changed

+7133
-0
lines changed

AUTHORS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
* Raphael Portmann
3030
* Nicolas Colombi
3131
* Leonie Villiger
32+
* Timo Schmid
3233
* Kam Lam Yeung
3334
* Sarah Hülsen
3435
* Timo Schmid

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ Code freeze date: YYYY-MM-DD
1313
### Added
1414

1515
- GitHub actions workflow for CLIMADA Petals compatibility tests [#855](https://github.com/CLIMADA-project/climada_python/pull/855)
16+
- `climada.util.calibrate` module for calibrating impact functions [#692](https://github.com/CLIMADA-project/climada_python/pull/692)
1617

1718
### Changed
1819

Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
"""
2+
This file is part of CLIMADA.
3+
4+
Copyright (C) 2017 ETH Zurich, CLIMADA contributors listed in AUTHORS.
5+
6+
CLIMADA is free software: you can redistribute it and/or modify it under the
7+
terms of the GNU General Public License as published by the Free
8+
Software Foundation, version 3.
9+
10+
CLIMADA is distributed in the hope that it will be useful, but WITHOUT ANY
11+
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
12+
PARTICULAR PURPOSE. See the GNU General Public License for more details.
13+
14+
You should have received a copy of the GNU General Public License along
15+
with CLIMADA. If not, see <https://www.gnu.org/licenses/>.
16+
17+
---
18+
Integration tests for calibration module
19+
"""
20+
21+
import unittest
22+
23+
import pandas as pd
24+
import numpy as np
25+
import numpy.testing as npt
26+
from scipy.optimize import NonlinearConstraint
27+
from sklearn.metrics import mean_squared_error
28+
from matplotlib.axes import Axes
29+
30+
from climada.entity import ImpactFuncSet, ImpactFunc
31+
32+
from climada.util.calibrate import (
33+
Input,
34+
ScipyMinimizeOptimizer,
35+
BayesianOptimizer,
36+
OutputEvaluator,
37+
BayesianOptimizerOutputEvaluator,
38+
BayesianOptimizerController,
39+
)
40+
41+
from climada.util.calibrate.test.test_base import hazard, exposure
42+
43+
44+
class TestScipyMinimizeOptimizer(unittest.TestCase):
45+
"""Test the TestScipyMinimizeOptimizer"""
46+
47+
def setUp(self) -> None:
48+
"""Prepare input for optimization"""
49+
self.hazard = hazard()
50+
self.hazard.frequency = np.ones_like(self.hazard.event_id)
51+
self.hazard.date = self.hazard.frequency
52+
self.hazard.event_name = ["event"] * len(self.hazard.event_id)
53+
self.exposure = exposure()
54+
self.events = [10, 1]
55+
self.hazard = self.hazard.select(event_id=self.events)
56+
self.data = pd.DataFrame(
57+
data={"a": [3, 1], "b": [0.2, 0.01]}, index=self.events
58+
)
59+
self.impact_to_dataframe = lambda impact: impact.impact_at_reg(["a", "b"])
60+
self.impact_func_creator = lambda slope: ImpactFuncSet(
61+
[
62+
ImpactFunc(
63+
intensity=np.array([0, 10]),
64+
mdd=np.array([0, 10 * slope]),
65+
paa=np.ones(2),
66+
id=1,
67+
haz_type="TEST",
68+
)
69+
]
70+
)
71+
self.input = Input(
72+
self.hazard,
73+
self.exposure,
74+
self.data,
75+
self.impact_func_creator,
76+
self.impact_to_dataframe,
77+
mean_squared_error,
78+
)
79+
80+
def test_single(self):
81+
"""Test with single parameter"""
82+
optimizer = ScipyMinimizeOptimizer(self.input)
83+
output = optimizer.run(params_init={"slope": 0.1})
84+
85+
# Result should be nearly exact
86+
self.assertTrue(output.result.success)
87+
self.assertAlmostEqual(output.params["slope"], 1.0)
88+
self.assertAlmostEqual(output.target, 0.0)
89+
90+
def test_bound(self):
91+
"""Test with single bound"""
92+
self.input.bounds = {"slope": (-1.0, 0.91)}
93+
optimizer = ScipyMinimizeOptimizer(self.input)
94+
output = optimizer.run(params_init={"slope": 0.1})
95+
96+
# Result should be very close to the bound
97+
self.assertTrue(output.result.success)
98+
self.assertGreater(output.params["slope"], 0.89)
99+
self.assertAlmostEqual(output.params["slope"], 0.91, places=2)
100+
101+
def test_multiple_constrained(self):
102+
"""Test with multiple constrained parameters"""
103+
# Set new generator
104+
self.input.impact_func_creator = lambda intensity_1, intensity_2: ImpactFuncSet(
105+
[
106+
ImpactFunc(
107+
intensity=np.array([0, intensity_1, intensity_2]),
108+
mdd=np.array([0, 1, 3]),
109+
paa=np.ones(3),
110+
id=1,
111+
haz_type="TEST",
112+
)
113+
]
114+
)
115+
116+
# Constraint: param[0] < param[1] (intensity_1 < intensity_2)
117+
self.input.constraints = NonlinearConstraint(
118+
lambda params: params[0] - params[1], -np.inf, 0.0
119+
)
120+
self.input.bounds = {"intensity_1": (0, 3.1), "intensity_2": (0, 3.1)}
121+
122+
# Run optimizer
123+
optimizer = ScipyMinimizeOptimizer(self.input)
124+
output = optimizer.run(
125+
params_init={"intensity_1": 2, "intensity_2": 2},
126+
options=dict(gtol=1e-5, xtol=1e-5),
127+
)
128+
129+
# Check results (low accuracy)
130+
self.assertTrue(output.result.success)
131+
print(output.result.message)
132+
print(output.result.status)
133+
self.assertAlmostEqual(output.params["intensity_1"], 1.0, places=2)
134+
self.assertGreater(output.params["intensity_2"], 2.8) # Should be 3.0
135+
self.assertAlmostEqual(output.target, 0.0, places=3)
136+
137+
138+
class TestBayesianOptimizer(unittest.TestCase):
139+
"""Integration tests for the BayesianOptimizer"""
140+
141+
def setUp(self) -> None:
142+
"""Prepare input for optimization"""
143+
self.hazard = hazard()
144+
self.hazard.frequency = np.ones_like(self.hazard.event_id)
145+
self.hazard.date = self.hazard.frequency
146+
self.hazard.event_name = ["event"] * len(self.hazard.event_id)
147+
self.exposure = exposure()
148+
self.events = [10, 1]
149+
self.hazard = self.hazard.select(event_id=self.events)
150+
self.data = pd.DataFrame(
151+
data={"a": [3, 1], "b": [0.2, 0.01]}, index=self.events
152+
)
153+
self.impact_to_dataframe = lambda impact: impact.impact_at_reg(["a", "b"])
154+
self.impact_func_creator = lambda slope: ImpactFuncSet(
155+
[
156+
ImpactFunc(
157+
intensity=np.array([0, 10]),
158+
mdd=np.array([0, 10 * slope]),
159+
paa=np.ones(2),
160+
id=1,
161+
haz_type="TEST",
162+
)
163+
]
164+
)
165+
self.input = Input(
166+
self.hazard,
167+
self.exposure,
168+
self.data,
169+
self.impact_func_creator,
170+
self.impact_to_dataframe,
171+
mean_squared_error,
172+
)
173+
174+
def test_single(self):
175+
"""Test with single parameter"""
176+
self.input.bounds = {"slope": (-1, 3)}
177+
controller = BayesianOptimizerController(
178+
init_points=10, n_iter=20, max_iterations=1
179+
)
180+
optimizer = BayesianOptimizer(self.input, random_state=1)
181+
output = optimizer.run(controller)
182+
183+
# Check result (low accuracy)
184+
self.assertAlmostEqual(output.params["slope"], 1.0, places=2)
185+
self.assertAlmostEqual(output.target, 0.0, places=3)
186+
self.assertEqual(output.p_space.dim, 1)
187+
self.assertTupleEqual(output.p_space_to_dataframe().shape, (30, 2))
188+
self.assertEqual(controller.iterations, 1)
189+
190+
def test_multiple_constrained(self):
191+
"""Test with multiple constrained parameters"""
192+
# Set new generator
193+
self.input.impact_func_creator = lambda intensity_1, intensity_2: ImpactFuncSet(
194+
[
195+
ImpactFunc(
196+
intensity=np.array([0, intensity_1, intensity_2]),
197+
mdd=np.array([0, 1, 3]),
198+
paa=np.ones(3),
199+
id=1,
200+
haz_type="TEST",
201+
)
202+
]
203+
)
204+
205+
# Constraint: param[0] < param[1] (intensity_1 < intensity_2)
206+
self.input.constraints = NonlinearConstraint(
207+
lambda intensity_1, intensity_2: intensity_1 - intensity_2, -np.inf, 0.0
208+
)
209+
self.input.bounds = {"intensity_1": (-1, 4), "intensity_2": (-1, 4)}
210+
# Run optimizer
211+
optimizer = BayesianOptimizer(self.input, random_state=1)
212+
controller = BayesianOptimizerController.from_input(
213+
self.input, sampling_base=5, max_iterations=3
214+
)
215+
output = optimizer.run(controller)
216+
217+
# Check results (low accuracy)
218+
self.assertEqual(output.p_space.dim, 2)
219+
self.assertAlmostEqual(output.params["intensity_1"], 1.0, places=2)
220+
self.assertAlmostEqual(output.params["intensity_2"], 3.0, places=1)
221+
self.assertAlmostEqual(output.target, 0.0, places=3)
222+
self.assertGreater(controller.iterations, 1)
223+
224+
# Check constraints in parameter space
225+
p_space = output.p_space_to_dataframe()
226+
self.assertSetEqual(
227+
set(p_space.columns.to_list()),
228+
{
229+
("Parameters", "intensity_1"),
230+
("Parameters", "intensity_2"),
231+
("Calibration", "Cost Function"),
232+
("Calibration", "Constraints Function"),
233+
("Calibration", "Allowed"),
234+
},
235+
)
236+
self.assertGreater(p_space.shape[0], 50) # Two times random iterations
237+
self.assertEqual(p_space.shape[1], 5)
238+
p_allowed = p_space.loc[p_space["Calibration", "Allowed"], "Parameters"]
239+
npt.assert_array_equal(
240+
(p_allowed["intensity_1"] < p_allowed["intensity_2"]).to_numpy(),
241+
np.full_like(p_allowed["intensity_1"].to_numpy(), True),
242+
)
243+
244+
def test_plots(self):
245+
"""Check if executing the default plots works"""
246+
self.input.bounds = {"slope": (-1, 3)}
247+
optimizer = BayesianOptimizer(self.input, random_state=1)
248+
controller = BayesianOptimizerController.from_input(
249+
self.input, max_iterations=1
250+
)
251+
output = optimizer.run(controller)
252+
253+
output_eval = OutputEvaluator(self.input, output)
254+
output_eval.impf_set.plot()
255+
output_eval.plot_at_event()
256+
output_eval.plot_at_region()
257+
output_eval.plot_event_region_heatmap()
258+
259+
output_eval = BayesianOptimizerOutputEvaluator(self.input, output)
260+
ax = output_eval.plot_impf_variability()
261+
self.assertIsInstance(ax, Axes)

climada/util/calibrate/__init__.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
"""
2+
This file is part of CLIMADA.
3+
4+
Copyright (C) 2017 ETH Zurich, CLIMADA contributors listed in AUTHORS.
5+
6+
CLIMADA is free software: you can redistribute it and/or modify it under the
7+
terms of the GNU General Public License as published by the Free
8+
Software Foundation, version 3.
9+
10+
CLIMADA is distributed in the hope that it will be useful, but WITHOUT ANY
11+
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
12+
PARTICULAR PURPOSE. See the GNU General Public License for more details.
13+
14+
You should have received a copy of the GNU General Public License along
15+
with CLIMADA. If not, see <https://www.gnu.org/licenses/>.
16+
17+
---
18+
Impact function calibration module
19+
"""
20+
21+
from .base import Input, OutputEvaluator
22+
from .bayesian_optimizer import (
23+
BayesianOptimizer,
24+
BayesianOptimizerController,
25+
BayesianOptimizerOutput,
26+
BayesianOptimizerOutputEvaluator,
27+
select_best
28+
)
29+
from .scipy_optimizer import ScipyMinimizeOptimizer

0 commit comments

Comments
 (0)