h2ofm 0.42.0__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.
- h2ofm/__init__.py +10 -0
- h2ofm/py.typed +0 -0
- h2ofm/timeh2o/__init__.py +6 -0
- h2ofm/timeh2o/_api.py +630 -0
- h2ofm/timeh2o/_checkpoints.py +139 -0
- h2ofm/timeh2o/_data/__init__.py +2 -0
- h2ofm/timeh2o/_data/dataset.py +80 -0
- h2ofm/timeh2o/_data/encoding.py +108 -0
- h2ofm/timeh2o/_data/preprocess.py +179 -0
- h2ofm/timeh2o/_model/__init__.py +2 -0
- h2ofm/timeh2o/_model/_refinement.py +134 -0
- h2ofm/timeh2o/_model/config.py +117 -0
- h2ofm/timeh2o/_model/flipeq.py +98 -0
- h2ofm/timeh2o/_model/layers.py +29 -0
- h2ofm/timeh2o/_model/model.py +341 -0
- h2ofm/timeh2o/_model/scaler.py +201 -0
- h2ofm/timeh2o/_pipeline/__init__.py +2 -0
- h2ofm/timeh2o/_pipeline/decoding.py +160 -0
- h2ofm/timeh2o/_pipeline/df_utils.py +375 -0
- h2ofm/timeh2o/_pipeline/pipeline.py +232 -0
- h2ofm/timeh2o/_pipeline/postprocess.py +47 -0
- h2ofm/timeh2o/_pipeline/tta.py +160 -0
- h2ofm/timeh2o/_pipeline/utils.py +168 -0
- h2ofm/timeh2o/_warnings.py +17 -0
- h2ofm-0.42.0.dist-info/METADATA +196 -0
- h2ofm-0.42.0.dist-info/RECORD +30 -0
- h2ofm-0.42.0.dist-info/WHEEL +5 -0
- h2ofm-0.42.0.dist-info/licenses/LICENSE +175 -0
- h2ofm-0.42.0.dist-info/licenses/THIRD_PARTY.md +33 -0
- h2ofm-0.42.0.dist-info/top_level.txt +1 -0
h2ofm/__init__.py
ADDED
h2ofm/py.typed
ADDED
|
File without changes
|
h2ofm/timeh2o/_api.py
ADDED
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
# Copyright 2026 H2O.ai, Inc.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import math
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from numbers import Integral, Real
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
import pandas as pd
|
|
14
|
+
import torch
|
|
15
|
+
from pandas.api import types as ptypes
|
|
16
|
+
|
|
17
|
+
from ._checkpoints import load_checkpoint, locate_checkpoint
|
|
18
|
+
from ._data.preprocess import from_arrays
|
|
19
|
+
from ._model.config import TimeH2OConfig
|
|
20
|
+
from ._pipeline.df_utils import (
|
|
21
|
+
ITEM,
|
|
22
|
+
ROW_ORDER_FREQ,
|
|
23
|
+
STEP,
|
|
24
|
+
TIME,
|
|
25
|
+
datetimes_to_integer_times,
|
|
26
|
+
infer_freq_from_df,
|
|
27
|
+
is_integer_time_column,
|
|
28
|
+
prepare_frames,
|
|
29
|
+
)
|
|
30
|
+
from ._pipeline.pipeline import TimeH2OPipeline
|
|
31
|
+
from ._pipeline.postprocess import clip_frame, clip_targets, nonnegative_targets
|
|
32
|
+
from ._pipeline.tta import blend_items, reflect_arrays
|
|
33
|
+
from ._pipeline.utils import extrapolate_quantiles, validate_quantile_levels
|
|
34
|
+
from ._warnings import warn
|
|
35
|
+
|
|
36
|
+
Array = np.ndarray | torch.Tensor
|
|
37
|
+
DEFAULT_QUANTILES = tuple(i / 10 for i in range(1, 10))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class Forecast:
|
|
42
|
+
"""The forecast of one input item, as CPU NumPy arrays.
|
|
43
|
+
|
|
44
|
+
A 1D history gives ``point`` shaped (horizon,) and ``quantiles`` shaped
|
|
45
|
+
(horizon, levels); a 2D (targets, history) item keeps its target axis,
|
|
46
|
+
(targets, horizon) and (targets, horizon, levels). ``point`` holds medians,
|
|
47
|
+
not means. The last quantile axis follows ``quantile_levels``, in the
|
|
48
|
+
order they were requested.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
point: np.ndarray
|
|
52
|
+
quantiles: np.ndarray
|
|
53
|
+
quantile_levels: tuple[float, ...]
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def median(self) -> np.ndarray:
|
|
57
|
+
"""The point forecast under its precise name; the same array as
|
|
58
|
+
``point``."""
|
|
59
|
+
return self.point
|
|
60
|
+
|
|
61
|
+
def quantile(self, level: float) -> np.ndarray:
|
|
62
|
+
"""The forecast at one requested quantile level, shaped like
|
|
63
|
+
``point``. Raises KeyError for a level that was not requested."""
|
|
64
|
+
if isinstance(level, bool) or not isinstance(level, Real):
|
|
65
|
+
raise TypeError(
|
|
66
|
+
f"quantile level must be a number, e.g. quantile(0.9); got {level!r}"
|
|
67
|
+
)
|
|
68
|
+
for index, requested in enumerate(self.quantile_levels):
|
|
69
|
+
if math.isclose(requested, level, rel_tol=0.0, abs_tol=1e-9):
|
|
70
|
+
return self.quantiles[..., index]
|
|
71
|
+
raise KeyError(
|
|
72
|
+
f"quantile level {level} was not requested; "
|
|
73
|
+
f"available: {list(self.quantile_levels)}"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _positive_int(value, name):
|
|
78
|
+
if isinstance(value, bool) or not isinstance(value, Integral) or value < 1:
|
|
79
|
+
raise ValueError(f"{name} must be a positive integer")
|
|
80
|
+
return int(value)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _flag(value, name):
|
|
84
|
+
if not isinstance(value, bool):
|
|
85
|
+
raise ValueError(f"{name} must be a boolean")
|
|
86
|
+
return value
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _real_tensor(item, what: str) -> torch.Tensor:
|
|
90
|
+
if isinstance(item, torch.Tensor):
|
|
91
|
+
if item.is_complex():
|
|
92
|
+
raise ValueError(f"{what} must contain real numeric values")
|
|
93
|
+
return item.detach().to(device="cpu", dtype=torch.float32).clone()
|
|
94
|
+
array = np.asarray(item)
|
|
95
|
+
if array.dtype.kind not in "biuf":
|
|
96
|
+
raise ValueError(f"{what} must contain real numeric values")
|
|
97
|
+
return torch.tensor(np.array(array, dtype=np.float32, copy=True))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _series(item) -> tuple[torch.Tensor, bool]:
|
|
101
|
+
"""One series as a (rows, history) tensor, and whether it was 1D."""
|
|
102
|
+
if isinstance(item, pd.DataFrame):
|
|
103
|
+
raise TypeError("use forecast_df() for dataframes")
|
|
104
|
+
if isinstance(item, (str, bytes)):
|
|
105
|
+
raise TypeError("A series must be an array, tensor or sequence of numbers")
|
|
106
|
+
item = _real_tensor(item, "Series")
|
|
107
|
+
if item.ndim not in (1, 2) or any(size == 0 for size in item.shape):
|
|
108
|
+
raise ValueError(
|
|
109
|
+
"A series must have nonempty shape (history,) or (targets, history); "
|
|
110
|
+
"use forecast_many() for several series"
|
|
111
|
+
)
|
|
112
|
+
if torch.isinf(item).any():
|
|
113
|
+
raise ValueError(
|
|
114
|
+
"Series may contain NaN for missing observations, but not infinity"
|
|
115
|
+
)
|
|
116
|
+
return item.reshape(-1, item.shape[-1]), item.ndim == 1
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _batch(contexts) -> tuple[list[torch.Tensor], list[bool]]:
|
|
120
|
+
"""Each series of a batch: a sequence, or an array iterated along axis 0."""
|
|
121
|
+
if isinstance(contexts, pd.DataFrame):
|
|
122
|
+
raise TypeError("use forecast_df() for dataframes")
|
|
123
|
+
if isinstance(contexts, (np.ndarray, torch.Tensor)):
|
|
124
|
+
if contexts.ndim not in (2, 3):
|
|
125
|
+
raise ValueError(
|
|
126
|
+
"An array of contexts must be 2D (series, history) or 3D "
|
|
127
|
+
"(series, targets, history); use forecast() for one series"
|
|
128
|
+
)
|
|
129
|
+
items = list(contexts)
|
|
130
|
+
elif isinstance(contexts, Sequence) and not isinstance(contexts, (str, bytes)):
|
|
131
|
+
if contexts and all(isinstance(v, Real) for v in contexts):
|
|
132
|
+
raise TypeError(
|
|
133
|
+
"contexts is a single series of numbers; use forecast() for one "
|
|
134
|
+
"series, or wrap it in a list"
|
|
135
|
+
)
|
|
136
|
+
items = list(contexts)
|
|
137
|
+
else:
|
|
138
|
+
raise TypeError("contexts must be a sequence of series")
|
|
139
|
+
if not items:
|
|
140
|
+
raise ValueError("contexts must contain at least one series")
|
|
141
|
+
series = [_series(item) for item in items]
|
|
142
|
+
return [t for t, _ in series], [u for _, u in series]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
_ROW_TYPES = (
|
|
146
|
+
np.ndarray,
|
|
147
|
+
torch.Tensor,
|
|
148
|
+
pd.Series,
|
|
149
|
+
pd.Index,
|
|
150
|
+
pd.Categorical,
|
|
151
|
+
list,
|
|
152
|
+
tuple,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _covariate_row(values, name: str) -> torch.Tensor | np.ndarray:
|
|
157
|
+
"""Convert non-boolean numeric rows to float32; keep other dtypes as labels."""
|
|
158
|
+
if isinstance(values, torch.Tensor):
|
|
159
|
+
values = values.detach().cpu()
|
|
160
|
+
if values.is_complex():
|
|
161
|
+
raise ValueError(f"{name} must contain real numbers or labels")
|
|
162
|
+
values = (values if values.dtype == torch.bool else values.float()).numpy()
|
|
163
|
+
if isinstance(values, np.ndarray):
|
|
164
|
+
if values.dtype.kind == "c":
|
|
165
|
+
raise ValueError(f"{name} must contain real numbers or labels")
|
|
166
|
+
if values.dtype.kind not in "iuf":
|
|
167
|
+
return values.astype(object)
|
|
168
|
+
row = torch.from_numpy(np.array(values, dtype=np.float32))
|
|
169
|
+
else:
|
|
170
|
+
series = values if isinstance(values, pd.Series) else pd.Series(values)
|
|
171
|
+
if ptypes.is_complex_dtype(series):
|
|
172
|
+
raise ValueError(f"{name} must contain real numbers or labels")
|
|
173
|
+
if not ptypes.is_numeric_dtype(series) or ptypes.is_bool_dtype(series):
|
|
174
|
+
return series.to_numpy(dtype=object)
|
|
175
|
+
row = torch.from_numpy(
|
|
176
|
+
series.to_numpy(dtype=np.float32, na_value=np.nan, copy=True)
|
|
177
|
+
)
|
|
178
|
+
if torch.isinf(row).any():
|
|
179
|
+
raise ValueError(f"{name} may contain NaN, but not infinity")
|
|
180
|
+
return row
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _covariate_items(covariates, items, name, extra) -> list[list | None]:
|
|
184
|
+
"""Parse covariate rows per item, spanning history plus extra steps."""
|
|
185
|
+
span = "history + horizon" if extra else "history"
|
|
186
|
+
if covariates is None:
|
|
187
|
+
return [None] * len(items)
|
|
188
|
+
if isinstance(covariates, (str, bytes)) or not isinstance(
|
|
189
|
+
covariates, (Sequence, np.ndarray, torch.Tensor)
|
|
190
|
+
):
|
|
191
|
+
raise TypeError(f"{name} must be a sequence with one entry per item")
|
|
192
|
+
if len(covariates) != len(items):
|
|
193
|
+
raise ValueError(f"{name} has {len(covariates)} entries for {len(items)} items")
|
|
194
|
+
result = []
|
|
195
|
+
for index, (entry, item) in enumerate(zip(covariates, items, strict=True)):
|
|
196
|
+
if entry is None:
|
|
197
|
+
result.append(None)
|
|
198
|
+
continue
|
|
199
|
+
length = item.shape[-1] + extra
|
|
200
|
+
if isinstance(entry, (np.ndarray, torch.Tensor)) and entry.ndim == 2:
|
|
201
|
+
parts, shape = list(entry), tuple(entry.shape)
|
|
202
|
+
elif isinstance(entry, (np.ndarray, torch.Tensor)):
|
|
203
|
+
parts, shape = ([entry] if entry.ndim == 1 else None), tuple(entry.shape)
|
|
204
|
+
elif isinstance(entry, (pd.Series, pd.Index, pd.Categorical)):
|
|
205
|
+
parts, shape = [entry], (len(entry),)
|
|
206
|
+
elif isinstance(entry, Sequence) and not isinstance(entry, (str, bytes)):
|
|
207
|
+
rows = bool(entry) and all(isinstance(r, _ROW_TYPES) for r in entry)
|
|
208
|
+
parts = list(entry) if rows else [entry]
|
|
209
|
+
shape = (len(parts), len(parts[0])) if rows else (len(entry),)
|
|
210
|
+
else:
|
|
211
|
+
raise TypeError(f"{name}[{index}] must be an array or a sequence of rows")
|
|
212
|
+
if (
|
|
213
|
+
parts is None
|
|
214
|
+
or shape[-1:] != (length,)
|
|
215
|
+
or any(len(row) != length for row in parts)
|
|
216
|
+
):
|
|
217
|
+
lengths = [len(row) for row in parts or ()]
|
|
218
|
+
got = f"rows of lengths {lengths}" if len(set(lengths)) > 1 else shape
|
|
219
|
+
raise ValueError(
|
|
220
|
+
f"{name}[{index}] must have shape ({span},) or "
|
|
221
|
+
f"(covariates, {span}) with {span} = {length}; got {got}"
|
|
222
|
+
)
|
|
223
|
+
rows = [_covariate_row(row, name) for row in parts]
|
|
224
|
+
result.append(rows or None)
|
|
225
|
+
return result
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _warn_past_covariates_ignored(what: str) -> None:
|
|
229
|
+
warn(
|
|
230
|
+
f"This checkpoint does not use past covariates; {what} are validated "
|
|
231
|
+
"and ignored. Pass covariates known through the horizon as future "
|
|
232
|
+
"covariates instead."
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class TimeH2O:
|
|
237
|
+
"""A loaded TimeH2O checkpoint.
|
|
238
|
+
|
|
239
|
+
Construct with :meth:`from_pretrained` and reuse the instance; nothing is
|
|
240
|
+
cached globally. :meth:`forecast` takes one series, :meth:`forecast_many`
|
|
241
|
+
several and :meth:`forecast_df` a long-format dataframe.
|
|
242
|
+
"""
|
|
243
|
+
|
|
244
|
+
def __init__(self, pipeline: TimeH2OPipeline, batch_size: int = 256):
|
|
245
|
+
self._pipeline = pipeline
|
|
246
|
+
self._batch_size = _positive_int(batch_size, "batch_size")
|
|
247
|
+
|
|
248
|
+
@classmethod
|
|
249
|
+
def from_pretrained(
|
|
250
|
+
cls,
|
|
251
|
+
pretrained_model_name_or_path: str | Path,
|
|
252
|
+
*,
|
|
253
|
+
device: str | torch.device = "auto",
|
|
254
|
+
revision: str | None = None,
|
|
255
|
+
token: str | bool | None = None,
|
|
256
|
+
cache_dir: str | Path | None = None,
|
|
257
|
+
local_files_only: bool = False,
|
|
258
|
+
batch_size: int = 256,
|
|
259
|
+
) -> TimeH2O:
|
|
260
|
+
"""Load a checkpoint: a directory with config.json and model.safetensors.
|
|
261
|
+
|
|
262
|
+
``pretrained_model_name_or_path`` is that directory, the
|
|
263
|
+
model.safetensors file inside it, or a Hugging Face model ID such as
|
|
264
|
+
``"h2oai/timeh2o-v1-tiny"``, as in transformers. A path that
|
|
265
|
+
exists locally takes precedence over a model ID. From the Hub only config.json and
|
|
266
|
+
model.safetensors are fetched, into the Hugging Face cache;
|
|
267
|
+
``revision``, ``token``, ``cache_dir`` and ``local_files_only`` apply
|
|
268
|
+
to that download only. config.json defines the architecture; every
|
|
269
|
+
weight must match it. Pickles and remote code are never loaded.
|
|
270
|
+
'auto' selects CUDA when available, otherwise CPU. All inference uses
|
|
271
|
+
float32 weights. ``batch_size`` caps the rows (targets and covariates
|
|
272
|
+
of whole series; targets only without cross-variate attention) the
|
|
273
|
+
model runs at once; it changes speed and memory, not forecasts.
|
|
274
|
+
"""
|
|
275
|
+
path = locate_checkpoint(
|
|
276
|
+
pretrained_model_name_or_path,
|
|
277
|
+
revision=revision,
|
|
278
|
+
token=token,
|
|
279
|
+
cache_dir=cache_dir,
|
|
280
|
+
local_files_only=local_files_only,
|
|
281
|
+
)
|
|
282
|
+
return cls(TimeH2OPipeline(load_checkpoint(path, device)), batch_size)
|
|
283
|
+
|
|
284
|
+
@property
|
|
285
|
+
def config(self) -> TimeH2OConfig:
|
|
286
|
+
"""The checkpoint's validated config.json."""
|
|
287
|
+
return self._pipeline.model.config
|
|
288
|
+
|
|
289
|
+
@property
|
|
290
|
+
def device(self) -> torch.device:
|
|
291
|
+
return self._pipeline.model.device
|
|
292
|
+
|
|
293
|
+
@property
|
|
294
|
+
def batch_size(self) -> int:
|
|
295
|
+
"""The most rows run at once; set in :meth:`from_pretrained`."""
|
|
296
|
+
return self._batch_size
|
|
297
|
+
|
|
298
|
+
def _options(self, horizon, quantile_levels, tta, clip, context, ar):
|
|
299
|
+
horizon = _positive_int(horizon, "horizon")
|
|
300
|
+
flip = _flag(tta, "flip_equivariant") and not self.config.flip_equivariant
|
|
301
|
+
_flag(clip, "clip_nonnegative")
|
|
302
|
+
if ar is not None and not isinstance(ar, bool):
|
|
303
|
+
raise ValueError("autoregressive must be a boolean or None")
|
|
304
|
+
levels = [float(q) for q in quantile_levels]
|
|
305
|
+
validate_quantile_levels(levels)
|
|
306
|
+
if len(set(levels)) != len(levels):
|
|
307
|
+
raise ValueError("quantile_levels must not contain duplicates")
|
|
308
|
+
options = dict(
|
|
309
|
+
batch_size=self._batch_size,
|
|
310
|
+
context_length=(
|
|
311
|
+
None if context is None else _positive_int(context, "context_length")
|
|
312
|
+
),
|
|
313
|
+
autoregressive=ar,
|
|
314
|
+
)
|
|
315
|
+
return horizon, levels, flip, options
|
|
316
|
+
|
|
317
|
+
@torch.no_grad()
|
|
318
|
+
def forecast(
|
|
319
|
+
self,
|
|
320
|
+
context: Array | Sequence[float],
|
|
321
|
+
horizon: int,
|
|
322
|
+
*,
|
|
323
|
+
past_covariates: Array | Sequence[float] | None = None,
|
|
324
|
+
future_covariates: Array | Sequence[float] | None = None,
|
|
325
|
+
quantile_levels: Sequence[float] = DEFAULT_QUANTILES,
|
|
326
|
+
flip_equivariant: bool = False,
|
|
327
|
+
clip_nonnegative: bool = False,
|
|
328
|
+
context_length: int | None = None,
|
|
329
|
+
autoregressive: bool | None = None,
|
|
330
|
+
) -> Forecast:
|
|
331
|
+
"""Forecast one series ``horizon`` steps ahead.
|
|
332
|
+
|
|
333
|
+
``context`` is a 1D (history,) series or a 2D (targets, history)
|
|
334
|
+
multi-target series. ``past_covariates`` and
|
|
335
|
+
``future_covariates`` are that series' covariates, shaped as described
|
|
336
|
+
in :meth:`forecast_many`. Returns a :class:`Forecast`; every option
|
|
337
|
+
behaves as in :meth:`forecast_many`.
|
|
338
|
+
"""
|
|
339
|
+
target, univariate = _series(context)
|
|
340
|
+
return self._forecast(
|
|
341
|
+
[target],
|
|
342
|
+
[univariate],
|
|
343
|
+
[past_covariates],
|
|
344
|
+
[future_covariates],
|
|
345
|
+
horizon,
|
|
346
|
+
quantile_levels,
|
|
347
|
+
flip_equivariant,
|
|
348
|
+
clip_nonnegative,
|
|
349
|
+
context_length,
|
|
350
|
+
autoregressive,
|
|
351
|
+
)[0]
|
|
352
|
+
|
|
353
|
+
@torch.no_grad()
|
|
354
|
+
def forecast_many(
|
|
355
|
+
self,
|
|
356
|
+
contexts: Sequence[Array | Sequence[float]] | Array,
|
|
357
|
+
horizon: int,
|
|
358
|
+
*,
|
|
359
|
+
past_covariates: Sequence[Array | Sequence[float] | None] | None = None,
|
|
360
|
+
future_covariates: Sequence[Array | Sequence[float] | None] | None = None,
|
|
361
|
+
quantile_levels: Sequence[float] = DEFAULT_QUANTILES,
|
|
362
|
+
flip_equivariant: bool = False,
|
|
363
|
+
clip_nonnegative: bool = False,
|
|
364
|
+
context_length: int | None = None,
|
|
365
|
+
autoregressive: bool | None = None,
|
|
366
|
+
) -> list[Forecast]:
|
|
367
|
+
"""Forecast several series ``horizon`` steps ahead.
|
|
368
|
+
|
|
369
|
+
``contexts`` is a sequence of series, each a 1D (history,) series or a
|
|
370
|
+
2D (targets, history) multi-target series;
|
|
371
|
+
histories may differ in length. A 2D array is a batch of 1D series and
|
|
372
|
+
a 3D array a batch of 2D series. NaN marks a missing observation; an
|
|
373
|
+
all-missing target is a zero-valued cold start. Returns one
|
|
374
|
+
:class:`Forecast` per series, in input order.
|
|
375
|
+
|
|
376
|
+
``past_covariates`` and ``future_covariates`` hold one entry per
|
|
377
|
+
series: None, or its covariates, with NaN for a missing value. Past
|
|
378
|
+
covariates are observed over the history only, shaped (history,) or
|
|
379
|
+
(covariates, history). The timeh2o-v1 checkpoints do not use them:
|
|
380
|
+
they are validated, then ignored. Future covariates are
|
|
381
|
+
observed over the history and known through the horizon, shaped
|
|
382
|
+
(history + horizon,) or (covariates, history + horizon). An entry
|
|
383
|
+
may also be a sequence of such rows. A row with a numeric,
|
|
384
|
+
non-boolean dtype is numeric; any other row (strings, objects,
|
|
385
|
+
booleans, categoricals) holds labels, encoded as :meth:`forecast_df`
|
|
386
|
+
encodes a categorical column, with None or NaN as a missing label.
|
|
387
|
+
|
|
388
|
+
``flip_equivariant=True``: flipping a history upside down (covariates
|
|
389
|
+
included) flips its forecast. Free for checkpoints that are
|
|
390
|
+
flip-equivariant by design (``config.flip_equivariant``); others
|
|
391
|
+
average with a flipped forecast, which roughly doubles inference time.
|
|
392
|
+
``clip_nonnegative=True`` clamps forecasts at zero for targets with a
|
|
393
|
+
nonnegative history; it runs last, after the flip blend and quantile
|
|
394
|
+
extrapolation.
|
|
395
|
+
|
|
396
|
+
``quantile_levels`` outside the trained 0.1..0.9 grid use
|
|
397
|
+
Gaussian-tail extrapolation. ``context_length`` may shorten the
|
|
398
|
+
checkpoint's context.
|
|
399
|
+
``autoregressive=True`` decodes horizons beyond the checkpoint's
|
|
400
|
+
max_horizon in blocks, False in a single pass; None follows the
|
|
401
|
+
checkpoint's saved default.
|
|
402
|
+
"""
|
|
403
|
+
targets, univariate = _batch(contexts)
|
|
404
|
+
size = len(targets)
|
|
405
|
+
return self._forecast(
|
|
406
|
+
targets,
|
|
407
|
+
univariate,
|
|
408
|
+
[None] * size if past_covariates is None else past_covariates,
|
|
409
|
+
[None] * size if future_covariates is None else future_covariates,
|
|
410
|
+
horizon,
|
|
411
|
+
quantile_levels,
|
|
412
|
+
flip_equivariant,
|
|
413
|
+
clip_nonnegative,
|
|
414
|
+
context_length,
|
|
415
|
+
autoregressive,
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
def _forecast(
|
|
419
|
+
self,
|
|
420
|
+
targets,
|
|
421
|
+
univariate,
|
|
422
|
+
past_covariates,
|
|
423
|
+
future_covariates,
|
|
424
|
+
horizon,
|
|
425
|
+
quantile_levels,
|
|
426
|
+
flip_equivariant,
|
|
427
|
+
clip_nonnegative,
|
|
428
|
+
context_length,
|
|
429
|
+
autoregressive,
|
|
430
|
+
) -> list[Forecast]:
|
|
431
|
+
"""Forecast parsed series; the covariates hold one entry per series."""
|
|
432
|
+
horizon, levels, flip, options = self._options(
|
|
433
|
+
horizon,
|
|
434
|
+
quantile_levels,
|
|
435
|
+
flip_equivariant,
|
|
436
|
+
clip_nonnegative,
|
|
437
|
+
context_length,
|
|
438
|
+
autoregressive,
|
|
439
|
+
)
|
|
440
|
+
past = _covariate_items(past_covariates, targets, "past_covariates", 0)
|
|
441
|
+
if any(entry is not None for entry in past):
|
|
442
|
+
_warn_past_covariates_ignored("past_covariates")
|
|
443
|
+
covariates = _covariate_items(
|
|
444
|
+
future_covariates, targets, "future_covariates", horizon
|
|
445
|
+
)
|
|
446
|
+
predictions = self._predict_arrays(targets, covariates, horizon, options)
|
|
447
|
+
if flip:
|
|
448
|
+
reflected = [
|
|
449
|
+
reflect_arrays(target, known)
|
|
450
|
+
for target, known in zip(targets, covariates, strict=True)
|
|
451
|
+
]
|
|
452
|
+
predictions = blend_items(
|
|
453
|
+
predictions,
|
|
454
|
+
self._predict_arrays(
|
|
455
|
+
[target for target, _, _ in reflected],
|
|
456
|
+
[known for _, known, _ in reflected],
|
|
457
|
+
horizon,
|
|
458
|
+
options,
|
|
459
|
+
),
|
|
460
|
+
[means for _, _, means in reflected],
|
|
461
|
+
)
|
|
462
|
+
native = self._pipeline.quantiles
|
|
463
|
+
# One call for all items: quantile maps act row by row.
|
|
464
|
+
extrapolated = extrapolate_quantiles(
|
|
465
|
+
levels, native, torch.cat(predictions).transpose(1, 2)
|
|
466
|
+
).numpy()
|
|
467
|
+
results, start = [], 0
|
|
468
|
+
for prediction, target, squeeze in zip(
|
|
469
|
+
predictions, targets, univariate, strict=True
|
|
470
|
+
):
|
|
471
|
+
point = prediction[:, native.index(0.5)].numpy()
|
|
472
|
+
stop = start + prediction.shape[0]
|
|
473
|
+
quantiles = extrapolated[start:stop].copy()
|
|
474
|
+
start = stop
|
|
475
|
+
if clip_nonnegative:
|
|
476
|
+
clamp = nonnegative_targets(target)
|
|
477
|
+
point = clip_targets(point, clamp)
|
|
478
|
+
quantiles = clip_targets(quantiles, clamp)
|
|
479
|
+
if squeeze:
|
|
480
|
+
point, quantiles = point[0], quantiles[0]
|
|
481
|
+
results.append(Forecast(point, quantiles, tuple(levels)))
|
|
482
|
+
return results
|
|
483
|
+
|
|
484
|
+
def _predict_arrays(self, targets, covariates, horizon, options):
|
|
485
|
+
"""Return (targets, trained quantiles, horizon) forecasts per series."""
|
|
486
|
+
items = from_arrays(targets, covariates, horizon)
|
|
487
|
+
flat = self._pipeline.predict(
|
|
488
|
+
[item for parts in items for item in parts], horizon, **options
|
|
489
|
+
)
|
|
490
|
+
joined, cursor = [], 0
|
|
491
|
+
for parts in items:
|
|
492
|
+
joined.append(torch.cat(flat[cursor : cursor + len(parts)]))
|
|
493
|
+
cursor += len(parts)
|
|
494
|
+
return joined
|
|
495
|
+
|
|
496
|
+
@torch.no_grad()
|
|
497
|
+
def forecast_df(
|
|
498
|
+
self,
|
|
499
|
+
context: pd.DataFrame,
|
|
500
|
+
horizon: int | pd.DataFrame,
|
|
501
|
+
*,
|
|
502
|
+
target: str | list[str] = "target",
|
|
503
|
+
id_column: str | None = None,
|
|
504
|
+
time_column: str | None = None,
|
|
505
|
+
freq: str | int | None = None,
|
|
506
|
+
quantile_levels: Sequence[float] = DEFAULT_QUANTILES,
|
|
507
|
+
flip_equivariant: bool = False,
|
|
508
|
+
clip_nonnegative: bool = False,
|
|
509
|
+
context_length: int | None = None,
|
|
510
|
+
autoregressive: bool | None = None,
|
|
511
|
+
) -> pd.DataFrame:
|
|
512
|
+
"""Forecast from historical rows, ``horizon`` steps or rows ahead.
|
|
513
|
+
|
|
514
|
+
``context`` contains only history, including any missing observations.
|
|
515
|
+
``horizon`` is the number of steps to forecast per item, or a frame of
|
|
516
|
+
horizon rows. In a horizon frame, target columns must be absent or
|
|
517
|
+
entirely missing; every covariate column it has is future-known, even
|
|
518
|
+
when all its horizon values are missing, while covariates absent from
|
|
519
|
+
it are past-only and ignored by v1. It must have the same items as
|
|
520
|
+
``context`` and an equal number of rows per item, which sets the
|
|
521
|
+
horizon. With an integer ``horizon``, every covariate is past-only.
|
|
522
|
+
|
|
523
|
+
``target`` names one target column or a list of target columns. Omit
|
|
524
|
+
``id_column`` for one series, and ``time_column`` to use per-item row order.
|
|
525
|
+
The time column may contain datetimes, datetime strings, or integer
|
|
526
|
+
values representing time steps; integer times are never converted to
|
|
527
|
+
dates. Supplied future times must continue history at its inferred
|
|
528
|
+
frequency, or ``freq`` when supplied: a pandas offset alias for
|
|
529
|
+
datetimes, the integer step between time values for integer times.
|
|
530
|
+
Inputs are never mutated.
|
|
531
|
+
|
|
532
|
+
Returns ``id_column`` (when given), ``time_column`` or ``step`` (1 to
|
|
533
|
+
``horizon``), then ``target_name``, ``predictions`` (the median), and one
|
|
534
|
+
column per requested quantile, named like ``"0.1"``. Augmentation and
|
|
535
|
+
decoding options behave as in :meth:`forecast`; categorical and boolean
|
|
536
|
+
covariates remain unchanged by the flip blend.
|
|
537
|
+
"""
|
|
538
|
+
horizon_df = horizon if isinstance(horizon, pd.DataFrame) else None
|
|
539
|
+
if horizon_df is None:
|
|
540
|
+
if not isinstance(horizon, Real):
|
|
541
|
+
raise TypeError(
|
|
542
|
+
"horizon must be a positive integer or a pandas dataframe"
|
|
543
|
+
)
|
|
544
|
+
horizon = _positive_int(horizon, "horizon")
|
|
545
|
+
targets = [target] if isinstance(target, str) else target
|
|
546
|
+
history, future, frame_horizon, items = prepare_frames(
|
|
547
|
+
context, horizon_df, id_column, time_column, targets
|
|
548
|
+
)
|
|
549
|
+
dropped = [
|
|
550
|
+
c
|
|
551
|
+
for c in context.columns
|
|
552
|
+
if c not in (id_column, time_column, *targets) and c not in history
|
|
553
|
+
]
|
|
554
|
+
if dropped:
|
|
555
|
+
_warn_past_covariates_ignored(f"past-only columns {dropped}")
|
|
556
|
+
if horizon_df is not None:
|
|
557
|
+
horizon = frame_horizon
|
|
558
|
+
horizon, levels, flip, options = self._options(
|
|
559
|
+
horizon,
|
|
560
|
+
quantile_levels,
|
|
561
|
+
flip_equivariant,
|
|
562
|
+
clip_nonnegative,
|
|
563
|
+
context_length,
|
|
564
|
+
autoregressive,
|
|
565
|
+
)
|
|
566
|
+
integer_time = time_column is not None and is_integer_time_column(
|
|
567
|
+
context[time_column]
|
|
568
|
+
)
|
|
569
|
+
if freq is not None and time_column is None:
|
|
570
|
+
raise ValueError("freq needs a time_column")
|
|
571
|
+
if isinstance(freq, bool) or (
|
|
572
|
+
integer_time and not isinstance(freq, int | None)
|
|
573
|
+
):
|
|
574
|
+
raise ValueError("freq must be an integer step for an integer time column")
|
|
575
|
+
if isinstance(freq, int):
|
|
576
|
+
if not integer_time:
|
|
577
|
+
raise ValueError("an integer freq needs an integer time column")
|
|
578
|
+
freq = f"{_positive_int(freq, 'freq')}s"
|
|
579
|
+
output_names = {str(q) for q in levels}
|
|
580
|
+
if any(
|
|
581
|
+
isinstance(c, str) and c in output_names for c in (id_column, time_column)
|
|
582
|
+
):
|
|
583
|
+
raise ValueError("key columns must not use quantile output names")
|
|
584
|
+
if time_column is None:
|
|
585
|
+
freq = ROW_ORDER_FREQ
|
|
586
|
+
elif freq is None:
|
|
587
|
+
freq = infer_freq_from_df(history, items, ITEM, TIME)
|
|
588
|
+
grid = self._pipeline.quantiles if flip else levels
|
|
589
|
+
predictions = self._pipeline.predict_df(
|
|
590
|
+
history,
|
|
591
|
+
future_df=future,
|
|
592
|
+
id_column=ITEM,
|
|
593
|
+
time_column=TIME,
|
|
594
|
+
target=targets,
|
|
595
|
+
horizon=horizon,
|
|
596
|
+
quantile_levels=grid,
|
|
597
|
+
freq=freq,
|
|
598
|
+
tta=flip,
|
|
599
|
+
**options,
|
|
600
|
+
)
|
|
601
|
+
if flip:
|
|
602
|
+
values = torch.from_numpy(predictions[[str(q) for q in grid]].to_numpy())
|
|
603
|
+
requested = extrapolate_quantiles(levels, grid, values).numpy()
|
|
604
|
+
for i, level in enumerate(levels):
|
|
605
|
+
predictions[str(level)] = requested[:, i]
|
|
606
|
+
if clip_nonnegative:
|
|
607
|
+
predictions = clip_frame(predictions, history, ITEM, targets, levels)
|
|
608
|
+
if time_column is None:
|
|
609
|
+
steps = predictions.groupby([ITEM, "target_name"], sort=False).cumcount()
|
|
610
|
+
predictions[STEP] = steps + 1
|
|
611
|
+
elif integer_time:
|
|
612
|
+
predictions[TIME] = datetimes_to_integer_times(predictions[TIME])
|
|
613
|
+
if id_column is not None:
|
|
614
|
+
predictions[ITEM] = items[predictions[ITEM].to_numpy()]
|
|
615
|
+
renames = {ITEM: id_column, TIME: time_column}
|
|
616
|
+
predictions = predictions.rename(
|
|
617
|
+
columns={k: v for k, v in renames.items() if v is not None}
|
|
618
|
+
)
|
|
619
|
+
keys = [
|
|
620
|
+
*([] if id_column is None else [id_column]),
|
|
621
|
+
STEP if time_column is None else time_column,
|
|
622
|
+
]
|
|
623
|
+
return predictions[
|
|
624
|
+
[
|
|
625
|
+
*keys,
|
|
626
|
+
"target_name",
|
|
627
|
+
"predictions",
|
|
628
|
+
*(str(q) for q in levels),
|
|
629
|
+
]
|
|
630
|
+
]
|