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.

Files changed (82) hide show
  1. autogluon/timeseries/configs/hyperparameter_presets.py +13 -28
  2. autogluon/timeseries/configs/predictor_presets.py +23 -39
  3. autogluon/timeseries/dataset/ts_dataframe.py +32 -34
  4. autogluon/timeseries/learner.py +67 -33
  5. autogluon/timeseries/metrics/__init__.py +4 -4
  6. autogluon/timeseries/metrics/abstract.py +8 -8
  7. autogluon/timeseries/metrics/point.py +9 -9
  8. autogluon/timeseries/metrics/quantile.py +4 -4
  9. autogluon/timeseries/models/__init__.py +2 -1
  10. autogluon/timeseries/models/abstract/abstract_timeseries_model.py +52 -50
  11. autogluon/timeseries/models/abstract/model_trial.py +2 -1
  12. autogluon/timeseries/models/abstract/tunable.py +8 -8
  13. autogluon/timeseries/models/autogluon_tabular/mlforecast.py +30 -26
  14. autogluon/timeseries/models/autogluon_tabular/per_step.py +13 -11
  15. autogluon/timeseries/models/autogluon_tabular/transforms.py +2 -2
  16. autogluon/timeseries/models/chronos/__init__.py +2 -1
  17. autogluon/timeseries/models/chronos/chronos2.py +395 -0
  18. autogluon/timeseries/models/chronos/model.py +30 -25
  19. autogluon/timeseries/models/chronos/utils.py +5 -5
  20. autogluon/timeseries/models/ensemble/__init__.py +17 -10
  21. autogluon/timeseries/models/ensemble/abstract.py +13 -9
  22. autogluon/timeseries/models/ensemble/array_based/__init__.py +2 -2
  23. autogluon/timeseries/models/ensemble/array_based/abstract.py +24 -31
  24. autogluon/timeseries/models/ensemble/array_based/models.py +146 -11
  25. autogluon/timeseries/models/ensemble/array_based/regressor/__init__.py +2 -0
  26. autogluon/timeseries/models/ensemble/array_based/regressor/abstract.py +6 -5
  27. autogluon/timeseries/models/ensemble/array_based/regressor/linear_stacker.py +186 -0
  28. autogluon/timeseries/models/ensemble/array_based/regressor/per_quantile_tabular.py +44 -83
  29. autogluon/timeseries/models/ensemble/array_based/regressor/tabular.py +21 -55
  30. autogluon/timeseries/models/ensemble/ensemble_selection.py +167 -0
  31. autogluon/timeseries/models/ensemble/per_item_greedy.py +172 -0
  32. autogluon/timeseries/models/ensemble/weighted/abstract.py +7 -3
  33. autogluon/timeseries/models/ensemble/weighted/basic.py +26 -13
  34. autogluon/timeseries/models/ensemble/weighted/greedy.py +21 -144
  35. autogluon/timeseries/models/gluonts/abstract.py +30 -29
  36. autogluon/timeseries/models/gluonts/dataset.py +9 -9
  37. autogluon/timeseries/models/gluonts/models.py +0 -7
  38. autogluon/timeseries/models/local/__init__.py +0 -7
  39. autogluon/timeseries/models/local/abstract_local_model.py +13 -16
  40. autogluon/timeseries/models/local/naive.py +2 -2
  41. autogluon/timeseries/models/local/npts.py +7 -1
  42. autogluon/timeseries/models/local/statsforecast.py +13 -13
  43. autogluon/timeseries/models/multi_window/multi_window_model.py +38 -23
  44. autogluon/timeseries/models/registry.py +3 -4
  45. autogluon/timeseries/models/toto/_internal/backbone/attention.py +3 -4
  46. autogluon/timeseries/models/toto/_internal/backbone/backbone.py +6 -6
  47. autogluon/timeseries/models/toto/_internal/backbone/rope.py +4 -9
  48. autogluon/timeseries/models/toto/_internal/backbone/rotary_embedding_torch.py +342 -0
  49. autogluon/timeseries/models/toto/_internal/backbone/scaler.py +2 -3
  50. autogluon/timeseries/models/toto/_internal/backbone/transformer.py +10 -10
  51. autogluon/timeseries/models/toto/_internal/dataset.py +2 -2
  52. autogluon/timeseries/models/toto/_internal/forecaster.py +8 -8
  53. autogluon/timeseries/models/toto/dataloader.py +4 -4
  54. autogluon/timeseries/models/toto/hf_pretrained_model.py +97 -16
  55. autogluon/timeseries/models/toto/model.py +30 -17
  56. autogluon/timeseries/predictor.py +531 -136
  57. autogluon/timeseries/regressor.py +18 -23
  58. autogluon/timeseries/splitter.py +2 -2
  59. autogluon/timeseries/trainer/ensemble_composer.py +323 -129
  60. autogluon/timeseries/trainer/model_set_builder.py +9 -9
  61. autogluon/timeseries/trainer/prediction_cache.py +16 -16
  62. autogluon/timeseries/trainer/trainer.py +235 -145
  63. autogluon/timeseries/trainer/utils.py +3 -4
  64. autogluon/timeseries/transforms/covariate_scaler.py +7 -7
  65. autogluon/timeseries/transforms/target_scaler.py +8 -8
  66. autogluon/timeseries/utils/constants.py +10 -0
  67. autogluon/timeseries/utils/datetime/lags.py +1 -3
  68. autogluon/timeseries/utils/datetime/seasonality.py +1 -3
  69. autogluon/timeseries/utils/features.py +22 -9
  70. autogluon/timeseries/utils/forecast.py +1 -2
  71. autogluon/timeseries/utils/timer.py +173 -0
  72. autogluon/timeseries/version.py +1 -1
  73. {autogluon_timeseries-1.4.1b20251115.dist-info → autogluon_timeseries-1.5.0b20251221.dist-info}/METADATA +23 -21
  74. autogluon_timeseries-1.5.0b20251221.dist-info/RECORD +103 -0
  75. autogluon_timeseries-1.4.1b20251115.dist-info/RECORD +0 -96
  76. /autogluon.timeseries-1.4.1b20251115-py3.9-nspkg.pth → /autogluon.timeseries-1.5.0b20251221-py3.11-nspkg.pth +0 -0
  77. {autogluon_timeseries-1.4.1b20251115.dist-info → autogluon_timeseries-1.5.0b20251221.dist-info}/WHEEL +0 -0
  78. {autogluon_timeseries-1.4.1b20251115.dist-info → autogluon_timeseries-1.5.0b20251221.dist-info}/licenses/LICENSE +0 -0
  79. {autogluon_timeseries-1.4.1b20251115.dist-info → autogluon_timeseries-1.5.0b20251221.dist-info}/licenses/NOTICE +0 -0
  80. {autogluon_timeseries-1.4.1b20251115.dist-info → autogluon_timeseries-1.5.0b20251221.dist-info}/namespace_packages.txt +0 -0
  81. {autogluon_timeseries-1.4.1b20251115.dist-info → autogluon_timeseries-1.5.0b20251221.dist-info}/top_level.txt +0 -0
  82. {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 TabularPredictor
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
- """TabularPredictor ensemble regressor using separate models per quantile plus dedicated mean model."""
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
- tabular_hyperparameters: Optional[dict] = None,
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
- 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
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
- **kwargs,
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
- 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
- )
52
+ y = pd.Series(labels.reshape(num_windows * num_items * prediction_length))
60
53
 
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
54
+ total_rounds = 1 + len(self.quantile_levels)
55
+ timer = SplitTimer(time_limit, rounds=total_rounds).start()
66
56
 
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)
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.load_predictors()
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
- # 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)
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, 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
- )
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 TabularPredictor
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
- """TabularPredictor ensemble regressor using AutoGluon-Tabular as a single
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
- tabular_hyperparameters: Optional[dict] = None,
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
- 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
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: Optional[int] = None,
41
- **kwargs,
39
+ time_limit: float | None = None,
42
40
  ) -> 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
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
- 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
-
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
- 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
-
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
- df = self._get_feature_df(base_model_mean_predictions, base_model_quantile_predictions)
56
+ X = self._get_feature_df(base_model_mean_predictions, base_model_quantile_predictions)
82
57
 
83
- pred = self.predictor.predict(df, as_pandas=False)
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 ensembles which assign one (global) weight per model."""
12
+ """Abstract base class for weighted ensemble models that assign global weights to base models.
14
13
 
15
- def __init__(self, name: Optional[str] = None, **kwargs):
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