1010import iris
1111import joblib
1212import numpy as np
13+ from iris .cube import Cube , CubeList
14+ from quantile_forest import RandomForestQuantileRegressor
1315
1416from improver import PostProcessingPlugin
1517from improver .calibration .quantile_regression_random_forest import (
2325
2426
2527class 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