autogluon.timeseries 1.4.1b20251115__py3-none-any.whl → 1.5.0b20251221__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.
- autogluon/timeseries/configs/hyperparameter_presets.py +13 -28
- autogluon/timeseries/configs/predictor_presets.py +23 -39
- autogluon/timeseries/dataset/ts_dataframe.py +32 -34
- autogluon/timeseries/learner.py +67 -33
- autogluon/timeseries/metrics/__init__.py +4 -4
- autogluon/timeseries/metrics/abstract.py +8 -8
- autogluon/timeseries/metrics/point.py +9 -9
- autogluon/timeseries/metrics/quantile.py +4 -4
- autogluon/timeseries/models/__init__.py +2 -1
- autogluon/timeseries/models/abstract/abstract_timeseries_model.py +52 -50
- autogluon/timeseries/models/abstract/model_trial.py +2 -1
- autogluon/timeseries/models/abstract/tunable.py +8 -8
- autogluon/timeseries/models/autogluon_tabular/mlforecast.py +30 -26
- autogluon/timeseries/models/autogluon_tabular/per_step.py +13 -11
- autogluon/timeseries/models/autogluon_tabular/transforms.py +2 -2
- autogluon/timeseries/models/chronos/__init__.py +2 -1
- autogluon/timeseries/models/chronos/chronos2.py +395 -0
- autogluon/timeseries/models/chronos/model.py +30 -25
- autogluon/timeseries/models/chronos/utils.py +5 -5
- autogluon/timeseries/models/ensemble/__init__.py +17 -10
- autogluon/timeseries/models/ensemble/abstract.py +13 -9
- autogluon/timeseries/models/ensemble/array_based/__init__.py +2 -2
- autogluon/timeseries/models/ensemble/array_based/abstract.py +24 -31
- autogluon/timeseries/models/ensemble/array_based/models.py +146 -11
- autogluon/timeseries/models/ensemble/array_based/regressor/__init__.py +2 -0
- autogluon/timeseries/models/ensemble/array_based/regressor/abstract.py +6 -5
- autogluon/timeseries/models/ensemble/array_based/regressor/linear_stacker.py +186 -0
- autogluon/timeseries/models/ensemble/array_based/regressor/per_quantile_tabular.py +44 -83
- autogluon/timeseries/models/ensemble/array_based/regressor/tabular.py +21 -55
- autogluon/timeseries/models/ensemble/ensemble_selection.py +167 -0
- autogluon/timeseries/models/ensemble/per_item_greedy.py +172 -0
- autogluon/timeseries/models/ensemble/weighted/abstract.py +7 -3
- autogluon/timeseries/models/ensemble/weighted/basic.py +26 -13
- autogluon/timeseries/models/ensemble/weighted/greedy.py +21 -144
- autogluon/timeseries/models/gluonts/abstract.py +30 -29
- autogluon/timeseries/models/gluonts/dataset.py +9 -9
- autogluon/timeseries/models/gluonts/models.py +0 -7
- autogluon/timeseries/models/local/__init__.py +0 -7
- autogluon/timeseries/models/local/abstract_local_model.py +13 -16
- autogluon/timeseries/models/local/naive.py +2 -2
- autogluon/timeseries/models/local/npts.py +7 -1
- autogluon/timeseries/models/local/statsforecast.py +13 -13
- autogluon/timeseries/models/multi_window/multi_window_model.py +38 -23
- autogluon/timeseries/models/registry.py +3 -4
- autogluon/timeseries/models/toto/_internal/backbone/attention.py +3 -4
- autogluon/timeseries/models/toto/_internal/backbone/backbone.py +6 -6
- autogluon/timeseries/models/toto/_internal/backbone/rope.py +4 -9
- autogluon/timeseries/models/toto/_internal/backbone/rotary_embedding_torch.py +342 -0
- autogluon/timeseries/models/toto/_internal/backbone/scaler.py +2 -3
- autogluon/timeseries/models/toto/_internal/backbone/transformer.py +10 -10
- autogluon/timeseries/models/toto/_internal/dataset.py +2 -2
- autogluon/timeseries/models/toto/_internal/forecaster.py +8 -8
- autogluon/timeseries/models/toto/dataloader.py +4 -4
- autogluon/timeseries/models/toto/hf_pretrained_model.py +97 -16
- autogluon/timeseries/models/toto/model.py +30 -17
- autogluon/timeseries/predictor.py +531 -136
- autogluon/timeseries/regressor.py +18 -23
- autogluon/timeseries/splitter.py +2 -2
- autogluon/timeseries/trainer/ensemble_composer.py +323 -129
- autogluon/timeseries/trainer/model_set_builder.py +9 -9
- autogluon/timeseries/trainer/prediction_cache.py +16 -16
- autogluon/timeseries/trainer/trainer.py +235 -145
- autogluon/timeseries/trainer/utils.py +3 -4
- autogluon/timeseries/transforms/covariate_scaler.py +7 -7
- autogluon/timeseries/transforms/target_scaler.py +8 -8
- autogluon/timeseries/utils/constants.py +10 -0
- autogluon/timeseries/utils/datetime/lags.py +1 -3
- autogluon/timeseries/utils/datetime/seasonality.py +1 -3
- autogluon/timeseries/utils/features.py +22 -9
- autogluon/timeseries/utils/forecast.py +1 -2
- autogluon/timeseries/utils/timer.py +173 -0
- autogluon/timeseries/version.py +1 -1
- {autogluon_timeseries-1.4.1b20251115.dist-info → autogluon_timeseries-1.5.0b20251221.dist-info}/METADATA +23 -21
- autogluon_timeseries-1.5.0b20251221.dist-info/RECORD +103 -0
- autogluon_timeseries-1.4.1b20251115.dist-info/RECORD +0 -96
- /autogluon.timeseries-1.4.1b20251115-py3.9-nspkg.pth → /autogluon.timeseries-1.5.0b20251221-py3.11-nspkg.pth +0 -0
- {autogluon_timeseries-1.4.1b20251115.dist-info → autogluon_timeseries-1.5.0b20251221.dist-info}/WHEEL +0 -0
- {autogluon_timeseries-1.4.1b20251115.dist-info → autogluon_timeseries-1.5.0b20251221.dist-info}/licenses/LICENSE +0 -0
- {autogluon_timeseries-1.4.1b20251115.dist-info → autogluon_timeseries-1.5.0b20251221.dist-info}/licenses/NOTICE +0 -0
- {autogluon_timeseries-1.4.1b20251115.dist-info → autogluon_timeseries-1.5.0b20251221.dist-info}/namespace_packages.txt +0 -0
- {autogluon_timeseries-1.4.1b20251115.dist-info → autogluon_timeseries-1.5.0b20251221.dist-info}/top_level.txt +0 -0
- {autogluon_timeseries-1.4.1b20251115.dist-info → autogluon_timeseries-1.5.0b20251221.dist-info}/zip-safe +0 -0
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import logging
|
|
2
|
-
import os
|
|
3
|
-
from typing import Optional
|
|
4
2
|
|
|
5
3
|
import numpy as np
|
|
6
4
|
import pandas as pd
|
|
7
5
|
from typing_extensions import Self
|
|
8
6
|
|
|
9
|
-
from autogluon.tabular import
|
|
7
|
+
from autogluon.tabular.registry import ag_model_registry as tabular_ag_model_registry
|
|
8
|
+
from autogluon.timeseries.utils.timer import SplitTimer
|
|
10
9
|
|
|
11
10
|
from .abstract import EnsembleRegressor
|
|
12
11
|
|
|
@@ -14,120 +13,82 @@ logger = logging.getLogger(__name__)
|
|
|
14
13
|
|
|
15
14
|
|
|
16
15
|
class PerQuantileTabularEnsembleRegressor(EnsembleRegressor):
|
|
17
|
-
"""
|
|
16
|
+
"""Ensemble regressor using separate models per quantile plus dedicated mean model."""
|
|
18
17
|
|
|
19
18
|
def __init__(
|
|
20
19
|
self,
|
|
21
|
-
path: str,
|
|
22
20
|
quantile_levels: list[float],
|
|
23
|
-
|
|
21
|
+
model_name: str,
|
|
22
|
+
model_hyperparameters: dict | None = None,
|
|
24
23
|
):
|
|
25
24
|
super().__init__()
|
|
26
|
-
self.path = path
|
|
27
25
|
self.quantile_levels = quantile_levels
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
self.
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
+
]
|
|
34
43
|
|
|
35
44
|
def fit(
|
|
36
45
|
self,
|
|
37
46
|
base_model_mean_predictions: np.ndarray,
|
|
38
47
|
base_model_quantile_predictions: np.ndarray,
|
|
39
48
|
labels: np.ndarray,
|
|
40
|
-
|
|
49
|
+
time_limit: float | None = None,
|
|
41
50
|
) -> Self:
|
|
42
|
-
"""Fit separate TabularPredictor for mean and each quantile level."""
|
|
43
|
-
# TODO: implement time_limit
|
|
44
|
-
|
|
45
51
|
num_windows, num_items, prediction_length = base_model_mean_predictions.shape[:3]
|
|
46
|
-
|
|
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
|
-
)
|
|
52
|
+
y = pd.Series(labels.reshape(num_windows * num_items * prediction_length))
|
|
60
53
|
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
54
|
+
total_rounds = 1 + len(self.quantile_levels)
|
|
55
|
+
timer = SplitTimer(time_limit, rounds=total_rounds).start()
|
|
66
56
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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()
|
|
74
67
|
|
|
75
68
|
return self
|
|
76
69
|
|
|
77
70
|
def _get_feature_df(self, predictions: np.ndarray, index: int) -> pd.DataFrame:
|
|
78
71
|
num_windows, num_items, prediction_length, _, num_models = predictions.shape
|
|
79
72
|
num_tabular_items = num_windows * num_items * prediction_length
|
|
80
|
-
|
|
81
|
-
df = pd.DataFrame(
|
|
73
|
+
return pd.DataFrame(
|
|
82
74
|
predictions[:, :, :, index].reshape(num_tabular_items, num_models),
|
|
83
75
|
columns=[f"model_{mi}" for mi in range(num_models)],
|
|
84
76
|
)
|
|
85
77
|
|
|
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
78
|
def predict(
|
|
102
79
|
self, base_model_mean_predictions: np.ndarray, base_model_quantile_predictions: np.ndarray
|
|
103
80
|
) -> tuple[np.ndarray, np.ndarray]:
|
|
104
|
-
self.
|
|
105
|
-
|
|
106
|
-
num_windows, num_items, prediction_length, _, _ = base_model_mean_predictions.shape
|
|
81
|
+
assert self.mean_model.is_fit()
|
|
82
|
+
num_windows, num_items, prediction_length = base_model_mean_predictions.shape[:3]
|
|
107
83
|
assert num_windows == 1, "Prediction expects a single window to be provided"
|
|
108
84
|
|
|
109
|
-
|
|
110
|
-
|
|
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)
|
|
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)
|
|
115
87
|
|
|
116
|
-
# predict quantiles
|
|
117
88
|
quantile_predictions_list = []
|
|
118
|
-
for i,
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
num_windows, num_items, prediction_length
|
|
122
|
-
)
|
|
123
|
-
)
|
|
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))
|
|
124
92
|
quantile_predictions = np.stack(quantile_predictions_list, axis=-1)
|
|
125
93
|
|
|
126
94
|
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
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import logging
|
|
2
|
-
from typing import Optional
|
|
3
2
|
|
|
4
3
|
import numpy as np
|
|
5
4
|
import pandas as pd
|
|
6
5
|
from typing_extensions import Self
|
|
7
6
|
|
|
8
|
-
from autogluon.tabular import
|
|
7
|
+
from autogluon.tabular.registry import ag_model_registry as tabular_ag_model_registry
|
|
9
8
|
|
|
10
9
|
from .abstract import EnsembleRegressor
|
|
11
10
|
|
|
@@ -13,55 +12,36 @@ logger = logging.getLogger(__name__)
|
|
|
13
12
|
|
|
14
13
|
|
|
15
14
|
class TabularEnsembleRegressor(EnsembleRegressor):
|
|
16
|
-
"""
|
|
17
|
-
quantile regressor for the target.
|
|
18
|
-
"""
|
|
15
|
+
"""Ensemble regressor based on a single model from AutoGluon-Tabular that predicts all quantiles simultaneously."""
|
|
19
16
|
|
|
20
17
|
def __init__(
|
|
21
18
|
self,
|
|
22
|
-
path: str,
|
|
23
19
|
quantile_levels: list[float],
|
|
24
|
-
|
|
20
|
+
model_name: str,
|
|
21
|
+
model_hyperparameters: dict | None = None,
|
|
25
22
|
):
|
|
26
23
|
super().__init__()
|
|
27
|
-
self.path = path
|
|
28
24
|
self.quantile_levels = quantile_levels
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
25
|
+
model_type = tabular_ag_model_registry.key_to_cls(model_name)
|
|
26
|
+
model_hyperparameters = model_hyperparameters or {}
|
|
27
|
+
self.model = model_type(
|
|
28
|
+
problem_type="quantile",
|
|
29
|
+
hyperparameters=model_hyperparameters | {"ag.quantile_levels": quantile_levels},
|
|
30
|
+
path="",
|
|
31
|
+
name=model_name,
|
|
32
|
+
)
|
|
34
33
|
|
|
35
34
|
def fit(
|
|
36
35
|
self,
|
|
37
36
|
base_model_mean_predictions: np.ndarray,
|
|
38
37
|
base_model_quantile_predictions: np.ndarray,
|
|
39
38
|
labels: np.ndarray,
|
|
40
|
-
time_limit:
|
|
41
|
-
**kwargs,
|
|
39
|
+
time_limit: float | None = None,
|
|
42
40
|
) -> Self:
|
|
43
|
-
|
|
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
|
|
41
|
+
X = self._get_feature_df(base_model_mean_predictions, base_model_quantile_predictions)
|
|
55
42
|
num_windows, num_items, prediction_length = base_model_mean_predictions.shape[:3]
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
self.predictor.fit(
|
|
60
|
-
df,
|
|
61
|
-
hyperparameters=self.tabular_hyperparameters,
|
|
62
|
-
time_limit=time_limit, # type: ignore
|
|
63
|
-
)
|
|
64
|
-
|
|
43
|
+
y = pd.Series(labels.reshape(num_windows * num_items * prediction_length))
|
|
44
|
+
self.model.fit(X=X, y=y, time_limit=time_limit)
|
|
65
45
|
return self
|
|
66
46
|
|
|
67
47
|
def predict(
|
|
@@ -69,18 +49,13 @@ class TabularEnsembleRegressor(EnsembleRegressor):
|
|
|
69
49
|
base_model_mean_predictions: np.ndarray,
|
|
70
50
|
base_model_quantile_predictions: np.ndarray,
|
|
71
51
|
) -> tuple[np.ndarray, np.ndarray]:
|
|
72
|
-
|
|
73
|
-
try:
|
|
74
|
-
self.predictor = TabularPredictor.load(self.path)
|
|
75
|
-
except FileNotFoundError:
|
|
76
|
-
raise ValueError("Model must be fitted before prediction")
|
|
77
|
-
|
|
52
|
+
assert self.model.is_fit()
|
|
78
53
|
num_windows, num_items, prediction_length = base_model_mean_predictions.shape[:3]
|
|
79
54
|
assert num_windows == 1, "Prediction expects a single window to be provided"
|
|
80
55
|
|
|
81
|
-
|
|
56
|
+
X = self._get_feature_df(base_model_mean_predictions, base_model_quantile_predictions)
|
|
82
57
|
|
|
83
|
-
pred = self.
|
|
58
|
+
pred = self.model.predict(X)
|
|
84
59
|
|
|
85
60
|
# Reshape back to (num_windows, num_items, prediction_length, num_quantiles)
|
|
86
61
|
pred = pred.reshape(num_windows, num_items, prediction_length, len(self.quantile_levels))
|
|
@@ -99,16 +74,13 @@ class TabularEnsembleRegressor(EnsembleRegressor):
|
|
|
99
74
|
) -> pd.DataFrame:
|
|
100
75
|
num_windows, num_items, prediction_length, _, num_models = base_model_mean_predictions.shape
|
|
101
76
|
num_tabular_items = num_windows * num_items * prediction_length
|
|
102
|
-
|
|
103
|
-
X = np.hstack(
|
|
77
|
+
features_array = np.hstack(
|
|
104
78
|
[
|
|
105
79
|
base_model_mean_predictions.reshape(num_tabular_items, -1),
|
|
106
80
|
base_model_quantile_predictions.reshape(num_tabular_items, -1),
|
|
107
81
|
]
|
|
108
82
|
)
|
|
109
|
-
|
|
110
|
-
df = pd.DataFrame(X, columns=self._get_feature_names(num_models))
|
|
111
|
-
return df
|
|
83
|
+
return pd.DataFrame(features_array, columns=self._get_feature_names(num_models))
|
|
112
84
|
|
|
113
85
|
def _get_feature_names(self, num_models: int) -> list[str]:
|
|
114
86
|
feature_names = []
|
|
@@ -133,9 +105,3 @@ class TabularEnsembleRegressor(EnsembleRegressor):
|
|
|
133
105
|
)
|
|
134
106
|
|
|
135
107
|
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
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import copy
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
import autogluon.core as ag
|
|
6
|
+
from autogluon.core.models.greedy_ensemble.ensemble_selection import EnsembleSelection
|
|
7
|
+
from autogluon.timeseries import TimeSeriesDataFrame
|
|
8
|
+
from autogluon.timeseries.metrics import TimeSeriesScorer
|
|
9
|
+
from autogluon.timeseries.utils.datetime import get_seasonality
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TimeSeriesEnsembleSelection(EnsembleSelection):
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
ensemble_size: int,
|
|
16
|
+
metric: TimeSeriesScorer,
|
|
17
|
+
problem_type: str = ag.constants.QUANTILE,
|
|
18
|
+
sorted_initialization: bool = False,
|
|
19
|
+
bagging: bool = False,
|
|
20
|
+
tie_breaker: str = "random",
|
|
21
|
+
random_state: np.random.RandomState | None = None,
|
|
22
|
+
prediction_length: int = 1,
|
|
23
|
+
target: str = "target",
|
|
24
|
+
**kwargs,
|
|
25
|
+
):
|
|
26
|
+
super().__init__(
|
|
27
|
+
ensemble_size=ensemble_size,
|
|
28
|
+
metric=metric, # type: ignore
|
|
29
|
+
problem_type=problem_type,
|
|
30
|
+
sorted_initialization=sorted_initialization,
|
|
31
|
+
bagging=bagging,
|
|
32
|
+
tie_breaker=tie_breaker,
|
|
33
|
+
random_state=random_state,
|
|
34
|
+
**kwargs,
|
|
35
|
+
)
|
|
36
|
+
self.prediction_length = prediction_length
|
|
37
|
+
self.target = target
|
|
38
|
+
self.metric: TimeSeriesScorer
|
|
39
|
+
|
|
40
|
+
self.dummy_pred_per_window = []
|
|
41
|
+
self.scorer_per_window = []
|
|
42
|
+
|
|
43
|
+
self.dummy_pred_per_window: list[TimeSeriesDataFrame] | None
|
|
44
|
+
self.scorer_per_window: list[TimeSeriesScorer] | None
|
|
45
|
+
self.data_future_per_window: list[TimeSeriesDataFrame] | None
|
|
46
|
+
|
|
47
|
+
def fit( # type: ignore
|
|
48
|
+
self,
|
|
49
|
+
predictions: list[list[TimeSeriesDataFrame]],
|
|
50
|
+
labels: list[TimeSeriesDataFrame],
|
|
51
|
+
time_limit: float | None = None,
|
|
52
|
+
):
|
|
53
|
+
return super().fit(
|
|
54
|
+
predictions=predictions, # type: ignore
|
|
55
|
+
labels=labels, # type: ignore
|
|
56
|
+
time_limit=time_limit,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def _fit( # type: ignore
|
|
60
|
+
self,
|
|
61
|
+
predictions: list[list[TimeSeriesDataFrame]],
|
|
62
|
+
labels: list[TimeSeriesDataFrame],
|
|
63
|
+
time_limit: float | None = None,
|
|
64
|
+
sample_weight: list[float] | None = None,
|
|
65
|
+
):
|
|
66
|
+
# Stack predictions for each model into a 3d tensor of shape [num_val_windows, num_rows, num_cols]
|
|
67
|
+
stacked_predictions = [np.stack(preds) for preds in predictions]
|
|
68
|
+
|
|
69
|
+
self.dummy_pred_per_window = []
|
|
70
|
+
self.scorer_per_window = []
|
|
71
|
+
self.data_future_per_window = []
|
|
72
|
+
|
|
73
|
+
seasonal_period = self.metric.seasonal_period
|
|
74
|
+
if seasonal_period is None:
|
|
75
|
+
seasonal_period = get_seasonality(labels[0].freq)
|
|
76
|
+
|
|
77
|
+
for window_idx, data in enumerate(labels):
|
|
78
|
+
dummy_pred = copy.deepcopy(predictions[0][window_idx])
|
|
79
|
+
# This should never happen; sanity check to make sure that all predictions have the same index
|
|
80
|
+
assert all(dummy_pred.index.equals(pred[window_idx].index) for pred in predictions)
|
|
81
|
+
assert all(dummy_pred.columns.equals(pred[window_idx].columns) for pred in predictions)
|
|
82
|
+
|
|
83
|
+
self.dummy_pred_per_window.append(dummy_pred)
|
|
84
|
+
|
|
85
|
+
scorer = copy.deepcopy(self.metric)
|
|
86
|
+
# Split the observed time series once to avoid repeated computations inside the evaluator
|
|
87
|
+
data_past = data.slice_by_timestep(None, -self.prediction_length)
|
|
88
|
+
data_future = data.slice_by_timestep(-self.prediction_length, None)
|
|
89
|
+
scorer.save_past_metrics(data_past, target=self.target, seasonal_period=seasonal_period)
|
|
90
|
+
self.scorer_per_window.append(scorer)
|
|
91
|
+
self.data_future_per_window.append(data_future)
|
|
92
|
+
|
|
93
|
+
super()._fit(
|
|
94
|
+
predictions=stacked_predictions,
|
|
95
|
+
labels=data_future, # type: ignore
|
|
96
|
+
time_limit=time_limit,
|
|
97
|
+
)
|
|
98
|
+
self.dummy_pred_per_window = None
|
|
99
|
+
self.evaluator_per_window = None
|
|
100
|
+
self.data_future_per_window = None
|
|
101
|
+
|
|
102
|
+
def _calculate_regret( # type: ignore
|
|
103
|
+
self,
|
|
104
|
+
y_true,
|
|
105
|
+
y_pred_proba,
|
|
106
|
+
metric: TimeSeriesScorer,
|
|
107
|
+
sample_weight=None,
|
|
108
|
+
):
|
|
109
|
+
# Compute average score across all validation windows
|
|
110
|
+
total_score = 0.0
|
|
111
|
+
|
|
112
|
+
assert self.data_future_per_window is not None
|
|
113
|
+
assert self.dummy_pred_per_window is not None
|
|
114
|
+
assert self.scorer_per_window is not None
|
|
115
|
+
|
|
116
|
+
for window_idx, data_future in enumerate(self.data_future_per_window):
|
|
117
|
+
dummy_pred = self.dummy_pred_per_window[window_idx]
|
|
118
|
+
dummy_pred[list(dummy_pred.columns)] = y_pred_proba[window_idx]
|
|
119
|
+
# We use scorer.compute_metric instead of scorer.score to avoid repeated calls to scorer.save_past_metrics
|
|
120
|
+
metric_value = self.scorer_per_window[window_idx].compute_metric(
|
|
121
|
+
data_future,
|
|
122
|
+
dummy_pred,
|
|
123
|
+
target=self.target,
|
|
124
|
+
)
|
|
125
|
+
total_score += metric.sign * metric_value
|
|
126
|
+
avg_score = total_score / len(self.data_future_per_window)
|
|
127
|
+
# score: higher is better, regret: lower is better, so we flip the sign
|
|
128
|
+
return -avg_score
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def fit_time_series_ensemble_selection(
|
|
132
|
+
data_per_window: list[TimeSeriesDataFrame],
|
|
133
|
+
predictions_per_window: dict[str, list[TimeSeriesDataFrame]],
|
|
134
|
+
ensemble_size: int,
|
|
135
|
+
eval_metric: TimeSeriesScorer,
|
|
136
|
+
prediction_length: int = 1,
|
|
137
|
+
target: str = "target",
|
|
138
|
+
time_limit: float | None = None,
|
|
139
|
+
) -> dict[str, float]:
|
|
140
|
+
"""Fit ensemble selection for time series forecasting and return ensemble weights.
|
|
141
|
+
|
|
142
|
+
Parameters
|
|
143
|
+
----------
|
|
144
|
+
data_per_window:
|
|
145
|
+
List of ground truth time series data for each validation window.
|
|
146
|
+
predictions_per_window:
|
|
147
|
+
Dictionary mapping model names to their predictions for each validation window.
|
|
148
|
+
ensemble_size:
|
|
149
|
+
Number of iterations of the ensemble selection algorithm.
|
|
150
|
+
|
|
151
|
+
Returns
|
|
152
|
+
-------
|
|
153
|
+
weights:
|
|
154
|
+
Dictionary mapping the model name to its weight in the ensemble.
|
|
155
|
+
"""
|
|
156
|
+
ensemble_selection = TimeSeriesEnsembleSelection(
|
|
157
|
+
ensemble_size=ensemble_size,
|
|
158
|
+
metric=eval_metric,
|
|
159
|
+
prediction_length=prediction_length,
|
|
160
|
+
target=target,
|
|
161
|
+
)
|
|
162
|
+
ensemble_selection.fit(
|
|
163
|
+
predictions=list(predictions_per_window.values()),
|
|
164
|
+
labels=data_per_window,
|
|
165
|
+
time_limit=time_limit,
|
|
166
|
+
)
|
|
167
|
+
return {model: float(weight) for model, weight in zip(predictions_per_window.keys(), ensemble_selection.weights_)}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import pprint
|
|
3
|
+
import time
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import pandas as pd
|
|
7
|
+
from joblib import Parallel, delayed
|
|
8
|
+
|
|
9
|
+
from autogluon.timeseries import TimeSeriesDataFrame
|
|
10
|
+
from autogluon.timeseries.utils.constants import AG_DEFAULT_N_JOBS
|
|
11
|
+
|
|
12
|
+
from .abstract import AbstractTimeSeriesEnsembleModel
|
|
13
|
+
from .ensemble_selection import fit_time_series_ensemble_selection
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PerItemGreedyEnsemble(AbstractTimeSeriesEnsembleModel):
|
|
19
|
+
"""Per-item greedy ensemble that fits separate weighted ensembles for each individual time series.
|
|
20
|
+
|
|
21
|
+
This ensemble applies the greedy Ensemble Selection algorithm by Caruana et al. [Car2004]_ independently
|
|
22
|
+
to each time series in the dataset, allowing for customized model combinations that adapt to the
|
|
23
|
+
specific characteristics of individual series. Each time series gets its own optimal ensemble weights
|
|
24
|
+
based on predictions for that particular series. If items not seen during training are provided at prediction
|
|
25
|
+
time, average model weight across the training items will be used for their predictions.
|
|
26
|
+
|
|
27
|
+
The per-item approach is particularly effective for datasets with heterogeneous time series that
|
|
28
|
+
exhibit different patterns, seasonalities, or noise characteristics.
|
|
29
|
+
|
|
30
|
+
The algorithm uses parallel processing to efficiently fit ensembles across all time series.
|
|
31
|
+
|
|
32
|
+
Other Parameters
|
|
33
|
+
----------------
|
|
34
|
+
ensemble_size : int, default = 100
|
|
35
|
+
Number of models (with replacement) to include in the ensemble.
|
|
36
|
+
n_jobs : int or float, default = joblib.cpu_count(only_physical_cores=True)
|
|
37
|
+
Number of CPU cores used to fit the ensembles in parallel.
|
|
38
|
+
|
|
39
|
+
References
|
|
40
|
+
----------
|
|
41
|
+
.. [Car2004] Caruana, Rich, et al. "Ensemble selection from libraries of models."
|
|
42
|
+
Proceedings of the twenty-first international conference on Machine learning. 2004.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(self, name: str | None = None, **kwargs):
|
|
46
|
+
if name is None:
|
|
47
|
+
name = "PerItemWeightedEnsemble"
|
|
48
|
+
super().__init__(name=name, **kwargs)
|
|
49
|
+
self.weights_df: pd.DataFrame
|
|
50
|
+
self.average_weight: pd.Series
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def model_names(self) -> list[str]:
|
|
54
|
+
return list(self.weights_df.columns)
|
|
55
|
+
|
|
56
|
+
def _get_default_hyperparameters(self) -> dict[str, Any]:
|
|
57
|
+
return {"ensemble_size": 100, "n_jobs": AG_DEFAULT_N_JOBS}
|
|
58
|
+
|
|
59
|
+
def _fit(
|
|
60
|
+
self,
|
|
61
|
+
predictions_per_window: dict[str, list[TimeSeriesDataFrame]],
|
|
62
|
+
data_per_window: list[TimeSeriesDataFrame],
|
|
63
|
+
model_scores: dict[str, float] | None = None,
|
|
64
|
+
time_limit: float | None = None,
|
|
65
|
+
) -> None:
|
|
66
|
+
model_names = list(predictions_per_window.keys())
|
|
67
|
+
item_ids = data_per_window[0].item_ids
|
|
68
|
+
n_jobs = min(self.get_hyperparameter("n_jobs"), len(item_ids))
|
|
69
|
+
|
|
70
|
+
predictions_per_item = self._split_predictions_per_item(predictions_per_window)
|
|
71
|
+
data_per_item = self._split_data_per_item(data_per_window)
|
|
72
|
+
|
|
73
|
+
ensemble_selection_kwargs = dict(
|
|
74
|
+
ensemble_size=self.get_hyperparameter("ensemble_size"),
|
|
75
|
+
eval_metric=self.eval_metric,
|
|
76
|
+
prediction_length=self.prediction_length,
|
|
77
|
+
target=self.target,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
time_limit_per_item = None if time_limit is None else time_limit * n_jobs / len(item_ids)
|
|
81
|
+
end_time = None if time_limit is None else time.time() + time_limit
|
|
82
|
+
|
|
83
|
+
# Fit ensemble for each item in parallel
|
|
84
|
+
executor = Parallel(n_jobs=n_jobs)
|
|
85
|
+
weights_per_item = executor(
|
|
86
|
+
delayed(self._fit_item_ensemble)(
|
|
87
|
+
data_per_item[item_id],
|
|
88
|
+
predictions_per_item[item_id],
|
|
89
|
+
time_limit_per_item=time_limit_per_item,
|
|
90
|
+
end_time=end_time,
|
|
91
|
+
**ensemble_selection_kwargs,
|
|
92
|
+
)
|
|
93
|
+
for item_id in item_ids
|
|
94
|
+
)
|
|
95
|
+
self.weights_df = pd.DataFrame(weights_per_item, index=item_ids, columns=model_names) # type: ignore
|
|
96
|
+
self.average_weight = self.weights_df.mean(axis=0)
|
|
97
|
+
|
|
98
|
+
# Drop models with zero average weight
|
|
99
|
+
if (self.average_weight == 0).any():
|
|
100
|
+
models_to_keep = self.average_weight[self.average_weight > 0].index
|
|
101
|
+
self.weights_df = self.weights_df[models_to_keep]
|
|
102
|
+
self.average_weight = self.average_weight[models_to_keep]
|
|
103
|
+
|
|
104
|
+
weights_for_printing = {model: round(float(weight), 2) for model, weight in self.average_weight.items()}
|
|
105
|
+
logger.info(f"\tAverage ensemble weights: {pprint.pformat(weights_for_printing, width=1000)}")
|
|
106
|
+
|
|
107
|
+
def _split_predictions_per_item(
|
|
108
|
+
self, predictions_per_window: dict[str, list[TimeSeriesDataFrame]]
|
|
109
|
+
) -> dict[str, dict[str, list[TimeSeriesDataFrame]]]:
|
|
110
|
+
"""Build a dictionary mapping item_id -> dict[model_name, list[TimeSeriesDataFrame]]."""
|
|
111
|
+
item_ids = list(predictions_per_window.values())[0][0].item_ids
|
|
112
|
+
|
|
113
|
+
predictions_per_item = {}
|
|
114
|
+
for i, item_id in enumerate(item_ids):
|
|
115
|
+
item_predictions = {}
|
|
116
|
+
for model_name, preds_per_window in predictions_per_window.items():
|
|
117
|
+
item_preds_per_window = [
|
|
118
|
+
pred.iloc[i * self.prediction_length : (i + 1) * self.prediction_length]
|
|
119
|
+
for pred in preds_per_window
|
|
120
|
+
]
|
|
121
|
+
item_predictions[model_name] = item_preds_per_window
|
|
122
|
+
predictions_per_item[item_id] = item_predictions
|
|
123
|
+
return predictions_per_item
|
|
124
|
+
|
|
125
|
+
def _split_data_per_item(self, data_per_window: list[TimeSeriesDataFrame]) -> dict[str, list[TimeSeriesDataFrame]]:
|
|
126
|
+
"""Build a dictionary mapping item_id -> ground truth values across all windows."""
|
|
127
|
+
item_ids = data_per_window[0].item_ids
|
|
128
|
+
data_per_item = {item_id: [] for item_id in item_ids}
|
|
129
|
+
|
|
130
|
+
for data in data_per_window:
|
|
131
|
+
indptr = data.get_indptr()
|
|
132
|
+
for item_idx, item_id in enumerate(item_ids):
|
|
133
|
+
new_slice = data.iloc[indptr[item_idx] : indptr[item_idx + 1]]
|
|
134
|
+
data_per_item[item_id].append(new_slice)
|
|
135
|
+
return data_per_item
|
|
136
|
+
|
|
137
|
+
@staticmethod
|
|
138
|
+
def _fit_item_ensemble(
|
|
139
|
+
data_per_window: list[TimeSeriesDataFrame],
|
|
140
|
+
predictions_per_window: dict[str, list[TimeSeriesDataFrame]],
|
|
141
|
+
time_limit_per_item: float | None = None,
|
|
142
|
+
end_time: float | None = None,
|
|
143
|
+
**ensemble_selection_kwargs,
|
|
144
|
+
) -> dict[str, float]:
|
|
145
|
+
"""Fit ensemble for a single item."""
|
|
146
|
+
if end_time is not None:
|
|
147
|
+
assert time_limit_per_item is not None
|
|
148
|
+
time_left = end_time - time.time()
|
|
149
|
+
time_limit_per_item = min(time_limit_per_item, time_left)
|
|
150
|
+
return fit_time_series_ensemble_selection(
|
|
151
|
+
data_per_window, predictions_per_window, time_limit=time_limit_per_item, **ensemble_selection_kwargs
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
def _predict(self, data: dict[str, TimeSeriesDataFrame], **kwargs) -> TimeSeriesDataFrame:
|
|
155
|
+
assert all(model in data for model in self.weights_df.columns)
|
|
156
|
+
item_ids = list(data.values())[0].item_ids
|
|
157
|
+
unseen_item_ids = set(item_ids) - set(self.weights_df.index)
|
|
158
|
+
if unseen_item_ids:
|
|
159
|
+
logger.debug(f"Using average weights for {len(unseen_item_ids)} unseen items")
|
|
160
|
+
weights = self.weights_df.reindex(item_ids).fillna(self.average_weight)
|
|
161
|
+
|
|
162
|
+
result = None
|
|
163
|
+
for model_name in self.weights_df.columns:
|
|
164
|
+
model_pred = data[model_name]
|
|
165
|
+
model_weights = weights[model_name].to_numpy().repeat(self.prediction_length)
|
|
166
|
+
weighted_pred = model_pred.to_data_frame().multiply(model_weights, axis=0)
|
|
167
|
+
result = weighted_pred if result is None else result + weighted_pred
|
|
168
|
+
|
|
169
|
+
return TimeSeriesDataFrame(result) # type: ignore
|
|
170
|
+
|
|
171
|
+
def remap_base_models(self, model_refit_map: dict[str, str]) -> None:
|
|
172
|
+
self.weights_df.rename(columns=model_refit_map, inplace=True)
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import functools
|
|
2
2
|
from abc import ABC
|
|
3
|
-
from typing import Optional
|
|
4
3
|
|
|
5
4
|
import numpy as np
|
|
6
5
|
|
|
@@ -10,9 +9,14 @@ from ..abstract import AbstractTimeSeriesEnsembleModel
|
|
|
10
9
|
|
|
11
10
|
|
|
12
11
|
class AbstractWeightedTimeSeriesEnsembleModel(AbstractTimeSeriesEnsembleModel, ABC):
|
|
13
|
-
"""Abstract class for weighted
|
|
12
|
+
"""Abstract base class for weighted ensemble models that assign global weights to base models.
|
|
14
13
|
|
|
15
|
-
|
|
14
|
+
Weighted ensembles combine predictions from multiple base models using learned or computed weights,
|
|
15
|
+
where each base model receives a single global weight applied across all time series and forecast
|
|
16
|
+
horizons. The final prediction is computed as a weighted linear combination of base model forecasts.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, name: str | None = None, **kwargs):
|
|
16
20
|
super().__init__(name=name, **kwargs)
|
|
17
21
|
self.model_to_weight: dict[str, float] = {}
|
|
18
22
|
|