Skip to content

Commit 6df548c

Browse files
authored
Merge pull request #10 from gavinevans/mobt_877_implement_qrf_extra
Add unit tests and alterations for QRF
2 parents 2d8319b + 1e37d96 commit 6df548c

8 files changed

Lines changed: 2199 additions & 222 deletions

improver/calibration/load_and_apply_quantile_regression_random_forest.py

Lines changed: 165 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
import iris
1111
import joblib
1212
import numpy as np
13+
from iris.cube import Cube, CubeList
14+
from quantile_forest import RandomForestQuantileRegressor
1315

1416
from improver import PostProcessingPlugin
1517
from improver.calibration.quantile_regression_random_forest import (
@@ -23,36 +25,27 @@
2325

2426

2527
class LoadAndApplyQRF(PostProcessingPlugin):
26-
def process(
28+
"""Load and apply the trained Quantile Regression Random Forest (QRF) model."""
29+
30+
def __init__(
2731
self,
28-
file_paths: pathlib.Path,
29-
feature_config: dict,
32+
feature_config: dict[str, list[str]],
3033
target_cube_name: str,
3134
transformation: str = None,
3235
pre_transform_addition: float = None,
3336
):
34-
"""Loading and applying the trained model for Quantile Regression Random Forest.
35-
36-
Load in the previously trained model for Quantile Regression Random
37-
Forest (QRF). The model is applied to the forecast that is supplied,
38-
so as to calibrate the forecast. The calibrated forecast is written
39-
to a cube. If no model is provided the input forecast is returned unchanged.
37+
"""Initialise the plugin.
4038
4139
Args:
42-
file_paths (cli.inputpaths):
43-
A list of input paths containing:
44-
- The path to a QRF trained model in pickle file format to be used
45-
for calibration.
46-
- The path to a NetCDF file containing the forecast to be calibrated.
47-
- Optionally, paths to NetCDF files containing additional preictors.
4840
feature_config (dict):
49-
Feature configuration defining the features to be used for quantile regression.
50-
The configuration is a dictionary of strings, where the keys are the names of
51-
the input cube(s) supplied, and the values are a list. This list can contain both
52-
computed features, such as the mean or standard deviation (std), or static
53-
features, such as the altitude. The computed features will be computed using
54-
the cube defined in the dictionary key. If the key is the feature itself e.g.
55-
a distance to water cube, then the value should state "static". This will ensure
41+
Feature configuration defining the features to be used for quantile
42+
regression. The configuration is a dictionary of strings, where the
43+
keys are the names of the input cube(s) supplied, and the values are
44+
a list. This list can contain both computed features, such as the mean
45+
or standard deviation (std), or static features, such as the altitude.
46+
The computed features will be computed using the cube defined in the
47+
dictionary key. If the key is the feature itself e.g. a distance to
48+
water cube, then the value should state "static". This will ensure
5649
the cube's data is used as the feature.
5750
The config will have the structure:
5851
"DYNAMIC_VARIABLE_NAME": ["FEATURE1", "FEATURE2"] e.g:
@@ -69,73 +62,155 @@ def process(
6962
Transformation to be applied to the data before fitting.
7063
pre_transform_addition (float):
7164
Value to be added before transformation.
72-
Returns:
73-
iris.cube.Cube:
74-
The calibrated forecast cube.
65+
7566
"""
67+
self.feature_config = feature_config
68+
self.target_cube_name = target_cube_name
69+
self.transformation = transformation
70+
self.pre_transform_addition = pre_transform_addition
7671

72+
def _get_inputs(
73+
self, file_paths: pathlib.Path
74+
) -> tuple[CubeList, Cube, RandomForestQuantileRegressor]:
75+
"""Get inputs from disk and separate the model and the features.
76+
77+
Args:
78+
file_paths: Path to the trained QRF model and the forecast to be calibrated,
79+
and the features, as required.
80+
81+
Returns:
82+
CubeList of the features cubes, the forecast cube, and the
83+
trained QRF model.
84+
85+
Raises:
86+
ValueError: If no QRF model is found in the provided file paths.
87+
ValueError: If no features are found in the provided file paths.
88+
ValueError: If the number of inputs does not match the number of file paths.
89+
"""
7790
cube_inputs = iris.cube.CubeList([])
7891
qrf_model = None
92+
7993
for file_path in file_paths:
8094
try:
8195
cube = iris.load_cube(file_path)
8296
cube_inputs.append(cube)
8397
except ValueError:
8498
qrf_model = joblib.load(file_path)
85-
99+
100+
if not cube_inputs:
101+
msg = (
102+
"No features found in the provided file paths. "
103+
"At least one feature must be provided."
104+
)
105+
raise ValueError(msg)
106+
86107
# Extract all additional cubes which are associated with a feature in the
87108
# feature_config.
109+
forecast_constraint = iris.Constraint(name=self.target_cube_name)
110+
forecast_cube = cube_inputs.extract(forecast_constraint)
111+
112+
if forecast_cube:
113+
(forecast_cube,) = forecast_cube
114+
else:
115+
msg = (
116+
"No target forecast provided. An input file representing the target "
117+
"must be provided, even if the target will not be used as a feature. "
118+
f"The target is '{self.target_cube_name}'."
119+
)
120+
raise ValueError(msg)
121+
122+
if not qrf_model:
123+
return None, forecast_cube, None
88124

89-
forecast_constraint = iris.Constraint(name=target_cube_name)
90-
forecast_cube = cube_inputs.extract_cube(forecast_constraint)
125+
if len(cube_inputs) != len(self.feature_config.keys()):
126+
msg = (
127+
"The number of cubes loaded does not match the number of features "
128+
"expected. The number of cubes loaded was: "
129+
f"{len(cube_inputs)}. The number of features expected was: "
130+
f"{len(self.feature_config.keys())}."
131+
)
132+
raise ValueError(msg)
91133

92134
if not qrf_model:
93135
# The specified model doesn't exist and the forecast will not be calibrated
94136
return forecast_cube
95137

96138
# If target diagnostic not a feature in the training then remove.
97-
if target_cube_name not in feature_config.keys():
139+
if self.target_cube_name not in self.feature_config.keys():
98140
cube_inputs.remove(forecast_cube)
99141

100-
# Calculate quantiles for the model fit
101-
n_percentiles = 19
142+
return cube_inputs, forecast_cube, qrf_model
143+
144+
@staticmethod
145+
def _compute_percentiles(forecast_cube: Cube, coord: str) -> list[float]:
146+
"""Compute the percentiles from the forecast cube.
147+
148+
Args:
149+
forecast_cube: Forecast to be calibrated.
150+
coord: Coordinate name. The length of the coordinate will be used to
151+
determine the number of percentiles to compute.
152+
153+
Returns:
154+
List of percentiles computed from the forecast cube.
155+
"""
156+
n_percentiles = len(forecast_cube.coord(coord).points)
102157
percentiles = (
103158
np.array(choose_set_of_percentiles(n_percentiles)) / 100
104159
).tolist()
160+
return percentiles
161+
162+
@staticmethod
163+
def _percentiles_to_realizations(cube_inputs: Cube) -> CubeList:
164+
"""Convert percentiles to realizations. The input forecasts are expected to
165+
be percentiles but these percentiles are rebadged as realizations.
166+
167+
Args:
168+
cube_inputs:
169+
List of cubes containing the features and the forecast to be calibrated.
170+
Some may be percentiles.
171+
Returns:
172+
cube_inputs:
173+
List of cubes with percentiles rebadged as realizations,
174+
where appropriate
175+
"""
105176

106177
# Ensure there is a realization dimension on all cubes. This assumes a percentile
107178
# dimension is present.
108179
realization_cube_inputs = iris.cube.CubeList([])
109180
for feature_cube in cube_inputs:
110-
try:
111-
feature_cube.coord("realization")
112-
realization_cube_inputs.append(feature_cube)
113-
except iris.exceptions.CoordinateNotFoundError:
181+
if feature_cube.coords("percentile"):
114182
feature_cube = RebadgePercentilesAsRealizations()(feature_cube)
115-
realization_cube_inputs.append(feature_cube)
183+
realization_cube_inputs.append(feature_cube)
116184
cube_inputs = realization_cube_inputs
185+
return cube_inputs
117186

118-
# Ensure the feature cubes have dimensions that can be used in the prep_feature function
187+
@staticmethod
188+
def _organise_cubes(
189+
cube_inputs: CubeList, forecast_cube: Cube
190+
) -> tuple[CubeList, Cube]:
191+
"""Promote the forecast period and forecast reference time coordinates to be
192+
dimension coordinates, if present, on the feature cubes and the template
193+
forecast cube.
119194
195+
Args:
196+
cube_inputs: CubeList of feature cubes, which may include the forecast to be
197+
forecast_cube: Forecast cube for use as a template.
198+
199+
Returns:
200+
Feature cubes and template cube with forecast period and
201+
forecast reference time promoted to dimension coordinates.
202+
"""
203+
# Ensure that forecast_period is a dimension on all cubes.
120204
fp_dim_cube_inputs = iris.cube.CubeList([])
121205
for feature_cube in cube_inputs:
122-
try:
206+
if feature_cube.coords("forecast_period", dim_coords=False):
123207
feature_cube = iris.util.new_axis(feature_cube, "forecast_period")
124-
fp_dim_cube_inputs.append(feature_cube)
125-
except ValueError:
126-
fp_dim_cube_inputs.append(feature_cube)
127-
cube_inputs = fp_dim_cube_inputs
128-
129-
frt_dim_cube_inputs = iris.cube.CubeList([])
130-
for feature_cube in cube_inputs:
131-
try:
208+
if feature_cube.coords("forecast_reference_time", dim_coords=False):
132209
feature_cube = iris.util.new_axis(
133210
feature_cube, "forecast_reference_time"
134211
)
135-
frt_dim_cube_inputs.append(feature_cube)
136-
except ValueError:
137-
frt_dim_cube_inputs.append(feature_cube)
138-
cube_inputs = frt_dim_cube_inputs
212+
fp_dim_cube_inputs.append(feature_cube)
213+
cube_inputs = fp_dim_cube_inputs
139214

140215
# Ensure the forecast cube has the same dimensions as the features
141216
template_forecast_cube = iris.util.new_axis(forecast_cube, "forecast_period")
@@ -145,14 +220,48 @@ def process(
145220

146221
# Check that the grids are the same for all dynamic predictors and the forecast
147222
assert_spatial_coords_match(cube_inputs)
223+
return cube_inputs, template_forecast_cube
224+
225+
def process(
226+
self,
227+
file_paths: pathlib.Path,
228+
) -> Cube:
229+
"""Loading and applying the trained model for Quantile Regression Random Forest.
148230
149-
if len(cube_inputs) + 1 != len(file_paths):
150-
raise ValueError("Unable to identify the correct number of inputs")
231+
Load in the previously trained model for Quantile Regression Random
232+
Forest (QRF). The model is applied to the forecast that is supplied,
233+
so as to calibrate the forecast. The calibrated forecast is written
234+
to a cube. If no model is provided the input forecast is returned unchanged.
235+
236+
Args:
237+
file_paths (cli.inputpaths):
238+
A list of input paths containing:
239+
- The path to a QRF trained model in pickle file format to be used
240+
for calibration.
241+
- The path to a NetCDF file containing the forecast to be calibrated.
242+
- Optionally, paths to NetCDF files containing additional preictors.
243+
244+
Returns:
245+
iris.cube.Cube:
246+
The calibrated forecast cube.
247+
"""
248+
cube_inputs, forecast_cube, qrf_model = self._get_inputs(file_paths)
249+
if not qrf_model:
250+
return forecast_cube
251+
if forecast_cube.coords("percentile"):
252+
percentiles = self._compute_percentiles(forecast_cube, "percentile")
253+
cube_inputs = self._percentiles_to_realizations(cube_inputs)
254+
elif forecast_cube.coords("realization"):
255+
percentiles = self._compute_percentiles(forecast_cube, "realization")
256+
257+
cube_inputs, template_forecast_cube = self._organise_cubes(
258+
cube_inputs, forecast_cube
259+
)
151260

152261
result = ApplyQuantileRegressionRandomForests(
153-
feature_config=feature_config,
262+
feature_config=self.feature_config,
154263
quantiles=percentiles,
155-
transformation=transformation,
156-
pre_transform_addition=pre_transform_addition,
157-
)(forecast_cube, template_forecast_cube, qrf_model, cube_inputs)
264+
transformation=self.transformation,
265+
pre_transform_addition=self.pre_transform_addition,
266+
)(qrf_model, cube_inputs, template_forecast_cube)
158267
return result

0 commit comments

Comments
 (0)