autogluon.timeseries 1.0.1b20240304__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.

Files changed (108) hide show
  1. autogluon/timeseries/configs/__init__.py +3 -2
  2. autogluon/timeseries/configs/hyperparameter_presets.py +62 -0
  3. autogluon/timeseries/configs/predictor_presets.py +84 -0
  4. autogluon/timeseries/dataset/ts_dataframe.py +339 -186
  5. autogluon/timeseries/learner.py +192 -60
  6. autogluon/timeseries/metrics/__init__.py +55 -11
  7. autogluon/timeseries/metrics/abstract.py +96 -25
  8. autogluon/timeseries/metrics/point.py +186 -39
  9. autogluon/timeseries/metrics/quantile.py +47 -20
  10. autogluon/timeseries/metrics/utils.py +6 -6
  11. autogluon/timeseries/models/__init__.py +13 -7
  12. autogluon/timeseries/models/abstract/__init__.py +2 -2
  13. autogluon/timeseries/models/abstract/abstract_timeseries_model.py +533 -273
  14. autogluon/timeseries/models/abstract/model_trial.py +10 -10
  15. autogluon/timeseries/models/abstract/tunable.py +189 -0
  16. autogluon/timeseries/models/autogluon_tabular/__init__.py +2 -0
  17. autogluon/timeseries/models/autogluon_tabular/mlforecast.py +369 -215
  18. autogluon/timeseries/models/autogluon_tabular/per_step.py +513 -0
  19. autogluon/timeseries/models/autogluon_tabular/transforms.py +67 -0
  20. autogluon/timeseries/models/autogluon_tabular/utils.py +3 -51
  21. autogluon/timeseries/models/chronos/__init__.py +4 -0
  22. autogluon/timeseries/models/chronos/chronos2.py +361 -0
  23. autogluon/timeseries/models/chronos/model.py +738 -0
  24. autogluon/timeseries/models/chronos/utils.py +369 -0
  25. autogluon/timeseries/models/ensemble/__init__.py +35 -2
  26. autogluon/timeseries/models/ensemble/{abstract_timeseries_ensemble.py → abstract.py} +50 -26
  27. autogluon/timeseries/models/ensemble/array_based/__init__.py +3 -0
  28. autogluon/timeseries/models/ensemble/array_based/abstract.py +236 -0
  29. autogluon/timeseries/models/ensemble/array_based/models.py +73 -0
  30. autogluon/timeseries/models/ensemble/array_based/regressor/__init__.py +12 -0
  31. autogluon/timeseries/models/ensemble/array_based/regressor/abstract.py +88 -0
  32. autogluon/timeseries/models/ensemble/array_based/regressor/linear_stacker.py +167 -0
  33. autogluon/timeseries/models/ensemble/array_based/regressor/per_quantile_tabular.py +94 -0
  34. autogluon/timeseries/models/ensemble/array_based/regressor/tabular.py +107 -0
  35. autogluon/timeseries/models/ensemble/ensemble_selection.py +167 -0
  36. autogluon/timeseries/models/ensemble/per_item_greedy.py +162 -0
  37. autogluon/timeseries/models/ensemble/weighted/__init__.py +8 -0
  38. autogluon/timeseries/models/ensemble/weighted/abstract.py +40 -0
  39. autogluon/timeseries/models/ensemble/weighted/basic.py +78 -0
  40. autogluon/timeseries/models/ensemble/weighted/greedy.py +57 -0
  41. autogluon/timeseries/models/gluonts/__init__.py +3 -1
  42. autogluon/timeseries/models/gluonts/abstract.py +583 -0
  43. autogluon/timeseries/models/gluonts/dataset.py +109 -0
  44. autogluon/timeseries/models/gluonts/{torch/models.py → models.py} +185 -44
  45. autogluon/timeseries/models/local/__init__.py +1 -10
  46. autogluon/timeseries/models/local/abstract_local_model.py +150 -97
  47. autogluon/timeseries/models/local/naive.py +31 -23
  48. autogluon/timeseries/models/local/npts.py +6 -2
  49. autogluon/timeseries/models/local/statsforecast.py +99 -112
  50. autogluon/timeseries/models/multi_window/multi_window_model.py +99 -40
  51. autogluon/timeseries/models/registry.py +64 -0
  52. autogluon/timeseries/models/toto/__init__.py +3 -0
  53. autogluon/timeseries/models/toto/_internal/__init__.py +9 -0
  54. autogluon/timeseries/models/toto/_internal/backbone/__init__.py +3 -0
  55. autogluon/timeseries/models/toto/_internal/backbone/attention.py +196 -0
  56. autogluon/timeseries/models/toto/_internal/backbone/backbone.py +262 -0
  57. autogluon/timeseries/models/toto/_internal/backbone/distribution.py +70 -0
  58. autogluon/timeseries/models/toto/_internal/backbone/kvcache.py +136 -0
  59. autogluon/timeseries/models/toto/_internal/backbone/rope.py +89 -0
  60. autogluon/timeseries/models/toto/_internal/backbone/rotary_embedding_torch.py +342 -0
  61. autogluon/timeseries/models/toto/_internal/backbone/scaler.py +305 -0
  62. autogluon/timeseries/models/toto/_internal/backbone/transformer.py +333 -0
  63. autogluon/timeseries/models/toto/_internal/dataset.py +165 -0
  64. autogluon/timeseries/models/toto/_internal/forecaster.py +423 -0
  65. autogluon/timeseries/models/toto/dataloader.py +108 -0
  66. autogluon/timeseries/models/toto/hf_pretrained_model.py +118 -0
  67. autogluon/timeseries/models/toto/model.py +236 -0
  68. autogluon/timeseries/predictor.py +826 -305
  69. autogluon/timeseries/regressor.py +253 -0
  70. autogluon/timeseries/splitter.py +10 -31
  71. autogluon/timeseries/trainer/__init__.py +2 -3
  72. autogluon/timeseries/trainer/ensemble_composer.py +439 -0
  73. autogluon/timeseries/trainer/model_set_builder.py +256 -0
  74. autogluon/timeseries/trainer/prediction_cache.py +149 -0
  75. autogluon/timeseries/trainer/trainer.py +1298 -0
  76. autogluon/timeseries/trainer/utils.py +17 -0
  77. autogluon/timeseries/transforms/__init__.py +2 -0
  78. autogluon/timeseries/transforms/covariate_scaler.py +164 -0
  79. autogluon/timeseries/transforms/target_scaler.py +149 -0
  80. autogluon/timeseries/utils/constants.py +10 -0
  81. autogluon/timeseries/utils/datetime/base.py +38 -20
  82. autogluon/timeseries/utils/datetime/lags.py +18 -16
  83. autogluon/timeseries/utils/datetime/seasonality.py +14 -14
  84. autogluon/timeseries/utils/datetime/time_features.py +17 -14
  85. autogluon/timeseries/utils/features.py +317 -53
  86. autogluon/timeseries/utils/forecast.py +31 -17
  87. autogluon/timeseries/utils/timer.py +173 -0
  88. autogluon/timeseries/utils/warning_filters.py +44 -6
  89. autogluon/timeseries/version.py +2 -1
  90. autogluon.timeseries-1.4.1b20251210-py3.11-nspkg.pth +1 -0
  91. {autogluon.timeseries-1.0.1b20240304.dist-info → autogluon_timeseries-1.4.1b20251210.dist-info}/METADATA +71 -47
  92. autogluon_timeseries-1.4.1b20251210.dist-info/RECORD +103 -0
  93. {autogluon.timeseries-1.0.1b20240304.dist-info → autogluon_timeseries-1.4.1b20251210.dist-info}/WHEEL +1 -1
  94. autogluon/timeseries/configs/presets_configs.py +0 -11
  95. autogluon/timeseries/evaluator.py +0 -6
  96. autogluon/timeseries/models/ensemble/greedy_ensemble.py +0 -170
  97. autogluon/timeseries/models/gluonts/abstract_gluonts.py +0 -550
  98. autogluon/timeseries/models/gluonts/torch/__init__.py +0 -0
  99. autogluon/timeseries/models/presets.py +0 -325
  100. autogluon/timeseries/trainer/abstract_trainer.py +0 -1144
  101. autogluon/timeseries/trainer/auto_trainer.py +0 -74
  102. autogluon.timeseries-1.0.1b20240304-py3.8-nspkg.pth +0 -1
  103. autogluon.timeseries-1.0.1b20240304.dist-info/RECORD +0 -58
  104. {autogluon.timeseries-1.0.1b20240304.dist-info → autogluon_timeseries-1.4.1b20251210.dist-info/licenses}/LICENSE +0 -0
  105. {autogluon.timeseries-1.0.1b20240304.dist-info → autogluon_timeseries-1.4.1b20251210.dist-info/licenses}/NOTICE +0 -0
  106. {autogluon.timeseries-1.0.1b20240304.dist-info → autogluon_timeseries-1.4.1b20251210.dist-info}/namespace_packages.txt +0 -0
  107. {autogluon.timeseries-1.0.1b20240304.dist-info → autogluon_timeseries-1.4.1b20251210.dist-info}/top_level.txt +0 -0
  108. {autogluon.timeseries-1.0.1b20240304.dist-info → autogluon_timeseries-1.4.1b20251210.dist-info}/zip-safe +0 -0
@@ -0,0 +1,513 @@
1
+ import logging
2
+ import math
3
+ import os
4
+ import time
5
+ from typing import Any, Callable, Literal, Type
6
+
7
+ import numpy as np
8
+ import pandas as pd
9
+ import scipy.stats
10
+ from joblib import Parallel, cpu_count, delayed
11
+
12
+ from autogluon.common.loaders import load_pkl
13
+ from autogluon.common.savers import save_pkl
14
+ from autogluon.common.utils.pandas_utils import get_approximate_df_mem_usage
15
+ from autogluon.common.utils.resource_utils import ResourceManager
16
+ from autogluon.core.constants import QUANTILE, REGRESSION
17
+ from autogluon.tabular.models import AbstractModel as AbstractTabularModel
18
+ from autogluon.tabular.registry import ag_model_registry
19
+ from autogluon.timeseries import TimeSeriesDataFrame
20
+ from autogluon.timeseries.models.abstract import AbstractTimeSeriesModel
21
+ from autogluon.timeseries.utils.datetime import get_lags_for_frequency, get_time_features_for_frequency
22
+ from autogluon.timeseries.utils.warning_filters import set_loggers_level, warning_filter
23
+
24
+ from .utils import MLF_ITEMID, MLF_TARGET, MLF_TIMESTAMP
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ class PerStepTabularModel(AbstractTimeSeriesModel):
30
+ """Fit a separate tabular regression model for each time step in the forecast horizon.
31
+
32
+ Each model has access to the following features:
33
+
34
+ - lag features (observed time series values) based on ``freq`` of the data
35
+ - time features (e.g., day of the week) based on the timestamp of the measurement
36
+ - known covariates (if available)
37
+ - static features of each item (if available)
38
+
39
+ This model is typically slower to fit compared to other tabular forecasting models.
40
+
41
+ If ``eval_metric.needs_quantile``, the tabular regression models will be trained with ``"quantile"`` problem type.
42
+ Otherwise, the models will be trained with ``"regression"`` problem type, and dummy quantiles will be
43
+ obtained by assuming that the residuals follow zero-mean normal distribution.
44
+
45
+ This model uses `mlforecast <https://github.com/Nixtla/mlforecast>`_ under the hood for efficient preprocessing,
46
+ but the implementation of the per-step forecasting strategy is different from the ``max_horizon`` in ``mlforecast``.
47
+
48
+
49
+ Other Parameters
50
+ ----------------
51
+ trailing_lags : list[int], default = None
52
+ Trailing window lags of the target that will be used as features for predictions.
53
+ Trailing lags are shifted per forecast step: model for step ``h`` uses ``[lag+h for lag in trailing_lags]``.
54
+ If None, defaults to ``[1, 2, ..., 12]``.
55
+ seasonal_lags : list[int], default = None
56
+ Seasonal lags of the target used as features. Unlike trailing lags, seasonal lags are not shifted
57
+ but filtered by availability: model for step ``h`` uses ``[lag for lag in seasonal_lags if lag > h]``.
58
+ If None, determined automatically based on data frequency.
59
+ date_features : list[str | Callable], default = None
60
+ Features computed from the dates. Can be pandas date attributes or functions that will take the dates as input.
61
+ If None, will be determined automatically based on the frequency of the data.
62
+ target_scaler : {"standard", "mean_abs", "min_max", "robust", None}, default = "mean_abs"
63
+ Scaling applied to each time series.
64
+ model_name : str, default = "CAT"
65
+ Name of the tabular regression model. See ``autogluon.tabular.registry.ag_model_registry`` or
66
+ `the documentation <https://auto.gluon.ai/stable/api/autogluon.tabular.models.html>`_ for the list of available
67
+ tabular models.
68
+ model_hyperparameters : dict[str, Any], optional
69
+ Hyperparameters passed to the tabular regression model.
70
+ validation_fraction : float or None, default = 0.1
71
+ Fraction of the training data to use for validation. If None or 0.0, no validation set is created.
72
+ Validation set contains the most recent observations (chronologically). Must be between 0.0 and 1.0.
73
+ max_num_items : int or None, default = 20_000
74
+ If not None, the model will randomly select this many time series for training and validation.
75
+ max_num_samples : int or None, default = 1_000_000
76
+ If not None, training dataset passed to the tabular regression model will contain at most this many rows
77
+ (starting from the end of each time series).
78
+ n_jobs : int or None, default = None
79
+ Number of parallel jobs for fitting models across forecast horizons.
80
+ If None, automatically determined based on available memory to prevent OOM errors.
81
+ """
82
+
83
+ ag_priority = 70
84
+ _dummy_freq = "D"
85
+
86
+ def __init__(self, *args, **kwargs):
87
+ super().__init__(*args, **kwargs)
88
+ # We save the relative paths to per-step models. Each worker process independently saves/loads the model.
89
+ # This is much more efficient than passing around model objects that can get really large
90
+ self._relative_paths_to_models: list[str]
91
+ self._trailing_lags: list[int]
92
+ self._seasonal_lags: list[int]
93
+ self._date_features: list[Callable]
94
+ self._model_cls: Type[AbstractTabularModel]
95
+ self._n_jobs: int
96
+ self._non_boolean_real_covariates: list[str] = []
97
+ self._max_ts_length: int | None = None
98
+
99
+ @property
100
+ def allowed_hyperparameters(self) -> list[str]:
101
+ # TODO: Differencing is currently not supported because it greatly complicates the preprocessing logic
102
+ return super().allowed_hyperparameters + [
103
+ "trailing_lags",
104
+ "seasonal_lags",
105
+ "date_features",
106
+ # "differences",
107
+ "validation_fraction",
108
+ "model_name",
109
+ "model_hyperparameters",
110
+ "max_num_items",
111
+ "max_num_samples",
112
+ "n_jobs",
113
+ ]
114
+
115
+ @property
116
+ def _ag_to_nixtla(self) -> dict:
117
+ return {
118
+ self.target: MLF_TARGET,
119
+ TimeSeriesDataFrame.ITEMID: MLF_ITEMID,
120
+ TimeSeriesDataFrame.TIMESTAMP: MLF_TIMESTAMP,
121
+ }
122
+
123
+ def _get_default_hyperparameters(self):
124
+ return {
125
+ "model_name": "CAT",
126
+ "model_hyperparameters": {},
127
+ "target_scaler": "mean_abs",
128
+ "validation_fraction": 0.1,
129
+ "max_num_samples": 1_000_000,
130
+ "max_num_items": 20_000,
131
+ }
132
+
133
+ @classmethod
134
+ def _fit_single_model(
135
+ cls,
136
+ train_df: pd.DataFrame,
137
+ path_root: str,
138
+ step: int,
139
+ model_cls: Type[AbstractTabularModel],
140
+ model_hyperparameters: dict,
141
+ problem_type: Literal["quantile", "regression"],
142
+ eval_metric: str,
143
+ validation_fraction: float | None,
144
+ quantile_levels: list[float],
145
+ lags: list[int],
146
+ date_features: list[Callable],
147
+ time_limit: float | None,
148
+ num_cpus: int,
149
+ verbosity: int,
150
+ ) -> str:
151
+ from mlforecast import MLForecast
152
+
153
+ start_time = time.monotonic()
154
+
155
+ mlf = MLForecast(models=[], freq=cls._dummy_freq, lags=lags, date_features=date_features)
156
+
157
+ with warning_filter():
158
+ features_df = mlf.preprocess(train_df, static_features=[], dropna=False)
159
+ del train_df
160
+ del mlf
161
+ # Sort chronologically for efficient train/test split
162
+ features_df = features_df.sort_values(by=MLF_TIMESTAMP)
163
+ item_ids = features_df[MLF_ITEMID]
164
+ X = features_df.drop(columns=[MLF_ITEMID, MLF_TIMESTAMP, MLF_TARGET])
165
+ y = features_df[MLF_TARGET]
166
+ del features_df
167
+
168
+ y_is_valid = np.isfinite(y)
169
+ X, y = X[y_is_valid], y[y_is_valid]
170
+ X = X.replace(float("inf"), float("nan"))
171
+ if validation_fraction is None or validation_fraction == 0.0:
172
+ X_val = None
173
+ y_val = None
174
+ else:
175
+ assert 0 < validation_fraction < 1, "validation_fraction must be between 0.0 and 1.0"
176
+ num_val = math.ceil(len(X) * validation_fraction)
177
+ X_val, y_val = X.iloc[-num_val:], y.iloc[-num_val:]
178
+ X, y = X.iloc[:-num_val], y.iloc[:-num_val]
179
+ if len(y) == 0:
180
+ raise ValueError("Not enough valid target values to fit model")
181
+
182
+ elapsed = time.monotonic() - start_time
183
+ time_left = time_limit - elapsed if time_limit is not None else None
184
+ if problem_type == QUANTILE:
185
+ model_hyperparameters = model_hyperparameters | {"ag.quantile_levels": quantile_levels}
186
+ try:
187
+ with set_loggers_level(regex=r"^autogluon.tabular.*", level=logging.ERROR):
188
+ model = model_cls(
189
+ path=os.path.join(path_root, f"step_{step}"),
190
+ name=model_cls.__name__, # explicitly provide name to avoid warnings
191
+ problem_type=problem_type,
192
+ eval_metric=eval_metric,
193
+ hyperparameters=model_hyperparameters,
194
+ )
195
+ model.fit(
196
+ X=X,
197
+ y=y,
198
+ X_val=X_val,
199
+ y_val=y_val,
200
+ time_limit=time_left,
201
+ num_cpus=num_cpus,
202
+ num_gpus=0, # num_cpus is only used if num_gpus is set as well
203
+ verbosity=verbosity,
204
+ )
205
+ except Exception as e:
206
+ raise RuntimeError(f"Failed when fitting model for {step=}") from e
207
+ model.save()
208
+ if problem_type == REGRESSION:
209
+ residuals_std = pd.Series((model.predict(X) - y) ** 2).groupby(item_ids).mean() ** 0.5
210
+ save_pkl.save(cls._get_residuals_std_path(model.path), residuals_std)
211
+ relative_path = os.path.relpath(path=model.path, start=path_root)
212
+ return relative_path
213
+
214
+ @staticmethod
215
+ def _get_n_jobs(
216
+ train_df: pd.DataFrame,
217
+ num_extra_dynamic_features: int,
218
+ model_cls: Type[AbstractTabularModel],
219
+ model_hyperparameters: dict,
220
+ overhead_factor: float = 2.0,
221
+ ) -> int:
222
+ """Estimate the maximum number of jobs that can be run in parallel without encountering OOM errors."""
223
+ mem_usage_per_column = get_approximate_df_mem_usage(train_df)
224
+ num_columns = len(train_df.columns)
225
+ mem_usage_per_job = mem_usage_per_column.sum()
226
+ try:
227
+ mem_usage_per_job += model_cls.estimate_memory_usage_static(
228
+ X=train_df, hyperparameters=model_hyperparameters, problem_type="regression"
229
+ )
230
+ except NotImplementedError:
231
+ mem_usage_per_job *= 2
232
+ # Extra scaling factor because the preprocessed DF will have more columns for lags + date features
233
+ mem_usage_per_job *= overhead_factor + (num_extra_dynamic_features + num_columns) / num_columns
234
+ max_jobs_by_memory = int(ResourceManager.get_available_virtual_mem() / mem_usage_per_job)
235
+ return max(1, max_jobs_by_memory)
236
+
237
+ def preprocess(
238
+ self,
239
+ data: TimeSeriesDataFrame,
240
+ known_covariates: TimeSeriesDataFrame | None = None,
241
+ is_train: bool = False,
242
+ **kwargs,
243
+ ):
244
+ # TODO: Make this toggleable with a hyperparameter
245
+ # We add a scaled version of non-boolean known real covariates, same as in MLForecast models
246
+ if is_train:
247
+ for col in self.covariate_metadata.known_covariates_real:
248
+ if not set(data[col].unique()) == set([0, 1]):
249
+ self._non_boolean_real_covariates.append(col)
250
+
251
+ if len(self._non_boolean_real_covariates) > 0:
252
+ item_ids = data.index.get_level_values(level=TimeSeriesDataFrame.ITEMID)
253
+ scale_per_column: dict[str, pd.Series] = {}
254
+ columns_grouped = data[self._non_boolean_real_covariates].abs().groupby(item_ids)
255
+ for col in self._non_boolean_real_covariates:
256
+ scale_per_column[col] = columns_grouped[col].mean()
257
+ data = data.assign(**{f"__scaled_{col}": data[col] / scale for col, scale in scale_per_column.items()})
258
+ if known_covariates is not None:
259
+ known_covariates = known_covariates.assign(
260
+ **{f"__scaled_{col}": known_covariates[col] / scale for col, scale in scale_per_column.items()}
261
+ )
262
+ data = data.astype({self.target: "float32"})
263
+ return data, known_covariates
264
+
265
+ def _get_train_df(
266
+ self, train_data: TimeSeriesDataFrame, max_num_items: int | None, max_num_samples: int | None
267
+ ) -> pd.DataFrame:
268
+ if max_num_items is not None and train_data.num_items > max_num_items:
269
+ items_to_keep = train_data.item_ids.to_series().sample(n=int(max_num_items)) # noqa: F841
270
+ train_data = train_data.query("item_id in @items_to_keep")
271
+
272
+ if max_num_samples is not None and len(train_data) > max_num_samples:
273
+ max_samples_per_ts = max(200, math.ceil(max_num_samples / train_data.num_items))
274
+ self._max_ts_length = max_samples_per_ts + self.prediction_length
275
+ train_data = train_data.slice_by_timestep(-self._max_ts_length, None)
276
+
277
+ if len(self.covariate_metadata.past_covariates) > 0:
278
+ train_data = train_data.drop(columns=self.covariate_metadata.past_covariates)
279
+
280
+ train_df = train_data.to_data_frame().reset_index()
281
+ if train_data.static_features is not None:
282
+ train_df = pd.merge(
283
+ left=train_df,
284
+ right=train_data.static_features,
285
+ left_on=TimeSeriesDataFrame.ITEMID,
286
+ right_index=True,
287
+ how="left",
288
+ )
289
+ train_df = train_df.rename(columns=self._ag_to_nixtla)
290
+ train_df = train_df.assign(**{MLF_TARGET: train_df[MLF_TARGET].fillna(float("inf"))})
291
+ return train_df
292
+
293
+ @staticmethod
294
+ def _get_lags_for_step(
295
+ trailing_lags: list[int],
296
+ seasonal_lags: list[int],
297
+ step: int,
298
+ ) -> list[int]:
299
+ """Get the list of lags that can be used by the model for the given step."""
300
+ shifted_trailing_lags = [lag + step for lag in trailing_lags]
301
+ # Only keep lags that are available for model predicting `step` values ahead at prediction time
302
+ valid_lags = [lag for lag in shifted_trailing_lags + seasonal_lags if lag > step]
303
+ return sorted(set(valid_lags))
304
+
305
+ def _fit(
306
+ self,
307
+ train_data: TimeSeriesDataFrame,
308
+ val_data: TimeSeriesDataFrame | None = None,
309
+ time_limit: float | None = None,
310
+ num_cpus: int | None = None,
311
+ num_gpus: int | None = None,
312
+ verbosity: int = 2,
313
+ **kwargs,
314
+ ) -> None:
315
+ self._check_fit_params()
316
+ self._log_unused_hyperparameters()
317
+ model_params = self.get_hyperparameters()
318
+
319
+ train_df = self._get_train_df(
320
+ train_data,
321
+ max_num_items=model_params["max_num_items"],
322
+ max_num_samples=model_params["max_num_samples"],
323
+ )
324
+
325
+ # Initialize MLForecast arguments
326
+ assert self.freq is not None
327
+ trailing_lags = model_params.get("trailing_lags")
328
+ if trailing_lags is None:
329
+ trailing_lags = list(range(1, 13))
330
+ # Ensure that lags have type list[int] and not, e.g., np.ndarray
331
+ self._trailing_lags = [int(lag) for lag in trailing_lags]
332
+ assert all(lag >= 1 for lag in self._trailing_lags), "trailing_lags must be >= 1"
333
+
334
+ seasonal_lags = model_params.get("seasonal_lags")
335
+ if seasonal_lags is None:
336
+ median_ts_length = int(train_df[MLF_ITEMID].value_counts(sort=False).median())
337
+ seasonal_lags = get_lags_for_frequency(self.freq, num_default_lags=0, lag_ub=median_ts_length)
338
+ self._seasonal_lags = [int(lag) for lag in seasonal_lags]
339
+ assert all(lag >= 1 for lag in self._seasonal_lags), "seasonal_lags must be >= 1"
340
+
341
+ date_features = model_params.get("date_features")
342
+ if date_features is None:
343
+ date_features = get_time_features_for_frequency(self.freq)
344
+ self._date_features = date_features
345
+
346
+ model_name = model_params["model_name"]
347
+ self._model_cls = ag_model_registry.key_to_cls(model_name)
348
+ model_hyperparameters = model_params["model_hyperparameters"]
349
+ # User-provided n_jobs takes priority over the automatic estimate
350
+ if model_params.get("n_jobs") is not None:
351
+ self._n_jobs = model_params["n_jobs"]
352
+ else:
353
+ self._n_jobs = self._get_n_jobs(
354
+ train_df,
355
+ num_extra_dynamic_features=len(set(self._seasonal_lags + self._trailing_lags))
356
+ + len(self._date_features),
357
+ model_cls=self._model_cls,
358
+ model_hyperparameters=model_hyperparameters,
359
+ )
360
+ n_jobs = min(self._n_jobs, self.prediction_length, cpu_count(only_physical_cores=True))
361
+
362
+ num_cpus_per_model = max(cpu_count(only_physical_cores=True) // n_jobs, 1)
363
+ if time_limit is not None:
364
+ time_limit_per_model = time_limit / math.ceil(self.prediction_length / n_jobs)
365
+ else:
366
+ time_limit_per_model = None
367
+
368
+ if self.eval_metric.needs_quantile:
369
+ problem_type = QUANTILE
370
+ eval_metric = "pinball_loss"
371
+ else:
372
+ problem_type = REGRESSION
373
+ eval_metric = self.eval_metric.equivalent_tabular_regression_metric or "mean_absolute_error"
374
+
375
+ supported_problem_types = self._model_cls.supported_problem_types()
376
+ if supported_problem_types is not None and problem_type not in supported_problem_types:
377
+ raise ValueError(
378
+ f"Chosen model_name='{model_name}' cannot be used by {self.name} with eval_metric={self.eval_metric}"
379
+ f"because {model_name} does not support problem_type={problem_type} ({supported_problem_types=})"
380
+ )
381
+ model_fit_kwargs = dict(
382
+ train_df=train_df,
383
+ path_root=self.path,
384
+ model_cls=self._model_cls,
385
+ quantile_levels=self.quantile_levels,
386
+ validation_fraction=model_params["validation_fraction"],
387
+ problem_type=problem_type,
388
+ eval_metric=eval_metric,
389
+ date_features=self._date_features,
390
+ time_limit=time_limit_per_model,
391
+ num_cpus=num_cpus_per_model,
392
+ model_hyperparameters=model_hyperparameters.copy(),
393
+ verbosity=verbosity - 1,
394
+ )
395
+
396
+ logger.debug(f"Fitting models in parallel with {n_jobs=}, {num_cpus_per_model=}, {time_limit_per_model=}")
397
+ self._relative_paths_to_models = Parallel(n_jobs=n_jobs)( # type: ignore
398
+ delayed(self._fit_single_model)(
399
+ step=step,
400
+ lags=self._get_lags_for_step(
401
+ seasonal_lags=self._seasonal_lags, trailing_lags=self._trailing_lags, step=step
402
+ ),
403
+ **model_fit_kwargs,
404
+ )
405
+ for step in range(self.prediction_length)
406
+ )
407
+
408
+ @classmethod
409
+ def _get_residuals_std_path(cls, model_path: str) -> str:
410
+ """Path to the pd.Series storing the standard deviation of residuals for each item_id."""
411
+ return os.path.join(model_path, "residuals_std.pkl")
412
+
413
+ @classmethod
414
+ def _predict_with_single_model(
415
+ cls,
416
+ full_df: pd.DataFrame,
417
+ path_to_model: str,
418
+ model_cls: Type[AbstractTabularModel],
419
+ step: int,
420
+ quantile_levels: list[float],
421
+ prediction_length: int,
422
+ lags: list[int],
423
+ date_features: list[Callable],
424
+ ) -> np.ndarray:
425
+ """Make predictions with the model for the given step.
426
+
427
+ Returns
428
+ -------
429
+ predictions
430
+ Predictions of the model for the given step. Shape: (num_items, len(quantile_levels)).
431
+ """
432
+ from mlforecast import MLForecast
433
+
434
+ mlf = MLForecast(models=[], freq=cls._dummy_freq, lags=lags, date_features=date_features)
435
+
436
+ with warning_filter():
437
+ features_df = mlf.preprocess(full_df, static_features=[], dropna=False)
438
+ del mlf
439
+
440
+ end_idx_per_item = np.cumsum(features_df[MLF_ITEMID].value_counts(sort=False).to_numpy(dtype="int32"))
441
+ features_for_step = features_df.iloc[end_idx_per_item - (prediction_length - step)]
442
+ try:
443
+ model: AbstractTabularModel = model_cls.load(path_to_model) # type: ignore
444
+ except:
445
+ logger.error(f"Could not load model for {step=} from {path_to_model}")
446
+ raise
447
+ predictions = model.predict(features_for_step)
448
+ if model.problem_type == REGRESSION:
449
+ predictions = np.tile(predictions[:, None], (1, len(quantile_levels)))
450
+ residuals_std: pd.Series = load_pkl.load(cls._get_residuals_std_path(model.path))
451
+ item_ids = features_for_step[MLF_ITEMID]
452
+ residuals_repeated = residuals_std.reindex(item_ids).fillna(residuals_std.mean()).to_numpy()
453
+ for i, q in enumerate(quantile_levels):
454
+ predictions[:, i] += scipy.stats.norm.ppf(q) * residuals_repeated
455
+ return predictions
456
+
457
+ def _predict(
458
+ self,
459
+ data: TimeSeriesDataFrame,
460
+ known_covariates: TimeSeriesDataFrame | None = None,
461
+ **kwargs,
462
+ ) -> TimeSeriesDataFrame:
463
+ if known_covariates is not None:
464
+ X_df = known_covariates
465
+ else:
466
+ X_df = TimeSeriesDataFrame(
467
+ pd.DataFrame(float("inf"), index=self.get_forecast_horizon_index(data), columns=[self.target])
468
+ )
469
+ full_df = pd.concat([data, X_df])
470
+ if self._max_ts_length is not None:
471
+ full_df = full_df.slice_by_timestep(-(self._max_ts_length + self.prediction_length), None)
472
+ full_df = full_df.to_data_frame().reset_index()
473
+ if data.static_features is not None:
474
+ full_df = pd.merge(
475
+ full_df, data.static_features, left_on=TimeSeriesDataFrame.ITEMID, right_index=True, how="left"
476
+ )
477
+
478
+ full_df = (
479
+ full_df.rename(columns=self._ag_to_nixtla)
480
+ .sort_values(by=[MLF_ITEMID, MLF_TIMESTAMP])
481
+ .reset_index(drop=True)
482
+ )
483
+ full_df = full_df.assign(**{MLF_TARGET: full_df[MLF_TARGET].fillna(float("inf"))})
484
+
485
+ model_predict_kwargs = dict(
486
+ full_df=full_df,
487
+ quantile_levels=self.quantile_levels,
488
+ prediction_length=self.prediction_length,
489
+ model_cls=self._model_cls,
490
+ date_features=self._date_features,
491
+ )
492
+ n_jobs = min(self._n_jobs, self.prediction_length, cpu_count(only_physical_cores=True))
493
+ predictions_per_step = Parallel(n_jobs=n_jobs)(
494
+ delayed(self._predict_with_single_model)(
495
+ step=step,
496
+ lags=self._get_lags_for_step(
497
+ seasonal_lags=self._seasonal_lags, trailing_lags=self._trailing_lags, step=step
498
+ ),
499
+ path_to_model=os.path.join(self.path, suffix),
500
+ **model_predict_kwargs,
501
+ )
502
+ for step, suffix in enumerate(self._relative_paths_to_models)
503
+ )
504
+ predictions = pd.DataFrame(
505
+ np.stack(predictions_per_step, axis=1).reshape([-1, len(self.quantile_levels)]),
506
+ columns=[str(q) for q in self.quantile_levels],
507
+ index=self.get_forecast_horizon_index(data),
508
+ )
509
+ predictions["mean"] = predictions["0.5"]
510
+ return TimeSeriesDataFrame(predictions)
511
+
512
+ def _more_tags(self) -> dict[str, Any]:
513
+ return {"allow_nan": True, "can_refit_full": True}
@@ -0,0 +1,67 @@
1
+ from typing import Literal
2
+
3
+ import numpy as np
4
+ import pandas as pd
5
+ from mlforecast.target_transforms import (
6
+ BaseTargetTransform,
7
+ GroupedArray,
8
+ _BaseGroupedArrayTargetTransform,
9
+ )
10
+
11
+ from autogluon.timeseries.dataset import TimeSeriesDataFrame
12
+ from autogluon.timeseries.transforms.target_scaler import TargetScaler, get_target_scaler
13
+
14
+ from .utils import MLF_ITEMID, MLF_TIMESTAMP
15
+
16
+
17
+ class MLForecastScaler(BaseTargetTransform):
18
+ def __init__(self, scaler_type: Literal["standard", "min_max", "mean_abs", "robust"]):
19
+ # For backward compatibility
20
+ self.scaler_type: Literal["standard", "min_max", "mean_abs", "robust"] = scaler_type
21
+ self.ag_scaler: TargetScaler
22
+
23
+ def _df_to_tsdf(self, df: pd.DataFrame) -> TimeSeriesDataFrame:
24
+ return TimeSeriesDataFrame(
25
+ df.rename(
26
+ columns={self.id_col: TimeSeriesDataFrame.ITEMID, self.time_col: TimeSeriesDataFrame.TIMESTAMP}
27
+ ).set_index([TimeSeriesDataFrame.ITEMID, TimeSeriesDataFrame.TIMESTAMP])
28
+ )
29
+
30
+ def _tsdf_to_df(self, ts_df: TimeSeriesDataFrame) -> pd.DataFrame:
31
+ return (
32
+ pd.DataFrame(ts_df)
33
+ .reset_index()
34
+ .rename(columns={TimeSeriesDataFrame.ITEMID: self.id_col, TimeSeriesDataFrame.TIMESTAMP: self.time_col})
35
+ )
36
+
37
+ def fit_transform(self, df: pd.DataFrame) -> pd.DataFrame: # type: ignore
38
+ self.ag_scaler = get_target_scaler(name=self.scaler_type, target=self.target_col)
39
+ transformed = self.ag_scaler.fit_transform(self._df_to_tsdf(df))
40
+ return self._tsdf_to_df(transformed)
41
+
42
+ def inverse_transform(self, df: pd.DataFrame) -> pd.DataFrame: # type: ignore
43
+ assert self.ag_scaler is not None
44
+ transformed = self.ag_scaler.inverse_transform(self._df_to_tsdf(df))
45
+ return self._tsdf_to_df(transformed)
46
+
47
+
48
+ def apply_inverse_transform(
49
+ df: pd.DataFrame,
50
+ transform: _BaseGroupedArrayTargetTransform | BaseTargetTransform,
51
+ ) -> pd.DataFrame:
52
+ """Apply inverse transformation to a dataframe, converting to GroupedArray if necessary"""
53
+ if isinstance(transform, BaseTargetTransform):
54
+ inverse_transformed = transform.inverse_transform(df=df)
55
+ assert isinstance(inverse_transformed, pd.DataFrame)
56
+ return inverse_transformed
57
+ elif isinstance(transform, _BaseGroupedArrayTargetTransform):
58
+ indptr = np.concatenate([[0], df[MLF_ITEMID].value_counts().cumsum()])
59
+ assignment = {}
60
+ for col in df.columns.drop([MLF_ITEMID, MLF_TIMESTAMP]):
61
+ ga = GroupedArray(data=df[col].to_numpy(), indptr=indptr)
62
+ assignment[col] = transform.inverse_transform(ga).data
63
+ return df.assign(**assignment)
64
+ else:
65
+ raise ValueError(
66
+ f"transform must be of type `_BaseGroupedArrayTargetTransform` or `BaseTargetTransform` (got {type(transform)})"
67
+ )
@@ -1,51 +1,3 @@
1
- import numpy as np
2
- import pandas as pd
3
- from mlforecast.target_transforms import BaseTargetTransform
4
-
5
-
6
- class StandardScaler(BaseTargetTransform):
7
- """Standardizes the series by subtracting mean and diving by standard deviation."""
8
-
9
- min_scale: float = 1e-2
10
-
11
- def fit_transform(self, df: pd.DataFrame) -> pd.DataFrame:
12
- self.stats_ = (
13
- df.replace([np.inf, -np.inf], np.nan)
14
- .groupby(self.id_col)[self.target_col]
15
- .agg(["mean", "std"])
16
- .rename(columns={"mean": "_mean", "std": "_scale"})
17
- )
18
- self.stats_["_scale"] = self.stats_["_scale"].clip(lower=self.min_scale)
19
- df = df.merge(self.stats_, on=self.id_col)
20
- df[self.target_col] = (df[self.target_col] - df["_mean"]) / df["_scale"]
21
- df = df.drop(columns=["_mean", "_scale"])
22
- return df
23
-
24
- def inverse_transform(self, df: pd.DataFrame) -> pd.DataFrame:
25
- df = df.merge(self.stats_, on=self.id_col)
26
- for col in df.columns.drop([self.id_col, self.time_col, "_mean", "_scale"]):
27
- df[col] = df[col] * df["_scale"] + df["_mean"]
28
- df = df.drop(columns=["_mean", "_scale"])
29
- return df
30
-
31
-
32
- class MeanAbsScaler(BaseTargetTransform):
33
- """Scales time series by diving by their mean absolute value."""
34
-
35
- min_scale: float = 1e-2
36
-
37
- def fit_transform(self, df: pd.DataFrame) -> pd.DataFrame:
38
- target = df[self.target_col].replace([np.inf, -np.inf], np.nan).abs()
39
- self.stats_ = target.groupby(df[self.id_col], sort=False).agg(["mean"]).rename(columns={"mean": "_scale"})
40
- self.stats_["_scale"] = self.stats_["_scale"].clip(lower=self.min_scale)
41
- df = df.merge(self.stats_, on=self.id_col)
42
- df[self.target_col] = df[self.target_col] / df["_scale"]
43
- df = df.drop(columns=["_scale"])
44
- return df
45
-
46
- def inverse_transform(self, df: pd.DataFrame) -> pd.DataFrame:
47
- df = df.merge(self.stats_, on=self.id_col)
48
- for col in df.columns.drop([self.id_col, self.time_col, "_scale"]):
49
- df[col] = df[col] * df["_scale"]
50
- df = df.drop(columns=["_scale"])
51
- return df
1
+ MLF_TARGET = "y"
2
+ MLF_ITEMID = "unique_id"
3
+ MLF_TIMESTAMP = "ds"
@@ -0,0 +1,4 @@
1
+ from .chronos2 import Chronos2Model
2
+ from .model import ChronosModel
3
+
4
+ __all__ = ["ChronosModel", "Chronos2Model"]