Skip to content

Commit 819aab5

Browse files
committed
Add first unit tests of calibration module
1 parent 443545e commit 819aab5

File tree

2 files changed

+189
-23
lines changed

2 files changed

+189
-23
lines changed

climada/util/calibrate/impact_func.py

Lines changed: 52 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,27 @@
1111
Bounds,
1212
LinearConstraint,
1313
NonlinearConstraint,
14-
OptimizeResult,
1514
minimize,
1615
)
1716
from bayes_opt import BayesianOptimization
1817

19-
from ...hazard import Hazard
20-
from ...entity import Exposures, ImpactFunc, ImpactFuncSet
21-
from ...engine import Impact, ImpactCalc
18+
from climada.hazard import Hazard
19+
from climada.entity import Exposures, ImpactFunc, ImpactFuncSet
20+
from climada.engine import Impact, ImpactCalc
21+
22+
23+
def cost_func_rmse(impact: Impact, data: pd.DataFrame) -> Number:
24+
return np.sqrt(((impact - data) ** 2).mean(axis=None))
25+
26+
27+
def impf_step_generator(threshold: Number, paa: Number) -> ImpactFuncSet:
28+
return ImpactFuncSet(
29+
[
30+
ImpactFunc.from_step_impf(
31+
haz_type="RF", intensity=(0, threshold, 100), paa=(0, paa)
32+
)
33+
]
34+
)
2235

2336

2437
@dataclass
@@ -28,7 +41,7 @@ class Input:
2841
hazard: Hazard
2942
exposure: Exposures
3043
data: pd.DataFrame
31-
cost_func: Callable[[Impact, pd.DataFrame], float]
44+
cost_func: Callable[[Impact, pd.DataFrame], Number]
3245
impact_func_gen: Callable[..., ImpactFuncSet]
3346
bounds: Optional[Mapping[str, Union[Bounds, Tuple[Number, Number]]]] = None
3447
constraints: Optional[
@@ -40,7 +53,7 @@ class Input:
4053

4154
def __post_init__(self):
4255
"""Prepare input data"""
43-
self.hazard = self.hazard.select(event_id=self.data.index)
56+
self.hazard = self.hazard.select(event_id=self.data.index.tolist())
4457
self.exposure.assign_centroids(self.hazard)
4558

4659

@@ -51,7 +64,7 @@ class Output:
5164
params: Mapping[str, Number]
5265
target: Number
5366
success: bool
54-
result: Optional[OptimizeResult] = None
67+
result: Optional[Any] = None
5568

5669

5770
@dataclass
@@ -88,23 +101,32 @@ def run(self, **opt_kwargs) -> Output:
88101
class ScipyMinimizeOptimizer(Optimizer):
89102
"""An optimization using scipy.optimize.minimize"""
90103

91-
_param_names: List[str] = field(default_factory=list)
104+
def __post_init__(self):
105+
"""Create a private attribute for storing the parameter names"""
106+
self._param_names: List[str] = list()
92107

93108
def _kwargs_to_impact_func_gen(self, *args, **kwargs) -> Dict[str, Any]:
94109
return dict(zip(self._param_names, args[0].flat))
95110

111+
def _select_by_param_names(self, mapping: Mapping[str, Any]) -> List[Any]:
112+
"""Return a list of entries from a map with matching keys or ``None``"""
113+
return [mapping.get(key) for key in self._param_names]
114+
96115
def run(self, params_init: Mapping[str, Number], **opt_kwargs):
97116
"""Execute the optimization"""
98117
self._param_names = list(params_init.keys())
99118

100119
# Transform data to match minimize input
101-
bounds = self.input.bounds
102-
if bounds is not None:
103-
bounds = [bounds.get(name) for name in self._param_names]
104-
105-
constraints = self.input.constraints
106-
if constraints is not None:
107-
constraints = [constraints.get(name) for name in self._param_names]
120+
bounds = (
121+
self._select_by_param_names(self.input.bounds)
122+
if self.input.bounds is not None
123+
else None
124+
)
125+
constraints = (
126+
self._select_by_param_names(self.input.constraints)
127+
if self.input.constraints is not None
128+
else None
129+
)
108130

109131
x0 = np.array(list(params_init.values()))
110132
res = minimize(
@@ -126,21 +148,28 @@ class BayesianOptimizer(Optimizer):
126148
verbose: InitVar[int] = 1
127149
random_state: InitVar[int] = 1
128150
allow_duplicate_points: InitVar[bool] = True
129-
init_kwds: InitVar[Mapping[str, Any]] = field(default_factory=dict)
151+
bayes_opt_kwds: InitVar[Optional[Mapping[str, Any]]] = None
130152

131-
def __post_init__(self, **kwargs):
153+
def __post_init__(
154+
self, verbose, random_state, allow_duplicate_points, bayes_opt_kwds
155+
):
132156
"""Create optimizer"""
133-
init_kwds = kwargs.pop("init_kwds")
134157
self.optimizer = BayesianOptimization(
135158
f=lambda **kwargs: self._opt_func(**kwargs),
136159
pbounds=self.input.bounds,
137-
**kwargs,
138-
**init_kwds,
160+
verbose=verbose,
161+
random_state=random_state,
162+
allow_duplicate_points=allow_duplicate_points,
163+
**bayes_opt_kwds,
139164
)
140165

141166
def run(self, init_points: int = 100, n_iter: int = 200, **opt_kwargs):
142167
"""Execute the optimization"""
143-
opt_kwargs.update(init_points=init_points, n_iter=n_iter)
144-
self.optimizer.maximize(**opt_kwargs)
168+
self.optimizer.maximize(init_points=init_points, n_iter=n_iter, **opt_kwargs)
145169
opt = self.optimizer.max
146-
return Output(params=opt["params"], target=opt["target"], success=True)
170+
return Output(
171+
params=opt["params"],
172+
target=opt["target"],
173+
success=True,
174+
result=self.optimizer,
175+
)
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,138 @@
11
"""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+
from shapely.geometry import Point
11+
12+
from climada.entity import Exposures, ImpactFuncSet
13+
from climada.hazard import Hazard, Centroids
14+
15+
from ..impact_func import Input, ScipyMinimizeOptimizer
16+
17+
18+
def hazard():
19+
"""Create a dummy hazard instance"""
20+
lat = [1, 2]
21+
lon = [0, 1]
22+
centroids = Centroids.from_lat_lon(lat=lat, lon=lon)
23+
event_id = np.array([1, 3, 10])
24+
intensity = csr_matrix([[1, 1], [2, 2], [3, 3]])
25+
return Hazard(event_id=event_id, centroids=centroids, intensity=intensity)
26+
27+
28+
def exposure():
29+
"""Create a dummy exposure instance"""
30+
return Exposures(data=dict(longitude=[0, 1, 100], latitude=[1, 2, 50]))
31+
32+
33+
class TestInputPostInit(unittest.TestCase):
34+
"""Test the post_init dunder method of Input"""
35+
36+
def setUp(self):
37+
"""Create default input instance"""
38+
# Create the hazard instance
39+
self.hazard = hazard()
40+
41+
# Create the exposure instance
42+
self.exposure = exposure()
43+
44+
# Create some data
45+
self.data_events = [10, 3]
46+
self.data = pd.DataFrame(data={"a": [1, 2]}, index=self.data_events)
47+
48+
# Create dummy funcs
49+
self.cost_func = lambda impact, data: 1.0
50+
self.impact_func_gen = lambda **kwargs: ImpactFuncSet()
51+
52+
def test_post_init_calls(self):
53+
"""Test if post_init calls stuff correctly using mocks"""
54+
# 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)
58+
59+
# Make first hazard mock return another instance
60+
hazard_mock_1.select.return_value = hazard_mock_2
61+
62+
# Create input
63+
input = Input(
64+
hazard=hazard_mock_1,
65+
exposure=exposure_mock,
66+
data=self.data,
67+
cost_func=self.cost_func,
68+
impact_func_gen=self.impact_func_gen,
69+
)
70+
71+
# Query checks
72+
hazard_mock_1.select.assert_called_once_with(event_id=self.data_events)
73+
self.assertNotEqual(input.hazard, hazard_mock_1)
74+
self.assertEqual(input.hazard, hazard_mock_2)
75+
exposure_mock.assign_centroids.assert_called_once_with(hazard_mock_2)
76+
77+
def test_post_init(self):
78+
"""Test if post_init results in a sensible hazard and exposure"""
79+
# Create input
80+
input = Input(
81+
hazard=self.hazard,
82+
exposure=self.exposure,
83+
data=self.data,
84+
cost_func=self.cost_func,
85+
impact_func_gen=self.impact_func_gen,
86+
)
87+
88+
# Check hazard and exposure
89+
npt.assert_array_equal(input.hazard.event_id, self.data.index)
90+
self.assertIn("centr_", input.exposure.gdf)
91+
npt.assert_array_equal(input.exposure.gdf["centr_"], [0, 1, -1])
92+
93+
94+
class TestScipyMinimizeOptimizer(unittest.TestCase):
95+
"""Tests for the optimizer based on scipy.optimize.minimize"""
96+
97+
def setUp(self):
98+
"""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
116+
)
117+
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,
122+
)
123+
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+
)

0 commit comments

Comments
 (0)