Skip to content

Commit 80c0e5f

Browse files
Feat/probabilistic ensemble (#1692)
* feat: allow for probabilistic regression ensemble model by passing num_samples to the ensemble() method * fix: propagating the new argument to the baseline * feat: improved the definition of probabilistic EnsembleModel * doc: updating the docstring and quickstart to include information about probabilistic ensembles * doc: improved docstring for regression_model argument of RegressionEnsembleModel * feat: adding unittests * fix: updated EnsembleModel unittest that were covering RegressionEnsembleModel * feat: added 2 new parameters to control training of regression model with probabilistic forecasting models, NaiveEnsembleModel also properly ensemble such probabilistic models (takes into account n_samples). * feat: simplify the ensembling method of NaiveEnsemble * doc: changed phrasing of the note about how to make EnsembleModel probabilistic * feat: improved the tests for stochastic naive ensemble * doc: added comments in regression ensemble tests, fixed a small typo in ensemble tests * fix: bug in samples reduction prior to regression model training * feat: improving the tests for regression ensemble model * fix: simplifiying tests synthax * Update CHANGELOG.md * fix: removed useless if else * feat: possible to not reduce the prediction for RegressionEnsembleModel, updated the tests accordingly * Apply suggestions from code review Co-authored-by: Dennis Bader <[email protected]> * fix: adressing reviewer comments * fix: ensemble with probabilistic forecasting models but deterministic regression cannot generate probabilistic forecast * fix: moving predict back to base ensemble class, removed horizontal stacking * Apply suggestions from code review Co-authored-by: Dennis Bader <[email protected]> * fix: addressing reviewer comments * fix: mixed (proba and deter) forecasting models sampling for regressor training --------- Co-authored-by: Dennis Bader <[email protected]>
1 parent 1f17580 commit 80c0e5f

File tree

7 files changed

+525
-29
lines changed

7 files changed

+525
-29
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ but cannot always guarantee backwards compatibility. Changes that may **break co
1717
- Improvements to `EnsembleModel`:
1818
- Model creation parameter `forecasting_models` now supports a mix of `LocalForecastingModel` and `GlobalForecastingModel` (single `TimeSeries` training/inference only, due to the local models). [#1745](https://github.com/unit8co/darts/pull/1745) by [Antoine Madrona](https://github.com/madtoinou).
1919
- Future and past covariates can now be used even if `forecasting_models` have different covariates support. The covariates passed to `fit()`/`predict()` are used only by models that support it. [#1745](https://github.com/unit8co/darts/pull/1745) by [Antoine Madrona](https://github.com/madtoinou).
20+
- `RegressionEnsembleModel` and `NaiveEnsembleModel` can generate probabilistic forecasts, probabilistics `forecasting_models` can be sampled to train the `regression_model`, updated the documentation (stacking technique). [#1692](https://github.com/unit8co/darts/pull/#1692) by [Antoine Madrona](https://github.com/madtoinou).
2021
- Improvements to `ShapExplainer`:
2122
- Added static covariates support to `ShapeExplainer`. [#1803](https://github.com/unit8co/darts/pull/#1803) by [Anne de Vries](https://github.com/anne-devries) and [Dennis Bader](https://github.com/dennisbader).
2223

darts/models/forecasting/baselines.py

+13-6
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from darts.logging import get_logger, raise_if_not
1313
from darts.models.forecasting.ensemble_model import EnsembleModel
1414
from darts.models.forecasting.forecasting_model import (
15-
GlobalForecastingModel,
15+
ForecastingModel,
1616
LocalForecastingModel,
1717
)
1818
from darts.timeseries import TimeSeries
@@ -164,7 +164,7 @@ def predict(self, n: int, num_samples: int = 1, verbose: bool = False):
164164
class NaiveEnsembleModel(EnsembleModel):
165165
def __init__(
166166
self,
167-
models: Union[List[LocalForecastingModel], List[GlobalForecastingModel]],
167+
models: List[ForecastingModel],
168168
show_warnings: bool = True,
169169
):
170170
"""Naive combination model
@@ -182,7 +182,12 @@ def __init__(
182182
show_warnings
183183
Whether to show warnings related to models covariates support.
184184
"""
185-
super().__init__(models=models, show_warnings=show_warnings)
185+
super().__init__(
186+
models=models,
187+
train_num_samples=None,
188+
train_samples_reduction=None,
189+
show_warnings=show_warnings,
190+
)
186191

187192
def fit(
188193
self,
@@ -209,11 +214,13 @@ def ensemble(
209214
self,
210215
predictions: Union[TimeSeries, Sequence[TimeSeries]],
211216
series: Optional[Sequence[TimeSeries]] = None,
217+
num_samples: int = 1,
212218
) -> Union[TimeSeries, Sequence[TimeSeries]]:
213219
def take_average(prediction: TimeSeries) -> TimeSeries:
214-
series = prediction.pd_dataframe(copy=False).sum(axis=1) / len(self.models)
215-
series.name = prediction.components[0]
216-
return TimeSeries.from_series(series)
220+
# average across the components, keep n_samples, rename components
221+
return prediction.mean(axis=1).with_columns_renamed(
222+
"components_mean", prediction.components[0]
223+
)
217224

218225
if isinstance(predictions, Sequence):
219226
return [take_average(p) for p in predictions]

darts/models/forecasting/ensemble_model.py

+97-6
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,14 @@
66
from functools import reduce
77
from typing import List, Optional, Sequence, Tuple, Union
88

9-
from darts.logging import get_logger, raise_if, raise_if_not
9+
from darts.logging import get_logger, raise_if, raise_if_not, raise_log
1010
from darts.models.forecasting.forecasting_model import (
1111
ForecastingModel,
1212
GlobalForecastingModel,
1313
LocalForecastingModel,
1414
)
1515
from darts.timeseries import TimeSeries
16+
from darts.utils.utils import series2seq
1617

1718
logger = get_logger(__name__)
1819

@@ -30,11 +31,28 @@ class EnsembleModel(GlobalForecastingModel):
3031
----------
3132
models
3233
List of forecasting models whose predictions to ensemble
34+
35+
.. note::
36+
if all the models are probabilistic, the `EnsembleModel` will also be probabilistic.
37+
..
38+
train_num_samples
39+
Number of prediction samples from each forecasting model for multi-level ensembles. The n_samples
40+
dimension will be reduced using the `train_samples_reduction` method.
41+
train_samples_reduction
42+
If `models` are probabilistic and `train_num_samples` > 1, method used to
43+
reduce the samples dimension to 1. Possible values: "mean", "median" or float value corresponding
44+
to the desired quantile.
3345
show_warnings
3446
Whether to show warnings related to models covariates support.
3547
"""
3648

37-
def __init__(self, models: List[ForecastingModel], show_warnings: bool = True):
49+
def __init__(
50+
self,
51+
models: List[ForecastingModel],
52+
train_num_samples: int,
53+
train_samples_reduction: Union[str, float],
54+
show_warnings: bool = True,
55+
):
3856
raise_if_not(
3957
isinstance(models, list) and models,
4058
"Cannot instantiate EnsembleModel with an empty list of models",
@@ -70,8 +88,44 @@ def __init__(self, models: List[ForecastingModel], show_warnings: bool = True):
7088
logger,
7189
)
7290

91+
raise_if(
92+
train_num_samples is not None
93+
and train_num_samples > 1
94+
and all([not m._is_probabilistic() for m in models]),
95+
"`train_num_samples` is greater than 1 but the `RegressionEnsembleModel` "
96+
"contains only deterministic models.",
97+
logger,
98+
)
99+
100+
supported_reduction = ["mean", "median"]
101+
if train_samples_reduction is None:
102+
pass
103+
elif isinstance(train_samples_reduction, float):
104+
raise_if_not(
105+
0.0 < train_samples_reduction < 1.0,
106+
f"if a float, `train_samples_reduction` must be between "
107+
f"0 and 1, received ({train_samples_reduction})",
108+
logger,
109+
)
110+
elif isinstance(train_samples_reduction, str):
111+
raise_if(
112+
train_samples_reduction not in supported_reduction,
113+
f"if a string, `train_samples_reduction` must be one of {supported_reduction}, "
114+
f"received ({train_samples_reduction})",
115+
logger,
116+
)
117+
else:
118+
raise_log(
119+
f"`train_samples_reduction` type not supported "
120+
f"({train_samples_reduction}). Must be `float` "
121+
f" or one of {supported_reduction}.",
122+
logger,
123+
)
124+
73125
super().__init__()
74126
self.models = models
127+
self.train_num_samples = train_num_samples
128+
self.train_samples_reduction = train_samples_reduction
75129

76130
if show_warnings:
77131
if (
@@ -94,6 +148,7 @@ def __init__(self, models: List[ForecastingModel], show_warnings: bool = True):
94148
"To hide these warnings, set `show_warnings=False`."
95149
)
96150

151+
@abstractmethod
97152
def fit(
98153
self,
99154
series: Union[TimeSeries, Sequence[TimeSeries]],
@@ -173,10 +228,21 @@ def _make_multiple_predictions(
173228
future_covariates=future_covariates
174229
if model.supports_future_covariates
175230
else None,
176-
num_samples=num_samples,
231+
num_samples=num_samples if model._is_probabilistic() else 1,
177232
)
178233
for model in self.models
179234
]
235+
236+
# reduce the probabilistics series
237+
if (
238+
self.train_samples_reduction is not None
239+
and self.train_num_samples is not None
240+
and self.train_num_samples > 1
241+
):
242+
predictions = [
243+
self._predictions_reduction(prediction) for prediction in predictions
244+
]
245+
180246
return (
181247
self._stack_ts_seq(predictions)
182248
if is_single_series
@@ -202,22 +268,30 @@ def predict(
202268
verbose=verbose,
203269
)
204270

271+
# for multi-level models, forecasting models can generate arbitrary number of samples
272+
if self.train_samples_reduction is None:
273+
pred_num_samples = num_samples
274+
else:
275+
pred_num_samples = self.train_num_samples
276+
205277
self._verify_past_future_covariates(past_covariates, future_covariates)
206278

207279
predictions = self._make_multiple_predictions(
208280
n=n,
209281
series=series,
210282
past_covariates=past_covariates,
211283
future_covariates=future_covariates,
212-
num_samples=num_samples,
284+
num_samples=pred_num_samples,
213285
)
214-
return self.ensemble(predictions, series=series)
286+
287+
return self.ensemble(predictions, series=series, num_samples=num_samples)
215288

216289
@abstractmethod
217290
def ensemble(
218291
self,
219292
predictions: Union[TimeSeries, Sequence[TimeSeries]],
220293
series: Optional[Sequence[TimeSeries]] = None,
294+
num_samples: int = 1,
221295
) -> Union[TimeSeries, Sequence[TimeSeries]]:
222296
"""
223297
Defines how to ensemble the individual models' predictions to produce a single prediction.
@@ -237,6 +311,20 @@ def ensemble(
237311
"""
238312
pass
239313

314+
def _predictions_reduction(self, predictions: TimeSeries) -> TimeSeries:
315+
"""Reduce the sample dimension of the forecasting models predictions"""
316+
is_single_series = isinstance(predictions, TimeSeries)
317+
predictions = series2seq(predictions)
318+
if self.train_samples_reduction == "median":
319+
predictions = [pred.median(axis=2) for pred in predictions]
320+
elif self.train_samples_reduction == "mean":
321+
predictions = [pred.mean(axis=2) for pred in predictions]
322+
else:
323+
predictions = [
324+
pred.quantile(self.train_samples_reduction) for pred in predictions
325+
]
326+
return predictions[0] if is_single_series else predictions
327+
240328
@property
241329
def min_train_series_length(self) -> int:
242330
return max(model.min_train_series_length for model in self.models)
@@ -271,9 +359,12 @@ def find_max_lag_or_none(lag_id, aggregator) -> Optional[int]:
271359
find_max_lag_or_none(i, agg) for i, agg in enumerate(lag_aggregators)
272360
)
273361

274-
def _is_probabilistic(self) -> bool:
362+
def _models_are_probabilistic(self) -> bool:
275363
return all([model._is_probabilistic() for model in self.models])
276364

365+
def _is_probabilistic(self) -> bool:
366+
return self._models_are_probabilistic()
367+
277368
@property
278369
def supports_past_covariates(self) -> bool:
279370
return any([model.supports_past_covariates for model in self.models])

darts/models/forecasting/regression_ensemble_model.py

+43-5
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,12 @@ def __init__(
2323
forecasting_models: List[ForecastingModel],
2424
regression_train_n_points: int,
2525
regression_model=None,
26+
regression_train_num_samples: Optional[int] = 1,
27+
regression_train_samples_reduction: Optional[Union[str, float]] = "median",
2628
show_warnings: bool = True,
2729
):
2830
"""
29-
Use a regression model for ensembling individual models' predictions.
31+
Use a regression model for ensembling individual models' predictions using the stacking technique [1]_.
3032
3133
The provided regression model must implement ``fit()`` and ``predict()`` methods
3234
(e.g. scikit-learn regression models). Note that here the regression model is used to learn how to
@@ -48,10 +50,35 @@ def __init__(
4850
regression_model
4951
Any regression model with ``predict()`` and ``fit()`` methods (e.g. from scikit-learn)
5052
Default: ``darts.model.LinearRegressionModel(fit_intercept=False)``
53+
54+
.. note::
55+
if `regression_model` is probabilistic, the `RegressionEnsembleModel` will also be probabilistic.
56+
..
57+
regression_train_num_samples
58+
Number of prediction samples from each forecasting model to train the regression model (samples are
59+
averaged). Should be set to 1 for deterministic models. Default: 1.
60+
61+
.. note::
62+
if `forecasting_models` contains a mix of probabilistic and deterministic models,
63+
`regression_train_num_samples will be passed only to the probabilistic ones.
64+
..
65+
regression_train_samples_reduction
66+
If `forecasting models` are probabilistic and `regression_train_num_samples` > 1, method used to
67+
reduce the samples before passing them to the regression model. Possible values: "mean", "median"
68+
or float value corresponding to the desired quantile. Default: "median"
5169
show_warnings
5270
Whether to show warnings related to forecasting_models covariates support.
71+
References
72+
----------
73+
.. [1] D. H. Wolpert, “Stacked generalization”, Neural Networks, vol. 5, no. 2, pp. 241–259, Jan. 1992
5374
"""
54-
super().__init__(models=forecasting_models, show_warnings=show_warnings)
75+
super().__init__(
76+
models=forecasting_models,
77+
train_num_samples=regression_train_num_samples,
78+
train_samples_reduction=regression_train_samples_reduction,
79+
show_warnings=show_warnings,
80+
)
81+
5582
if regression_model is None:
5683
regression_model = LinearRegressionModel(
5784
lags=None, lags_future_covariates=[0], fit_intercept=False
@@ -104,7 +131,7 @@ def fit(
104131

105132
raise_if(
106133
train_n_points_too_big,
107-
"regression_train_n_points parameter too big (must be smaller or "
134+
"`regression_train_n_points` parameter too big (must be smaller or "
108135
"equal to the number of points in training_series)",
109136
logger,
110137
)
@@ -134,7 +161,7 @@ def fit(
134161
series=forecast_training,
135162
past_covariates=past_covariates,
136163
future_covariates=future_covariates,
137-
num_samples=1,
164+
num_samples=self.train_num_samples,
138165
)
139166

140167
# train the regression model on the individual models' predictions
@@ -160,6 +187,7 @@ def ensemble(
160187
self,
161188
predictions: Union[TimeSeries, Sequence[TimeSeries]],
162189
series: Optional[Sequence[TimeSeries]] = None,
190+
num_samples: int = 1,
163191
) -> Union[TimeSeries, Sequence[TimeSeries]]:
164192

165193
is_single_series = isinstance(series, TimeSeries) or series is None
@@ -168,7 +196,10 @@ def ensemble(
168196

169197
ensembled = [
170198
self.regression_model.predict(
171-
n=len(prediction), series=serie, future_covariates=prediction
199+
n=len(prediction),
200+
series=serie,
201+
future_covariates=prediction,
202+
num_samples=num_samples,
172203
)
173204
for serie, prediction in zip(series, predictions)
174205
]
@@ -187,3 +218,10 @@ def extreme_lags(
187218
]:
188219
extreme_lags_ = super().extreme_lags
189220
return (extreme_lags_[0] - self.train_n_points,) + extreme_lags_[1:]
221+
222+
def _is_probabilistic(self) -> bool:
223+
"""
224+
A RegressionEnsembleModel is probabilistic if its regression
225+
model is probabilistic (ensembling layer)
226+
"""
227+
return self.regression_model._is_probabilistic()

0 commit comments

Comments
 (0)