autogluon.timeseries 1.4.1b20250906__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.
- autogluon/timeseries/configs/hyperparameter_presets.py +2 -2
- autogluon/timeseries/dataset/ts_dataframe.py +97 -86
- autogluon/timeseries/learner.py +68 -35
- 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 +5 -5
- autogluon/timeseries/metrics/utils.py +4 -4
- autogluon/timeseries/models/__init__.py +4 -1
- autogluon/timeseries/models/abstract/abstract_timeseries_model.py +52 -39
- autogluon/timeseries/models/abstract/model_trial.py +2 -1
- autogluon/timeseries/models/abstract/tunable.py +8 -8
- autogluon/timeseries/models/autogluon_tabular/mlforecast.py +58 -62
- autogluon/timeseries/models/autogluon_tabular/per_step.py +26 -15
- autogluon/timeseries/models/autogluon_tabular/transforms.py +11 -9
- autogluon/timeseries/models/chronos/__init__.py +2 -1
- autogluon/timeseries/models/chronos/chronos2.py +361 -0
- autogluon/timeseries/models/chronos/model.py +125 -87
- autogluon/timeseries/models/chronos/{pipeline/utils.py → utils.py} +68 -36
- autogluon/timeseries/models/ensemble/__init__.py +34 -2
- autogluon/timeseries/models/ensemble/abstract.py +5 -42
- autogluon/timeseries/models/ensemble/array_based/__init__.py +3 -0
- autogluon/timeseries/models/ensemble/array_based/abstract.py +236 -0
- autogluon/timeseries/models/ensemble/array_based/models.py +73 -0
- autogluon/timeseries/models/ensemble/array_based/regressor/__init__.py +12 -0
- autogluon/timeseries/models/ensemble/array_based/regressor/abstract.py +88 -0
- autogluon/timeseries/models/ensemble/array_based/regressor/linear_stacker.py +167 -0
- autogluon/timeseries/models/ensemble/array_based/regressor/per_quantile_tabular.py +94 -0
- autogluon/timeseries/models/ensemble/array_based/regressor/tabular.py +107 -0
- autogluon/timeseries/models/ensemble/{greedy.py → ensemble_selection.py} +41 -61
- autogluon/timeseries/models/ensemble/per_item_greedy.py +162 -0
- autogluon/timeseries/models/ensemble/weighted/__init__.py +8 -0
- autogluon/timeseries/models/ensemble/weighted/abstract.py +40 -0
- autogluon/timeseries/models/ensemble/{basic.py → weighted/basic.py} +6 -16
- autogluon/timeseries/models/ensemble/weighted/greedy.py +57 -0
- autogluon/timeseries/models/gluonts/abstract.py +25 -25
- autogluon/timeseries/models/gluonts/dataset.py +11 -11
- autogluon/timeseries/models/local/__init__.py +0 -7
- autogluon/timeseries/models/local/abstract_local_model.py +15 -18
- autogluon/timeseries/models/local/naive.py +2 -2
- autogluon/timeseries/models/local/npts.py +1 -1
- autogluon/timeseries/models/local/statsforecast.py +12 -12
- autogluon/timeseries/models/multi_window/multi_window_model.py +39 -24
- autogluon/timeseries/models/registry.py +3 -4
- 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 +196 -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 +89 -0
- autogluon/timeseries/models/toto/_internal/backbone/rotary_embedding_torch.py +342 -0
- autogluon/timeseries/models/toto/_internal/backbone/scaler.py +305 -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 +118 -0
- autogluon/timeseries/models/toto/model.py +236 -0
- autogluon/timeseries/predictor.py +301 -103
- autogluon/timeseries/regressor.py +27 -30
- autogluon/timeseries/splitter.py +3 -27
- autogluon/timeseries/trainer/ensemble_composer.py +439 -0
- autogluon/timeseries/trainer/model_set_builder.py +9 -9
- autogluon/timeseries/trainer/prediction_cache.py +16 -16
- autogluon/timeseries/trainer/trainer.py +300 -275
- autogluon/timeseries/trainer/utils.py +17 -0
- autogluon/timeseries/transforms/covariate_scaler.py +8 -8
- autogluon/timeseries/transforms/target_scaler.py +15 -15
- 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 +18 -14
- autogluon/timeseries/utils/forecast.py +6 -7
- autogluon/timeseries/utils/timer.py +173 -0
- autogluon/timeseries/version.py +1 -1
- autogluon.timeseries-1.4.1b20251210-py3.11-nspkg.pth +1 -0
- {autogluon.timeseries-1.4.1b20250906.dist-info → autogluon_timeseries-1.4.1b20251210.dist-info}/METADATA +39 -22
- autogluon_timeseries-1.4.1b20251210.dist-info/RECORD +103 -0
- {autogluon.timeseries-1.4.1b20250906.dist-info → autogluon_timeseries-1.4.1b20251210.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 -580
- autogluon.timeseries-1.4.1b20250906-py3.9-nspkg.pth +0 -1
- autogluon.timeseries-1.4.1b20250906.dist-info/RECORD +0 -75
- {autogluon.timeseries-1.4.1b20250906.dist-info → autogluon_timeseries-1.4.1b20251210.dist-info/licenses}/LICENSE +0 -0
- {autogluon.timeseries-1.4.1b20250906.dist-info → autogluon_timeseries-1.4.1b20251210.dist-info/licenses}/NOTICE +0 -0
- {autogluon.timeseries-1.4.1b20250906.dist-info → autogluon_timeseries-1.4.1b20251210.dist-info}/namespace_packages.txt +0 -0
- {autogluon.timeseries-1.4.1b20250906.dist-info → autogluon_timeseries-1.4.1b20251210.dist-info}/top_level.txt +0 -0
- {autogluon.timeseries-1.4.1b20250906.dist-info → autogluon_timeseries-1.4.1b20251210.dist-info}/zip-safe +0 -0
|
@@ -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
|
|
@@ -0,0 +1,107 @@
|
|
|
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
|
+
|
|
9
|
+
from .abstract import EnsembleRegressor
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TabularEnsembleRegressor(EnsembleRegressor):
|
|
15
|
+
"""Ensemble regressor based on a single model from AutoGluon-Tabular that predicts all quantiles simultaneously."""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
quantile_levels: list[float],
|
|
20
|
+
model_name: str,
|
|
21
|
+
model_hyperparameters: dict | None = None,
|
|
22
|
+
):
|
|
23
|
+
super().__init__()
|
|
24
|
+
self.quantile_levels = quantile_levels
|
|
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
|
+
)
|
|
33
|
+
|
|
34
|
+
def fit(
|
|
35
|
+
self,
|
|
36
|
+
base_model_mean_predictions: np.ndarray,
|
|
37
|
+
base_model_quantile_predictions: np.ndarray,
|
|
38
|
+
labels: np.ndarray,
|
|
39
|
+
time_limit: float | None = None,
|
|
40
|
+
) -> Self:
|
|
41
|
+
X = self._get_feature_df(base_model_mean_predictions, base_model_quantile_predictions)
|
|
42
|
+
num_windows, num_items, prediction_length = base_model_mean_predictions.shape[:3]
|
|
43
|
+
y = pd.Series(labels.reshape(num_windows * num_items * prediction_length))
|
|
44
|
+
self.model.fit(X=X, y=y, time_limit=time_limit)
|
|
45
|
+
return self
|
|
46
|
+
|
|
47
|
+
def predict(
|
|
48
|
+
self,
|
|
49
|
+
base_model_mean_predictions: np.ndarray,
|
|
50
|
+
base_model_quantile_predictions: np.ndarray,
|
|
51
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
52
|
+
assert self.model.is_fit()
|
|
53
|
+
num_windows, num_items, prediction_length = base_model_mean_predictions.shape[:3]
|
|
54
|
+
assert num_windows == 1, "Prediction expects a single window to be provided"
|
|
55
|
+
|
|
56
|
+
X = self._get_feature_df(base_model_mean_predictions, base_model_quantile_predictions)
|
|
57
|
+
|
|
58
|
+
pred = self.model.predict(X)
|
|
59
|
+
|
|
60
|
+
# Reshape back to (num_windows, num_items, prediction_length, num_quantiles)
|
|
61
|
+
pred = pred.reshape(num_windows, num_items, prediction_length, len(self.quantile_levels))
|
|
62
|
+
|
|
63
|
+
# Use median quantile as mean prediction
|
|
64
|
+
median_idx = self._get_median_quantile_index()
|
|
65
|
+
mean_pred = pred[:, :, :, median_idx : median_idx + 1]
|
|
66
|
+
quantile_pred = pred
|
|
67
|
+
|
|
68
|
+
return mean_pred, quantile_pred
|
|
69
|
+
|
|
70
|
+
def _get_feature_df(
|
|
71
|
+
self,
|
|
72
|
+
base_model_mean_predictions: np.ndarray,
|
|
73
|
+
base_model_quantile_predictions: np.ndarray,
|
|
74
|
+
) -> pd.DataFrame:
|
|
75
|
+
num_windows, num_items, prediction_length, _, num_models = base_model_mean_predictions.shape
|
|
76
|
+
num_tabular_items = num_windows * num_items * prediction_length
|
|
77
|
+
features_array = np.hstack(
|
|
78
|
+
[
|
|
79
|
+
base_model_mean_predictions.reshape(num_tabular_items, -1),
|
|
80
|
+
base_model_quantile_predictions.reshape(num_tabular_items, -1),
|
|
81
|
+
]
|
|
82
|
+
)
|
|
83
|
+
return pd.DataFrame(features_array, columns=self._get_feature_names(num_models))
|
|
84
|
+
|
|
85
|
+
def _get_feature_names(self, num_models: int) -> list[str]:
|
|
86
|
+
feature_names = []
|
|
87
|
+
for mi in range(num_models):
|
|
88
|
+
feature_names.append(f"model_{mi}_mean")
|
|
89
|
+
for quantile in self.quantile_levels:
|
|
90
|
+
for mi in range(num_models):
|
|
91
|
+
feature_names.append(f"model_{mi}_q{quantile}")
|
|
92
|
+
|
|
93
|
+
return feature_names
|
|
94
|
+
|
|
95
|
+
def _get_median_quantile_index(self):
|
|
96
|
+
"""Get quantile index closest to 0.5"""
|
|
97
|
+
quantile_array = np.array(self.quantile_levels)
|
|
98
|
+
median_idx = int(np.argmin(np.abs(quantile_array - 0.5)))
|
|
99
|
+
selected_quantile = quantile_array[median_idx]
|
|
100
|
+
|
|
101
|
+
if selected_quantile != 0.5:
|
|
102
|
+
logger.warning(
|
|
103
|
+
f"Selected quantile {selected_quantile} is not exactly 0.5. "
|
|
104
|
+
f"Using closest available quantile for median prediction."
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
return median_idx
|
|
@@ -1,7 +1,4 @@
|
|
|
1
1
|
import copy
|
|
2
|
-
import logging
|
|
3
|
-
import pprint
|
|
4
|
-
from typing import Any, Optional
|
|
5
2
|
|
|
6
3
|
import numpy as np
|
|
7
4
|
|
|
@@ -11,10 +8,6 @@ from autogluon.timeseries import TimeSeriesDataFrame
|
|
|
11
8
|
from autogluon.timeseries.metrics import TimeSeriesScorer
|
|
12
9
|
from autogluon.timeseries.utils.datetime import get_seasonality
|
|
13
10
|
|
|
14
|
-
from .abstract import AbstractWeightedTimeSeriesEnsembleModel
|
|
15
|
-
|
|
16
|
-
logger = logging.getLogger(__name__)
|
|
17
|
-
|
|
18
11
|
|
|
19
12
|
class TimeSeriesEnsembleSelection(EnsembleSelection):
|
|
20
13
|
def __init__(
|
|
@@ -25,7 +18,7 @@ class TimeSeriesEnsembleSelection(EnsembleSelection):
|
|
|
25
18
|
sorted_initialization: bool = False,
|
|
26
19
|
bagging: bool = False,
|
|
27
20
|
tie_breaker: str = "random",
|
|
28
|
-
random_state:
|
|
21
|
+
random_state: np.random.RandomState | None = None,
|
|
29
22
|
prediction_length: int = 1,
|
|
30
23
|
target: str = "target",
|
|
31
24
|
**kwargs,
|
|
@@ -47,15 +40,15 @@ class TimeSeriesEnsembleSelection(EnsembleSelection):
|
|
|
47
40
|
self.dummy_pred_per_window = []
|
|
48
41
|
self.scorer_per_window = []
|
|
49
42
|
|
|
50
|
-
self.dummy_pred_per_window:
|
|
51
|
-
self.scorer_per_window:
|
|
52
|
-
self.data_future_per_window:
|
|
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
|
|
53
46
|
|
|
54
47
|
def fit( # type: ignore
|
|
55
48
|
self,
|
|
56
49
|
predictions: list[list[TimeSeriesDataFrame]],
|
|
57
50
|
labels: list[TimeSeriesDataFrame],
|
|
58
|
-
time_limit:
|
|
51
|
+
time_limit: float | None = None,
|
|
59
52
|
):
|
|
60
53
|
return super().fit(
|
|
61
54
|
predictions=predictions, # type: ignore
|
|
@@ -67,8 +60,8 @@ class TimeSeriesEnsembleSelection(EnsembleSelection):
|
|
|
67
60
|
self,
|
|
68
61
|
predictions: list[list[TimeSeriesDataFrame]],
|
|
69
62
|
labels: list[TimeSeriesDataFrame],
|
|
70
|
-
time_limit:
|
|
71
|
-
sample_weight:
|
|
63
|
+
time_limit: float | None = None,
|
|
64
|
+
sample_weight: list[float] | None = None,
|
|
72
65
|
):
|
|
73
66
|
# Stack predictions for each model into a 3d tensor of shape [num_val_windows, num_rows, num_cols]
|
|
74
67
|
stacked_predictions = [np.stack(preds) for preds in predictions]
|
|
@@ -135,53 +128,40 @@ class TimeSeriesEnsembleSelection(EnsembleSelection):
|
|
|
135
128
|
return -avg_score
|
|
136
129
|
|
|
137
130
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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.
|
|
146
141
|
|
|
147
|
-
|
|
142
|
+
Parameters
|
|
148
143
|
----------
|
|
149
|
-
|
|
150
|
-
|
|
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.
|
|
151
155
|
"""
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
self,
|
|
165
|
-
predictions_per_window: dict[str, list[TimeSeriesDataFrame]],
|
|
166
|
-
data_per_window: list[TimeSeriesDataFrame],
|
|
167
|
-
model_scores: Optional[dict[str, float]] = None,
|
|
168
|
-
time_limit: Optional[float] = None,
|
|
169
|
-
):
|
|
170
|
-
ensemble_selection = TimeSeriesEnsembleSelection(
|
|
171
|
-
ensemble_size=self.get_hyperparameters()["ensemble_size"],
|
|
172
|
-
metric=self.eval_metric,
|
|
173
|
-
prediction_length=self.prediction_length,
|
|
174
|
-
target=self.target,
|
|
175
|
-
)
|
|
176
|
-
ensemble_selection.fit(
|
|
177
|
-
predictions=list(predictions_per_window.values()),
|
|
178
|
-
labels=data_per_window,
|
|
179
|
-
time_limit=time_limit,
|
|
180
|
-
)
|
|
181
|
-
self.model_to_weight = {}
|
|
182
|
-
for model_name, weight in zip(predictions_per_window.keys(), ensemble_selection.weights_):
|
|
183
|
-
if weight != 0:
|
|
184
|
-
self.model_to_weight[model_name] = weight
|
|
185
|
-
|
|
186
|
-
weights_for_printing = {model: round(float(weight), 2) for model, weight in self.model_to_weight.items()}
|
|
187
|
-
logger.info(f"\tEnsemble weights: {pprint.pformat(weights_for_printing, width=200)}")
|
|
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,162 @@
|
|
|
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
|
+
"""Fits a separate greedy weighted ensemble for each individual time series in the dataset.
|
|
20
|
+
Constructs a weighted ensemble using the greedy Ensemble Selection algorithm by Caruana et al. [Car2004]
|
|
21
|
+
|
|
22
|
+
Other Parameters
|
|
23
|
+
----------------
|
|
24
|
+
ensemble_size: int, default = 100
|
|
25
|
+
Number of models (with replacement) to include in the ensemble.
|
|
26
|
+
n_jobs : int or float, default = joblib.cpu_count(only_physical_cores=True)
|
|
27
|
+
Number of CPU cores used to fit the ensembles in parallel.
|
|
28
|
+
|
|
29
|
+
References
|
|
30
|
+
----------
|
|
31
|
+
.. [Car2004] Caruana, Rich, et al. "Ensemble selection from libraries of models."
|
|
32
|
+
Proceedings of the twenty-first international conference on Machine learning. 2004.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, name: str | None = None, **kwargs):
|
|
36
|
+
if name is None:
|
|
37
|
+
name = "PerItemWeightedEnsemble"
|
|
38
|
+
super().__init__(name=name, **kwargs)
|
|
39
|
+
self.weights_df: pd.DataFrame
|
|
40
|
+
self.average_weight: pd.Series
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def model_names(self) -> list[str]:
|
|
44
|
+
return list(self.weights_df.columns)
|
|
45
|
+
|
|
46
|
+
def _get_default_hyperparameters(self) -> dict[str, Any]:
|
|
47
|
+
return {"ensemble_size": 100, "n_jobs": AG_DEFAULT_N_JOBS}
|
|
48
|
+
|
|
49
|
+
def _fit(
|
|
50
|
+
self,
|
|
51
|
+
predictions_per_window: dict[str, list[TimeSeriesDataFrame]],
|
|
52
|
+
data_per_window: list[TimeSeriesDataFrame],
|
|
53
|
+
model_scores: dict[str, float] | None = None,
|
|
54
|
+
time_limit: float | None = None,
|
|
55
|
+
) -> None:
|
|
56
|
+
model_names = list(predictions_per_window.keys())
|
|
57
|
+
item_ids = data_per_window[0].item_ids
|
|
58
|
+
n_jobs = min(self.get_hyperparameter("n_jobs"), len(item_ids))
|
|
59
|
+
|
|
60
|
+
predictions_per_item = self._split_predictions_per_item(predictions_per_window)
|
|
61
|
+
data_per_item = self._split_data_per_item(data_per_window)
|
|
62
|
+
|
|
63
|
+
ensemble_selection_kwargs = dict(
|
|
64
|
+
ensemble_size=self.get_hyperparameter("ensemble_size"),
|
|
65
|
+
eval_metric=self.eval_metric,
|
|
66
|
+
prediction_length=self.prediction_length,
|
|
67
|
+
target=self.target,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
time_limit_per_item = None if time_limit is None else time_limit * n_jobs / len(item_ids)
|
|
71
|
+
end_time = None if time_limit is None else time.time() + time_limit
|
|
72
|
+
|
|
73
|
+
# Fit ensemble for each item in parallel
|
|
74
|
+
executor = Parallel(n_jobs=n_jobs)
|
|
75
|
+
weights_per_item = executor(
|
|
76
|
+
delayed(self._fit_item_ensemble)(
|
|
77
|
+
data_per_item[item_id],
|
|
78
|
+
predictions_per_item[item_id],
|
|
79
|
+
time_limit_per_item=time_limit_per_item,
|
|
80
|
+
end_time=end_time,
|
|
81
|
+
**ensemble_selection_kwargs,
|
|
82
|
+
)
|
|
83
|
+
for item_id in item_ids
|
|
84
|
+
)
|
|
85
|
+
self.weights_df = pd.DataFrame(weights_per_item, index=item_ids, columns=model_names)
|
|
86
|
+
self.average_weight = self.weights_df.mean(axis=0)
|
|
87
|
+
|
|
88
|
+
# Drop models with zero average weight
|
|
89
|
+
if (self.average_weight == 0).any():
|
|
90
|
+
models_to_keep = self.average_weight[self.average_weight > 0].index
|
|
91
|
+
self.weights_df = self.weights_df[models_to_keep]
|
|
92
|
+
self.average_weight = self.average_weight[models_to_keep]
|
|
93
|
+
|
|
94
|
+
weights_for_printing = {model: round(float(weight), 2) for model, weight in self.average_weight.items()}
|
|
95
|
+
logger.info(f"\tAverage ensemble weights: {pprint.pformat(weights_for_printing, width=200)}")
|
|
96
|
+
|
|
97
|
+
def _split_predictions_per_item(
|
|
98
|
+
self, predictions_per_window: dict[str, list[TimeSeriesDataFrame]]
|
|
99
|
+
) -> dict[str, dict[str, list[TimeSeriesDataFrame]]]:
|
|
100
|
+
"""Build a dictionary mapping item_id -> dict[model_name, list[TimeSeriesDataFrame]]."""
|
|
101
|
+
item_ids = list(predictions_per_window.values())[0][0].item_ids
|
|
102
|
+
|
|
103
|
+
predictions_per_item = {}
|
|
104
|
+
for i, item_id in enumerate(item_ids):
|
|
105
|
+
item_predictions = {}
|
|
106
|
+
for model_name, preds_per_window in predictions_per_window.items():
|
|
107
|
+
item_preds_per_window = [
|
|
108
|
+
pred.iloc[i * self.prediction_length : (i + 1) * self.prediction_length]
|
|
109
|
+
for pred in preds_per_window
|
|
110
|
+
]
|
|
111
|
+
item_predictions[model_name] = item_preds_per_window
|
|
112
|
+
predictions_per_item[item_id] = item_predictions
|
|
113
|
+
return predictions_per_item
|
|
114
|
+
|
|
115
|
+
def _split_data_per_item(self, data_per_window: list[TimeSeriesDataFrame]) -> dict[str, list[TimeSeriesDataFrame]]:
|
|
116
|
+
"""Build a dictionary mapping item_id -> ground truth values across all windows."""
|
|
117
|
+
item_ids = data_per_window[0].item_ids
|
|
118
|
+
data_per_item = {item_id: [] for item_id in item_ids}
|
|
119
|
+
|
|
120
|
+
for data in data_per_window:
|
|
121
|
+
indptr = data.get_indptr()
|
|
122
|
+
for item_idx, item_id in enumerate(item_ids):
|
|
123
|
+
new_slice = data.iloc[indptr[item_idx] : indptr[item_idx + 1]]
|
|
124
|
+
data_per_item[item_id].append(new_slice)
|
|
125
|
+
return data_per_item
|
|
126
|
+
|
|
127
|
+
@staticmethod
|
|
128
|
+
def _fit_item_ensemble(
|
|
129
|
+
data_per_window: list[TimeSeriesDataFrame],
|
|
130
|
+
predictions_per_window: dict[str, list[TimeSeriesDataFrame]],
|
|
131
|
+
time_limit_per_item: float | None = None,
|
|
132
|
+
end_time: float | None = None,
|
|
133
|
+
**ensemble_selection_kwargs,
|
|
134
|
+
) -> dict[str, float]:
|
|
135
|
+
"""Fit ensemble for a single item."""
|
|
136
|
+
if end_time is not None:
|
|
137
|
+
assert time_limit_per_item is not None
|
|
138
|
+
time_left = end_time - time.time()
|
|
139
|
+
time_limit_per_item = min(time_limit_per_item, time_left)
|
|
140
|
+
return fit_time_series_ensemble_selection(
|
|
141
|
+
data_per_window, predictions_per_window, time_limit=time_limit_per_item, **ensemble_selection_kwargs
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
def _predict(self, data: dict[str, TimeSeriesDataFrame], **kwargs) -> TimeSeriesDataFrame:
|
|
145
|
+
assert all(model in data for model in self.weights_df.columns)
|
|
146
|
+
item_ids = list(data.values())[0].item_ids
|
|
147
|
+
unseen_item_ids = set(item_ids) - set(self.weights_df.index)
|
|
148
|
+
if unseen_item_ids:
|
|
149
|
+
logger.debug(f"Using average weights for {len(unseen_item_ids)} unseen items")
|
|
150
|
+
weights = self.weights_df.reindex(item_ids).fillna(self.average_weight)
|
|
151
|
+
|
|
152
|
+
result = None
|
|
153
|
+
for model_name in self.weights_df.columns:
|
|
154
|
+
model_pred = data[model_name]
|
|
155
|
+
model_weights = weights[model_name].to_numpy().repeat(self.prediction_length)
|
|
156
|
+
weighted_pred = model_pred.to_data_frame().multiply(model_weights, axis=0)
|
|
157
|
+
result = weighted_pred if result is None else result + weighted_pred
|
|
158
|
+
|
|
159
|
+
return TimeSeriesDataFrame(result)
|
|
160
|
+
|
|
161
|
+
def remap_base_models(self, model_refit_map: dict[str, str]) -> None:
|
|
162
|
+
self.weights_df.rename(columns=model_refit_map, inplace=True)
|