autogluon.timeseries 1.3.2b20250712__py3-none-any.whl → 1.4.1b20251116__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. autogluon/timeseries/configs/__init__.py +3 -2
  2. autogluon/timeseries/configs/hyperparameter_presets.py +62 -0
  3. autogluon/timeseries/configs/predictor_presets.py +84 -0
  4. autogluon/timeseries/dataset/ts_dataframe.py +98 -72
  5. autogluon/timeseries/learner.py +19 -18
  6. autogluon/timeseries/metrics/__init__.py +5 -5
  7. autogluon/timeseries/metrics/abstract.py +17 -17
  8. autogluon/timeseries/metrics/point.py +1 -1
  9. autogluon/timeseries/metrics/quantile.py +2 -2
  10. autogluon/timeseries/metrics/utils.py +4 -4
  11. autogluon/timeseries/models/__init__.py +4 -0
  12. autogluon/timeseries/models/abstract/abstract_timeseries_model.py +52 -75
  13. autogluon/timeseries/models/abstract/tunable.py +6 -6
  14. autogluon/timeseries/models/autogluon_tabular/mlforecast.py +72 -76
  15. autogluon/timeseries/models/autogluon_tabular/per_step.py +104 -46
  16. autogluon/timeseries/models/autogluon_tabular/transforms.py +9 -7
  17. autogluon/timeseries/models/chronos/model.py +115 -78
  18. autogluon/timeseries/models/chronos/{pipeline/utils.py → utils.py} +76 -44
  19. autogluon/timeseries/models/ensemble/__init__.py +29 -2
  20. autogluon/timeseries/models/ensemble/abstract.py +16 -52
  21. autogluon/timeseries/models/ensemble/array_based/__init__.py +3 -0
  22. autogluon/timeseries/models/ensemble/array_based/abstract.py +247 -0
  23. autogluon/timeseries/models/ensemble/array_based/models.py +50 -0
  24. autogluon/timeseries/models/ensemble/array_based/regressor/__init__.py +10 -0
  25. autogluon/timeseries/models/ensemble/array_based/regressor/abstract.py +87 -0
  26. autogluon/timeseries/models/ensemble/array_based/regressor/per_quantile_tabular.py +133 -0
  27. autogluon/timeseries/models/ensemble/array_based/regressor/tabular.py +141 -0
  28. autogluon/timeseries/models/ensemble/weighted/__init__.py +8 -0
  29. autogluon/timeseries/models/ensemble/weighted/abstract.py +41 -0
  30. autogluon/timeseries/models/ensemble/{basic.py → weighted/basic.py} +8 -18
  31. autogluon/timeseries/models/ensemble/{greedy.py → weighted/greedy.py} +13 -13
  32. autogluon/timeseries/models/gluonts/abstract.py +26 -26
  33. autogluon/timeseries/models/gluonts/dataset.py +4 -4
  34. autogluon/timeseries/models/gluonts/models.py +27 -12
  35. autogluon/timeseries/models/local/abstract_local_model.py +14 -14
  36. autogluon/timeseries/models/local/naive.py +4 -0
  37. autogluon/timeseries/models/local/npts.py +1 -0
  38. autogluon/timeseries/models/local/statsforecast.py +30 -14
  39. autogluon/timeseries/models/multi_window/multi_window_model.py +34 -23
  40. autogluon/timeseries/models/registry.py +65 -0
  41. autogluon/timeseries/models/toto/__init__.py +3 -0
  42. autogluon/timeseries/models/toto/_internal/__init__.py +9 -0
  43. autogluon/timeseries/models/toto/_internal/backbone/__init__.py +3 -0
  44. autogluon/timeseries/models/toto/_internal/backbone/attention.py +197 -0
  45. autogluon/timeseries/models/toto/_internal/backbone/backbone.py +262 -0
  46. autogluon/timeseries/models/toto/_internal/backbone/distribution.py +70 -0
  47. autogluon/timeseries/models/toto/_internal/backbone/kvcache.py +136 -0
  48. autogluon/timeseries/models/toto/_internal/backbone/rope.py +94 -0
  49. autogluon/timeseries/models/toto/_internal/backbone/scaler.py +306 -0
  50. autogluon/timeseries/models/toto/_internal/backbone/transformer.py +333 -0
  51. autogluon/timeseries/models/toto/_internal/dataset.py +165 -0
  52. autogluon/timeseries/models/toto/_internal/forecaster.py +423 -0
  53. autogluon/timeseries/models/toto/dataloader.py +108 -0
  54. autogluon/timeseries/models/toto/hf_pretrained_model.py +119 -0
  55. autogluon/timeseries/models/toto/model.py +236 -0
  56. autogluon/timeseries/predictor.py +94 -107
  57. autogluon/timeseries/regressor.py +31 -27
  58. autogluon/timeseries/splitter.py +7 -31
  59. autogluon/timeseries/trainer/__init__.py +3 -0
  60. autogluon/timeseries/trainer/ensemble_composer.py +250 -0
  61. autogluon/timeseries/trainer/model_set_builder.py +256 -0
  62. autogluon/timeseries/trainer/prediction_cache.py +149 -0
  63. autogluon/timeseries/{trainer.py → trainer/trainer.py} +182 -307
  64. autogluon/timeseries/trainer/utils.py +18 -0
  65. autogluon/timeseries/transforms/covariate_scaler.py +4 -4
  66. autogluon/timeseries/transforms/target_scaler.py +14 -14
  67. autogluon/timeseries/utils/datetime/lags.py +2 -2
  68. autogluon/timeseries/utils/datetime/time_features.py +2 -2
  69. autogluon/timeseries/utils/features.py +41 -37
  70. autogluon/timeseries/utils/forecast.py +5 -5
  71. autogluon/timeseries/utils/warning_filters.py +3 -1
  72. autogluon/timeseries/version.py +1 -1
  73. autogluon.timeseries-1.4.1b20251116-py3.9-nspkg.pth +1 -0
  74. {autogluon.timeseries-1.3.2b20250712.dist-info → autogluon_timeseries-1.4.1b20251116.dist-info}/METADATA +32 -17
  75. autogluon_timeseries-1.4.1b20251116.dist-info/RECORD +96 -0
  76. {autogluon.timeseries-1.3.2b20250712.dist-info → autogluon_timeseries-1.4.1b20251116.dist-info}/WHEEL +1 -1
  77. autogluon/timeseries/configs/presets_configs.py +0 -79
  78. autogluon/timeseries/evaluator.py +0 -6
  79. autogluon/timeseries/models/chronos/pipeline/__init__.py +0 -10
  80. autogluon/timeseries/models/chronos/pipeline/base.py +0 -160
  81. autogluon/timeseries/models/chronos/pipeline/chronos.py +0 -544
  82. autogluon/timeseries/models/chronos/pipeline/chronos_bolt.py +0 -530
  83. autogluon/timeseries/models/presets.py +0 -358
  84. autogluon.timeseries-1.3.2b20250712-py3.9-nspkg.pth +0 -1
  85. autogluon.timeseries-1.3.2b20250712.dist-info/RECORD +0 -71
  86. {autogluon.timeseries-1.3.2b20250712.dist-info → autogluon_timeseries-1.4.1b20251116.dist-info/licenses}/LICENSE +0 -0
  87. {autogluon.timeseries-1.3.2b20250712.dist-info → autogluon_timeseries-1.4.1b20251116.dist-info/licenses}/NOTICE +0 -0
  88. {autogluon.timeseries-1.3.2b20250712.dist-info → autogluon_timeseries-1.4.1b20251116.dist-info}/namespace_packages.txt +0 -0
  89. {autogluon.timeseries-1.3.2b20250712.dist-info → autogluon_timeseries-1.4.1b20251116.dist-info}/top_level.txt +0 -0
  90. {autogluon.timeseries-1.3.2b20250712.dist-info → autogluon_timeseries-1.4.1b20251116.dist-info}/zip-safe +0 -0
@@ -1,9 +1,7 @@
1
- import functools
2
1
  import logging
3
2
  from abc import ABC, abstractmethod
4
- from typing import Dict, List, Optional
3
+ from typing import Optional
5
4
 
6
- import numpy as np
7
5
  from typing_extensions import final
8
6
 
9
7
  from autogluon.core.utils.exceptions import TimeLimitExceeded
@@ -18,32 +16,32 @@ class AbstractTimeSeriesEnsembleModel(TimeSeriesModelBase, ABC):
18
16
 
19
17
  @property
20
18
  @abstractmethod
21
- def model_names(self) -> List[str]:
19
+ def model_names(self) -> list[str]:
22
20
  """Names of base models included in the ensemble."""
23
21
  pass
24
22
 
25
23
  @final
26
24
  def fit(
27
25
  self,
28
- predictions_per_window: Dict[str, List[TimeSeriesDataFrame]],
29
- data_per_window: List[TimeSeriesDataFrame],
30
- model_scores: Optional[Dict[str, float]] = None,
26
+ predictions_per_window: dict[str, list[TimeSeriesDataFrame]],
27
+ data_per_window: list[TimeSeriesDataFrame],
28
+ model_scores: Optional[dict[str, float]] = None,
31
29
  time_limit: Optional[float] = None,
32
30
  ):
33
31
  """Fit ensemble model given predictions of candidate base models and the true data.
34
32
 
35
33
  Parameters
36
34
  ----------
37
- predictions_per_window : Dict[str, List[TimeSeriesDataFrame]]
35
+ predictions_per_window
38
36
  Dictionary that maps the names of component models to their respective predictions for each validation
39
37
  window.
40
- data_per_window : List[TimeSeriesDataFrame]
38
+ data_per_window
41
39
  Observed ground truth data used to train the ensemble for each validation window. Each entry in the list
42
40
  includes both the forecast horizon (for which the predictions are given in ``predictions``), as well as the
43
41
  "history".
44
- model_scores : Optional[Dict[str, float]]
42
+ model_scores
45
43
  Scores (higher is better) for the models that will constitute the ensemble.
46
- time_limit : Optional[float]
44
+ time_limit
47
45
  Maximum allowed time for training in seconds.
48
46
  """
49
47
  if time_limit is not None and time_limit <= 0:
@@ -67,18 +65,18 @@ class AbstractTimeSeriesEnsembleModel(TimeSeriesModelBase, ABC):
67
65
 
68
66
  def _fit(
69
67
  self,
70
- predictions_per_window: Dict[str, List[TimeSeriesDataFrame]],
71
- data_per_window: List[TimeSeriesDataFrame],
72
- model_scores: Optional[Dict[str, float]] = None,
68
+ predictions_per_window: dict[str, list[TimeSeriesDataFrame]],
69
+ data_per_window: list[TimeSeriesDataFrame],
70
+ model_scores: Optional[dict[str, float]] = None,
73
71
  time_limit: Optional[float] = None,
74
- ):
72
+ ) -> None:
75
73
  """Private method for `fit`. See `fit` for documentation of arguments. Apart from the model
76
74
  training logic, `fit` additionally implements other logic such as keeping track of the time limit.
77
75
  """
78
76
  raise NotImplementedError
79
77
 
80
78
  @final
81
- def predict(self, data: Dict[str, TimeSeriesDataFrame], **kwargs) -> TimeSeriesDataFrame:
79
+ def predict(self, data: dict[str, TimeSeriesDataFrame], **kwargs) -> TimeSeriesDataFrame:
82
80
  if not set(self.model_names).issubset(set(data.keys())):
83
81
  raise ValueError(
84
82
  f"Set of models given for prediction in {self.name} differ from those provided during initialization."
@@ -93,47 +91,13 @@ class AbstractTimeSeriesEnsembleModel(TimeSeriesModelBase, ABC):
93
91
  return self._predict(data=data, **kwargs)
94
92
 
95
93
  @abstractmethod
96
- def _predict(self, data: Dict[str, TimeSeriesDataFrame], **kwargs) -> TimeSeriesDataFrame:
94
+ def _predict(self, data: dict[str, TimeSeriesDataFrame], **kwargs) -> TimeSeriesDataFrame:
97
95
  pass
98
96
 
99
97
  @abstractmethod
100
- def remap_base_models(self, model_refit_map: Dict[str, str]) -> None:
98
+ def remap_base_models(self, model_refit_map: dict[str, str]) -> None:
101
99
  """Update names of the base models based on the mapping in model_refit_map.
102
100
 
103
101
  This method should be called after performing refit_full to point to the refitted base models, if necessary.
104
102
  """
105
103
  pass
106
-
107
-
108
- class AbstractWeightedTimeSeriesEnsembleModel(AbstractTimeSeriesEnsembleModel, ABC):
109
- """Abstract class for weighted ensembles which assign one (global) weight per model."""
110
-
111
- def __init__(self, name: Optional[str] = None, **kwargs):
112
- if name is None:
113
- name = "WeightedEnsemble"
114
- super().__init__(name=name, **kwargs)
115
- self.model_to_weight: Dict[str, float] = {}
116
-
117
- @property
118
- def model_names(self) -> List[str]:
119
- return list(self.model_to_weight.keys())
120
-
121
- @property
122
- def model_weights(self) -> np.ndarray:
123
- return np.array(list(self.model_to_weight.values()), dtype=np.float64)
124
-
125
- def _predict(self, data: Dict[str, TimeSeriesDataFrame], **kwargs) -> TimeSeriesDataFrame:
126
- weighted_predictions = [data[model_name] * weight for model_name, weight in self.model_to_weight.items()]
127
- return functools.reduce(lambda x, y: x + y, weighted_predictions)
128
-
129
- def get_info(self) -> dict:
130
- info = super().get_info()
131
- info["model_weights"] = self.model_to_weight.copy()
132
- return info
133
-
134
- def remap_base_models(self, model_refit_map: Dict[str, str]) -> None:
135
- updated_weights = {}
136
- for model, weight in self.model_to_weight.items():
137
- model_full_name = model_refit_map.get(model, model)
138
- updated_weights[model_full_name] = weight
139
- self.model_to_weight = updated_weights
@@ -0,0 +1,3 @@
1
+ from .models import MedianEnsemble, PerQuantileTabularEnsemble, TabularEnsemble
2
+
3
+ __all__ = ["MedianEnsemble", "PerQuantileTabularEnsemble", "TabularEnsemble"]
@@ -0,0 +1,247 @@
1
+ import os
2
+ from abc import ABC, abstractmethod
3
+ from typing import Any, Optional, Sequence, Union
4
+
5
+ import numpy as np
6
+ from typing_extensions import Self
7
+
8
+ from autogluon.timeseries.dataset import TimeSeriesDataFrame
9
+ from autogluon.timeseries.metrics.abstract import TimeSeriesScorer
10
+ from autogluon.timeseries.utils.features import CovariateMetadata
11
+
12
+ from ..abstract import AbstractTimeSeriesEnsembleModel
13
+ from .regressor import EnsembleRegressor
14
+
15
+
16
+ class ArrayBasedTimeSeriesEnsembleModel(AbstractTimeSeriesEnsembleModel, ABC):
17
+ """Abstract base class for time series ensemble models which operate on arrays of base model
18
+ predictions for training and inference.
19
+
20
+ Other Parameters
21
+ ----------------
22
+ isotonization: str, default = "sort"
23
+ The isotonization method to use (i.e. the algorithm to prevent quantile non-crossing).
24
+ Currently only "sort" is supported.
25
+ detect_and_ignore_failures: bool, default = True
26
+ Whether to detect and ignore "failed models", defined as models which have a loss that is larger
27
+ than 10x the median loss of all the models. This can be very important for the regression-based
28
+ ensembles, as moving the weight from such a "failed model" to zero can require a long training
29
+ time.
30
+ """
31
+
32
+ def __init__(
33
+ self,
34
+ path: Optional[str] = None,
35
+ name: Optional[str] = None,
36
+ hyperparameters: Optional[dict[str, Any]] = None,
37
+ freq: Optional[str] = None,
38
+ prediction_length: int = 1,
39
+ covariate_metadata: Optional[CovariateMetadata] = None,
40
+ target: str = "target",
41
+ quantile_levels: Sequence[float] = (0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9),
42
+ eval_metric: Union[str, TimeSeriesScorer, None] = None,
43
+ ):
44
+ super().__init__(
45
+ path=path,
46
+ name=name,
47
+ hyperparameters=hyperparameters,
48
+ freq=freq,
49
+ prediction_length=prediction_length,
50
+ covariate_metadata=covariate_metadata,
51
+ target=target,
52
+ quantile_levels=quantile_levels,
53
+ eval_metric=eval_metric,
54
+ )
55
+ self.ensemble_regressor: Optional[EnsembleRegressor] = None
56
+ self._model_names: list[str] = []
57
+
58
+ def _get_default_hyperparameters(self) -> dict[str, Any]:
59
+ return {
60
+ "isotonization": "sort",
61
+ "detect_and_ignore_failures": True,
62
+ }
63
+
64
+ @classmethod
65
+ def load(cls, path: str, reset_paths: bool = True, load_oof: bool = False, verbose: bool = True) -> Self:
66
+ model = super().load(path=path, reset_paths=reset_paths, load_oof=load_oof, verbose=verbose)
67
+
68
+ if reset_paths and model.ensemble_regressor is not None:
69
+ model.ensemble_regressor.set_path(os.path.join(model.path, "ensemble_regressor"))
70
+
71
+ return model
72
+
73
+ @staticmethod
74
+ def to_array(df: TimeSeriesDataFrame) -> np.ndarray:
75
+ """Given a TimeSeriesDataFrame object, return a single array composing the values contained
76
+ in the data frame.
77
+
78
+ Parameters
79
+ ----------
80
+ df
81
+ TimeSeriesDataFrame to convert to an array. Must contain exactly `prediction_length`
82
+ values for each item. The columns of `df` can correspond to ground truth values
83
+ or predictions (in which case, these will be the mean or quantile forecasts).
84
+
85
+ Returns
86
+ -------
87
+ array
88
+ of shape (num_items, prediction_length, num_outputs).
89
+ """
90
+ assert df.index.is_monotonic_increasing
91
+ array = df.to_numpy()
92
+ num_items = df.num_items
93
+ shape = (
94
+ num_items,
95
+ df.shape[0] // num_items, # timesteps per item
96
+ df.shape[1], # num_outputs
97
+ )
98
+ return array.reshape(shape)
99
+
100
+ def _get_base_model_predictions(
101
+ self,
102
+ predictions_per_window: Union[dict[str, list[TimeSeriesDataFrame]], dict[str, TimeSeriesDataFrame]],
103
+ ) -> tuple[np.ndarray, np.ndarray]:
104
+ """Given a mapping from model names to a list of data frames representing
105
+ their predictions per window, return a multidimensional array representation.
106
+
107
+ Parameters
108
+ ----------
109
+ predictions_per_window
110
+ A dictionary with list[TimeSeriesDataFrame] values, where each TimeSeriesDataFrame
111
+ contains predictions for the window in question. If the dictionary values are
112
+ TimeSeriesDataFrame, they will be treated like a single window.
113
+
114
+ Returns
115
+ -------
116
+ base_model_mean_predictions
117
+ Array of shape (num_windows, num_items, prediction_length, 1, num_models)
118
+ base_model_quantile_predictions
119
+ Array of shape (num_windows, num_items, prediction_length, num_quantiles, num_models)
120
+ """
121
+
122
+ if not predictions_per_window:
123
+ raise ValueError("No base model predictions are provided.")
124
+
125
+ first_prediction = list(predictions_per_window.values())[0]
126
+ if isinstance(first_prediction, TimeSeriesDataFrame):
127
+ predictions_per_window = {k: [v] for k, v in predictions_per_window.items()} # type: ignore
128
+
129
+ predictions = {
130
+ model_name: [self.to_array(window) for window in windows] # type: ignore
131
+ for model_name, windows in predictions_per_window.items()
132
+ }
133
+ base_model_predictions = np.stack([x for x in predictions.values()], axis=-1)
134
+
135
+ return base_model_predictions[:, :, :, :1, :], base_model_predictions[:, :, :, 1:, :]
136
+
137
+ def _isotonize(self, prediction_array: np.ndarray) -> np.ndarray:
138
+ """Apply isotonization to ensure quantile non-crossing.
139
+
140
+ Parameters
141
+ ----------
142
+ prediction_array
143
+ Array of shape (num_windows, num_items, prediction_length, num_quantiles)
144
+
145
+ Returns
146
+ -------
147
+ isotonized_array
148
+ Array with same shape but quantiles sorted along last dimension
149
+ """
150
+ isotonization = self.get_hyperparameters()["isotonization"]
151
+ if isotonization == "sort":
152
+ return np.sort(prediction_array, axis=-1)
153
+ return prediction_array
154
+
155
+ def _fit(
156
+ self,
157
+ predictions_per_window: dict[str, list[TimeSeriesDataFrame]],
158
+ data_per_window: list[TimeSeriesDataFrame],
159
+ model_scores: Optional[dict[str, float]] = None,
160
+ time_limit: Optional[float] = None,
161
+ ) -> None:
162
+ # process inputs
163
+ filtered_predictions = self._filter_failed_models(predictions_per_window, model_scores)
164
+ base_model_mean_predictions, base_model_quantile_predictions = self._get_base_model_predictions(
165
+ filtered_predictions
166
+ )
167
+
168
+ # process labels
169
+ ground_truth_per_window = [y.slice_by_timestep(-self.prediction_length, None) for y in data_per_window]
170
+ labels = np.stack(
171
+ [self.to_array(gt) for gt in ground_truth_per_window], axis=0
172
+ ) # (num_windows, num_items, prediction_length, 1)
173
+
174
+ self._model_names = list(filtered_predictions.keys())
175
+ self.ensemble_regressor = self._get_ensemble_regressor()
176
+ self.ensemble_regressor.fit(
177
+ base_model_mean_predictions=base_model_mean_predictions,
178
+ base_model_quantile_predictions=base_model_quantile_predictions,
179
+ labels=labels,
180
+ time_limit=time_limit,
181
+ )
182
+
183
+ @abstractmethod
184
+ def _get_ensemble_regressor(self) -> EnsembleRegressor:
185
+ pass
186
+
187
+ def _predict(self, data: dict[str, TimeSeriesDataFrame], **kwargs) -> TimeSeriesDataFrame:
188
+ if self.ensemble_regressor is None:
189
+ if not self._model_names:
190
+ raise ValueError("Ensemble model has not been fitted yet.")
191
+ # Try to recreate the regressor (for loaded models)
192
+ self.ensemble_regressor = self._get_ensemble_regressor()
193
+
194
+ input_data = {}
195
+ for m in self.model_names:
196
+ assert m in data, f"Predictions for model {m} not provided during ensemble prediction."
197
+ input_data[m] = data[m]
198
+
199
+ base_model_mean_predictions, base_model_quantile_predictions = self._get_base_model_predictions(input_data)
200
+
201
+ mean_predictions, quantile_predictions = self.ensemble_regressor.predict(
202
+ base_model_mean_predictions=base_model_mean_predictions,
203
+ base_model_quantile_predictions=base_model_quantile_predictions,
204
+ )
205
+
206
+ quantile_predictions = self._isotonize(quantile_predictions)
207
+ prediction_array = np.concatenate([mean_predictions, quantile_predictions], axis=-1)
208
+
209
+ output = list(input_data.values())[0].copy()
210
+ num_folds, num_items, num_timesteps, num_outputs = prediction_array.shape
211
+ assert (num_folds, num_timesteps) == (1, self.prediction_length)
212
+ assert len(output.columns) == num_outputs
213
+
214
+ output[output.columns] = prediction_array.reshape((num_items * num_timesteps, num_outputs))
215
+
216
+ return output
217
+
218
+ @property
219
+ def model_names(self) -> list[str]:
220
+ return self._model_names
221
+
222
+ def remap_base_models(self, model_refit_map: dict[str, str]) -> None:
223
+ """Update names of the base models based on the mapping in model_refit_map."""
224
+ self._model_names = [model_refit_map.get(name, name) for name in self._model_names]
225
+
226
+ def _filter_failed_models(
227
+ self,
228
+ predictions_per_window: dict[str, list[TimeSeriesDataFrame]],
229
+ model_scores: Optional[dict[str, float]],
230
+ ) -> dict[str, list[TimeSeriesDataFrame]]:
231
+ """Filter out failed models based on detect_and_ignore_failures setting."""
232
+ if not self.get_hyperparameters()["detect_and_ignore_failures"]:
233
+ return predictions_per_window
234
+
235
+ if model_scores is None or len(model_scores) == 0:
236
+ return predictions_per_window
237
+
238
+ valid_scores = {k: v for k, v in model_scores.items() if np.isfinite(v)}
239
+ if len(valid_scores) == 0:
240
+ raise ValueError("All models have NaN scores. At least one model must run successfully to fit an ensemble")
241
+
242
+ losses = {k: -v for k, v in valid_scores.items()}
243
+ median_loss = np.nanmedian(list(losses.values()))
244
+ threshold = 10 * median_loss
245
+ good_models = {k for k, loss in losses.items() if loss <= threshold}
246
+
247
+ return {k: v for k, v in predictions_per_window.items() if k in good_models}
@@ -0,0 +1,50 @@
1
+ import os
2
+ from abc import ABC
3
+ from typing import Any, Type
4
+
5
+ from .abstract import ArrayBasedTimeSeriesEnsembleModel
6
+ from .regressor import (
7
+ EnsembleRegressor,
8
+ MedianEnsembleRegressor,
9
+ PerQuantileTabularEnsembleRegressor,
10
+ TabularEnsembleRegressor,
11
+ )
12
+
13
+
14
+ class MedianEnsemble(ArrayBasedTimeSeriesEnsembleModel):
15
+ def _get_ensemble_regressor(self) -> MedianEnsembleRegressor:
16
+ return MedianEnsembleRegressor()
17
+
18
+
19
+ class BaseTabularEnsemble(ArrayBasedTimeSeriesEnsembleModel, ABC):
20
+ ensemble_regressor_type: Type[EnsembleRegressor]
21
+
22
+ def _get_default_hyperparameters(self) -> dict[str, Any]:
23
+ default_hps = super()._get_default_hyperparameters()
24
+ default_hps.update(
25
+ {
26
+ "tabular_hyperparameters": {"GBM": {}},
27
+ }
28
+ )
29
+ return default_hps
30
+
31
+ def _get_ensemble_regressor(self):
32
+ return self.ensemble_regressor_type(
33
+ path=os.path.join(self.path, "ensemble_regressor"),
34
+ quantile_levels=list(self.quantile_levels),
35
+ tabular_hyperparameters=self.get_hyperparameters()["tabular_hyperparameters"],
36
+ )
37
+
38
+
39
+ class TabularEnsemble(BaseTabularEnsemble):
40
+ """Time series ensemble model using single AutoGluon TabularPredictor for all quantiles."""
41
+
42
+ ensemble_regressor_type = TabularEnsembleRegressor
43
+
44
+
45
+ class PerQuantileTabularEnsemble(BaseTabularEnsemble):
46
+ """Time series ensemble model using separate `TabularPredictor` instances for each quantile in
47
+ addition to a dedicated `TabularPredictor` for the mean (point) forecast.
48
+ """
49
+
50
+ ensemble_regressor_type = PerQuantileTabularEnsembleRegressor
@@ -0,0 +1,10 @@
1
+ from .abstract import EnsembleRegressor, MedianEnsembleRegressor
2
+ from .per_quantile_tabular import PerQuantileTabularEnsembleRegressor
3
+ from .tabular import TabularEnsembleRegressor
4
+
5
+ __all__ = [
6
+ "EnsembleRegressor",
7
+ "MedianEnsembleRegressor",
8
+ "PerQuantileTabularEnsembleRegressor",
9
+ "TabularEnsembleRegressor",
10
+ ]
@@ -0,0 +1,87 @@
1
+ from abc import ABC, abstractmethod
2
+
3
+ import numpy as np
4
+ from typing_extensions import Self
5
+
6
+
7
+ class EnsembleRegressor(ABC):
8
+ def __init__(self, *args, **kwargs):
9
+ pass
10
+
11
+ def set_path(self, path: str) -> None:
12
+ pass
13
+
14
+ @abstractmethod
15
+ def fit(
16
+ self,
17
+ base_model_mean_predictions: np.ndarray,
18
+ base_model_quantile_predictions: np.ndarray,
19
+ labels: np.ndarray,
20
+ **kwargs,
21
+ ) -> Self:
22
+ """
23
+ Parameters
24
+ ----------
25
+ base_model_mean_predictions
26
+ Mean (point) predictions of base models. Array of shape
27
+ (num_windows, num_items, prediction_length, 1, num_models)
28
+
29
+ base_model_quantile_predictions
30
+ Quantile predictions of base models. Array of shape
31
+ (num_windows, num_items, prediction_length, num_quantiles, num_models)
32
+
33
+ labels
34
+ Ground truth array of shape
35
+ (num_windows, num_items, prediction_length, 1)
36
+ """
37
+ pass
38
+
39
+ @abstractmethod
40
+ def predict(
41
+ self,
42
+ base_model_mean_predictions: np.ndarray,
43
+ base_model_quantile_predictions: np.ndarray,
44
+ ) -> tuple[np.ndarray, np.ndarray]:
45
+ """Predict with the fitted ensemble regressor for a single window.
46
+ The items do not have to refer to the same item indices used when fitting
47
+ the model.
48
+
49
+ Parameters
50
+ ----------
51
+ base_model_mean_predictions
52
+ Mean (point) predictions of base models. Array of shape
53
+ (1, num_items, prediction_length, 1, num_models)
54
+
55
+ base_model_quantile_predictions
56
+ Quantile predictions of base models. Array of shape
57
+ (1, num_items, prediction_length, num_quantiles, num_models)
58
+
59
+ Returns
60
+ -------
61
+ ensemble_mean_predictions
62
+ Array of shape (1, num_items, prediction_length, 1)
63
+ ensemble_quantile_predictions
64
+ Array of shape (1, num_items, prediction_length, num_quantiles)
65
+ """
66
+ pass
67
+
68
+
69
+ class MedianEnsembleRegressor(EnsembleRegressor):
70
+ def fit(
71
+ self,
72
+ base_model_mean_predictions: np.ndarray,
73
+ base_model_quantile_predictions: np.ndarray,
74
+ labels: np.ndarray,
75
+ **kwargs,
76
+ ) -> Self:
77
+ return self
78
+
79
+ def predict(
80
+ self,
81
+ base_model_mean_predictions: np.ndarray,
82
+ base_model_quantile_predictions: np.ndarray,
83
+ ) -> tuple[np.ndarray, np.ndarray]:
84
+ return (
85
+ np.nanmedian(base_model_mean_predictions, axis=-1),
86
+ np.nanmedian(base_model_quantile_predictions, axis=-1),
87
+ )
@@ -0,0 +1,133 @@
1
+ import logging
2
+ import os
3
+ from typing import Optional
4
+
5
+ import numpy as np
6
+ import pandas as pd
7
+ from typing_extensions import Self
8
+
9
+ from autogluon.tabular import TabularPredictor
10
+
11
+ from .abstract import EnsembleRegressor
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class PerQuantileTabularEnsembleRegressor(EnsembleRegressor):
17
+ """TabularPredictor ensemble regressor using separate models per quantile plus dedicated mean model."""
18
+
19
+ def __init__(
20
+ self,
21
+ path: str,
22
+ quantile_levels: list[float],
23
+ tabular_hyperparameters: Optional[dict] = None,
24
+ ):
25
+ super().__init__()
26
+ self.path = path
27
+ self.quantile_levels = quantile_levels
28
+ self.tabular_hyperparameters = tabular_hyperparameters or {}
29
+ self.quantile_predictors: list[TabularPredictor] = []
30
+ self.mean_predictor: Optional[TabularPredictor] = None
31
+
32
+ def set_path(self, path: str) -> None:
33
+ self.path = path
34
+
35
+ def fit(
36
+ self,
37
+ base_model_mean_predictions: np.ndarray,
38
+ base_model_quantile_predictions: np.ndarray,
39
+ labels: np.ndarray,
40
+ **kwargs,
41
+ ) -> Self:
42
+ """Fit separate TabularPredictor for mean and each quantile level."""
43
+ # TODO: implement time_limit
44
+
45
+ num_windows, num_items, prediction_length = base_model_mean_predictions.shape[:3]
46
+ target = labels.reshape(num_windows * num_items * prediction_length).ravel()
47
+
48
+ # fit mean predictor, based on mean predictions of base models
49
+ mean_df = self._get_feature_df(base_model_mean_predictions, 0)
50
+ mean_df["target"] = target
51
+ self.mean_predictor = TabularPredictor(
52
+ label="target",
53
+ path=os.path.join(self.path, "mean"),
54
+ verbosity=1,
55
+ problem_type="regression",
56
+ ).fit(
57
+ mean_df,
58
+ hyperparameters=self.tabular_hyperparameters,
59
+ )
60
+
61
+ # fit quantile predictors, each quantile predictor is based on the
62
+ # estimates of that quantile from base models
63
+ for i, quantile in enumerate(self.quantile_levels):
64
+ q_df = self._get_feature_df(base_model_quantile_predictions, i)
65
+ q_df["target"] = target
66
+
67
+ predictor = TabularPredictor(
68
+ label="target",
69
+ path=os.path.join(self.path, f"quantile_{quantile}"),
70
+ verbosity=1,
71
+ problem_type="regression",
72
+ ).fit(q_df, hyperparameters=self.tabular_hyperparameters)
73
+ self.quantile_predictors.append(predictor)
74
+
75
+ return self
76
+
77
+ def _get_feature_df(self, predictions: np.ndarray, index: int) -> pd.DataFrame:
78
+ num_windows, num_items, prediction_length, _, num_models = predictions.shape
79
+ num_tabular_items = num_windows * num_items * prediction_length
80
+
81
+ df = pd.DataFrame(
82
+ predictions[:, :, :, index].reshape(num_tabular_items, num_models),
83
+ columns=[f"model_{mi}" for mi in range(num_models)],
84
+ )
85
+
86
+ return df
87
+
88
+ def load_predictors(self):
89
+ if self.mean_predictor is None or len(self.quantile_predictors) < len(self.quantile_levels):
90
+ try:
91
+ self.mean_predictor = TabularPredictor.load(os.path.join(self.path, "mean"))
92
+
93
+ self.quantile_predictors = []
94
+ for quantile in self.quantile_levels:
95
+ predictor = TabularPredictor.load(os.path.join(self.path, f"quantile_{quantile}"))
96
+ self.quantile_predictors.append(predictor)
97
+
98
+ except FileNotFoundError:
99
+ raise ValueError("Model must be fitted before loading for prediction")
100
+
101
+ def predict(
102
+ self, base_model_mean_predictions: np.ndarray, base_model_quantile_predictions: np.ndarray
103
+ ) -> tuple[np.ndarray, np.ndarray]:
104
+ self.load_predictors()
105
+
106
+ num_windows, num_items, prediction_length, _, _ = base_model_mean_predictions.shape
107
+ assert num_windows == 1, "Prediction expects a single window to be provided"
108
+
109
+ # predict means
110
+ assert self.mean_predictor is not None
111
+ mean_predictions = self.mean_predictor.predict(
112
+ self._get_feature_df(base_model_mean_predictions, 0),
113
+ as_pandas=False,
114
+ ).reshape(num_windows, num_items, prediction_length, 1)
115
+
116
+ # predict quantiles
117
+ quantile_predictions_list = []
118
+ for i, predictor in enumerate(self.quantile_predictors):
119
+ quantile_predictions_list.append(
120
+ predictor.predict(self._get_feature_df(base_model_quantile_predictions, i), as_pandas=False).reshape(
121
+ num_windows, num_items, prediction_length
122
+ )
123
+ )
124
+ quantile_predictions = np.stack(quantile_predictions_list, axis=-1)
125
+
126
+ return mean_predictions, quantile_predictions
127
+
128
+ def __getstate__(self):
129
+ state = self.__dict__.copy()
130
+ # Remove predictors to avoid pickling heavy TabularPredictor objects
131
+ state["mean_predictor"] = None
132
+ state["quantile_predictors"] = []
133
+ return state