Skip to content

Commit 2af6f09

Browse files
committed
Add unit and integration tests, update code base
1 parent 107a836 commit 2af6f09

File tree

2 files changed

+182
-66
lines changed

2 files changed

+182
-66
lines changed

climada/util/calibrate/impact_func.py

Lines changed: 35 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,12 @@
2020
from climada.engine import Impact, ImpactCalc
2121

2222

23-
def cost_func_rmse(impact: Impact, data: pd.DataFrame) -> Number:
24-
return np.sqrt(((impact - data) ** 2).mean(axis=None))
23+
def cost_func_rmse(
24+
impact: Impact,
25+
data: pd.DataFrame,
26+
impact_proc: Callable[[Impact], pd.DataFrame] = lambda x: x.impact_at_reg(),
27+
) -> Number:
28+
return np.sqrt(np.mean(((impact_proc(impact) - data) ** 2).to_numpy()))
2529

2630

2731
def impf_step_generator(threshold: Number, paa: Number) -> ImpactFuncSet:
@@ -34,6 +38,9 @@ def impf_step_generator(threshold: Number, paa: Number) -> ImpactFuncSet:
3438
)
3539

3640

41+
ConstraintType = Union[LinearConstraint, NonlinearConstraint, Mapping]
42+
43+
3744
@dataclass
3845
class Input:
3946
"""Define the static input for a calibration task"""
@@ -44,17 +51,23 @@ class Input:
4451
cost_func: Callable[[Impact, pd.DataFrame], Number]
4552
impact_func_gen: Callable[..., ImpactFuncSet]
4653
bounds: Optional[Mapping[str, Union[Bounds, Tuple[Number, Number]]]] = None
47-
constraints: Optional[
48-
Mapping[str, Union[LinearConstraint, NonlinearConstraint, Mapping]]
49-
] = None
54+
constraints: Optional[Union[ConstraintType, list[ConstraintType]]] = None
5055
impact_calc_kwds: Mapping[str, Any] = field(
51-
default_factory=lambda: dict(assign_centroids=False)
56+
default_factory=lambda: {"assign_centroids": False}
5257
)
58+
align: InitVar[bool] = True
5359

54-
def __post_init__(self):
60+
def __post_init__(self, align):
5561
"""Prepare input data"""
56-
self.hazard = self.hazard.select(event_id=self.data.index.tolist())
57-
self.exposure.assign_centroids(self.hazard)
62+
if align:
63+
event_diff = np.setdiff1d(self.data.index, self.hazard.event_id)
64+
if event_diff.size > 0:
65+
raise RuntimeError(
66+
"Event IDs in 'data' do not match event IDs in 'hazard': \n"
67+
f"{event_diff}"
68+
)
69+
self.hazard = self.hazard.select(event_id=self.data.index.tolist())
70+
self.exposure.assign_centroids(self.hazard)
5871

5972

6073
@dataclass
@@ -76,7 +89,7 @@ class Optimizer(ABC):
7689
def _target_func(self, impact: Impact, data: pd.DataFrame):
7790
return self.input.cost_func(impact, data)
7891

79-
def _kwargs_to_impact_func_gen(self, *args, **kwargs) -> Dict[str, Any]:
92+
def _kwargs_to_impact_func_gen(self, *_, **kwargs) -> Dict[str, Any]:
8093
"""Define how the parameters to 'opt_func' must be transformed"""
8194
return kwargs
8295

@@ -88,7 +101,7 @@ def _opt_func(self, *args, **kwargs):
88101
exposures=self.input.exposure,
89102
impfset=impf_set,
90103
hazard=self.input.hazard,
91-
).impact(assign_centroids=False, **self.input.impact_calc_kwds)
104+
).impact(**self.input.impact_calc_kwds)
92105
return self._target_func(impact, self.input.data)
93106

94107
@abstractmethod
@@ -105,35 +118,36 @@ def __post_init__(self):
105118
"""Create a private attribute for storing the parameter names"""
106119
self._param_names: List[str] = list()
107120

108-
def _kwargs_to_impact_func_gen(self, *args, **kwargs) -> Dict[str, Any]:
121+
def _kwargs_to_impact_func_gen(self, *args, **_) -> Dict[str, Any]:
109122
return dict(zip(self._param_names, args[0].flat))
110123

111124
def _select_by_param_names(self, mapping: Mapping[str, Any]) -> List[Any]:
112125
"""Return a list of entries from a map with matching keys or ``None``"""
113126
return [mapping.get(key) for key in self._param_names]
114127

115-
def run(self, params_init: Mapping[str, Number], **opt_kwargs):
128+
def run(self, **opt_kwargs):
116129
"""Execute the optimization"""
130+
# Parse kwargs
131+
params_init = opt_kwargs.pop("params_init")
132+
method = opt_kwargs.pop("method", "trust-constr")
133+
134+
# Store names to rebuild dict when the minimize iterator returns an array
117135
self._param_names = list(params_init.keys())
118136

119-
# Transform data to match minimize input
137+
# Transform bounds to match minimize input
120138
bounds = (
121139
self._select_by_param_names(self.input.bounds)
122140
if self.input.bounds is not None
123141
else None
124142
)
125-
constraints = (
126-
self._select_by_param_names(self.input.constraints)
127-
if self.input.constraints is not None
128-
else None
129-
)
130143

131144
x0 = np.array(list(params_init.values()))
132145
res = minimize(
133-
fun=lambda x: self._opt_func(x),
146+
fun=self._opt_func,
134147
x0=x0,
135148
bounds=bounds,
136-
constraints=constraints,
149+
constraints=self.input.constraints,
150+
method=method,
137151
**opt_kwargs,
138152
)
139153

Lines changed: 147 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,19 @@
11
"""Tests for calibration module"""
22

33
import unittest
4-
from unittest.mock import create_autospec
4+
from unittest.mock import create_autospec, patch, MagicMock
5+
from typing import Optional, List
56

67
import numpy as np
78
import numpy.testing as npt
89
import pandas as pd
910
from scipy.sparse import csr_matrix
10-
from shapely.geometry import Point
11+
from scipy.optimize import OptimizeResult
1112

1213
from climada.entity import Exposures, ImpactFuncSet
1314
from climada.hazard import Hazard, Centroids
1415

15-
from ..impact_func import Input, ScipyMinimizeOptimizer
16+
from climada.util.calibrate.impact_func import Input, ScipyMinimizeOptimizer
1617

1718

1819
def hazard():
@@ -21,13 +22,20 @@ def hazard():
2122
lon = [0, 1]
2223
centroids = Centroids.from_lat_lon(lat=lat, lon=lon)
2324
event_id = np.array([1, 3, 10])
24-
intensity = csr_matrix([[1, 1], [2, 2], [3, 3]])
25+
intensity = csr_matrix([[1, 0.1], [2, 0.2], [3, 2]])
2526
return Hazard(event_id=event_id, centroids=centroids, intensity=intensity)
2627

2728

2829
def exposure():
2930
"""Create a dummy exposure instance"""
30-
return Exposures(data=dict(longitude=[0, 1, 100], latitude=[1, 2, 50]))
31+
return Exposures(
32+
data=dict(
33+
longitude=[0, 1, 100],
34+
latitude=[1, 2, 50],
35+
value=[1, 0.1, 1e6],
36+
impf_=[1, 1, 1],
37+
)
38+
)
3139

3240

3341
class TestInputPostInit(unittest.TestCase):
@@ -49,12 +57,16 @@ def setUp(self):
4957
self.cost_func = lambda impact, data: 1.0
5058
self.impact_func_gen = lambda **kwargs: ImpactFuncSet()
5159

52-
def test_post_init_calls(self):
60+
@patch("climada.util.calibrate.impact_func.np.setdiff1d")
61+
def test_post_init_calls(self, setdiff1d_mock):
5362
"""Test if post_init calls stuff correctly using mocks"""
5463
# Create mocks
55-
hazard_mock_1 = create_autospec(Hazard, instance=True)
56-
hazard_mock_2 = create_autospec(Hazard, instance=True)
57-
exposure_mock = create_autospec(Exposures, instance=True)
64+
hazard_mock_1 = create_autospec(Hazard())
65+
event_id = [10]
66+
hazard_mock_1.event_id = event_id
67+
hazard_mock_2 = create_autospec(Hazard())
68+
exposure_mock = create_autospec(Exposures())
69+
setdiff1d_mock.return_value = np.array([])
5870

5971
# Make first hazard mock return another instance
6072
hazard_mock_1.select.return_value = hazard_mock_2
@@ -69,6 +81,8 @@ def test_post_init_calls(self):
6981
)
7082

7183
# Query checks
84+
npt.assert_array_equal(setdiff1d_mock.call_args.args[0], self.data_events)
85+
npt.assert_array_equal(setdiff1d_mock.call_args.args[1], event_id)
7286
hazard_mock_1.select.assert_called_once_with(event_id=self.data_events)
7387
self.assertNotEqual(input.hazard, hazard_mock_1)
7488
self.assertEqual(input.hazard, hazard_mock_2)
@@ -90,49 +104,137 @@ def test_post_init(self):
90104
self.assertIn("centr_", input.exposure.gdf)
91105
npt.assert_array_equal(input.exposure.gdf["centr_"], [0, 1, -1])
92106

107+
def test_non_matching_events(self):
108+
"""Test if non-matching events result in errors"""
109+
data = pd.DataFrame(data={"a": [1, 2, 3]}, index=[9, 3, 12])
110+
input_kwargs = {
111+
"hazard": self.hazard,
112+
"exposure": self.exposure,
113+
"data": data,
114+
"cost_func": self.cost_func,
115+
"impact_func_gen": self.impact_func_gen,
116+
"align": False,
117+
}
118+
119+
# No error without alignment
120+
Input(**input_kwargs)
121+
122+
# Error with alignment
123+
input_kwargs.update(align=True)
124+
with self.assertRaises(RuntimeError) as cm:
125+
Input(**input_kwargs)
126+
127+
self.assertIn(
128+
"Event IDs in 'data' do not match event IDs in 'hazard'", str(cm.exception)
129+
)
130+
self.assertIn("9", str(cm.exception))
131+
self.assertIn("12", str(cm.exception))
132+
self.assertNotIn("3", str(cm.exception))
133+
93134

94135
class TestScipyMinimizeOptimizer(unittest.TestCase):
95136
"""Tests for the optimizer based on scipy.optimize.minimize"""
96137

97138
def setUp(self):
98139
"""Mock the input and create the optimizer"""
99-
self.input = create_autospec(Input, instance=True)
100-
self.optimizer = ScipyMinimizeOptimizer(self.input)
101-
102-
def test_kwargs_to_impact_func_gen(self):
103-
"""Test the _kwargs_to_impact_func_gen method"""
104-
# _param_names is empty in the beginning
105-
x = np.array([1, 2, 3])
106-
self.assertDictEqual(self.optimizer._kwargs_to_impact_func_gen(x), {})
107-
108-
# Now populate it and try again
109-
self.optimizer._param_names = ["x_2", "x_1", "x_3"]
110-
result = {"x_2": 1, "x_1": 2, "x_3": 3}
111-
self.assertDictEqual(self.optimizer._kwargs_to_impact_func_gen(x), result)
112-
113-
# Other arguments are ignored
114-
self.assertDictEqual(
115-
self.optimizer._kwargs_to_impact_func_gen(x, x + 3), result
140+
self.input = MagicMock(
141+
spec_set=Input(
142+
hazard=create_autospec(Hazard, instance=True),
143+
exposure=create_autospec(Exposures, instance=True),
144+
data=create_autospec(pd.DataFrame, instance=True),
145+
cost_func=MagicMock(),
146+
impact_func_gen=MagicMock(),
147+
align=False,
148+
)
116149
)
150+
self.optimizer = ScipyMinimizeOptimizer(self.input)
117151

118-
# Array is flattened, iterator stops
119-
self.assertDictEqual(
120-
self.optimizer._kwargs_to_impact_func_gen(np.array([[1, 2], [3, 4]])),
121-
result,
152+
@patch("climada.util.calibrate.impact_func.ImpactCalc", autospec=True)
153+
def test_kwargs_to_impact_func_gen(self, _):
154+
"""Test transform of minimize func arguments to impact_func_gen arguments
155+
156+
We test the method '_kwargs_to_impact_func_gen' through 'run' because it is
157+
private.
158+
"""
159+
# Create stubs
160+
self.input.constraints = None
161+
self.input.bounds = None
162+
self.input.cost_func.return_value = 1.0
163+
164+
# Call 'run', make sure that 'minimize' is only with these parameters
165+
params_init = {"x_2": 1, "x 1": 2, "x_3": 3} # NOTE: Also works with whitespace
166+
self.optimizer.run(params_init=params_init, options={"maxiter": 1})
167+
168+
# Check call to '_kwargs_to_impact_func_gen'
169+
self.input.impact_func_gen.assert_any_call(**params_init)
170+
171+
def test_output(self):
172+
"""Check output reporting"""
173+
params_init = {"x_2": 1, "x 1": 2, "x_3": 3}
174+
target_func_value = 1.12
175+
self.input.constraints = None
176+
self.input.bounds = None
177+
178+
# Mock the optimization function and call 'run'
179+
with patch.object(self.optimizer, "_opt_func") as opt_func_mock:
180+
opt_func_mock.return_value = target_func_value
181+
output = self.optimizer.run(params_init=params_init, options={"maxiter": 1})
182+
183+
# Assert output
184+
self.assertListEqual(list(output.params.keys()), list(params_init.keys()))
185+
npt.assert_allclose(list(output.params.values()), list(params_init.values()))
186+
self.assertEqual(output.target, target_func_value)
187+
self.assertTrue(output.success) # NOTE: For scipy.optimize, this means no error
188+
self.assertIsInstance(output.result, OptimizeResult)
189+
190+
@patch("climada.util.calibrate.impact_func.minimize", autospec=True)
191+
def test_bounds_select(self, minimize_mock):
192+
"""Test the _select_by_param_names method
193+
194+
We test the method '_select_by_param_names' through 'run' because it is private.
195+
"""
196+
197+
def assert_bounds_in_call(bounds: Optional[List]):
198+
"""Check if scipy.optimize.minimize was called with the expected kwargs"""
199+
call_kwargs = minimize_mock.call_args.kwargs
200+
print(minimize_mock.call_args)
201+
202+
if bounds is None:
203+
self.assertIsNone(call_kwargs["bounds"])
204+
else:
205+
self.assertListEqual(call_kwargs["bounds"], bounds)
206+
207+
# Initialize params and mock return value
208+
params_init = {"x_2": 1, "x_1": 2, "x_3": 3}
209+
minimize_mock.return_value = OptimizeResult(
210+
x=np.array(list(params_init.values())), fun=0, success=True
122211
)
123212

124-
def test_select_by_keys(self):
125-
"""Test the _select_by_keys method"""
126-
param_names = ["a", "b", "c", "d"]
127-
mapping = dict(zip(param_names, [1, "2", (1, 2)]))
128-
129-
# _param_names is empty in the beginning
130-
self.assertListEqual(self.optimizer._select_by_param_names(mapping), [])
131-
132-
# Set _param_names
133-
self.optimizer._param_names = param_names
134-
135-
# Check result
136-
self.assertListEqual(
137-
self.optimizer._select_by_param_names(mapping), [1, "2", (1, 2), None]
138-
)
213+
# Set constraints and bounds to None (default)
214+
self.input.bounds = None
215+
216+
# Call the optimizer (constraints and bounds are None)
217+
self.optimizer.run(params_init=params_init)
218+
self.assertListEqual(self.optimizer._param_names, list(params_init.keys()))
219+
minimize_mock.assert_called_once()
220+
assert_bounds_in_call(None)
221+
minimize_mock.reset_mock()
222+
223+
# Set new bounds and constraints
224+
self.input.bounds = {"x_1": "a", "x_4": "b", "x_3": (1, 2)}
225+
self.input.constraints = {"x_5": [1], "x_2": 2}
226+
227+
# Call the optimizer
228+
self.optimizer.run(params_init=params_init)
229+
self.assertListEqual(self.optimizer._param_names, list(params_init.keys()))
230+
minimize_mock.assert_called_once()
231+
assert_bounds_in_call(bounds=[None, "a", (1, 2)])
232+
233+
234+
# Execute Tests
235+
if __name__ == "__main__":
236+
TESTS = unittest.TestLoader().loadTestsFromTestCase(TestInputPostInit)
237+
TESTS.addTests(
238+
unittest.TestLoader().loadTestsFromTestCase(TestScipyMinimizeOptimizer)
239+
)
240+
unittest.TextTestRunner(verbosity=2).run(TESTS)

0 commit comments

Comments
 (0)