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
@@ -2,7 +2,8 @@ import logging
2
2
  import os
3
3
  import time
4
4
  import traceback
5
- from typing import Iterator, Optional
5
+ from pathlib import Path
6
+ from typing import Any, Iterator
6
7
 
7
8
  import networkx as nx
8
9
  import numpy as np
@@ -10,8 +11,12 @@ from typing_extensions import Self
10
11
 
11
12
  from autogluon.timeseries import TimeSeriesDataFrame
12
13
  from autogluon.timeseries.metrics import TimeSeriesScorer
13
- from autogluon.timeseries.models.ensemble import AbstractTimeSeriesEnsembleModel, get_ensemble_class
14
- from autogluon.timeseries.splitter import AbstractWindowSplitter
14
+ from autogluon.timeseries.models.ensemble import (
15
+ AbstractTimeSeriesEnsembleModel,
16
+ PerformanceWeightedEnsemble,
17
+ get_ensemble_class,
18
+ )
19
+ from autogluon.timeseries.utils.timer import SplitTimer
15
20
  from autogluon.timeseries.utils.warning_filters import warning_filter
16
21
 
17
22
  from .utils import log_scores_and_times
@@ -20,18 +25,59 @@ logger = logging.getLogger("autogluon.timeseries.trainer")
20
25
 
21
26
 
22
27
  class EnsembleComposer:
23
- """Helper class for TimeSeriesTrainer to build multi-layer stack ensembles."""
28
+ """Helper class for TimeSeriesTrainer to build multi-layer stack ensembles.
29
+
30
+ This class depends on the trainer to provide the necessary initialization parameters, training
31
+ and validation data, as well as having fit the base (non-ensemble) models and persisted their
32
+ out-of-fold predictions which will be used for ensemble training.
33
+
34
+ Parameters
35
+ ----------
36
+ path
37
+ Path of the calling TimeSeriesTrainer. EnsembleComposer finds the model objects and their
38
+ out-of-fold prediction artifacts with respect to this path. EnsembleComposer only saves
39
+ ensemble models and their out-of-fold predictions to this folder (i.e., does not pickle
40
+ itself).
41
+ prediction_length
42
+ Number of time steps to forecast.
43
+ eval_metric
44
+ Metric used to evaluate ensemble performance.
45
+ target
46
+ Name of the target column in the time series data.
47
+ num_windows_per_layer
48
+ Number of windows used for training each ensemble layer. Length must match the number of layers
49
+ in ensemble_hyperparameters. Example: (3, 2) means first layer uses 3 windows, second layer uses
50
+ 2 windows.
51
+
52
+ Base models must have OOF predictions saved for all sum(num_windows_per_layer) windows, prior
53
+ to this class being called.
54
+ ensemble_hyperparameters
55
+ Ensemble configuration. A list of dicts, one per layer. If an ensemble model should be fitted
56
+ with multiple hyperparameter configurations, a list of dicts may be provided as the value.
57
+ Each layer's dict maps ensemble names to either a single hyperparameter dict or a list of
58
+ hyperparameter dicts.
59
+
60
+ Examples:
61
+ - ``[{"GreedyEnsemble": {}}, {"GreedyEnsemble": {}}]`` for 2 layers of greedy ensembles.
62
+ - ``[{"GreedyEnsemble": [{"ensemble_size": 10}, {"ensemble_size": 20}]}]`` for a single layer of
63
+ two greedy ensembles, with differing ensemble sizes.
64
+ quantile_levels
65
+ Quantile levels for probabilistic forecasting.
66
+ model_graph
67
+ Directed graph containing base models and their metadata (val_score, fit_time, etc.). Only
68
+ base models (nodes without predecessors) are used for ensemble training.
69
+ """
24
70
 
25
71
  def __init__(
26
72
  self,
27
- path,
73
+ path: str,
28
74
  prediction_length: int,
29
75
  eval_metric: TimeSeriesScorer,
30
76
  target: str,
77
+ num_windows_per_layer: tuple[int, ...],
78
+ ensemble_hyperparameters: list[dict[str, dict | list[dict]]],
31
79
  quantile_levels: list[float],
32
80
  model_graph: nx.DiGraph,
33
- ensemble_hyperparameters: dict,
34
- window_splitter: AbstractWindowSplitter,
35
81
  ):
36
82
  self.eval_metric = eval_metric
37
83
  self.path = path
@@ -39,17 +85,25 @@ class EnsembleComposer:
39
85
  self.target = target
40
86
  self.quantile_levels = quantile_levels
41
87
 
42
- self.ensemble_hyperparameters = ensemble_hyperparameters
88
+ self.num_windows_per_layer = num_windows_per_layer
89
+ self.num_layers = len(num_windows_per_layer)
43
90
 
44
- self.window_splitter = window_splitter
91
+ if len(ensemble_hyperparameters) != self.num_layers:
92
+ raise ValueError(
93
+ "Number of ensemble_hyperparameters must match the number of layers. "
94
+ f"Received {len(ensemble_hyperparameters)} ensemble_hyperparameters, "
95
+ f"but {self.num_layers} layers."
96
+ )
97
+ self.ensemble_hyperparameters = ensemble_hyperparameters
45
98
 
46
99
  self.banned_model_names = list(model_graph.nodes)
47
100
  self.model_graph = self._get_base_model_graph(source_graph=model_graph)
48
101
 
49
102
  @staticmethod
50
103
  def _get_base_model_graph(source_graph: nx.DiGraph) -> nx.DiGraph:
51
- """Return a model graph by copying only base models (nodes without predecessors)
52
- This ensures we start fresh for ensemble building.
104
+ """Return a model graph by copying only base models (nodes without predecessors).
105
+
106
+ This ensures we start fresh for training ensembles.
53
107
  """
54
108
  rootset = EnsembleComposer._get_rootset(source_graph)
55
109
 
@@ -63,108 +117,256 @@ class EnsembleComposer:
63
117
  def _get_rootset(graph: nx.DiGraph) -> list[str]:
64
118
  return [n for n in graph.nodes if not list(graph.predecessors(n))]
65
119
 
120
+ def _load_model(self, model_name: str) -> Any:
121
+ """Load a model from the graph by name."""
122
+ attrs = self.model_graph.nodes[model_name]
123
+ model_path = os.path.join(self.path, *attrs["path"])
124
+ return attrs["type"].load(path=model_path)
125
+
126
+ def _iter_models(self, layer: int) -> Iterator[tuple[str, Any]]:
127
+ """Iterate over models in a specific layer of the model graph.
128
+
129
+ Parameters
130
+ ----------
131
+ layer
132
+ Layer index (0 for base models, 1+ for ensemble layers)
133
+
134
+ Yields
135
+ ------
136
+ model_name
137
+ Name of the model
138
+ model
139
+ Loaded model instance
140
+ """
141
+ rootset = self._get_rootset(self.model_graph)
142
+ layer_iter = nx.traversal.bfs_layers(self.model_graph, rootset)
143
+ for layer_idx, layer_keys in enumerate(layer_iter):
144
+ if layer_idx != layer:
145
+ continue
146
+
147
+ for model_name in layer_keys:
148
+ model = self._load_model(model_name)
149
+ yield model_name, model
150
+
66
151
  def iter_ensembles(self) -> Iterator[tuple[int, AbstractTimeSeriesEnsembleModel, list[str]]]:
67
- """Iterate over trained ensemble models, layer by layer.
152
+ """Iterate over trained ensemble models, layer by layer. Used by the Trainer to copy the
153
+ fitted models in EnsembleComposer's ``model_graph``.
68
154
 
69
155
  Yields
70
156
  ------
71
- layer_ix
157
+ layer_idx
72
158
  The layer index of the ensemble.
73
159
  model
74
160
  The ensemble model object
75
161
  base_model_names
76
162
  The names of the base models that are part of the ensemble.
77
163
  """
78
- rootset = self._get_rootset(self.model_graph)
79
-
80
- for layer_ix, layer in enumerate(nx.traversal.bfs_layers(self.model_graph, rootset)):
81
- if layer_ix == 0: # we don't need base models
82
- continue
83
-
84
- for model_name in layer:
85
- attrs = self.model_graph.nodes[model_name]
86
- model_path = os.path.join(self.path, *attrs["path"])
87
- model = attrs["type"].load(path=model_path)
88
-
89
- yield (
90
- layer_ix,
91
- model,
92
- list(self.model_graph.predecessors(model_name)),
93
- )
164
+ for layer_idx in range(1, self.num_layers + 1):
165
+ for model_name, model in self._iter_models(layer=layer_idx):
166
+ yield (layer_idx, model, list(self.model_graph.predecessors(model_name)))
94
167
 
95
168
  def fit(
96
169
  self,
97
- train_data: TimeSeriesDataFrame,
98
- val_data: Optional[TimeSeriesDataFrame] = None,
99
- time_limit: Optional[float] = None,
170
+ data_per_window: list[TimeSeriesDataFrame],
171
+ predictions_per_window: dict[str, list[TimeSeriesDataFrame]],
172
+ time_limit: float | None = None,
100
173
  ) -> Self:
101
- base_model_scores = {k: self.model_graph.nodes[k]["val_score"] for k in self.model_graph.nodes}
102
- model_names = list(base_model_scores.keys())
103
-
104
- if not self._can_fit_ensemble(time_limit, len(model_names)):
174
+ base_model_names = [name for name, _ in self._iter_models(layer=0)]
175
+ if not self._can_fit_ensemble(time_limit, len(base_model_names)):
105
176
  return self
106
177
 
107
- logger.info(f"Fitting {len(self.ensemble_hyperparameters)} ensemble(s).")
108
-
109
- # get target and base model prediction data for ensemble training
110
- data_per_window = self._get_validation_windows(train_data=train_data, val_data=val_data)
111
- predictions_per_window = self._get_base_model_predictions(model_names)
112
-
113
- for ensemble_name, ensemble_hp_dict in self.ensemble_hyperparameters.items():
114
- try:
115
- time_start = time.monotonic()
116
- ensemble_class = get_ensemble_class(ensemble_name)
117
- ensemble = ensemble_class(
118
- eval_metric=self.eval_metric,
119
- target=self.target,
120
- prediction_length=self.prediction_length,
121
- path=self.path,
122
- freq=data_per_window[0].freq,
123
- quantile_levels=self.quantile_levels,
124
- hyperparameters=ensemble_hp_dict,
178
+ num_ensembles = sum(
179
+ len(list(self.iter_layer_models_and_hps(layer))) for layer in range(1, self.num_layers + 1)
180
+ )
181
+ logger.info(f"Fitting {num_ensembles} ensemble(s), in {self.num_layers} layers.")
182
+
183
+ assert len(data_per_window) == sum(self.num_windows_per_layer)
184
+
185
+ def get_inputs_for_layer(layer_idx, model_names):
186
+ """Retrieve predictions from previous layer models for current layer training."""
187
+ if layer_idx == 1:
188
+ # we need base models, so we use predictions_per_window provided by the trainer,
189
+ # which contains base model predictions for all windows where ensembles will be
190
+ # trained.
191
+ num_windows = self.num_windows_per_layer[0]
192
+ inputs = {name: predictions_per_window[name][:num_windows] for name in model_names}
193
+ else:
194
+ # if layer_idx > 1, we will be relying on predictions of previously trained ensembles
195
+ window_start = -sum(self.num_windows_per_layer[layer_idx - 1 :])
196
+ window_slice = slice(
197
+ window_start,
198
+ window_start + self.num_windows_per_layer[layer_idx - 1] if layer_idx < self.num_layers else None,
125
199
  )
126
- # update name to prevent name collisions
127
- ensemble.name = self._get_ensemble_model_name(ensemble.name)
128
-
129
- with warning_filter():
130
- ensemble.fit(
131
- predictions_per_window=predictions_per_window,
132
- data_per_window=data_per_window,
133
- model_scores=base_model_scores,
134
- time_limit=time_limit,
200
+
201
+ inputs = {}
202
+ for model_name in model_names:
203
+ oof_predictions = self._get_model_oof_predictions(model_name)
204
+ inputs[model_name] = oof_predictions[window_slice]
205
+
206
+ return inputs
207
+
208
+ def get_ground_truth_for_layer(layer_idx):
209
+ window_start = sum(self.num_windows_per_layer[: layer_idx - 1])
210
+ window_end = window_start + self.num_windows_per_layer[layer_idx - 1]
211
+ return data_per_window[window_start:window_end]
212
+
213
+ main_loop_timer = SplitTimer(time_limit, rounds=num_ensembles).start()
214
+
215
+ # main loop over layers of ensembles
216
+ for layer_idx in range(1, self.num_layers + 1):
217
+ layer_input_model_names = [name for name, _ in self._iter_models(layer=layer_idx - 1)]
218
+ layer_input_model_scores = {
219
+ name: self.model_graph.nodes[name]["val_score"] for name in layer_input_model_names
220
+ }
221
+
222
+ layer_predictions_per_window = get_inputs_for_layer(layer_idx, model_names=layer_input_model_names)
223
+ layer_data_per_window = get_ground_truth_for_layer(layer_idx)
224
+
225
+ for ensemble_name, ensemble_hp_dict in self.iter_layer_models_and_hps(layer_idx):
226
+ try:
227
+ # train the ensemble model
228
+ time_start = time.monotonic()
229
+
230
+ ensemble = self._fit_single_ensemble(
231
+ model_name=ensemble_name,
232
+ hyperparameters=ensemble_hp_dict,
233
+ predictions_per_window=layer_predictions_per_window,
234
+ data_per_window=layer_data_per_window,
235
+ base_model_scores=layer_input_model_scores,
236
+ layer_idx=layer_idx,
237
+ time_limit=main_loop_timer.round_time_remaining(),
135
238
  )
136
- ensemble.fit_time = time.monotonic() - time_start
239
+ ensemble.fit_time = time.monotonic() - time_start
240
+
241
+ # for all windows of all layers starting from this layer, predict and save predictions
242
+ predictions = []
243
+ predict_time = 0
244
+ for pred_layer_idx in range(layer_idx, self.num_layers + 1):
245
+ predict_time_start = time.monotonic()
246
+
247
+ pred_base_predictions = get_inputs_for_layer(pred_layer_idx, ensemble.model_names)
248
+ for window_idx in range(self.num_windows_per_layer[pred_layer_idx - 1]):
249
+ prediction = ensemble.predict(
250
+ {n: pred_base_predictions[n][window_idx] for n in ensemble.model_names}
251
+ )
252
+ predictions.append(prediction)
253
+
254
+ predict_time = time.monotonic() - predict_time_start
255
+
256
+ # record marginal prediction time per window in the last layer's data
257
+ ensemble.predict_time_marginal = predict_time / self.num_windows_per_layer[-1]
258
+ ensemble.cache_oof_predictions(predictions)
259
+
260
+ # compute validation score using the last layer's validation windows
261
+ last_layer_oof_predictions = ensemble.get_oof_predictions()[-self.num_windows_per_layer[-1] :]
262
+ last_layer_ground_truth = get_ground_truth_for_layer(self.num_layers)
263
+ score_per_fold = [
264
+ self.eval_metric(data, prediction, target=self.target)
265
+ for prediction, data in zip(last_layer_oof_predictions, last_layer_ground_truth)
266
+ ]
267
+ ensemble.val_score = float(np.mean(score_per_fold, dtype=np.float64))
268
+
269
+ # add model to the graph, compute predict time, and save
270
+ self._add_model(ensemble, base_models=ensemble.model_names)
271
+ ensemble.predict_time = self._calculate_predict_time(ensemble)
272
+ self.model_graph.nodes[ensemble.name]["predict_time"] = ensemble.predict_time
273
+ ensemble.save()
274
+
275
+ # log performance
276
+ log_scores_and_times(
277
+ ensemble.val_score,
278
+ ensemble.fit_time,
279
+ ensemble.predict_time,
280
+ eval_metric_name=self.eval_metric.name_with_sign,
281
+ )
282
+
283
+ # check time and advance round
284
+ if main_loop_timer.timed_out():
285
+ logger.warning(
286
+ "Time limit exceeded during ensemble training, will stop training new ensembles."
287
+ )
288
+ return self
137
289
 
138
- score_per_fold = []
139
- for window_idx, data in enumerate(data_per_window):
140
- predictions = ensemble.predict(
141
- {n: predictions_per_window[n][window_idx] for n in ensemble.model_names}
290
+ except Exception as err: # noqa
291
+ logger.error(
292
+ f"\tWarning: Exception caused {ensemble_name} to fail during training... Skipping this model."
142
293
  )
143
- score_per_fold.append(self.eval_metric.score(data, predictions, self.target))
144
- ensemble.val_score = float(np.mean(score_per_fold, dtype=np.float64))
294
+ logger.error(f"\t{err}")
295
+ logger.debug(traceback.format_exc())
145
296
 
146
- # TODO: add ensemble's own time to predict_time
147
- ensemble.predict_time = self._calculate_base_models_predict_time(ensemble.model_names)
297
+ finally:
298
+ main_loop_timer.next_round()
148
299
 
149
- log_scores_and_times(
150
- ensemble.val_score,
151
- ensemble.fit_time,
152
- ensemble.predict_time,
153
- eval_metric_name=self.eval_metric.name_with_sign,
154
- )
300
+ return self
155
301
 
156
- self._add_model(ensemble, base_models=ensemble.model_names)
302
+ def iter_layer_models_and_hps(self, layer_idx: int):
303
+ layer_hps = self.ensemble_hyperparameters[layer_idx - 1]
157
304
 
158
- # Save the ensemble model to disk
159
- ensemble.save()
160
- except Exception as err: # noqa
161
- logger.error(
162
- f"\tWarning: Exception caused {ensemble_name} to fail during training... Skipping this model."
163
- )
164
- logger.error(f"\t{err}")
165
- logger.debug(traceback.format_exc())
305
+ for model_name, hps in layer_hps.items():
306
+ if isinstance(hps, list):
307
+ # If a list is provided, create one ensemble per hyperparameter dict
308
+ for hp in hps:
309
+ yield model_name, hp
310
+ else:
311
+ yield model_name, hps
166
312
 
167
- return self
313
+ def _fit_single_ensemble(
314
+ self,
315
+ model_name: str,
316
+ hyperparameters: dict,
317
+ predictions_per_window: dict[str, list[TimeSeriesDataFrame]],
318
+ data_per_window: list[TimeSeriesDataFrame],
319
+ base_model_scores: dict[str, float],
320
+ layer_idx: int,
321
+ time_limit: float | None = None,
322
+ ) -> AbstractTimeSeriesEnsembleModel:
323
+ ensemble_class = get_ensemble_class(model_name)
324
+
325
+ # TODO: remove this after PerformanceWeightedEnsemble is removed. This is a temporary fix
326
+ # to make sure PerformanceWeightedEnsemble is not fit on the validation scores of future
327
+ # out-of-fold splits.
328
+ if layer_idx < self.num_layers and ensemble_class is PerformanceWeightedEnsemble:
329
+ raise RuntimeError(
330
+ "PerformanceWeightedEnsemble is not supported for multi-layer stack ensembles, except "
331
+ "when it's used in the last layer of the ensemble."
332
+ )
333
+
334
+ ensemble: AbstractTimeSeriesEnsembleModel = ensemble_class(
335
+ eval_metric=self.eval_metric,
336
+ target=self.target,
337
+ prediction_length=self.prediction_length,
338
+ path=self.path,
339
+ freq=data_per_window[0].freq,
340
+ quantile_levels=self.quantile_levels,
341
+ hyperparameters=hyperparameters,
342
+ )
343
+
344
+ # update name to prevent name collisions
345
+ old_name = ensemble.name
346
+ ensemble.name = self._get_ensemble_model_name(ensemble.name, layer_idx)
347
+ if ensemble.name != old_name:
348
+ path_obj = Path(ensemble.path)
349
+ ensemble.path = str(path_obj.parent / ensemble.name)
350
+
351
+ fit_log_message = f"Training ensemble model {ensemble.name}. "
352
+ if time_limit is not None:
353
+ fit_log_message += f"Training for up to {time_limit:.1f}s."
354
+ logger.info(fit_log_message)
355
+
356
+ with warning_filter():
357
+ ensemble.fit(
358
+ predictions_per_window=predictions_per_window,
359
+ data_per_window=data_per_window,
360
+ model_scores=base_model_scores,
361
+ time_limit=time_limit,
362
+ )
363
+
364
+ return ensemble
365
+
366
+ def _get_model_oof_predictions(self, model_name: str) -> list[TimeSeriesDataFrame]:
367
+ model_attrs = self.model_graph.nodes[model_name]
368
+ model_path = os.path.join(self.path, *model_attrs["path"])
369
+ return model_attrs["type"].load_oof_predictions(path=model_path)
168
370
 
169
371
  def _add_model(self, model, base_models: list[str]):
170
372
  self.model_graph.add_node(
@@ -177,10 +379,11 @@ class EnsembleComposer:
177
379
  )
178
380
  for base_model in base_models:
179
381
  self.model_graph.add_edge(base_model, model.name)
382
+ self.banned_model_names.append(model.name)
180
383
 
181
384
  def _can_fit_ensemble(
182
385
  self,
183
- time_limit: Optional[float],
386
+ time_limit: float | None,
184
387
  num_models_available_for_ensemble: int,
185
388
  ) -> bool:
186
389
  if time_limit is not None and time_limit <= 0:
@@ -200,51 +403,42 @@ class EnsembleComposer:
200
403
 
201
404
  return True
202
405
 
203
- def _get_validation_windows(
204
- self, train_data: TimeSeriesDataFrame, val_data: Optional[TimeSeriesDataFrame]
205
- ) -> list[TimeSeriesDataFrame]:
206
- # TODO: update for window/stack-layer logic and refit logic
207
- if val_data is None:
208
- return [val_fold for _, val_fold in self.window_splitter.split(train_data)]
209
- else:
210
- return [val_data]
211
-
212
- def _get_ensemble_model_name(self, name: str) -> str:
406
+ def _get_ensemble_model_name(self, name: str, layer_idx: int) -> str:
213
407
  """Revise name for an ensemble model, ensuring we don't have name collisions"""
214
408
  base_name = name
409
+ layer_suffix = f"_L{layer_idx + 1}" if self.num_layers > 1 else ""
410
+ name = f"{base_name}" + layer_suffix
215
411
  increment = 1
216
412
  while name in self.banned_model_names:
217
413
  increment += 1
218
- name = f"{base_name}_{increment}"
414
+ name = f"{base_name}_{increment}" + layer_suffix
219
415
  return name
220
416
 
221
- def _get_base_model_predictions(self, model_names: list[str]) -> dict[str, list[TimeSeriesDataFrame]]:
222
- """Get base model predictions for ensemble training / inference."""
223
- # TODO: update for window/stack-layer logic and refit logic
224
- predictions_per_window = {}
225
-
226
- for model_name in model_names:
227
- model_attrs = self.model_graph.nodes[model_name]
228
-
229
- model_path = os.path.join(self.path, *model_attrs["path"])
230
- model_type = model_attrs["type"]
231
-
232
- predictions_per_window[model_name] = model_type.load_oof_predictions(path=model_path)
233
-
234
- return predictions_per_window
235
-
236
- def _calculate_base_models_predict_time(self, model_names: list[str]) -> float:
417
+ def _calculate_predict_time(self, model: AbstractTimeSeriesEnsembleModel) -> float:
237
418
  """Calculate ensemble predict time as sum of base model predict times."""
238
- return sum(self.model_graph.nodes[name]["predict_time"] for name in model_names)
239
-
240
-
241
- def validate_ensemble_hyperparameters(hyperparameters) -> dict:
242
- """Validate ensemble hyperparameters dict."""
243
- if not isinstance(hyperparameters, dict):
244
- raise ValueError(f"ensemble_hyperparameters must be dict, got {type(hyperparameters)}")
245
-
246
- # Validate all ensemble names are known
247
- for ensemble_name, ensemble_hyperparameters in hyperparameters.items():
248
- get_ensemble_class(ensemble_name) # Will raise if unknown
249
- assert isinstance(ensemble_hyperparameters, dict)
250
- return hyperparameters
419
+ assert model.predict_time_marginal is not None
420
+ predict_time = model.predict_time_marginal
421
+ for model_name in nx.ancestors(self.model_graph, model.name):
422
+ ancestor = self._load_model(model_name)
423
+ if isinstance(ancestor, AbstractTimeSeriesEnsembleModel):
424
+ assert ancestor.predict_time_marginal is not None
425
+ predict_time += ancestor.predict_time_marginal
426
+ else:
427
+ predict_time += ancestor.predict_time
428
+
429
+ return predict_time
430
+
431
+
432
+ def validate_ensemble_hyperparameters(hyperparameters: list[dict[str, dict | list[dict]]]) -> None:
433
+ if not isinstance(hyperparameters, list):
434
+ raise ValueError(f"ensemble_hyperparameters must be list, got {type(hyperparameters)}")
435
+
436
+ for layer_idx, layer_hp in enumerate(hyperparameters):
437
+ if not isinstance(layer_hp, dict):
438
+ raise ValueError(f"Layer {layer_idx} hyperparameters must be dict, got {type(layer_hp)}")
439
+ for ensemble_name, ensemble_hp in layer_hp.items():
440
+ get_ensemble_class(ensemble_name) # Will raise if unknown
441
+ hp_is_dict = isinstance(ensemble_hp, dict)
442
+ hp_is_valid_list = isinstance(ensemble_hp, list) and all(isinstance(d, dict) for d in ensemble_hp)
443
+ if not (hp_is_dict or hp_is_valid_list):
444
+ raise ValueError(f"Hyperparameters for {ensemble_name} must be dict or list, got {type(ensemble_hp)}")
@@ -2,7 +2,7 @@ import copy
2
2
  import logging
3
3
  import re
4
4
  from collections import defaultdict
5
- from typing import Any, Optional, Type, Union
5
+ from typing import Any, Type
6
6
 
7
7
  from autogluon.common import space
8
8
  from autogluon.core import constants
@@ -16,7 +16,7 @@ from autogluon.timeseries.utils.features import CovariateMetadata
16
16
  logger = logging.getLogger(__name__)
17
17
 
18
18
 
19
- ModelKey = Union[str, Type[AbstractTimeSeriesModel]]
19
+ ModelKey = str | Type[AbstractTimeSeriesModel]
20
20
  ModelHyperparameters = dict[str, Any]
21
21
  TrainerHyperparameterSpec = dict[ModelKey, list[ModelHyperparameters]]
22
22
 
@@ -34,7 +34,7 @@ class TrainableModelSetBuilder:
34
34
  def __init__(
35
35
  self,
36
36
  path: str,
37
- freq: Optional[str],
37
+ freq: str | None,
38
38
  prediction_length: int,
39
39
  eval_metric: TimeSeriesScorer,
40
40
  target: str,
@@ -53,10 +53,10 @@ class TrainableModelSetBuilder:
53
53
 
54
54
  def get_model_set(
55
55
  self,
56
- hyperparameters: Union[str, dict, None],
56
+ hyperparameters: str | dict | None,
57
57
  hyperparameter_tune: bool,
58
- excluded_model_types: Optional[list[str]],
59
- banned_model_names: Optional[list[str]] = None,
58
+ excluded_model_types: list[str] | None,
59
+ banned_model_names: list[str] | None = None,
60
60
  ) -> list[AbstractTimeSeriesModel]:
61
61
  """Resolve hyperparameters and create the requested list of models"""
62
62
  models = []
@@ -153,9 +153,9 @@ class HyperparameterBuilder:
153
153
 
154
154
  def __init__(
155
155
  self,
156
- hyperparameters: Union[str, dict, None],
156
+ hyperparameters: str | dict | None,
157
157
  hyperparameter_tune: bool,
158
- excluded_model_types: Optional[list[str]],
158
+ excluded_model_types: list[str] | None,
159
159
  ):
160
160
  self.hyperparameters = hyperparameters
161
161
  self.hyperparameter_tune = hyperparameter_tune
@@ -184,7 +184,7 @@ class HyperparameterBuilder:
184
184
 
185
185
  def _check_and_clean_hyperparameters(
186
186
  self,
187
- hyperparameters: dict[ModelKey, Union[ModelHyperparameters, list[ModelHyperparameters]]],
187
+ hyperparameters: dict[ModelKey, ModelHyperparameters | list[ModelHyperparameters]],
188
188
  ) -> TrainerHyperparameterSpec:
189
189
  """Convert the hyperparameters dictionary to a unified format:
190
190
  - Remove 'Model' suffix from model names, if present