Skip to content

Commit 24c1fc3

Browse files
committed
Split tests into multiple files, finish up
1 parent 67ef797 commit 24c1fc3

File tree

7 files changed

+497
-292
lines changed

7 files changed

+497
-292
lines changed

climada/test/test_util_calibrate.py

Lines changed: 96 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
"""Integration tests for calibration utility module"""
22

33
import unittest
4-
from functools import partial
54

65
import pandas as pd
76
import numpy as np
7+
import numpy.testing as npt
88
from scipy.optimize import NonlinearConstraint
9+
from sklearn.metrics import mean_squared_error
910

1011
from climada.entity import ImpactFuncSet, ImpactFunc
1112

12-
from climada.util.calibrate import Input, ScipyMinimizeOptimizer, rmse, impact_at_reg
13+
from climada.util.calibrate import Input, ScipyMinimizeOptimizer, BayesianOptimizer
1314

1415
from climada.util.calibrate.test.test_calibrate import hazard, exposure
1516

@@ -29,7 +30,7 @@ def setUp(self) -> None:
2930
self.data = pd.DataFrame(
3031
data={"a": [3, 1], "b": [0.2, 0.01]}, index=self.events
3132
)
32-
self.impact_to_dataframe = partial(impact_at_reg, region_ids=["a", "b"])
33+
self.impact_to_dataframe = lambda impact: impact.impact_at_reg(["a", "b"])
3334
self.impact_func_creator = lambda slope: ImpactFuncSet(
3435
[
3536
ImpactFunc(
@@ -46,7 +47,8 @@ def setUp(self) -> None:
4647
self.data,
4748
self.impact_func_creator,
4849
self.impact_to_dataframe,
49-
rmse,
50+
mean_squared_error,
51+
# lambda x,y: mean_squared_error(x, y, squared=True),
5052
)
5153

5254
def test_single(self):
@@ -94,10 +96,98 @@ def test_multiple_constrained(self):
9496
optimizer = ScipyMinimizeOptimizer(self.input)
9597
output = optimizer.run(
9698
params_init={"intensity_1": 2, "intensity_2": 2},
99+
options=dict(gtol=1e-5, xtol=1e-5),
97100
)
98101

99102
# Check results (low accuracy)
100103
self.assertTrue(output.result.success)
101-
self.assertAlmostEqual(output.params["intensity_1"], 1.0, places=3)
102-
self.assertAlmostEqual(output.params["intensity_2"], 3.0, places=3)
104+
print(output.result.message)
105+
print(output.result.status)
106+
self.assertAlmostEqual(output.params["intensity_1"], 1.0, places=2)
107+
self.assertGreater(output.params["intensity_2"], 2.8) # Should be 3.0
103108
self.assertAlmostEqual(output.target, 0.0, places=3)
109+
110+
111+
class TestBayesianOptimizer(unittest.TestCase):
112+
"""Integration tests for the BayesianOptimizer"""
113+
114+
def setUp(self) -> None:
115+
"""Prepare input for optimization"""
116+
self.hazard = hazard()
117+
self.hazard.frequency = np.ones_like(self.hazard.event_id)
118+
self.hazard.date = self.hazard.frequency
119+
self.hazard.event_name = ["event"] * len(self.hazard.event_id)
120+
self.exposure = exposure()
121+
self.events = [10, 1]
122+
self.hazard = self.hazard.select(event_id=self.events)
123+
self.data = pd.DataFrame(
124+
data={"a": [3, 1], "b": [0.2, 0.01]}, index=self.events
125+
)
126+
self.impact_to_dataframe = lambda impact: impact.impact_at_reg(["a", "b"])
127+
self.impact_func_creator = lambda slope: ImpactFuncSet(
128+
[
129+
ImpactFunc(
130+
intensity=np.array([0, 10]),
131+
mdd=np.array([0, 10 * slope]),
132+
paa=np.ones(2),
133+
id=1,
134+
)
135+
]
136+
)
137+
self.input = Input(
138+
self.hazard,
139+
self.exposure,
140+
self.data,
141+
self.impact_func_creator,
142+
self.impact_to_dataframe,
143+
mean_squared_error,
144+
)
145+
146+
def test_single(self):
147+
"""Test with single parameter"""
148+
self.input.bounds = {"slope": (-1, 3)}
149+
optimizer = BayesianOptimizer(self.input)
150+
output = optimizer.run(init_points=10, n_iter=20, random_state=1)
151+
152+
# Check result (low accuracy)
153+
self.assertAlmostEqual(output.params["slope"], 1.0, places=2)
154+
self.assertAlmostEqual(output.target, 0.0, places=3)
155+
self.assertEqual(output.p_space.dim, 1)
156+
self.assertTupleEqual(output.p_space_to_dataframe().shape, (30, 2))
157+
158+
def test_multiple_constrained(self):
159+
"""Test with multiple constrained parameters"""
160+
# Set new generator
161+
self.input.impact_func_creator = lambda intensity_1, intensity_2: ImpactFuncSet(
162+
[
163+
ImpactFunc(
164+
intensity=np.array([0, intensity_1, intensity_2]),
165+
mdd=np.array([0, 1, 3]),
166+
paa=np.ones(3),
167+
id=1,
168+
)
169+
]
170+
)
171+
172+
# Constraint: param[0] < param[1] (intensity_1 < intensity_2)
173+
self.input.constraints = NonlinearConstraint(
174+
lambda params: params[0] - params[1], -np.inf, 0.0
175+
)
176+
self.input.bounds = {"intensity_1": (-1, 4), "intensity_2": (-1, 4)}
177+
# Run optimizer
178+
optimizer = BayesianOptimizer(self.input)
179+
output = optimizer.run(n_iter=200, random_state=1)
180+
181+
# Check results (low accuracy)
182+
self.assertEqual(output.p_space.dim, 2)
183+
self.assertAlmostEqual(output.params["intensity_1"], 1.0, places=2)
184+
self.assertAlmostEqual(output.params["intensity_2"], 3.0, places=1)
185+
self.assertAlmostEqual(output.target, 0.0, places=3)
186+
187+
# Check constraints in parameter space
188+
p_space = output.p_space_to_dataframe()
189+
self.assertSetEqual(
190+
set(p_space.columns.to_list()),
191+
{"intensity_1", "intensity_2", "Cost Function"},
192+
)
193+
self.assertTupleEqual(p_space.shape, (300, 3))

climada/util/calibrate/bayesian_optimizer.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ def p_space_to_dataframe(self):
153153
function value (``Cost Function``) and whose rows are the optimizer
154154
iterations.
155155
"""
156+
# TODO: Handle constraints!!!
156157
data = {
157158
self.p_space.keys[i]: self.p_space.params[..., i]
158159
for i in range(self.p_space.dim)

climada/util/calibrate/scipy_optimizer.py

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,19 @@
99
from .base import Output, Optimizer
1010

1111

12+
@dataclass
13+
class ScipyMinimizeOptimizerOutput(Output):
14+
"""Output of a calibration with :py:class:`ScipyMinimizeOptimizer`
15+
16+
Attributes
17+
----------
18+
result : scipy.minimize.OptimizeResult
19+
The OptimizeResult instance returned by ``scipy.optimize.minimize``.
20+
"""
21+
22+
result: OptimizeResult
23+
24+
1225
@dataclass
1326
class ScipyMinimizeOptimizer(Optimizer):
1427
"""An optimization using scipy.optimize.minimize
@@ -42,7 +55,7 @@ def _select_by_param_names(self, mapping: Mapping[str, Any]) -> List[Any]:
4255
"""Return a list of entries from a map with matching keys or ``None``"""
4356
return [mapping.get(key) for key in self._param_names]
4457

45-
def run(self, **opt_kwargs) -> Output:
58+
def run(self, **opt_kwargs) -> ScipyMinimizeOptimizerOutput:
4659
"""Execute the optimization
4760
4861
Parameters
@@ -64,7 +77,12 @@ def run(self, **opt_kwargs) -> Output:
6477
associated ``scipy.optimize.OptimizeResult`` instance.
6578
"""
6679
# Parse kwargs
67-
params_init = opt_kwargs.pop("params_init")
80+
try:
81+
params_init = opt_kwargs.pop("params_init")
82+
except KeyError as err:
83+
raise RuntimeError(
84+
"ScipyMinimizeOptimizer.run requires 'params_init' mapping as argument"
85+
) from err
6886
method = opt_kwargs.pop("method", "trust-constr")
6987

7088
# Store names to rebuild dict when the minimize iterator returns an array
@@ -89,16 +107,3 @@ def run(self, **opt_kwargs) -> Output:
89107

90108
params = dict(zip(self._param_names, res.x.flat))
91109
return ScipyMinimizeOptimizerOutput(params=params, target=res.fun, result=res)
92-
93-
94-
@dataclass
95-
class ScipyMinimizeOptimizerOutput(Output):
96-
"""Output of a calibration with :py:class:`ScipyMinimizeOptimizer`
97-
98-
Attributes
99-
----------
100-
result : scipy.minimize.OptimizeResult
101-
The OptimizeResult instance returned by ``scipy.optimize.minimize``.
102-
"""
103-
104-
result: OptimizeResult
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
"""Tests for calibration module"""
2+
3+
import unittest
4+
from unittest.mock import create_autospec
5+
6+
import numpy as np
7+
import numpy.testing as npt
8+
import pandas as pd
9+
from scipy.sparse import csr_matrix
10+
11+
from climada.entity import Exposures, ImpactFunc, ImpactFuncSet
12+
from climada.hazard import Hazard, Centroids
13+
14+
from climada.util.calibrate import Input
15+
from climada.util.calibrate.base import Optimizer
16+
17+
18+
class ConcreteOptimizer(Optimizer):
19+
"""An instance for testing. Implements 'run' without doing anything"""
20+
21+
def run(self, **_):
22+
pass
23+
24+
25+
def hazard():
26+
"""Create a dummy hazard instance"""
27+
lat = [1, 2]
28+
lon = [0, 1]
29+
centroids = Centroids.from_lat_lon(lat=lat, lon=lon)
30+
event_id = np.array([1, 3, 10])
31+
intensity = csr_matrix([[1, 0.1], [2, 0.2], [3, 2]])
32+
return Hazard(event_id=event_id, centroids=centroids, intensity=intensity)
33+
34+
35+
def exposure():
36+
"""Create a dummy exposure instance"""
37+
return Exposures(
38+
data=dict(
39+
longitude=[0, 1, 100],
40+
latitude=[1, 2, 50],
41+
value=[1, 0.1, 1e6],
42+
impf_=[1, 1, 1],
43+
)
44+
)
45+
46+
47+
class TestInputPostInit(unittest.TestCase):
48+
"""Test the post_init dunder method of Input"""
49+
50+
def setUp(self):
51+
"""Create default input instance"""
52+
# Create the hazard instance
53+
self.hazard = hazard()
54+
55+
# Create the exposure instance
56+
self.exposure = exposure()
57+
58+
# Create some data
59+
self.data_events = [10, 3]
60+
self.data = pd.DataFrame(data={"a": [1, 2]}, index=self.data_events)
61+
62+
# Create dummy funcs
63+
self.impact_to_dataframe = lambda _: pd.DataFrame()
64+
self.cost_func = lambda impact, data: 1.0
65+
self.impact_func_gen = lambda **kwargs: ImpactFuncSet()
66+
67+
def test_post_init_calls(self):
68+
"""Test if post_init calls stuff correctly using mocks"""
69+
# Create mocks
70+
exposure_mock = create_autospec(Exposures())
71+
72+
# Default
73+
Input(
74+
hazard=self.hazard,
75+
exposure=exposure_mock,
76+
data=self.data,
77+
cost_func=self.cost_func,
78+
impact_func_creator=self.impact_func_gen,
79+
impact_to_dataframe=self.impact_to_dataframe,
80+
)
81+
exposure_mock.assign_centroids.assert_called_once_with(self.hazard)
82+
exposure_mock.reset_mock()
83+
84+
# Default
85+
Input(
86+
hazard=self.hazard,
87+
exposure=exposure_mock,
88+
data=self.data,
89+
cost_func=self.cost_func,
90+
impact_func_creator=self.impact_func_gen,
91+
impact_to_dataframe=self.impact_to_dataframe,
92+
assign_centroids=False,
93+
)
94+
exposure_mock.assign_centroids.assert_not_called()
95+
96+
def test_post_init(self):
97+
"""Test if post_init results in a sensible hazard and exposure"""
98+
# Create input
99+
input = Input(
100+
hazard=self.hazard,
101+
exposure=self.exposure,
102+
data=self.data,
103+
cost_func=self.cost_func,
104+
impact_func_creator=self.impact_func_gen,
105+
impact_to_dataframe=self.impact_to_dataframe,
106+
)
107+
108+
# Check hazard and exposure
109+
self.assertIn("centr_", input.exposure.gdf)
110+
npt.assert_array_equal(input.exposure.gdf["centr_"], [0, 1, -1])
111+
112+
113+
class TestOptimizer(unittest.TestCase):
114+
"""Base class for testing optimizers. Creates an input mock"""
115+
116+
def setUp(self):
117+
"""Mock the input"""
118+
self.input = Input(
119+
hazard=hazard(),
120+
exposure=exposure(),
121+
data=pd.DataFrame(data={"col1": [1, 2], "col2": [2, 3]}, index=[0, 1]),
122+
cost_func=lambda x, y: (x + y).sum(axis=None),
123+
impact_func_creator=lambda _: ImpactFuncSet([ImpactFunc()]),
124+
impact_to_dataframe=lambda x: x.impact_at_reg(),
125+
)
126+
self.optimizer = ConcreteOptimizer(self.input)
127+
128+
def test_align_impact_with_data(self):
129+
"""Check alignment of impact and data"""
130+
self.input.data = pd.DataFrame(
131+
data={"col1": [1, 2], "col2": [2, 3]}, index=[0, 1]
132+
)
133+
impact_df = pd.DataFrame(data={"col2": [1, 2], "col3": [2, 3]}, index=[1, 2])
134+
135+
# missing_data_value = np.nan
136+
data_aligned, impact_df_aligned = self.optimizer._align_impact_with_data(
137+
impact_df
138+
)
139+
data_aligned_test = pd.DataFrame(
140+
data={"col1": [1, 2, 0], "col2": [2, 3, 0], "col3": [0, 0, 0]},
141+
index=[0, 1, 2],
142+
dtype="float",
143+
)
144+
pd.testing.assert_frame_equal(data_aligned, data_aligned_test)
145+
pd.testing.assert_frame_equal(
146+
impact_df_aligned,
147+
pd.DataFrame(
148+
data={"col1": [0, 0, 0], "col2": [0, 1, 0], "col3": [0, 0, 0]},
149+
index=[0, 1, 2],
150+
dtype="float",
151+
),
152+
)
153+
154+
# Different missing data value
155+
self.input.missing_data_value = 0
156+
data_aligned, impact_df_aligned = self.optimizer._align_impact_with_data(
157+
impact_df
158+
)
159+
pd.testing.assert_frame_equal(data_aligned, data_aligned_test)
160+
pd.testing.assert_frame_equal(
161+
impact_df_aligned,
162+
pd.DataFrame(
163+
data={"col1": [0, 0, 0], "col2": [0, 1, 2], "col3": [0, 2, 3]},
164+
index=[0, 1, 2],
165+
dtype="float",
166+
),
167+
)
168+
169+
# Check error
170+
with self.assertRaisesRegex(ValueError, "NaN values computed in impact!"):
171+
data_aligned, impact_df_aligned = self.optimizer._align_impact_with_data(
172+
pd.DataFrame(data={"col1": [np.nan], "col2": [2, 3]}, index=[1, 2])
173+
)
174+
175+
176+
# Execute Tests
177+
if __name__ == "__main__":
178+
TESTS = unittest.TestLoader().loadTestsFromTestCase(TestInputPostInit)
179+
TESTS.addTests(unittest.TestLoader().loadTestsFromTestCase(TestOptimizer))
180+
unittest.TextTestRunner(verbosity=2).run(TESTS)

0 commit comments

Comments
 (0)