autogluon.timeseries 1.4.1b20250830__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.
- autogluon/timeseries/dataset/ts_dataframe.py +66 -53
- autogluon/timeseries/learner.py +5 -4
- autogluon/timeseries/metrics/quantile.py +1 -1
- autogluon/timeseries/metrics/utils.py +4 -4
- autogluon/timeseries/models/__init__.py +2 -0
- autogluon/timeseries/models/autogluon_tabular/mlforecast.py +28 -36
- autogluon/timeseries/models/autogluon_tabular/per_step.py +14 -5
- autogluon/timeseries/models/autogluon_tabular/transforms.py +9 -7
- autogluon/timeseries/models/chronos/model.py +104 -68
- autogluon/timeseries/models/chronos/{pipeline/utils.py → utils.py} +64 -32
- autogluon/timeseries/models/ensemble/__init__.py +29 -2
- autogluon/timeseries/models/ensemble/abstract.py +1 -37
- autogluon/timeseries/models/ensemble/array_based/__init__.py +3 -0
- autogluon/timeseries/models/ensemble/array_based/abstract.py +247 -0
- autogluon/timeseries/models/ensemble/array_based/models.py +50 -0
- autogluon/timeseries/models/ensemble/array_based/regressor/__init__.py +10 -0
- autogluon/timeseries/models/ensemble/array_based/regressor/abstract.py +87 -0
- autogluon/timeseries/models/ensemble/array_based/regressor/per_quantile_tabular.py +133 -0
- autogluon/timeseries/models/ensemble/array_based/regressor/tabular.py +141 -0
- autogluon/timeseries/models/ensemble/weighted/__init__.py +8 -0
- autogluon/timeseries/models/ensemble/weighted/abstract.py +41 -0
- autogluon/timeseries/models/ensemble/{basic.py → weighted/basic.py} +0 -10
- autogluon/timeseries/models/gluonts/abstract.py +2 -2
- autogluon/timeseries/models/gluonts/dataset.py +2 -2
- autogluon/timeseries/models/local/abstract_local_model.py +2 -2
- autogluon/timeseries/models/multi_window/multi_window_model.py +1 -1
- autogluon/timeseries/models/toto/__init__.py +3 -0
- autogluon/timeseries/models/toto/_internal/__init__.py +9 -0
- autogluon/timeseries/models/toto/_internal/backbone/__init__.py +3 -0
- autogluon/timeseries/models/toto/_internal/backbone/attention.py +197 -0
- autogluon/timeseries/models/toto/_internal/backbone/backbone.py +262 -0
- autogluon/timeseries/models/toto/_internal/backbone/distribution.py +70 -0
- autogluon/timeseries/models/toto/_internal/backbone/kvcache.py +136 -0
- autogluon/timeseries/models/toto/_internal/backbone/rope.py +94 -0
- autogluon/timeseries/models/toto/_internal/backbone/scaler.py +306 -0
- autogluon/timeseries/models/toto/_internal/backbone/transformer.py +333 -0
- autogluon/timeseries/models/toto/_internal/dataset.py +165 -0
- autogluon/timeseries/models/toto/_internal/forecaster.py +423 -0
- autogluon/timeseries/models/toto/dataloader.py +108 -0
- autogluon/timeseries/models/toto/hf_pretrained_model.py +119 -0
- autogluon/timeseries/models/toto/model.py +236 -0
- autogluon/timeseries/predictor.py +10 -26
- autogluon/timeseries/regressor.py +9 -7
- autogluon/timeseries/splitter.py +1 -25
- autogluon/timeseries/trainer/ensemble_composer.py +250 -0
- autogluon/timeseries/trainer/trainer.py +124 -193
- autogluon/timeseries/trainer/utils.py +18 -0
- autogluon/timeseries/transforms/covariate_scaler.py +1 -1
- autogluon/timeseries/transforms/target_scaler.py +7 -7
- autogluon/timeseries/utils/features.py +9 -5
- autogluon/timeseries/utils/forecast.py +5 -5
- autogluon/timeseries/version.py +1 -1
- autogluon.timeseries-1.4.1b20251116-py3.9-nspkg.pth +1 -0
- {autogluon.timeseries-1.4.1b20250830.dist-info → autogluon_timeseries-1.4.1b20251116.dist-info}/METADATA +28 -13
- autogluon_timeseries-1.4.1b20251116.dist-info/RECORD +96 -0
- {autogluon.timeseries-1.4.1b20250830.dist-info → autogluon_timeseries-1.4.1b20251116.dist-info}/WHEEL +1 -1
- autogluon/timeseries/evaluator.py +0 -6
- autogluon/timeseries/models/chronos/pipeline/__init__.py +0 -10
- autogluon/timeseries/models/chronos/pipeline/base.py +0 -160
- autogluon/timeseries/models/chronos/pipeline/chronos.py +0 -544
- autogluon/timeseries/models/chronos/pipeline/chronos_bolt.py +0 -530
- autogluon.timeseries-1.4.1b20250830-py3.9-nspkg.pth +0 -1
- autogluon.timeseries-1.4.1b20250830.dist-info/RECORD +0 -75
- /autogluon/timeseries/models/ensemble/{greedy.py → weighted/greedy.py} +0 -0
- {autogluon.timeseries-1.4.1b20250830.dist-info → autogluon_timeseries-1.4.1b20251116.dist-info/licenses}/LICENSE +0 -0
- {autogluon.timeseries-1.4.1b20250830.dist-info → autogluon_timeseries-1.4.1b20251116.dist-info/licenses}/NOTICE +0 -0
- {autogluon.timeseries-1.4.1b20250830.dist-info → autogluon_timeseries-1.4.1b20251116.dist-info}/namespace_packages.txt +0 -0
- {autogluon.timeseries-1.4.1b20250830.dist-info → autogluon_timeseries-1.4.1b20251116.dist-info}/top_level.txt +0 -0
- {autogluon.timeseries-1.4.1b20250830.dist-info → autogluon_timeseries-1.4.1b20251116.dist-info}/zip-safe +0 -0
|
@@ -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
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
import pandas as pd
|
|
6
|
+
from typing_extensions import Self
|
|
7
|
+
|
|
8
|
+
from autogluon.tabular import TabularPredictor
|
|
9
|
+
|
|
10
|
+
from .abstract import EnsembleRegressor
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TabularEnsembleRegressor(EnsembleRegressor):
|
|
16
|
+
"""TabularPredictor ensemble regressor using AutoGluon-Tabular as a single
|
|
17
|
+
quantile regressor for the target.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
path: str,
|
|
23
|
+
quantile_levels: list[float],
|
|
24
|
+
tabular_hyperparameters: Optional[dict] = None,
|
|
25
|
+
):
|
|
26
|
+
super().__init__()
|
|
27
|
+
self.path = path
|
|
28
|
+
self.quantile_levels = quantile_levels
|
|
29
|
+
self.tabular_hyperparameters = tabular_hyperparameters or {}
|
|
30
|
+
self.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
|
+
time_limit: Optional[int] = None,
|
|
41
|
+
**kwargs,
|
|
42
|
+
) -> Self:
|
|
43
|
+
self.predictor = TabularPredictor(
|
|
44
|
+
path=self.path,
|
|
45
|
+
label="target",
|
|
46
|
+
problem_type="quantile",
|
|
47
|
+
quantile_levels=self.quantile_levels,
|
|
48
|
+
verbosity=1,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# get features
|
|
52
|
+
df = self._get_feature_df(base_model_mean_predictions, base_model_quantile_predictions)
|
|
53
|
+
|
|
54
|
+
# get labels
|
|
55
|
+
num_windows, num_items, prediction_length = base_model_mean_predictions.shape[:3]
|
|
56
|
+
label_series = labels.reshape(num_windows * num_items * prediction_length)
|
|
57
|
+
df["target"] = label_series
|
|
58
|
+
|
|
59
|
+
self.predictor.fit(
|
|
60
|
+
df,
|
|
61
|
+
hyperparameters=self.tabular_hyperparameters,
|
|
62
|
+
time_limit=time_limit, # type: ignore
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
return self
|
|
66
|
+
|
|
67
|
+
def predict(
|
|
68
|
+
self,
|
|
69
|
+
base_model_mean_predictions: np.ndarray,
|
|
70
|
+
base_model_quantile_predictions: np.ndarray,
|
|
71
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
72
|
+
if self.predictor is None:
|
|
73
|
+
try:
|
|
74
|
+
self.predictor = TabularPredictor.load(self.path)
|
|
75
|
+
except FileNotFoundError:
|
|
76
|
+
raise ValueError("Model must be fitted before prediction")
|
|
77
|
+
|
|
78
|
+
num_windows, num_items, prediction_length = base_model_mean_predictions.shape[:3]
|
|
79
|
+
assert num_windows == 1, "Prediction expects a single window to be provided"
|
|
80
|
+
|
|
81
|
+
df = self._get_feature_df(base_model_mean_predictions, base_model_quantile_predictions)
|
|
82
|
+
|
|
83
|
+
pred = self.predictor.predict(df, as_pandas=False)
|
|
84
|
+
|
|
85
|
+
# Reshape back to (num_windows, num_items, prediction_length, num_quantiles)
|
|
86
|
+
pred = pred.reshape(num_windows, num_items, prediction_length, len(self.quantile_levels))
|
|
87
|
+
|
|
88
|
+
# Use median quantile as mean prediction
|
|
89
|
+
median_idx = self._get_median_quantile_index()
|
|
90
|
+
mean_pred = pred[:, :, :, median_idx : median_idx + 1]
|
|
91
|
+
quantile_pred = pred
|
|
92
|
+
|
|
93
|
+
return mean_pred, quantile_pred
|
|
94
|
+
|
|
95
|
+
def _get_feature_df(
|
|
96
|
+
self,
|
|
97
|
+
base_model_mean_predictions: np.ndarray,
|
|
98
|
+
base_model_quantile_predictions: np.ndarray,
|
|
99
|
+
) -> pd.DataFrame:
|
|
100
|
+
num_windows, num_items, prediction_length, _, num_models = base_model_mean_predictions.shape
|
|
101
|
+
num_tabular_items = num_windows * num_items * prediction_length
|
|
102
|
+
|
|
103
|
+
X = np.hstack(
|
|
104
|
+
[
|
|
105
|
+
base_model_mean_predictions.reshape(num_tabular_items, -1),
|
|
106
|
+
base_model_quantile_predictions.reshape(num_tabular_items, -1),
|
|
107
|
+
]
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
df = pd.DataFrame(X, columns=self._get_feature_names(num_models))
|
|
111
|
+
return df
|
|
112
|
+
|
|
113
|
+
def _get_feature_names(self, num_models: int) -> list[str]:
|
|
114
|
+
feature_names = []
|
|
115
|
+
for mi in range(num_models):
|
|
116
|
+
feature_names.append(f"model_{mi}_mean")
|
|
117
|
+
for quantile in self.quantile_levels:
|
|
118
|
+
for mi in range(num_models):
|
|
119
|
+
feature_names.append(f"model_{mi}_q{quantile}")
|
|
120
|
+
|
|
121
|
+
return feature_names
|
|
122
|
+
|
|
123
|
+
def _get_median_quantile_index(self):
|
|
124
|
+
"""Get quantile index closest to 0.5"""
|
|
125
|
+
quantile_array = np.array(self.quantile_levels)
|
|
126
|
+
median_idx = int(np.argmin(np.abs(quantile_array - 0.5)))
|
|
127
|
+
selected_quantile = quantile_array[median_idx]
|
|
128
|
+
|
|
129
|
+
if selected_quantile != 0.5:
|
|
130
|
+
logger.warning(
|
|
131
|
+
f"Selected quantile {selected_quantile} is not exactly 0.5. "
|
|
132
|
+
f"Using closest available quantile for median prediction."
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
return median_idx
|
|
136
|
+
|
|
137
|
+
def __getstate__(self):
|
|
138
|
+
state = self.__dict__.copy()
|
|
139
|
+
# Remove the predictor to avoid pickling heavy TabularPredictor objects
|
|
140
|
+
state["predictor"] = None
|
|
141
|
+
return state
|