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.
- autogluon/timeseries/configs/__init__.py +3 -2
- autogluon/timeseries/configs/hyperparameter_presets.py +62 -0
- autogluon/timeseries/configs/predictor_presets.py +84 -0
- autogluon/timeseries/dataset/ts_dataframe.py +339 -186
- autogluon/timeseries/learner.py +192 -60
- autogluon/timeseries/metrics/__init__.py +55 -11
- autogluon/timeseries/metrics/abstract.py +96 -25
- autogluon/timeseries/metrics/point.py +186 -39
- autogluon/timeseries/metrics/quantile.py +47 -20
- autogluon/timeseries/metrics/utils.py +6 -6
- autogluon/timeseries/models/__init__.py +13 -7
- autogluon/timeseries/models/abstract/__init__.py +2 -2
- autogluon/timeseries/models/abstract/abstract_timeseries_model.py +533 -273
- autogluon/timeseries/models/abstract/model_trial.py +10 -10
- autogluon/timeseries/models/abstract/tunable.py +189 -0
- autogluon/timeseries/models/autogluon_tabular/__init__.py +2 -0
- autogluon/timeseries/models/autogluon_tabular/mlforecast.py +369 -215
- autogluon/timeseries/models/autogluon_tabular/per_step.py +513 -0
- autogluon/timeseries/models/autogluon_tabular/transforms.py +67 -0
- autogluon/timeseries/models/autogluon_tabular/utils.py +3 -51
- autogluon/timeseries/models/chronos/__init__.py +4 -0
- autogluon/timeseries/models/chronos/chronos2.py +361 -0
- autogluon/timeseries/models/chronos/model.py +738 -0
- autogluon/timeseries/models/chronos/utils.py +369 -0
- autogluon/timeseries/models/ensemble/__init__.py +35 -2
- autogluon/timeseries/models/ensemble/{abstract_timeseries_ensemble.py → abstract.py} +50 -26
- autogluon/timeseries/models/ensemble/array_based/__init__.py +3 -0
- autogluon/timeseries/models/ensemble/array_based/abstract.py +236 -0
- autogluon/timeseries/models/ensemble/array_based/models.py +73 -0
- autogluon/timeseries/models/ensemble/array_based/regressor/__init__.py +12 -0
- autogluon/timeseries/models/ensemble/array_based/regressor/abstract.py +88 -0
- autogluon/timeseries/models/ensemble/array_based/regressor/linear_stacker.py +167 -0
- autogluon/timeseries/models/ensemble/array_based/regressor/per_quantile_tabular.py +94 -0
- autogluon/timeseries/models/ensemble/array_based/regressor/tabular.py +107 -0
- autogluon/timeseries/models/ensemble/ensemble_selection.py +167 -0
- autogluon/timeseries/models/ensemble/per_item_greedy.py +162 -0
- autogluon/timeseries/models/ensemble/weighted/__init__.py +8 -0
- autogluon/timeseries/models/ensemble/weighted/abstract.py +40 -0
- autogluon/timeseries/models/ensemble/weighted/basic.py +78 -0
- autogluon/timeseries/models/ensemble/weighted/greedy.py +57 -0
- autogluon/timeseries/models/gluonts/__init__.py +3 -1
- autogluon/timeseries/models/gluonts/abstract.py +583 -0
- autogluon/timeseries/models/gluonts/dataset.py +109 -0
- autogluon/timeseries/models/gluonts/{torch/models.py → models.py} +185 -44
- autogluon/timeseries/models/local/__init__.py +1 -10
- autogluon/timeseries/models/local/abstract_local_model.py +150 -97
- autogluon/timeseries/models/local/naive.py +31 -23
- autogluon/timeseries/models/local/npts.py +6 -2
- autogluon/timeseries/models/local/statsforecast.py +99 -112
- autogluon/timeseries/models/multi_window/multi_window_model.py +99 -40
- autogluon/timeseries/models/registry.py +64 -0
- autogluon/timeseries/models/toto/__init__.py +3 -0
- autogluon/timeseries/models/toto/_internal/__init__.py +9 -0
- autogluon/timeseries/models/toto/_internal/backbone/__init__.py +3 -0
- autogluon/timeseries/models/toto/_internal/backbone/attention.py +196 -0
- autogluon/timeseries/models/toto/_internal/backbone/backbone.py +262 -0
- autogluon/timeseries/models/toto/_internal/backbone/distribution.py +70 -0
- autogluon/timeseries/models/toto/_internal/backbone/kvcache.py +136 -0
- autogluon/timeseries/models/toto/_internal/backbone/rope.py +89 -0
- autogluon/timeseries/models/toto/_internal/backbone/rotary_embedding_torch.py +342 -0
- autogluon/timeseries/models/toto/_internal/backbone/scaler.py +305 -0
- autogluon/timeseries/models/toto/_internal/backbone/transformer.py +333 -0
- autogluon/timeseries/models/toto/_internal/dataset.py +165 -0
- autogluon/timeseries/models/toto/_internal/forecaster.py +423 -0
- autogluon/timeseries/models/toto/dataloader.py +108 -0
- autogluon/timeseries/models/toto/hf_pretrained_model.py +118 -0
- autogluon/timeseries/models/toto/model.py +236 -0
- autogluon/timeseries/predictor.py +826 -305
- autogluon/timeseries/regressor.py +253 -0
- autogluon/timeseries/splitter.py +10 -31
- autogluon/timeseries/trainer/__init__.py +2 -3
- autogluon/timeseries/trainer/ensemble_composer.py +439 -0
- autogluon/timeseries/trainer/model_set_builder.py +256 -0
- autogluon/timeseries/trainer/prediction_cache.py +149 -0
- autogluon/timeseries/trainer/trainer.py +1298 -0
- autogluon/timeseries/trainer/utils.py +17 -0
- autogluon/timeseries/transforms/__init__.py +2 -0
- autogluon/timeseries/transforms/covariate_scaler.py +164 -0
- autogluon/timeseries/transforms/target_scaler.py +149 -0
- autogluon/timeseries/utils/constants.py +10 -0
- autogluon/timeseries/utils/datetime/base.py +38 -20
- autogluon/timeseries/utils/datetime/lags.py +18 -16
- autogluon/timeseries/utils/datetime/seasonality.py +14 -14
- autogluon/timeseries/utils/datetime/time_features.py +17 -14
- autogluon/timeseries/utils/features.py +317 -53
- autogluon/timeseries/utils/forecast.py +31 -17
- autogluon/timeseries/utils/timer.py +173 -0
- autogluon/timeseries/utils/warning_filters.py +44 -6
- autogluon/timeseries/version.py +2 -1
- autogluon.timeseries-1.4.1b20251210-py3.11-nspkg.pth +1 -0
- {autogluon.timeseries-1.0.1b20240304.dist-info → autogluon_timeseries-1.4.1b20251210.dist-info}/METADATA +71 -47
- autogluon_timeseries-1.4.1b20251210.dist-info/RECORD +103 -0
- {autogluon.timeseries-1.0.1b20240304.dist-info → autogluon_timeseries-1.4.1b20251210.dist-info}/WHEEL +1 -1
- autogluon/timeseries/configs/presets_configs.py +0 -11
- autogluon/timeseries/evaluator.py +0 -6
- autogluon/timeseries/models/ensemble/greedy_ensemble.py +0 -170
- autogluon/timeseries/models/gluonts/abstract_gluonts.py +0 -550
- autogluon/timeseries/models/gluonts/torch/__init__.py +0 -0
- autogluon/timeseries/models/presets.py +0 -325
- autogluon/timeseries/trainer/abstract_trainer.py +0 -1144
- autogluon/timeseries/trainer/auto_trainer.py +0 -74
- autogluon.timeseries-1.0.1b20240304-py3.8-nspkg.pth +0 -1
- autogluon.timeseries-1.0.1b20240304.dist-info/RECORD +0 -58
- {autogluon.timeseries-1.0.1b20240304.dist-info → autogluon_timeseries-1.4.1b20251210.dist-info/licenses}/LICENSE +0 -0
- {autogluon.timeseries-1.0.1b20240304.dist-info → autogluon_timeseries-1.4.1b20251210.dist-info/licenses}/NOTICE +0 -0
- {autogluon.timeseries-1.0.1b20240304.dist-info → autogluon_timeseries-1.4.1b20251210.dist-info}/namespace_packages.txt +0 -0
- {autogluon.timeseries-1.0.1b20240304.dist-info → autogluon_timeseries-1.4.1b20251210.dist-info}/top_level.txt +0 -0
- {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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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"
|