Skip to content

Commit 4e1f104

Browse files
committed
Add evaluator for calibration output
1 parent 91cfd83 commit 4e1f104

File tree

5 files changed

+193
-3
lines changed

5 files changed

+193
-3
lines changed

climada/util/calibrate/base.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,14 @@
66
from numbers import Number
77

88
import pandas as pd
9+
import numpy as np
910
from scipy.optimize import Bounds, LinearConstraint, NonlinearConstraint
11+
import seaborn as sns
1012

1113
from climada.hazard import Hazard
1214
from climada.entity import Exposures, ImpactFuncSet
1315
from climada.engine import Impact, ImpactCalc
16+
import climada.util.coordinates as u_coord
1417

1518
ConstraintType = Union[LinearConstraint, NonlinearConstraint, Mapping]
1619

@@ -94,6 +97,113 @@ class Output:
9497
target: Number
9598

9699

100+
@dataclass
101+
class OutputEvaluator:
102+
"""Evaluate the output of a calibration task
103+
104+
Parameters
105+
----------
106+
input : Input
107+
The input object for the optimization task.
108+
output : Output
109+
The output object returned by the optimization task.
110+
111+
Attributes
112+
----------
113+
impf_set : climada.entity.ImpactFuncSet
114+
The impact function set built from the optimized parameters
115+
impact : climada.engine.Impact
116+
An impact object calculated using the optimal :py:attr:`impf_set`
117+
"""
118+
119+
input: Input
120+
output: Output
121+
122+
def __post_init__(self):
123+
"""Compute the impact for the optimal parameters"""
124+
self.impf_set = self.input.impact_func_creator(**self.output.params)
125+
self.impact = ImpactCalc(
126+
exposures=self.input.exposure,
127+
impfset=self.impf_set,
128+
hazard=self.input.hazard,
129+
).impact(assign_centroids=True, save_mat=True)
130+
self._impact_label = f"Impact [{self.input.exposure.value_unit}]"
131+
132+
def plot_impf_set(self, **plot_kwargs):
133+
"""Plot the optimized impact functions"""
134+
return self.impf_set.plot(**plot_kwargs)
135+
136+
def plot_at_event(self, **plot_kwargs):
137+
data = (
138+
pd.concat(
139+
[
140+
pd.Series([self.impact.at_event]),
141+
self.input.data.sum(axis="columns"),
142+
],
143+
ignore_index=True,
144+
axis=1,
145+
)
146+
.rename(columns={0: "Model", 1: "Data"})
147+
.set_index(self.input.hazard.event_name)
148+
)
149+
ylabel = plot_kwargs.pop("ylabel", self._impact_label)
150+
return data.plot.bar(ylabel=ylabel, **plot_kwargs)
151+
152+
def plot_at_region(self, agg_regions=None, **plot_kwargs):
153+
data = pd.concat(
154+
[
155+
self.impact.impact_at_reg(agg_regions).sum(axis="index"),
156+
self.input.data.sum(axis="index"),
157+
],
158+
axis=1,
159+
).rename(columns={0: "Model", 1: "Data"})
160+
161+
# Use nice country names if no agg_regions were given
162+
if agg_regions is None:
163+
data = data.rename(
164+
index=lambda x: u_coord.country_to_iso(x, representation="name")
165+
)
166+
167+
ylabel = plot_kwargs.pop("ylabel", self._impact_label)
168+
return data.plot.bar(ylabel=ylabel, **plot_kwargs)
169+
170+
def plot_event_region_heatmap(self, agg_regions=None, **plot_kwargs):
171+
# Data preparation
172+
agg = self.impact.impact_at_reg(agg_regions)
173+
data = (agg + 1) / (self.input.data + 1)
174+
data = data.transform(np.log10).replace(0, np.nan)
175+
data = data.where((agg < 1) & (self.input.data < 1))
176+
177+
# Use nice country names if no agg_regions were given
178+
if agg_regions is None:
179+
data = data.rename(
180+
index=lambda x: u_coord.country_to_iso(x, representation="name")
181+
)
182+
183+
# Default plot settings
184+
annot = plot_kwargs.pop("annot", True)
185+
vmax = plot_kwargs.pop("vmax", 3)
186+
vmin = plot_kwargs.pop("vmin", -vmax)
187+
center = plot_kwargs.pop("center", 0)
188+
fmt = plot_kwargs.pop("fmt", ".1f")
189+
cmap = plot_kwargs.pop("cmap", "RdBu_r")
190+
cbar_kws = plot_kwargs.pop(
191+
"cbar_kws", {"label": r"Model Error $\log_{10}(\mathrm{Impact})$"}
192+
)
193+
194+
return sns.heatmap(
195+
data,
196+
annot=annot,
197+
vmin=vmin,
198+
vmax=vmax,
199+
center=center,
200+
fmt=fmt,
201+
cmap=cmap,
202+
cbar_kws=cbar_kws,
203+
**plot_kwargs,
204+
)
205+
206+
97207
@dataclass
98208
class Optimizer(ABC):
99209
"""Abstract base class (interface) for an optimization

climada/util/calibrate/bayesian_optimizer.py

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from dataclasses import dataclass, InitVar
44
from typing import Mapping, Optional, Any
55
from numbers import Number
6+
from itertools import combinations
67

78
import pandas as pd
89
from bayes_opt import BayesianOptimization
@@ -147,14 +148,56 @@ def p_space_to_dataframe(self):
147148
Returns
148149
-------
149150
pandas.DataFrame
150-
Data frame whose columns are the parameter values and the associated target
151-
function value (``target``) and whose rows are the optimizer iterations.
151+
Data frame whose columns are the parameter values and the associated cost
152+
function value (``Cost Function``) and whose rows are the optimizer
153+
iterations.
152154
"""
153155
data = {
154156
self.p_space.keys[i]: self.p_space.params[..., i]
155157
for i in range(self.p_space.dim)
156158
}
157-
data["target"] = self.p_space.target
159+
data["Cost Function"] = -self.p_space.target
158160
data = pd.DataFrame.from_dict(data)
159161
data.index.rename("Iteration", inplace=True)
160162
return data
163+
164+
def plot_p_space(
165+
self,
166+
p_space_df: Optional[pd.DataFrame] = None,
167+
min_def: Optional[str] = "Cost Function",
168+
min_fmt: str = "x",
169+
min_color: str = "r",
170+
**plot_kwargs
171+
):
172+
"""Plot the parameter space"""
173+
if p_space_df is None:
174+
p_space_df = self.p_space_to_dataframe()
175+
176+
# Plot defaults
177+
cmap = plot_kwargs.pop("cmap", "viridis_r")
178+
s = plot_kwargs.pop("s", 40)
179+
c = plot_kwargs.pop("c", "Cost Function")
180+
181+
# Ignore cost dimension
182+
params = p_space_df.columns.tolist()
183+
try:
184+
params.remove(c)
185+
except ValueError:
186+
pass
187+
188+
# Iterate over parameter combinations
189+
for p_first, p_second in combinations(params, 2):
190+
ax = p_space_df.plot(
191+
kind="scatter",
192+
x=p_first,
193+
y=p_second,
194+
c=c,
195+
s=s,
196+
cmap=cmap,
197+
**plot_kwargs,
198+
)
199+
200+
# Plot the minimum
201+
if min_def is not None:
202+
best = p_space_df.iloc[p_space_df.idxmin()[min_def]]
203+
ax.plot(best[p_first], best[p_second], min_fmt, color=min_color)

climada/util/calibrate/func.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
"""Default functions"""
2+
from typing import Sequence, Optional
3+
4+
import pandas as pd
5+
import numpy as np
6+
7+
from climada.engine import Impact
8+
9+
def rmse(impact: pd.DataFrame, data: pd.DataFrame):
10+
return np.sqrt(np.mean(((impact - data) ** 2).to_numpy()))
11+
12+
def rmsf(impact: pd.DataFrame, data: pd.DataFrame):
13+
return np.sqrt(np.mean((((impact + 1) / (data + 1)) ** 2).to_numpy()))
14+
15+
def impact_at_reg(impact: Impact, region_ids: Optional[Sequence] = None):
16+
return impact.impact_at_reg(agg_regions=region_ids)

climada/util/calibrate/test/test_calibrate.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,21 @@ def test_kwargs_to_impact_func_creator(self, _):
243243
for args in call_args:
244244
self.assertSequenceEqual(args.kwargs.keys(), self.input.bounds.keys())
245245

246+
@patch("climada.util.calibrate.base.ImpactCalc", autospec=True)
247+
def test_target_func(self, _):
248+
"""Test if cost function is transformed correctly
249+
250+
We test the method '_target_func' through 'run' because it is
251+
private"""
252+
self.input.bounds = {"x_2": (0, 1), "x 1": (1, 2)}
253+
self.input.cost_func.side_effect = [1.0, -1.0]
254+
self.optimizer = BayesianOptimizer(self.input)
255+
256+
# Call 'run'
257+
output = self.optimizer.run(init_points=1, n_iter=1)
258+
259+
# Check target space
260+
npt.assert_array_equal(output.p_space.target, [-1.0, 1.0])
246261

247262
# Execute Tests
248263
if __name__ == "__main__":

climada/util/coordinates.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
import re
2929
import warnings
3030
import zipfile
31+
from typing import Union, Sequence
3132

3233
from cartopy.io import shapereader
3334
import dask.dataframe as dd
@@ -1463,6 +1464,11 @@ def country_natid2iso(natids, representation="alpha3"):
14631464
iso_list = country_to_iso(iso_list, representation)
14641465
return iso_list[0] if return_str else iso_list
14651466

1467+
1468+
def iso_to_country(iso: Union[str, int, Sequence[Union[str, int]]], attr):
1469+
"""Convert an ISO 3166 code to a country name"""
1470+
1471+
14661472
def country_iso2natid(isos):
14671473
"""Convert ISO 3166-1 alpha-3 codes to internal NatIDs
14681474

0 commit comments

Comments
 (0)