autogluon.timeseries 1.0.1b20240304__py3-none-any.whl → 1.4.1b20251210__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.

Potentially problematic release.


This version of autogluon.timeseries might be problematic. Click here for more details.

Files changed (108) 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 +339 -186
  5. autogluon/timeseries/learner.py +192 -60
  6. autogluon/timeseries/metrics/__init__.py +55 -11
  7. autogluon/timeseries/metrics/abstract.py +96 -25
  8. autogluon/timeseries/metrics/point.py +186 -39
  9. autogluon/timeseries/metrics/quantile.py +47 -20
  10. autogluon/timeseries/metrics/utils.py +6 -6
  11. autogluon/timeseries/models/__init__.py +13 -7
  12. autogluon/timeseries/models/abstract/__init__.py +2 -2
  13. autogluon/timeseries/models/abstract/abstract_timeseries_model.py +533 -273
  14. autogluon/timeseries/models/abstract/model_trial.py +10 -10
  15. autogluon/timeseries/models/abstract/tunable.py +189 -0
  16. autogluon/timeseries/models/autogluon_tabular/__init__.py +2 -0
  17. autogluon/timeseries/models/autogluon_tabular/mlforecast.py +369 -215
  18. autogluon/timeseries/models/autogluon_tabular/per_step.py +513 -0
  19. autogluon/timeseries/models/autogluon_tabular/transforms.py +67 -0
  20. autogluon/timeseries/models/autogluon_tabular/utils.py +3 -51
  21. autogluon/timeseries/models/chronos/__init__.py +4 -0
  22. autogluon/timeseries/models/chronos/chronos2.py +361 -0
  23. autogluon/timeseries/models/chronos/model.py +738 -0
  24. autogluon/timeseries/models/chronos/utils.py +369 -0
  25. autogluon/timeseries/models/ensemble/__init__.py +35 -2
  26. autogluon/timeseries/models/ensemble/{abstract_timeseries_ensemble.py → abstract.py} +50 -26
  27. autogluon/timeseries/models/ensemble/array_based/__init__.py +3 -0
  28. autogluon/timeseries/models/ensemble/array_based/abstract.py +236 -0
  29. autogluon/timeseries/models/ensemble/array_based/models.py +73 -0
  30. autogluon/timeseries/models/ensemble/array_based/regressor/__init__.py +12 -0
  31. autogluon/timeseries/models/ensemble/array_based/regressor/abstract.py +88 -0
  32. autogluon/timeseries/models/ensemble/array_based/regressor/linear_stacker.py +167 -0
  33. autogluon/timeseries/models/ensemble/array_based/regressor/per_quantile_tabular.py +94 -0
  34. autogluon/timeseries/models/ensemble/array_based/regressor/tabular.py +107 -0
  35. autogluon/timeseries/models/ensemble/ensemble_selection.py +167 -0
  36. autogluon/timeseries/models/ensemble/per_item_greedy.py +162 -0
  37. autogluon/timeseries/models/ensemble/weighted/__init__.py +8 -0
  38. autogluon/timeseries/models/ensemble/weighted/abstract.py +40 -0
  39. autogluon/timeseries/models/ensemble/weighted/basic.py +78 -0
  40. autogluon/timeseries/models/ensemble/weighted/greedy.py +57 -0
  41. autogluon/timeseries/models/gluonts/__init__.py +3 -1
  42. autogluon/timeseries/models/gluonts/abstract.py +583 -0
  43. autogluon/timeseries/models/gluonts/dataset.py +109 -0
  44. autogluon/timeseries/models/gluonts/{torch/models.py → models.py} +185 -44
  45. autogluon/timeseries/models/local/__init__.py +1 -10
  46. autogluon/timeseries/models/local/abstract_local_model.py +150 -97
  47. autogluon/timeseries/models/local/naive.py +31 -23
  48. autogluon/timeseries/models/local/npts.py +6 -2
  49. autogluon/timeseries/models/local/statsforecast.py +99 -112
  50. autogluon/timeseries/models/multi_window/multi_window_model.py +99 -40
  51. autogluon/timeseries/models/registry.py +64 -0
  52. autogluon/timeseries/models/toto/__init__.py +3 -0
  53. autogluon/timeseries/models/toto/_internal/__init__.py +9 -0
  54. autogluon/timeseries/models/toto/_internal/backbone/__init__.py +3 -0
  55. autogluon/timeseries/models/toto/_internal/backbone/attention.py +196 -0
  56. autogluon/timeseries/models/toto/_internal/backbone/backbone.py +262 -0
  57. autogluon/timeseries/models/toto/_internal/backbone/distribution.py +70 -0
  58. autogluon/timeseries/models/toto/_internal/backbone/kvcache.py +136 -0
  59. autogluon/timeseries/models/toto/_internal/backbone/rope.py +89 -0
  60. autogluon/timeseries/models/toto/_internal/backbone/rotary_embedding_torch.py +342 -0
  61. autogluon/timeseries/models/toto/_internal/backbone/scaler.py +305 -0
  62. autogluon/timeseries/models/toto/_internal/backbone/transformer.py +333 -0
  63. autogluon/timeseries/models/toto/_internal/dataset.py +165 -0
  64. autogluon/timeseries/models/toto/_internal/forecaster.py +423 -0
  65. autogluon/timeseries/models/toto/dataloader.py +108 -0
  66. autogluon/timeseries/models/toto/hf_pretrained_model.py +118 -0
  67. autogluon/timeseries/models/toto/model.py +236 -0
  68. autogluon/timeseries/predictor.py +826 -305
  69. autogluon/timeseries/regressor.py +253 -0
  70. autogluon/timeseries/splitter.py +10 -31
  71. autogluon/timeseries/trainer/__init__.py +2 -3
  72. autogluon/timeseries/trainer/ensemble_composer.py +439 -0
  73. autogluon/timeseries/trainer/model_set_builder.py +256 -0
  74. autogluon/timeseries/trainer/prediction_cache.py +149 -0
  75. autogluon/timeseries/trainer/trainer.py +1298 -0
  76. autogluon/timeseries/trainer/utils.py +17 -0
  77. autogluon/timeseries/transforms/__init__.py +2 -0
  78. autogluon/timeseries/transforms/covariate_scaler.py +164 -0
  79. autogluon/timeseries/transforms/target_scaler.py +149 -0
  80. autogluon/timeseries/utils/constants.py +10 -0
  81. autogluon/timeseries/utils/datetime/base.py +38 -20
  82. autogluon/timeseries/utils/datetime/lags.py +18 -16
  83. autogluon/timeseries/utils/datetime/seasonality.py +14 -14
  84. autogluon/timeseries/utils/datetime/time_features.py +17 -14
  85. autogluon/timeseries/utils/features.py +317 -53
  86. autogluon/timeseries/utils/forecast.py +31 -17
  87. autogluon/timeseries/utils/timer.py +173 -0
  88. autogluon/timeseries/utils/warning_filters.py +44 -6
  89. autogluon/timeseries/version.py +2 -1
  90. autogluon.timeseries-1.4.1b20251210-py3.11-nspkg.pth +1 -0
  91. {autogluon.timeseries-1.0.1b20240304.dist-info → autogluon_timeseries-1.4.1b20251210.dist-info}/METADATA +71 -47
  92. autogluon_timeseries-1.4.1b20251210.dist-info/RECORD +103 -0
  93. {autogluon.timeseries-1.0.1b20240304.dist-info → autogluon_timeseries-1.4.1b20251210.dist-info}/WHEEL +1 -1
  94. autogluon/timeseries/configs/presets_configs.py +0 -11
  95. autogluon/timeseries/evaluator.py +0 -6
  96. autogluon/timeseries/models/ensemble/greedy_ensemble.py +0 -170
  97. autogluon/timeseries/models/gluonts/abstract_gluonts.py +0 -550
  98. autogluon/timeseries/models/gluonts/torch/__init__.py +0 -0
  99. autogluon/timeseries/models/presets.py +0 -325
  100. autogluon/timeseries/trainer/abstract_trainer.py +0 -1144
  101. autogluon/timeseries/trainer/auto_trainer.py +0 -74
  102. autogluon.timeseries-1.0.1b20240304-py3.8-nspkg.pth +0 -1
  103. autogluon.timeseries-1.0.1b20240304.dist-info/RECORD +0 -58
  104. {autogluon.timeseries-1.0.1b20240304.dist-info → autogluon_timeseries-1.4.1b20251210.dist-info/licenses}/LICENSE +0 -0
  105. {autogluon.timeseries-1.0.1b20240304.dist-info → autogluon_timeseries-1.4.1b20251210.dist-info/licenses}/NOTICE +0 -0
  106. {autogluon.timeseries-1.0.1b20240304.dist-info → autogluon_timeseries-1.4.1b20251210.dist-info}/namespace_packages.txt +0 -0
  107. {autogluon.timeseries-1.0.1b20240304.dist-info → autogluon_timeseries-1.4.1b20251210.dist-info}/top_level.txt +0 -0
  108. {autogluon.timeseries-1.0.1b20240304.dist-info → autogluon_timeseries-1.4.1b20251210.dist-info}/zip-safe +0 -0
@@ -0,0 +1,236 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Any, Sequence
3
+
4
+ import numpy as np
5
+
6
+ from autogluon.timeseries.dataset import TimeSeriesDataFrame
7
+ from autogluon.timeseries.metrics.abstract import TimeSeriesScorer
8
+ from autogluon.timeseries.utils.features import CovariateMetadata
9
+
10
+ from ..abstract import AbstractTimeSeriesEnsembleModel
11
+ from .regressor import EnsembleRegressor
12
+
13
+
14
+ class ArrayBasedTimeSeriesEnsembleModel(AbstractTimeSeriesEnsembleModel, ABC):
15
+ """Abstract base class for time series ensemble models which operate on arrays of base model
16
+ predictions for training and inference.
17
+
18
+ Other Parameters
19
+ ----------------
20
+ isotonization: str, default = "sort"
21
+ The isotonization method to use (i.e. the algorithm to prevent quantile non-crossing).
22
+ Currently only "sort" is supported.
23
+ detect_and_ignore_failures: bool, default = True
24
+ Whether to detect and ignore "failed models", defined as models which have a loss that is larger
25
+ than 10x the median loss of all the models. This can be very important for the regression-based
26
+ ensembles, as moving the weight from such a "failed model" to zero can require a long training
27
+ time.
28
+ """
29
+
30
+ def __init__(
31
+ self,
32
+ path: str | None = None,
33
+ name: str | None = None,
34
+ hyperparameters: dict[str, Any] | None = None,
35
+ freq: str | None = None,
36
+ prediction_length: int = 1,
37
+ covariate_metadata: CovariateMetadata | None = None,
38
+ target: str = "target",
39
+ quantile_levels: Sequence[float] = (0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9),
40
+ eval_metric: str | TimeSeriesScorer | None = None,
41
+ ):
42
+ super().__init__(
43
+ path=path,
44
+ name=name,
45
+ hyperparameters=hyperparameters,
46
+ freq=freq,
47
+ prediction_length=prediction_length,
48
+ covariate_metadata=covariate_metadata,
49
+ target=target,
50
+ quantile_levels=quantile_levels,
51
+ eval_metric=eval_metric,
52
+ )
53
+ self.ensemble_regressor: EnsembleRegressor | None = None
54
+ self._model_names: list[str] = []
55
+
56
+ def _get_default_hyperparameters(self) -> dict[str, Any]:
57
+ return {
58
+ "isotonization": "sort",
59
+ "detect_and_ignore_failures": True,
60
+ }
61
+
62
+ @staticmethod
63
+ def to_array(df: TimeSeriesDataFrame) -> np.ndarray:
64
+ """Given a TimeSeriesDataFrame object, return a single array composing the values contained
65
+ in the data frame.
66
+
67
+ Parameters
68
+ ----------
69
+ df
70
+ TimeSeriesDataFrame to convert to an array. Must contain exactly `prediction_length`
71
+ values for each item. The columns of `df` can correspond to ground truth values
72
+ or predictions (in which case, these will be the mean or quantile forecasts).
73
+
74
+ Returns
75
+ -------
76
+ array
77
+ of shape (num_items, prediction_length, num_outputs).
78
+ """
79
+ assert df.index.is_monotonic_increasing
80
+ array = df.to_numpy()
81
+ num_items = df.num_items
82
+ shape = (
83
+ num_items,
84
+ df.shape[0] // num_items, # timesteps per item
85
+ df.shape[1], # num_outputs
86
+ )
87
+ return array.reshape(shape)
88
+
89
+ def _get_base_model_predictions(
90
+ self,
91
+ predictions_per_window: dict[str, list[TimeSeriesDataFrame]] | dict[str, TimeSeriesDataFrame],
92
+ ) -> tuple[np.ndarray, np.ndarray]:
93
+ """Given a mapping from model names to a list of data frames representing
94
+ their predictions per window, return a multidimensional array representation.
95
+
96
+ Parameters
97
+ ----------
98
+ predictions_per_window
99
+ A dictionary with list[TimeSeriesDataFrame] values, where each TimeSeriesDataFrame
100
+ contains predictions for the window in question. If the dictionary values are
101
+ TimeSeriesDataFrame, they will be treated like a single window.
102
+
103
+ Returns
104
+ -------
105
+ base_model_mean_predictions
106
+ Array of shape (num_windows, num_items, prediction_length, 1, num_models)
107
+ base_model_quantile_predictions
108
+ Array of shape (num_windows, num_items, prediction_length, num_quantiles, num_models)
109
+ """
110
+
111
+ if not predictions_per_window:
112
+ raise ValueError("No base model predictions are provided.")
113
+
114
+ first_prediction = list(predictions_per_window.values())[0]
115
+ if isinstance(first_prediction, TimeSeriesDataFrame):
116
+ predictions_per_window = {k: [v] for k, v in predictions_per_window.items()} # type: ignore
117
+
118
+ predictions = {
119
+ model_name: [self.to_array(window) for window in windows] # type: ignore
120
+ for model_name, windows in predictions_per_window.items()
121
+ }
122
+ base_model_predictions = np.stack([x for x in predictions.values()], axis=-1)
123
+
124
+ return base_model_predictions[:, :, :, :1, :], base_model_predictions[:, :, :, 1:, :]
125
+
126
+ def _isotonize(self, prediction_array: np.ndarray) -> np.ndarray:
127
+ """Apply isotonization to ensure quantile non-crossing.
128
+
129
+ Parameters
130
+ ----------
131
+ prediction_array
132
+ Array of shape (num_windows, num_items, prediction_length, num_quantiles)
133
+
134
+ Returns
135
+ -------
136
+ isotonized_array
137
+ Array with same shape but quantiles sorted along last dimension
138
+ """
139
+ isotonization = self.get_hyperparameter("isotonization")
140
+ if isotonization == "sort":
141
+ return np.sort(prediction_array, axis=-1)
142
+ return prediction_array
143
+
144
+ def _fit(
145
+ self,
146
+ predictions_per_window: dict[str, list[TimeSeriesDataFrame]],
147
+ data_per_window: list[TimeSeriesDataFrame],
148
+ model_scores: dict[str, float] | None = None,
149
+ time_limit: float | None = None,
150
+ ) -> None:
151
+ # process inputs
152
+ filtered_predictions = self._filter_failed_models(predictions_per_window, model_scores)
153
+ base_model_mean_predictions, base_model_quantile_predictions = self._get_base_model_predictions(
154
+ filtered_predictions
155
+ )
156
+
157
+ # process labels
158
+ ground_truth_per_window = [y.slice_by_timestep(-self.prediction_length, None) for y in data_per_window]
159
+ labels = np.stack(
160
+ [self.to_array(gt) for gt in ground_truth_per_window], axis=0
161
+ ) # (num_windows, num_items, prediction_length, 1)
162
+
163
+ self._model_names = list(filtered_predictions.keys())
164
+ self.ensemble_regressor = self._get_ensemble_regressor()
165
+ self.ensemble_regressor.fit(
166
+ base_model_mean_predictions=base_model_mean_predictions,
167
+ base_model_quantile_predictions=base_model_quantile_predictions,
168
+ labels=labels,
169
+ time_limit=time_limit,
170
+ )
171
+
172
+ @abstractmethod
173
+ def _get_ensemble_regressor(self) -> EnsembleRegressor:
174
+ pass
175
+
176
+ def _predict(self, data: dict[str, TimeSeriesDataFrame], **kwargs) -> TimeSeriesDataFrame:
177
+ if self.ensemble_regressor is None:
178
+ if not self._model_names:
179
+ raise ValueError("Ensemble model has not been fitted yet.")
180
+ # Try to recreate the regressor (for loaded models)
181
+ self.ensemble_regressor = self._get_ensemble_regressor()
182
+
183
+ input_data = {}
184
+ for m in self.model_names:
185
+ assert m in data, f"Predictions for model {m} not provided during ensemble prediction."
186
+ input_data[m] = data[m]
187
+
188
+ base_model_mean_predictions, base_model_quantile_predictions = self._get_base_model_predictions(input_data)
189
+
190
+ mean_predictions, quantile_predictions = self.ensemble_regressor.predict(
191
+ base_model_mean_predictions=base_model_mean_predictions,
192
+ base_model_quantile_predictions=base_model_quantile_predictions,
193
+ )
194
+
195
+ quantile_predictions = self._isotonize(quantile_predictions)
196
+ prediction_array = np.concatenate([mean_predictions, quantile_predictions], axis=-1)
197
+
198
+ output = list(input_data.values())[0].copy()
199
+ num_folds, num_items, num_timesteps, num_outputs = prediction_array.shape
200
+ assert (num_folds, num_timesteps) == (1, self.prediction_length)
201
+ assert len(output.columns) == num_outputs
202
+
203
+ output[output.columns] = prediction_array.reshape((num_items * num_timesteps, num_outputs))
204
+
205
+ return output
206
+
207
+ @property
208
+ def model_names(self) -> list[str]:
209
+ return self._model_names
210
+
211
+ def remap_base_models(self, model_refit_map: dict[str, str]) -> None:
212
+ """Update names of the base models based on the mapping in model_refit_map."""
213
+ self._model_names = [model_refit_map.get(name, name) for name in self._model_names]
214
+
215
+ def _filter_failed_models(
216
+ self,
217
+ predictions_per_window: dict[str, list[TimeSeriesDataFrame]],
218
+ model_scores: dict[str, float] | None,
219
+ ) -> dict[str, list[TimeSeriesDataFrame]]:
220
+ """Filter out failed models based on detect_and_ignore_failures setting."""
221
+ if not self.get_hyperparameter("detect_and_ignore_failures"):
222
+ return predictions_per_window
223
+
224
+ if model_scores is None or len(model_scores) == 0:
225
+ return predictions_per_window
226
+
227
+ valid_scores = {k: v for k, v in model_scores.items() if np.isfinite(v)}
228
+ if len(valid_scores) == 0:
229
+ raise ValueError("All models have NaN scores. At least one model must run successfully to fit an ensemble")
230
+
231
+ losses = {k: -v for k, v in valid_scores.items()}
232
+ median_loss = np.nanmedian(list(losses.values()))
233
+ threshold = 10 * median_loss
234
+ good_models = {k for k, loss in losses.items() if loss <= threshold}
235
+
236
+ return {k: v for k, v in predictions_per_window.items() if k in good_models}
@@ -0,0 +1,73 @@
1
+ from abc import ABC
2
+ from typing import Any, Type
3
+
4
+ from .abstract import ArrayBasedTimeSeriesEnsembleModel
5
+ from .regressor import (
6
+ EnsembleRegressor,
7
+ LinearStackerEnsembleRegressor,
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({"model_name": "GBM", "model_hyperparameters": {}})
25
+ return default_hps
26
+
27
+ def _get_ensemble_regressor(self):
28
+ hyperparameters = self.get_hyperparameters()
29
+ return self.ensemble_regressor_type(
30
+ quantile_levels=list(self.quantile_levels),
31
+ model_name=hyperparameters["model_name"],
32
+ model_hyperparameters=hyperparameters["model_hyperparameters"],
33
+ )
34
+
35
+
36
+ class TabularEnsemble(BaseTabularEnsemble):
37
+ """Time series ensemble model using a single AutoGluon-Tabular model for all quantiles."""
38
+
39
+ ensemble_regressor_type = TabularEnsembleRegressor
40
+
41
+
42
+ class PerQuantileTabularEnsemble(BaseTabularEnsemble):
43
+ """Time series ensemble model using separate AutoGluon-Tabular models for each quantile in
44
+ addition to a dedicated model for the mean (point) forecast.
45
+ """
46
+
47
+ ensemble_regressor_type = PerQuantileTabularEnsembleRegressor
48
+
49
+
50
+ class LinearStackerEnsemble(ArrayBasedTimeSeriesEnsembleModel):
51
+ """Time series ensemble model using linear stacker with PyTorch optimization."""
52
+
53
+ def _get_default_hyperparameters(self) -> dict[str, Any]:
54
+ default_hps = super()._get_default_hyperparameters()
55
+ default_hps.update(
56
+ {
57
+ "weights_per": "m",
58
+ "lr": 0.1,
59
+ "max_epochs": 10000,
60
+ "relative_tolerance": 1e-7,
61
+ }
62
+ )
63
+ return default_hps
64
+
65
+ def _get_ensemble_regressor(self) -> LinearStackerEnsembleRegressor:
66
+ hps = self.get_hyperparameters()
67
+ return LinearStackerEnsembleRegressor(
68
+ quantile_levels=list(self.quantile_levels),
69
+ weights_per=hps["weights_per"],
70
+ lr=hps["lr"],
71
+ max_epochs=hps["max_epochs"],
72
+ relative_tolerance=hps["relative_tolerance"],
73
+ )
@@ -0,0 +1,12 @@
1
+ from .abstract import EnsembleRegressor, MedianEnsembleRegressor
2
+ from .linear_stacker import LinearStackerEnsembleRegressor
3
+ from .per_quantile_tabular import PerQuantileTabularEnsembleRegressor
4
+ from .tabular import TabularEnsembleRegressor
5
+
6
+ __all__ = [
7
+ "EnsembleRegressor",
8
+ "LinearStackerEnsembleRegressor",
9
+ "MedianEnsembleRegressor",
10
+ "PerQuantileTabularEnsembleRegressor",
11
+ "TabularEnsembleRegressor",
12
+ ]
@@ -0,0 +1,88 @@
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
+ @abstractmethod
12
+ def fit(
13
+ self,
14
+ base_model_mean_predictions: np.ndarray,
15
+ base_model_quantile_predictions: np.ndarray,
16
+ labels: np.ndarray,
17
+ time_limit: float | None = None,
18
+ ) -> Self:
19
+ """
20
+ Parameters
21
+ ----------
22
+ base_model_mean_predictions
23
+ Mean (point) predictions of base models. Array of shape
24
+ (num_windows, num_items, prediction_length, 1, num_models)
25
+
26
+ base_model_quantile_predictions
27
+ Quantile predictions of base models. Array of shape
28
+ (num_windows, num_items, prediction_length, num_quantiles, num_models)
29
+
30
+ labels
31
+ Ground truth array of shape
32
+ (num_windows, num_items, prediction_length, 1)
33
+
34
+ time_limit
35
+ Approximately how long `fit` will run (wall-clock time in seconds). If
36
+ not specified, training time will not be limited.
37
+ """
38
+ pass
39
+
40
+ @abstractmethod
41
+ def predict(
42
+ self,
43
+ base_model_mean_predictions: np.ndarray,
44
+ base_model_quantile_predictions: np.ndarray,
45
+ ) -> tuple[np.ndarray, np.ndarray]:
46
+ """Predict with the fitted ensemble regressor for a single window.
47
+ The items do not have to refer to the same item indices used when fitting
48
+ the model.
49
+
50
+ Parameters
51
+ ----------
52
+ base_model_mean_predictions
53
+ Mean (point) predictions of base models. Array of shape
54
+ (1, num_items, prediction_length, 1, num_models)
55
+
56
+ base_model_quantile_predictions
57
+ Quantile predictions of base models. Array of shape
58
+ (1, num_items, prediction_length, num_quantiles, num_models)
59
+
60
+ Returns
61
+ -------
62
+ ensemble_mean_predictions
63
+ Array of shape (1, num_items, prediction_length, 1)
64
+ ensemble_quantile_predictions
65
+ Array of shape (1, num_items, prediction_length, num_quantiles)
66
+ """
67
+ pass
68
+
69
+
70
+ class MedianEnsembleRegressor(EnsembleRegressor):
71
+ def fit(
72
+ self,
73
+ base_model_mean_predictions: np.ndarray,
74
+ base_model_quantile_predictions: np.ndarray,
75
+ labels: np.ndarray,
76
+ time_limit: float | None = None,
77
+ ) -> Self:
78
+ return self
79
+
80
+ def predict(
81
+ self,
82
+ base_model_mean_predictions: np.ndarray,
83
+ base_model_quantile_predictions: np.ndarray,
84
+ ) -> tuple[np.ndarray, np.ndarray]:
85
+ return (
86
+ np.nanmedian(base_model_mean_predictions, axis=-1),
87
+ np.nanmedian(base_model_quantile_predictions, axis=-1),
88
+ )
@@ -0,0 +1,167 @@
1
+ from typing import Literal
2
+
3
+ import numpy as np
4
+ from typing_extensions import Self
5
+
6
+ from autogluon.timeseries.utils.timer import Timer
7
+
8
+ from .abstract import EnsembleRegressor
9
+
10
+
11
+ class LinearStackerEnsembleRegressor(EnsembleRegressor):
12
+ """Linear stacker ensemble regressor using PyTorch optimization with softmax weights.
13
+
14
+ Implements weighted averaging of base model predictions with learnable weights optimized
15
+ via gradient descent. Uses PyTorch during training for optimization, then stores weights
16
+ as numpy arrays for efficient prediction.
17
+
18
+ Parameters
19
+ ----------
20
+ quantile_levels
21
+ List of quantile levels for quantile predictions (e.g., [0.1, 0.5, 0.9]).
22
+ weights_per
23
+ Weight configuration specifying which dimensions to learn weights for:
24
+ - "m": Per-model weights (shape: num_models), defaults to "m"
25
+ - "mt": Per-model and per-time weights (shape: prediction_length, num_models)
26
+ - "mq": Per-model and per-model-output (quantiles and mean) weights
27
+ (shape: num_quantiles+1, num_models)
28
+ - "mtq": Per-model, per-time, and per-quantile weights
29
+ (shape: prediction_length, num_quantiles+1, num_models)
30
+ lr
31
+ Learning rate for Adam optimizer. Defaults to 0.1.
32
+ max_epochs
33
+ Maximum number of training epochs. Defaults to 10000.
34
+ relative_tolerance
35
+ Convergence tolerance for relative loss change between epochs. Defaults to 1e-7.
36
+ """
37
+
38
+ def __init__(
39
+ self,
40
+ quantile_levels: list[float],
41
+ weights_per: Literal["m", "mt", "mq", "mtq"] = "m",
42
+ lr: float = 0.1,
43
+ max_epochs: int = 10_000,
44
+ relative_tolerance: float = 1e-7,
45
+ ):
46
+ super().__init__()
47
+ self.quantile_levels = quantile_levels
48
+ self.weights_per = weights_per
49
+ self.lr = lr
50
+ self.max_epochs = max_epochs
51
+ self.relative_tolerance = relative_tolerance
52
+
53
+ # Learned weights (stored as numpy arrays)
54
+ self.weights: np.ndarray | None = None
55
+
56
+ def _compute_weight_shape(self, base_model_predictions_shape: tuple) -> tuple:
57
+ """Compute weight tensor shape based on weights_per configuration."""
58
+ _, _, prediction_length, num_outputs, num_models = base_model_predictions_shape
59
+
60
+ shapes = {
61
+ "m": (1, 1, num_models),
62
+ "mt": (prediction_length, 1, num_models),
63
+ "mq": (1, num_outputs, num_models),
64
+ "mtq": (prediction_length, num_outputs, num_models),
65
+ }
66
+ try:
67
+ return (1, 1) + shapes[self.weights_per]
68
+ except KeyError:
69
+ raise ValueError(f"Unsupported weights_per: {self.weights_per}")
70
+
71
+ def make_weighted_average_module(self, base_model_predictions_shape: tuple):
72
+ import torch
73
+
74
+ class WeightedAverage(torch.nn.Module):
75
+ def __init__(self, shape):
76
+ super().__init__()
77
+ self.raw_weights = torch.nn.Parameter(torch.zeros(*shape, dtype=torch.float32))
78
+
79
+ def get_normalized_weights(self):
80
+ return torch.softmax(self.raw_weights, dim=-1) # softmax over models
81
+
82
+ def forward(self, base_model_predictions: torch.Tensor):
83
+ return torch.sum(self.get_normalized_weights() * base_model_predictions, dim=-1)
84
+
85
+ return WeightedAverage(self._compute_weight_shape(base_model_predictions_shape))
86
+
87
+ def fit(
88
+ self,
89
+ base_model_mean_predictions: np.ndarray,
90
+ base_model_quantile_predictions: np.ndarray,
91
+ labels: np.ndarray,
92
+ time_limit: float | None = None,
93
+ ) -> Self:
94
+ import torch
95
+
96
+ def _ql(
97
+ labels_tensor: torch.Tensor,
98
+ ensemble_predictions: torch.Tensor,
99
+ ) -> torch.Tensor:
100
+ """Compute the weighted quantile loss on predictions and ground truth (labels).
101
+ Considering that the first dimension of predictions is the mean, we treat
102
+ mean predictions on the same footing as median (0.5) predictions as contribution
103
+ to the overall weighted quantile loss.
104
+ """
105
+ quantile_levels = torch.tensor([0.5] + self.quantile_levels, dtype=torch.float32)
106
+ error = labels_tensor - ensemble_predictions # (num_windows, num_items, num_time, num_outputs)
107
+ quantile_loss = torch.maximum(quantile_levels * error, (quantile_levels - 1) * error)
108
+ return torch.mean(quantile_loss)
109
+
110
+ timer = Timer(time_limit).start()
111
+
112
+ base_model_predictions = torch.tensor(
113
+ np.concatenate(
114
+ [base_model_mean_predictions, base_model_quantile_predictions],
115
+ axis=3,
116
+ ),
117
+ dtype=torch.float32,
118
+ )
119
+ labels_tensor = torch.tensor(labels, dtype=torch.float32)
120
+
121
+ weighted_average = self.make_weighted_average_module(base_model_predictions.shape)
122
+
123
+ optimizer = torch.optim.Adam(weighted_average.parameters(), lr=self.lr)
124
+
125
+ prev_loss = float("inf")
126
+ for _ in range(self.max_epochs):
127
+ optimizer.zero_grad()
128
+
129
+ ensemble_predictions = weighted_average(base_model_predictions)
130
+
131
+ loss = _ql(labels_tensor, ensemble_predictions)
132
+ loss.backward()
133
+ optimizer.step()
134
+
135
+ loss_change = abs(prev_loss - loss.item()) / (loss.item() + 1e-8)
136
+ if loss_change < self.relative_tolerance:
137
+ break
138
+ prev_loss = loss.item()
139
+
140
+ if timer.timed_out():
141
+ break
142
+
143
+ # store final weights as numpy array
144
+ # TODO: add sparsification to ensure negligible weights are dropped
145
+ with torch.no_grad():
146
+ self.weights = weighted_average.get_normalized_weights().detach().numpy()
147
+
148
+ return self
149
+
150
+ def predict(
151
+ self,
152
+ base_model_mean_predictions: np.ndarray,
153
+ base_model_quantile_predictions: np.ndarray,
154
+ ) -> tuple[np.ndarray, np.ndarray]:
155
+ if self.weights is None:
156
+ raise ValueError("Model must be fitted before prediction")
157
+
158
+ # combine base model predictions
159
+ all_predictions = np.concatenate([base_model_mean_predictions, base_model_quantile_predictions], axis=3)
160
+
161
+ # predict
162
+ ensemble_pred = np.sum(self.weights * all_predictions, axis=-1)
163
+
164
+ mean_predictions = ensemble_pred[:, :, :, :1]
165
+ quantile_predictions = ensemble_pred[:, :, :, 1:]
166
+
167
+ return mean_predictions, quantile_predictions
@@ -0,0 +1,94 @@
1
+ import logging
2
+
3
+ import numpy as np
4
+ import pandas as pd
5
+ from typing_extensions import Self
6
+
7
+ from autogluon.tabular.registry import ag_model_registry as tabular_ag_model_registry
8
+ from autogluon.timeseries.utils.timer import SplitTimer
9
+
10
+ from .abstract import EnsembleRegressor
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class PerQuantileTabularEnsembleRegressor(EnsembleRegressor):
16
+ """Ensemble regressor using separate models per quantile plus dedicated mean model."""
17
+
18
+ def __init__(
19
+ self,
20
+ quantile_levels: list[float],
21
+ model_name: str,
22
+ model_hyperparameters: dict | None = None,
23
+ ):
24
+ super().__init__()
25
+ self.quantile_levels = quantile_levels
26
+ model_type = tabular_ag_model_registry.key_to_cls(model_name)
27
+ model_hyperparameters = model_hyperparameters or {}
28
+ self.mean_model = model_type(
29
+ problem_type="regression",
30
+ hyperparameters=model_hyperparameters,
31
+ path="",
32
+ name=f"{model_name}_mean",
33
+ )
34
+ self.quantile_models = [
35
+ model_type(
36
+ problem_type="quantile",
37
+ hyperparameters=model_hyperparameters | {"ag.quantile_levels": [quantile]},
38
+ path="",
39
+ name=f"{model_name}_q{quantile}",
40
+ )
41
+ for quantile in quantile_levels
42
+ ]
43
+
44
+ def fit(
45
+ self,
46
+ base_model_mean_predictions: np.ndarray,
47
+ base_model_quantile_predictions: np.ndarray,
48
+ labels: np.ndarray,
49
+ time_limit: float | None = None,
50
+ ) -> Self:
51
+ num_windows, num_items, prediction_length = base_model_mean_predictions.shape[:3]
52
+ y = pd.Series(labels.reshape(num_windows * num_items * prediction_length))
53
+
54
+ total_rounds = 1 + len(self.quantile_levels)
55
+ timer = SplitTimer(time_limit, rounds=total_rounds).start()
56
+
57
+ # Fit mean model
58
+ X_mean = self._get_feature_df(base_model_mean_predictions, 0)
59
+ self.mean_model.fit(X=X_mean, y=y, time_limit=timer.round_time_remaining())
60
+ timer.next_round()
61
+
62
+ # Fit quantile models
63
+ for i, model in enumerate(self.quantile_models):
64
+ X_q = self._get_feature_df(base_model_quantile_predictions, i)
65
+ model.fit(X=X_q, y=y, time_limit=timer.round_time_remaining())
66
+ timer.next_round()
67
+
68
+ return self
69
+
70
+ def _get_feature_df(self, predictions: np.ndarray, index: int) -> pd.DataFrame:
71
+ num_windows, num_items, prediction_length, _, num_models = predictions.shape
72
+ num_tabular_items = num_windows * num_items * prediction_length
73
+ return pd.DataFrame(
74
+ predictions[:, :, :, index].reshape(num_tabular_items, num_models),
75
+ columns=[f"model_{mi}" for mi in range(num_models)],
76
+ )
77
+
78
+ def predict(
79
+ self, base_model_mean_predictions: np.ndarray, base_model_quantile_predictions: np.ndarray
80
+ ) -> tuple[np.ndarray, np.ndarray]:
81
+ assert self.mean_model.is_fit()
82
+ num_windows, num_items, prediction_length = base_model_mean_predictions.shape[:3]
83
+ assert num_windows == 1, "Prediction expects a single window to be provided"
84
+
85
+ X_mean = self._get_feature_df(base_model_mean_predictions, 0)
86
+ mean_predictions = self.mean_model.predict(X_mean).reshape(num_windows, num_items, prediction_length, 1)
87
+
88
+ quantile_predictions_list = []
89
+ for i, model in enumerate(self.quantile_models):
90
+ X_q = self._get_feature_df(base_model_quantile_predictions, i)
91
+ quantile_predictions_list.append(model.predict(X_q).reshape(num_windows, num_items, prediction_length))
92
+ quantile_predictions = np.stack(quantile_predictions_list, axis=-1)
93
+
94
+ return mean_predictions, quantile_predictions