Skip to content

Commit af6f7ed

Browse files
authored
Merge pull request #11 from gavinevans/mobt_877_implement_qrf2
Add acceptance tests for QRF
2 parents 6df548c + 8665eff commit af6f7ed

13 files changed

Lines changed: 1172 additions & 720 deletions

improver/calibration/load_and_apply_quantile_regression_random_forest.py

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33
#
44
# This file is part of 'IMPROVER' and is released under the BSD 3-Clause license.
55
# See LICENSE in the root of the repository for full licensing details.
6-
"""Script to load and apply the trained Quantile Regression Random Forest (QRF) model."""
6+
"""Script to load and apply the trained Quantile Regression Random Forest (QRF)
7+
model."""
78

89
import pathlib
10+
from typing import Optional
911

1012
import iris
1113
import joblib
@@ -14,6 +16,7 @@
1416
from quantile_forest import RandomForestQuantileRegressor
1517

1618
from improver import PostProcessingPlugin
19+
from improver.calibration import add_warning_comment
1720
from improver.calibration.quantile_regression_random_forest import (
1821
ApplyQuantileRegressionRandomForests,
1922
)
@@ -31,8 +34,8 @@ def __init__(
3134
self,
3235
feature_config: dict[str, list[str]],
3336
target_cube_name: str,
34-
transformation: str = None,
35-
pre_transform_addition: float = None,
37+
transformation: Optional[str] = None,
38+
pre_transform_addition: Optional[float] = None,
3639
):
3740
"""Initialise the plugin.
3841
@@ -70,13 +73,13 @@ def __init__(
7073
self.pre_transform_addition = pre_transform_addition
7174

7275
def _get_inputs(
73-
self, file_paths: pathlib.Path
76+
self, file_paths: list[pathlib.Path]
7477
) -> tuple[CubeList, Cube, RandomForestQuantileRegressor]:
7578
"""Get inputs from disk and separate the model and the features.
7679
7780
Args:
78-
file_paths: Path to the trained QRF model and the forecast to be calibrated,
79-
and the features, as required.
81+
file_paths: List of paths to the trained QRF model and the forecast to be
82+
calibrated and the features, as required.
8083
8184
Returns:
8285
CubeList of the features cubes, the forecast cube, and the
@@ -120,6 +123,7 @@ def _get_inputs(
120123
raise ValueError(msg)
121124

122125
if not qrf_model:
126+
forecast_cube = add_warning_comment(forecast_cube)
123127
return None, forecast_cube, None
124128

125129
if len(cube_inputs) != len(self.feature_config.keys()):
@@ -134,7 +138,7 @@ def _get_inputs(
134138
if not qrf_model:
135139
# The specified model doesn't exist and the forecast will not be calibrated
136140
return forecast_cube
137-
141+
138142
# If target diagnostic not a feature in the training then remove.
139143
if self.target_cube_name not in self.feature_config.keys():
140144
cube_inputs.remove(forecast_cube)
@@ -160,7 +164,7 @@ def _compute_percentiles(forecast_cube: Cube, coord: str) -> list[float]:
160164
return percentiles
161165

162166
@staticmethod
163-
def _percentiles_to_realizations(cube_inputs: Cube) -> CubeList:
167+
def _percentiles_to_realizations(cube_inputs: CubeList) -> CubeList:
164168
"""Convert percentiles to realizations. The input forecasts are expected to
165169
be percentiles but these percentiles are rebadged as realizations.
166170
@@ -174,8 +178,8 @@ def _percentiles_to_realizations(cube_inputs: Cube) -> CubeList:
174178
where appropriate
175179
"""
176180

177-
# Ensure there is a realization dimension on all cubes. This assumes a percentile
178-
# dimension is present.
181+
# Ensure there is a realization dimension on all cubes. This assumes a
182+
# percentile dimension is present.
179183
realization_cube_inputs = iris.cube.CubeList([])
180184
for feature_cube in cube_inputs:
181185
if feature_cube.coords("percentile"):
@@ -224,22 +228,20 @@ def _organise_cubes(
224228

225229
def process(
226230
self,
227-
file_paths: pathlib.Path,
231+
file_paths: list[pathlib.Path],
228232
) -> Cube:
229-
"""Loading and applying the trained model for Quantile Regression Random Forest.
230-
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.
233+
"""Load and applying the trained Quantile Regression Random Forest (QRF) model.
234+
The model is applied to the forecast supplied to calibrate the forecast.
235+
The calibrated forecast is written to a cube. If no model is provided the
236+
input forecast is returned unchanged.
235237
236238
Args:
237239
file_paths (cli.inputpaths):
238240
A list of input paths containing:
239241
- The path to a QRF trained model in pickle file format to be used
240242
for calibration.
241243
- The path to a NetCDF file containing the forecast to be calibrated.
242-
- Optionally, paths to NetCDF files containing additional preictors.
244+
- Optionally, paths to NetCDF files containing additional predictors.
243245
244246
Returns:
245247
iris.cube.Cube:

improver/calibration/load_and_train_quantile_regression_random_forest.py

Lines changed: 126 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22
#
33
# This file is part of 'IMPROVER' and is released under the BSD 3-Clause license.
44
# See LICENSE in the root of the repository for full licensing details.
5-
"""Script to load inputs and train a model using Quantile Regression Random Forest (QRF)."""
5+
"""Script to load inputs and train a model using Quantile Regression Random Forest
6+
(QRF)."""
67

78
import pathlib
89
from pathlib import Path
10+
from typing import Optional
911

1012
import iris
1113
import numpy as np
@@ -56,8 +58,24 @@ def __init__(
5658
self.pre_transform_addition = pre_transform_addition
5759
self.compression = compression
5860

59-
def _split_cubes_and_parquet_files(self, file_paths):
60-
"""Split the input file paths into cubes and parquet files."""
61+
def _split_cubes_and_parquet_files(
62+
self, file_paths: list[pathlib.Path | str]
63+
) -> tuple[Optional[pathlib.Path], Optional[pathlib.Path], iris.cube.CubeList]:
64+
"""Split the input file paths into cubes and parquet files.
65+
66+
Args:
67+
file_paths: List of file paths.
68+
69+
Returns:
70+
Tuple containing the items below if found:
71+
- Path to the forecast parquet file.
72+
- Path to the truth parquet file.
73+
- List of cubes loaded from the NetCDF files.
74+
75+
Raises:
76+
ValueError: If the number of cubes loaded does not match the number of
77+
features expected.
78+
"""
6179

6280
forecast_table_path = None
6381
truth_table_path = None
@@ -69,7 +87,8 @@ def _split_cubes_and_parquet_files(self, file_paths):
6987
cube = load_cube(str(file_path))
7088
cube_inputs.append(cube)
7189
except IsADirectoryError:
72-
# For loop here because the read_schema must read a .parquet file rather than a directory.
90+
# For loop here because the read_schema must read a .parquet file
91+
# rather than a directory.
7392
for file in Path(file_path).glob("**/*.parquet"):
7493
try:
7594
pq.read_schema(file).field("forecast_period")
@@ -79,17 +98,18 @@ def _split_cubes_and_parquet_files(self, file_paths):
7998
if forecast_table_path and truth_table_path:
8099
break
81100
except OSError:
82-
print("The directory doesn't exist, calibration is skipped for this cycle")
83-
return
101+
# This will occur when the filepath does not exist. In this case,
102+
# return None.
103+
return None, None, None
84104

85105
if len(self.feature_config.keys()) not in [
86106
len(cube_inputs),
87107
len(cube_inputs) + 1,
88108
]:
89109
msg = (
90110
"The number of cubes loaded does not match the number of features "
91-
"expected. These can mismatch if the some features are coming from the "
92-
"historic forecast. The number of cubes loaded was: "
111+
"expected. These can mismatch if some features are coming from the "
112+
"historic forecast table. The number of cubes loaded was: "
93113
f"{len(cube_inputs)}. The number of features expected was: "
94114
f"{len(self.feature_config.keys())}."
95115
)
@@ -98,9 +118,29 @@ def _split_cubes_and_parquet_files(self, file_paths):
98118
return forecast_table_path, truth_table_path, cube_inputs
99119

100120
def _read_parquet_files(
101-
self, forecast_table_path, truth_table_path, forecast_periods
102-
):
103-
"""Read the forecast and truth data from parquet files."""
121+
self,
122+
forecast_table_path: pathlib.Path | str,
123+
truth_table_path: pathlib.Path | str,
124+
forecast_periods: list[int],
125+
) -> tuple[pd.DataFrame, pd.DataFrame]:
126+
"""Read the forecast and truth data from parquet files.
127+
128+
Args:
129+
forecast_table_path: Path to the forecast parquet file.
130+
truth_table_path: Path to the truth parquet file.
131+
forecast_periods: List of forecast periods in seconds.
132+
133+
Returns:
134+
Tuple containing:
135+
- DataFrame containing the forecast data.
136+
- DataFrame containing the truth data.
137+
138+
Raises:
139+
ValueError: If the forecast parquet file does not contain the expected
140+
fields.
141+
ValueError: If the truth parquet file does not contain the expected
142+
fields.
143+
"""
104144
cycletimes = []
105145

106146
for forecast_period in forecast_periods:
@@ -151,7 +191,8 @@ def _read_parquet_files(
151191
engine="pyarrow",
152192
)
153193

154-
# Convert df columns from ms to pandas timestamp object to work with existing code
194+
# Convert df columns from ms to pandas timestamp object to work with existing
195+
# code
155196
for column in ["time", "forecast_reference_time", "blend_time"]:
156197
forecast_df[column] = pd.to_datetime(
157198
forecast_df[column], unit="ns", utc=True
@@ -166,7 +207,7 @@ def _read_parquet_files(
166207
truth_df = pd.read_parquet(
167208
truth_table_path, filters=filters, schema=TRUTH_SCHEMA, engine="pyarrow"
168209
)
169-
210+
170211
truth_df["time"] = pd.to_datetime(truth_df["time"], unit="ns", utc=True)
171212

172213
if truth_df.empty:
@@ -177,9 +218,28 @@ def _read_parquet_files(
177218
raise IOError(msg)
178219
return forecast_df, truth_df
179220

180-
def _dataframe_to_cubes(self, forecast_df, truth_df, forecast_periods):
221+
def _dataframe_to_cubes(
222+
self,
223+
forecast_df: pd.DataFrame,
224+
truth_df: pd.DataFrame,
225+
forecast_periods: list[int],
226+
) -> tuple[iris.cube.Cube, iris.cube.Cube]:
181227
"""Convert the forecast and truth dataframes to cubes at each forecast period
182-
required."""
228+
required.
229+
230+
Args:
231+
forecast_df: DataFrame containing the forecast data.
232+
truth_df: DataFrame containing the truth data.
233+
forecast_periods: List of forecast periods in seconds.
234+
235+
Returns:
236+
Tuple containing:
237+
- Cube containing the forecast data.
238+
- Cube containing the truth data.
239+
240+
Raises:
241+
ValueError: The forecast has failed to concatenate into a single cube.
242+
"""
183243
forecast_cubes = iris.cube.CubeList([])
184244
truth_cubes = iris.cube.CubeList([])
185245

@@ -226,14 +286,28 @@ def _dataframe_to_cubes(self, forecast_df, truth_df, forecast_periods):
226286
return forecast_cube, truth_cube
227287

228288
@staticmethod
229-
def filter_bad_sites(forecast_cube, truth_cube, cube_inputs):
230-
"""Remove sites that have NaNs in the data."""
231-
bad_site_ids = []
289+
def filter_bad_sites(
290+
forecast_cube: iris.cube.Cube,
291+
truth_cube: iris.cube.Cube,
292+
cube_inputs: iris.cube.CubeList,
293+
) -> tuple[iris.cube.Cube, iris.cube.Cube, iris.cube.CubeList]:
294+
"""Remove sites that have NaNs in the data.
295+
296+
Args:
297+
forecast_cube: Cube containing the forecast data.
298+
truth_cube: Cube containing the truth data.
299+
cube_inputs: List of additional feature cubes.
300+
301+
Returns:
302+
Tuple containing:
303+
- Cube containing the forecast data with bad sites removed.
304+
- Cube containing the truth data with bad sites removed.
305+
- List of additional feature cubes with bad sites removed.
306+
"""
232307
nan_mask = np.any(np.isnan(truth_cube.data), axis=truth_cube.coord_dims("time"))
233308
all_site_ids = truth_cube.coord("wmo_id").points
234309
bad_site_ids = all_site_ids[nan_mask]
235-
wmo_ids = set(all_site_ids) - set(bad_site_ids)
236-
constr = iris.Constraint(wmo_id=lambda cell: cell in wmo_ids)
310+
constr = iris.Constraint(wmo_id=lambda cell: cell not in bad_site_ids.tolist())
237311
truth_cube = truth_cube.extract(constr)
238312
forecast_cube = forecast_cube.extract(constr)
239313
feature_cube_inputs = iris.cube.CubeList([])
@@ -247,13 +321,11 @@ def process(
247321
self,
248322
file_paths: list[pathlib.Path | str],
249323
model_output: str = None,
250-
):
251-
"""Loading input files and training a model using Quantile Regression Random Forest.
252-
253-
Loads in arguments for training a Quantile Regression Random Forest (QRF)
254-
model which can later be applied to calibrate the forecast.
255-
Two sources of input data must be provided: historical forecasts and
256-
historical truth data (to use in calibration). The model is output as a pickle file.
324+
) -> None:
325+
"""Load input files and training a Quantile Regression Random Forest (QRF)
326+
model. This model can be applied later to calibrate the forecast. Two sources
327+
of input data must be provided: historical forecasts and historical truth data
328+
(to use in calibration). The model is output as a pickle file.
257329
258330
Args:
259331
file_paths (cli.inputpaths):
@@ -266,15 +338,16 @@ def process(
266338
for calibration.
267339
- Optionally, paths to NetCDF files containing additional predictors.
268340
feature_config (dict):
269-
Feature configuration defining the features to be used for quantile regression.
270-
The configuration is a dictionary of strings, where the keys are the names of
271-
the input cube(s) supplied, and the values are a list. This list can contain both
272-
computed features, such as the mean or standard deviation (std), or static
273-
features, such as the altitude. The computed features will be computed using
274-
the cube defined in the dictionary key. If the key is the feature itself e.g.
275-
a distance to water cube, then the value should state "static". This will ensure
276-
the cube's data is used as the feature.
277-
The config will have the structure:
341+
Feature configuration defining the features to be used for quantile
342+
regression. The configuration is a dictionary of strings, where the
343+
keys are the names of the input cube(s) supplied, and the values are
344+
a list. This list can contain both computed features, such as the mean
345+
or standard deviation (std), or static features, such as the altitude.
346+
The computed features will be computed using the cube defined in the
347+
dictionary key. If the key is the feature itself e.g. a distance to
348+
water cube, then the value should state "static". This will ensure
349+
the cube's data is used as the feature. The config will have the
350+
structure:
278351
"DYNAMIC_VARIABLE_NAME": ["FEATURE1", "FEATURE2"] e.g:
279352
{
280353
"air_temperature": ["mean", "std", "altitude"],
@@ -286,8 +359,10 @@ def process(
286359
calibrated. This will be used to filter the target forecast and truth
287360
dataframes.
288361
forecast_period (int):
289-
Range of forecast periods to be including in training in hours in the
290-
form: "start:end:interval" e.g. "6:18:6".
362+
Range of forecast periods to be calibrated in hours in the form:
363+
"start:end:interval" e.g. "6:18:6" or a single forecast period e.g. "6".
364+
The end value is exclusive, so "6:18:6" will calibrate the 6 and 12
365+
hours.
291366
cycletime (str):
292367
Cycletime of the forecast to be calibrated in a format similar to
293368
20170109T0000Z. This is used to filter the correct blendtimes from
@@ -321,8 +396,19 @@ def process(
321396
if not forecast_table_path or not truth_table_path:
322397
return None
323398

324-
forecast_periods = list(range(*map(int, self.forecast_periods.split(":"))))
325-
forecast_periods = [fp * 3600 for fp in forecast_periods]
399+
if ":" in self.forecast_periods:
400+
forecast_periods = list(range(*map(int, self.forecast_periods.split(":"))))
401+
forecast_periods = [fp * 3600 for fp in forecast_periods]
402+
else:
403+
try:
404+
forecast_periods = [int(self.forecast_periods) * 3600]
405+
except ValueError:
406+
msg = (
407+
"The forecast_periods argument must be a single integer or "
408+
"a range in the form 'start:end:interval'. The forecast period"
409+
f"provided was: {self.forecast_periods}."
410+
)
411+
raise ValueError(msg)
326412

327413
forecast_df, truth_df = self._read_parquet_files(
328414
forecast_table_path, truth_table_path, forecast_periods

0 commit comments

Comments
 (0)