continuousintelligence 0.1.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.
- continuousintelligence/__init__.py +9 -0
- continuousintelligence/_data/NOTICE +16 -0
- continuousintelligence/_data/bike_sharing.csv +145 -0
- continuousintelligence/_example.py +20 -0
- continuousintelligence/_loader.py +140 -0
- continuousintelligence/_plot.py +158 -0
- continuousintelligence/_vendor/__init__.py +1 -0
- continuousintelligence/_vendor/mamba3/LICENSE +201 -0
- continuousintelligence/_vendor/mamba3/__init__.py +6 -0
- continuousintelligence/_vendor/mamba3/angle_dt.py +213 -0
- continuousintelligence/_vendor/mamba3/layernorm_gated.py +115 -0
- continuousintelligence/_vendor/mamba3/mamba3_mimo_fwd.py +483 -0
- continuousintelligence/_vendor/mamba3/mamba3_mimo_utils.py +112 -0
- continuousintelligence/_vendor/mamba3/utils.py +25 -0
- continuousintelligence/ci_proto/__init__.py +5 -0
- continuousintelligence/ci_proto/_calendar.py +153 -0
- continuousintelligence/ci_proto/_checkpoint.py +106 -0
- continuousintelligence/ci_proto/_cuda.py +135 -0
- continuousintelligence/ci_proto/_forecast.py +708 -0
- continuousintelligence/ci_proto/_groups.py +198 -0
- continuousintelligence/ci_proto/_mamba.py +190 -0
- continuousintelligence/ci_proto/_model.py +489 -0
- continuousintelligence-0.1.0.dist-info/METADATA +29 -0
- continuousintelligence-0.1.0.dist-info/RECORD +30 -0
- continuousintelligence-0.1.0.dist-info/WHEEL +5 -0
- continuousintelligence-0.1.0.dist-info/licenses/LICENCE +95 -0
- continuousintelligence-0.1.0.dist-info/licenses/NOTICE +33 -0
- continuousintelligence-0.1.0.dist-info/licenses/src/continuousintelligence/_data/NOTICE +16 -0
- continuousintelligence-0.1.0.dist-info/licenses/src/continuousintelligence/_vendor/mamba3/LICENSE +201 -0
- continuousintelligence-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
"""The ci-proto preprocessing and quantile forecasting path.
|
|
2
|
+
|
|
3
|
+
Includes postprocessing adapted by Continuous Intelligence from TiRex-2:
|
|
4
|
+
https://github.com/NX-AI/tirex-2/blob/bb5427f4a97379a17756e67bc846c4f4b569eb48/src/tirex2/model/component/postprocessor.py
|
|
5
|
+
|
|
6
|
+
Copyright (c) NXAI GmbH.
|
|
7
|
+
Upstream portions are licensed under Apache-2.0; see NOTICE.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from collections.abc import Callable, Iterable, Mapping, Sequence
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from numbers import Integral
|
|
16
|
+
|
|
17
|
+
import torch
|
|
18
|
+
from torch import Tensor, nn
|
|
19
|
+
from torch.nn.functional import pad
|
|
20
|
+
|
|
21
|
+
from ._model import Model, forecast_layout
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Forecaster:
|
|
25
|
+
"""ci-proto with preprocessing and calibration."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, model: Model, tta: dict[str, float]) -> None:
|
|
28
|
+
self._model = model
|
|
29
|
+
self._tta = tta
|
|
30
|
+
self._long_model: Callable[[], Forecaster] | None = None
|
|
31
|
+
self._info: dict | None = None
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def device(self) -> torch.device:
|
|
35
|
+
"""Device used for the neural network."""
|
|
36
|
+
return next(self._model.parameters()).device
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def quantiles(self) -> Tensor:
|
|
40
|
+
"""CPU tensor of quantile levels, shape ``[quantiles]``.
|
|
41
|
+
|
|
42
|
+
ci-proto returns nine quantiles between 0.1 and 0.9 in increments of 0.1.
|
|
43
|
+
"""
|
|
44
|
+
return self._model.quantiles.detach().cpu().clone()
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def info(self) -> dict | None:
|
|
48
|
+
"""Model description from the bundle's ``config.json``: name, parameter count, quantile
|
|
49
|
+
levels and licence. ``None`` when loaded without one."""
|
|
50
|
+
return None if self._info is None else json.loads(json.dumps(self._info))
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def backend(self) -> str:
|
|
54
|
+
"""Selected inference backend: ``mamba3`` (fused CUDA) or ``torch`` (portable)."""
|
|
55
|
+
return getattr(self._model, "backend", "torch")
|
|
56
|
+
|
|
57
|
+
def forecast(
|
|
58
|
+
self,
|
|
59
|
+
history: Tensor,
|
|
60
|
+
horizon: int = 320,
|
|
61
|
+
*,
|
|
62
|
+
past_covariates: Tensor | None = None,
|
|
63
|
+
future_covariates: Tensor | None = None,
|
|
64
|
+
categorical_past: tuple[int, ...] = (),
|
|
65
|
+
categorical_future: tuple[int, ...] = (),
|
|
66
|
+
timestamps: Tensor | None = None,
|
|
67
|
+
freq: str | None = None,
|
|
68
|
+
series_ids: Sequence[str] | None = None,
|
|
69
|
+
static_features: Mapping[str, Sequence[object]] | None = None,
|
|
70
|
+
) -> Tensor:
|
|
71
|
+
"""Forecast joint variables, or independent series identified by ``series_ids``.
|
|
72
|
+
|
|
73
|
+
``history``: float tensor ``[variables, time]``; NaN denotes missing data.
|
|
74
|
+
``past_covariates``: ``[past_variables, time]``.
|
|
75
|
+
``future_covariates``: ``[known_variables, time + horizon]``; extra future
|
|
76
|
+
steps are ignored. Only supply values known at forecast time. Categorical
|
|
77
|
+
row indices refer to numeric category codes in the corresponding tensor.
|
|
78
|
+
|
|
79
|
+
``timestamps``: int64 epoch nanoseconds over history plus horizon, shared
|
|
80
|
+
``[time + horizon]`` or per-series ``[variables, time + horizon]``. Supply
|
|
81
|
+
``freq`` to generate ci-proto's calendar features. Without timestamps,
|
|
82
|
+
calendar features are omitted. Leading padding uses int64's minimum value.
|
|
83
|
+
|
|
84
|
+
``series_ids`` enables cohort policies for independent univariate rows.
|
|
85
|
+
Supply the complete cohort for a window. ``static_features`` maps column
|
|
86
|
+
names to values in input row order. Cohort covariates can also be shaped
|
|
87
|
+
``[variables, covariates, time]``, including the horizon for known values.
|
|
88
|
+
|
|
89
|
+
Returns CPU float32 quantiles ``[variables, quantiles, horizon]``, in
|
|
90
|
+
original units, with levels given by ``self.quantiles``. ci-proto returns
|
|
91
|
+
nine quantiles between 0.1 and 0.9 in increments of 0.1. Horizon is any
|
|
92
|
+
positive integer and defaults to 320.
|
|
93
|
+
"""
|
|
94
|
+
model, tta = self._model, self._tta
|
|
95
|
+
if type(horizon) is not int or horizon < 1:
|
|
96
|
+
raise ValueError("horizon must be a positive integer")
|
|
97
|
+
if (
|
|
98
|
+
self._long_model is not None
|
|
99
|
+
and isinstance(history, Tensor)
|
|
100
|
+
and history.ndim == 2
|
|
101
|
+
and history.shape[-1] > model.config.context_len
|
|
102
|
+
):
|
|
103
|
+
long = self._long_model()
|
|
104
|
+
model, tta = long._model, long._tta
|
|
105
|
+
device = next(model.parameters()).device
|
|
106
|
+
history = _rows(history, "history", device)
|
|
107
|
+
count, length = history.shape
|
|
108
|
+
stamps = _timestamps(timestamps, freq, count, length, horizon)
|
|
109
|
+
if stamps is not None and tta["calendar_covariates"] and freq is None:
|
|
110
|
+
raise ValueError("freq is required to generate calendar covariates")
|
|
111
|
+
if series_ids is not None:
|
|
112
|
+
past = _cohort_covariates(
|
|
113
|
+
past_covariates, "past_covariates", count, length, 0, device, categorical_past
|
|
114
|
+
)
|
|
115
|
+
future = _cohort_covariates(
|
|
116
|
+
future_covariates,
|
|
117
|
+
"future_covariates",
|
|
118
|
+
count,
|
|
119
|
+
length,
|
|
120
|
+
horizon,
|
|
121
|
+
device,
|
|
122
|
+
categorical_future,
|
|
123
|
+
)
|
|
124
|
+
cases = _cohort_cases(
|
|
125
|
+
history,
|
|
126
|
+
horizon,
|
|
127
|
+
tta,
|
|
128
|
+
past,
|
|
129
|
+
future,
|
|
130
|
+
categorical_past,
|
|
131
|
+
categorical_future,
|
|
132
|
+
stamps,
|
|
133
|
+
freq,
|
|
134
|
+
series_ids,
|
|
135
|
+
static_features,
|
|
136
|
+
)
|
|
137
|
+
return _run_cases(model, cases, count, horizon, tta)
|
|
138
|
+
if static_features is not None:
|
|
139
|
+
raise ValueError("static_features requires series_ids")
|
|
140
|
+
if stamps is not None:
|
|
141
|
+
if not torch.equal(stamps, stamps[:1].expand_as(stamps)):
|
|
142
|
+
raise ValueError("different timestamp axes require series_ids")
|
|
143
|
+
stamps = stamps[0]
|
|
144
|
+
start = int((stamps != torch.iinfo(torch.int64).min).long().argmax())
|
|
145
|
+
history, stamps = history[:, start:], stamps[start:]
|
|
146
|
+
if past_covariates is not None:
|
|
147
|
+
past_covariates = past_covariates[:, start:]
|
|
148
|
+
if future_covariates is not None:
|
|
149
|
+
future_covariates = future_covariates[:, start:]
|
|
150
|
+
context, future = _context(
|
|
151
|
+
history,
|
|
152
|
+
horizon,
|
|
153
|
+
past_covariates,
|
|
154
|
+
future_covariates,
|
|
155
|
+
categorical_past,
|
|
156
|
+
categorical_future,
|
|
157
|
+
bool(tta["target_encode_categoricals"]),
|
|
158
|
+
)
|
|
159
|
+
context, future = _with_calendar(
|
|
160
|
+
context, future, count, stamps, freq, bool(tta["calendar_covariates"])
|
|
161
|
+
)
|
|
162
|
+
return _predict(model, context[None], future[None], count, horizon, tta)[0]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _rows(
|
|
166
|
+
value: Tensor,
|
|
167
|
+
name: str,
|
|
168
|
+
device: torch.device,
|
|
169
|
+
*,
|
|
170
|
+
dtype: torch.dtype | None = torch.float32,
|
|
171
|
+
length: int | None = None,
|
|
172
|
+
) -> Tensor:
|
|
173
|
+
if not isinstance(value, Tensor) or value.ndim != 2 or min(value.shape) < 1:
|
|
174
|
+
raise ValueError(f"{name} must be a nonempty [variables, time] tensor")
|
|
175
|
+
value = value[:, :length]
|
|
176
|
+
if value.is_complex() or bool(torch.isinf(value).any()):
|
|
177
|
+
raise ValueError(f"{name} must contain real finite values or nan")
|
|
178
|
+
result = value.to(device=device, dtype=dtype)
|
|
179
|
+
if bool(torch.isinf(result).any()):
|
|
180
|
+
raise ValueError(f"{name} exceeds the float32 range")
|
|
181
|
+
return result
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _target_encode(context: Tensor, codes: Tensor) -> Tensor:
|
|
185
|
+
"""Replace categories with smoothed target means fitted only on history."""
|
|
186
|
+
if codes.dtype == torch.bool:
|
|
187
|
+
codes = codes.to(torch.int64)
|
|
188
|
+
rows, past = context.shape
|
|
189
|
+
observed = torch.isfinite(context)
|
|
190
|
+
target = torch.where(observed, context.to(torch.float64), 0.0)
|
|
191
|
+
count = observed.sum(dim=-1)
|
|
192
|
+
mean = torch.where(count > 0, target.sum(dim=-1) / count.clamp(min=1), 0.0)
|
|
193
|
+
missing = torch.isnan(codes)
|
|
194
|
+
categories = torch.unique(codes[~missing])
|
|
195
|
+
found = torch.searchsorted(categories, codes.nan_to_num().contiguous())
|
|
196
|
+
slots = torch.where(missing, categories.numel(), found)
|
|
197
|
+
width = categories.numel() + 1
|
|
198
|
+
offsets = torch.arange(rows, device=codes.device)[:, None] * width
|
|
199
|
+
index = (offsets + slots[:, :past]).flatten()
|
|
200
|
+
sums = torch.bincount(index, weights=target.flatten(), minlength=rows * width)
|
|
201
|
+
counts = torch.bincount(
|
|
202
|
+
index, weights=observed.to(torch.float64).flatten(), minlength=rows * width
|
|
203
|
+
)
|
|
204
|
+
lookup = (mean[:, None] + sums.view(rows, width)) / (1.0 + counts.view(rows, width))
|
|
205
|
+
return lookup.gather(1, slots).to(torch.float32)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _encode_covariates(
|
|
209
|
+
rows: Tensor, indices: tuple[int, ...], history: Tensor, name: str, encode: bool
|
|
210
|
+
) -> Tensor:
|
|
211
|
+
if len(set(indices)) != len(indices) or any(
|
|
212
|
+
not isinstance(index, Integral) or isinstance(index, bool) or not 0 <= index < rows.shape[0]
|
|
213
|
+
for index in indices
|
|
214
|
+
):
|
|
215
|
+
raise ValueError(f"{name} must contain unique valid covariate row indices")
|
|
216
|
+
encoded = rows.to(torch.float32, copy=True)
|
|
217
|
+
if indices and encode:
|
|
218
|
+
index = [int(value) for value in indices]
|
|
219
|
+
context = torch.nanmean(history.to(torch.float64), dim=0, keepdim=True)
|
|
220
|
+
encoded[index] = _target_encode(context.expand(len(index), -1), rows[index])
|
|
221
|
+
if bool(torch.isinf(encoded).any()):
|
|
222
|
+
raise ValueError("numeric covariates exceed the float32 range")
|
|
223
|
+
return encoded
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _difference_mask(
|
|
227
|
+
rows: Tensor, config: Mapping[str, float], lengths: Tensor | None = None
|
|
228
|
+
) -> Tensor:
|
|
229
|
+
"""Detect strong linear trends and monotone running totals on observed history."""
|
|
230
|
+
total = rows.shape[-1]
|
|
231
|
+
window = (
|
|
232
|
+
max(1, round(config["trend_window"] * total))
|
|
233
|
+
if lengths is None
|
|
234
|
+
else (lengths.double() * config["trend_window"]).round().clamp_min(1)
|
|
235
|
+
)
|
|
236
|
+
values = rows.to(torch.float64)
|
|
237
|
+
index = torch.arange(total, dtype=torch.float64, device=rows.device).expand_as(values)
|
|
238
|
+
start = total - window if lengths is None else total - window[:, None]
|
|
239
|
+
observed = torch.isfinite(values) & (index >= start)
|
|
240
|
+
v = torch.where(observed, values, torch.zeros_like(values))
|
|
241
|
+
t = torch.where(observed, index, torch.zeros_like(index))
|
|
242
|
+
n = observed.sum(dim=-1).to(torch.float64)
|
|
243
|
+
n_safe = n.clamp(min=2.0)
|
|
244
|
+
var_t = ((t * t).sum(dim=-1) - t.sum(dim=-1) ** 2 / n_safe).clamp(min=1e-12)
|
|
245
|
+
var_v = (v * v).sum(dim=-1) - v.sum(dim=-1) ** 2 / n_safe
|
|
246
|
+
cov_tv = (t * v).sum(dim=-1) - t.sum(dim=-1) * v.sum(dim=-1) / n_safe
|
|
247
|
+
deviation = torch.sqrt((var_v / (n_safe - 1.0)).clamp(min=0.0))
|
|
248
|
+
score = (cov_tv / var_t / deviation.clamp(min=1e-12)).abs() * window
|
|
249
|
+
r_squared = cov_tv**2 / (var_t * var_v.clamp(min=1e-12))
|
|
250
|
+
trending = (
|
|
251
|
+
(score >= config["trend_threshold"])
|
|
252
|
+
& (r_squared >= config["trend_min_r2"])
|
|
253
|
+
& (n >= 4)
|
|
254
|
+
& (deviation > 1e-12)
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
if not config["tta_running_totals"]:
|
|
258
|
+
return trending
|
|
259
|
+
observed = torch.isfinite(rows)
|
|
260
|
+
index = torch.arange(total, device=rows.device).expand_as(rows)
|
|
261
|
+
latest = torch.where(observed, index, torch.zeros_like(index)).cummax(dim=-1).values
|
|
262
|
+
filled = rows.gather(-1, latest)
|
|
263
|
+
steps = filled[..., 1:] - filled[..., :-1]
|
|
264
|
+
finite = torch.isfinite(steps)
|
|
265
|
+
rising = (~finite | (steps >= 0)).all(dim=-1)
|
|
266
|
+
falling = (~finite | (steps <= 0)).all(dim=-1)
|
|
267
|
+
counted = finite & observed[..., 1:]
|
|
268
|
+
moving = (counted & (steps != 0)).sum(dim=-1)
|
|
269
|
+
share = moving / counted.sum(dim=-1).clamp(min=1)
|
|
270
|
+
running = (observed.sum(dim=-1) >= 4) & (moving > 0) & (share >= 0.5)
|
|
271
|
+
return trending | (running & (rising | falling))
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _last_observed(history: Tensor) -> Tensor:
|
|
275
|
+
observed = torch.isfinite(history)
|
|
276
|
+
last_index = observed.long().cumsum(dim=-1).argmax(dim=-1)
|
|
277
|
+
return torch.nan_to_num(history.gather(-1, last_index[:, None]).squeeze(-1), nan=0.0)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _calibrate(
|
|
281
|
+
pred: Tensor,
|
|
282
|
+
history: Tensor,
|
|
283
|
+
differenced: Tensor,
|
|
284
|
+
config: Mapping[str, float],
|
|
285
|
+
*,
|
|
286
|
+
anchors: Tensor | None = None,
|
|
287
|
+
has_trend: bool | None = None,
|
|
288
|
+
) -> Tensor:
|
|
289
|
+
median = pred[:, pred.shape[1] // 2 : pred.shape[1] // 2 + 1]
|
|
290
|
+
steps = torch.arange(1, pred.shape[-1] + 1, dtype=pred.dtype, device=pred.device)
|
|
291
|
+
factor = config["raw_band_scale"] ** 0.5 * steps ** (config["raw_band_exponent"] - 0.5)
|
|
292
|
+
calibrated = median + factor * (pred - median)
|
|
293
|
+
if bool(differenced.any()) if has_trend is None else has_trend:
|
|
294
|
+
selected = pred[differenced]
|
|
295
|
+
middle = selected[:, selected.shape[1] // 2 : selected.shape[1] // 2 + 1]
|
|
296
|
+
offset = selected - middle
|
|
297
|
+
band = offset.sum(dim=-1, keepdim=True).sign() * torch.sqrt(
|
|
298
|
+
torch.cumsum(offset * offset, dim=-1)
|
|
299
|
+
)
|
|
300
|
+
band = band * steps ** (config["diff_band_exponent"] - 0.5)
|
|
301
|
+
if anchors is None:
|
|
302
|
+
anchors = _last_observed(history)
|
|
303
|
+
calibrated[differenced] = (
|
|
304
|
+
torch.cumsum(middle, dim=-1)
|
|
305
|
+
+ config["diff_band_scale"] ** 0.5 * band
|
|
306
|
+
+ anchors[differenced, None, None]
|
|
307
|
+
)
|
|
308
|
+
return calibrated.sort(dim=1).values
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _forecast_once(
|
|
312
|
+
model: nn.Module,
|
|
313
|
+
context: Tensor,
|
|
314
|
+
future: Tensor,
|
|
315
|
+
n_targets: int,
|
|
316
|
+
horizon: int,
|
|
317
|
+
tta: Mapping[str, float],
|
|
318
|
+
lengths: Tensor,
|
|
319
|
+
*,
|
|
320
|
+
sign_flip: bool = False,
|
|
321
|
+
) -> Tensor:
|
|
322
|
+
"""Forecast equal-width groups, optionally including their independent negative views."""
|
|
323
|
+
cfg = model.config
|
|
324
|
+
patch = cfg.output_patch_size
|
|
325
|
+
window_len = max(cfg.future_len, -(-horizon // patch) * patch)
|
|
326
|
+
samples, channels, width = context.shape
|
|
327
|
+
history = context[:, :n_targets].flatten(0, 1)
|
|
328
|
+
row_lengths = lengths.repeat_interleave(n_targets)
|
|
329
|
+
differenced = (
|
|
330
|
+
_difference_mask(history, tta, row_lengths)
|
|
331
|
+
if tta["postprocess"] and tta["tta_diff"] and horizon <= tta["differencing_max_horizon"]
|
|
332
|
+
else torch.zeros(len(history), dtype=torch.bool, device=history.device)
|
|
333
|
+
)
|
|
334
|
+
has_trend = bool(differenced.any())
|
|
335
|
+
anchors = _last_observed(history) if has_trend else None
|
|
336
|
+
if has_trend:
|
|
337
|
+
diff = torch.cat([torch.zeros_like(history[:, :1]), history[:, 1:] - history[:, :-1]], -1)
|
|
338
|
+
start = width - row_lengths
|
|
339
|
+
valid = torch.arange(width, device=history.device) >= start[:, None]
|
|
340
|
+
diff = torch.where(valid, diff, torch.nan).scatter(1, start[:, None], 0.0)
|
|
341
|
+
targets = torch.where(differenced[:, None], diff, history).reshape(
|
|
342
|
+
samples, n_targets, width
|
|
343
|
+
)
|
|
344
|
+
context = torch.cat([targets, context[:, n_targets:]], dim=1)
|
|
345
|
+
n_known = future.shape[1]
|
|
346
|
+
window = context.new_full((samples, channels, window_len), torch.nan)
|
|
347
|
+
window[:, n_targets : n_targets + n_known, :horizon] = future
|
|
348
|
+
observed = torch.isfinite(context).flatten(0, 1).any(0)
|
|
349
|
+
span = width - int(observed.int().argmax()) if bool(observed.any()) else 1
|
|
350
|
+
context_len = max(patch, 1 << (span - 1).bit_length())
|
|
351
|
+
if cfg.context_len is not None:
|
|
352
|
+
context_len = min(context_len, cfg.context_len)
|
|
353
|
+
x = torch.cat([context[..., -context_len:], window], dim=-1)
|
|
354
|
+
x = pad(x, (context_len + window_len - x.shape[-1], 0), value=torch.nan)
|
|
355
|
+
if sign_flip:
|
|
356
|
+
x = torch.cat([x, -x])
|
|
357
|
+
differenced = differenced.repeat(2)
|
|
358
|
+
if anchors is not None:
|
|
359
|
+
anchors = torch.cat([anchors, -anchors])
|
|
360
|
+
layout = forecast_layout(len(x), channels, n_targets, n_known, x.device)
|
|
361
|
+
batch = {
|
|
362
|
+
"x": x.flatten(0, 1),
|
|
363
|
+
"group_vector": layout.groups,
|
|
364
|
+
"target_mask": layout.targets,
|
|
365
|
+
"future_covariate_mask": layout.future,
|
|
366
|
+
"past_covariate_mask": layout.past,
|
|
367
|
+
}
|
|
368
|
+
with torch.no_grad():
|
|
369
|
+
pred = model(batch, future_len=window_len, horizon=horizon, layout=layout)
|
|
370
|
+
pred = pred.reshape(len(x), channels, -1, horizon)[:, :n_targets].flatten(0, 1)
|
|
371
|
+
if tta["postprocess"]:
|
|
372
|
+
pred = _calibrate(pred, history, differenced, tta, anchors=anchors, has_trend=has_trend)
|
|
373
|
+
return pred.reshape(len(x), n_targets, -1, horizon)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _context(
|
|
377
|
+
history: Tensor,
|
|
378
|
+
horizon: int,
|
|
379
|
+
past_covariates: Tensor | None,
|
|
380
|
+
future_covariates: Tensor | None,
|
|
381
|
+
categorical_past: tuple[int, ...],
|
|
382
|
+
categorical_future: tuple[int, ...],
|
|
383
|
+
encode_categoricals: bool,
|
|
384
|
+
) -> tuple[Tensor, Tensor]:
|
|
385
|
+
device = history.device
|
|
386
|
+
n_targets, length = history.shape
|
|
387
|
+
parts = [history]
|
|
388
|
+
future = history.new_empty((0, horizon))
|
|
389
|
+
if future_covariates is not None:
|
|
390
|
+
rows = _rows(
|
|
391
|
+
future_covariates, "future_covariates", device, dtype=None, length=length + horizon
|
|
392
|
+
)
|
|
393
|
+
if rows.shape[1] < length + horizon:
|
|
394
|
+
raise ValueError("future_covariates must cover history plus horizon")
|
|
395
|
+
rows = _encode_covariates(
|
|
396
|
+
rows, categorical_future, history, "categorical_future", encode_categoricals
|
|
397
|
+
)
|
|
398
|
+
parts.append(rows[:, :length])
|
|
399
|
+
future = rows[:, length:]
|
|
400
|
+
elif categorical_future:
|
|
401
|
+
raise ValueError("categorical_future requires future_covariates")
|
|
402
|
+
if past_covariates is not None:
|
|
403
|
+
rows = _rows(past_covariates, "past_covariates", device, dtype=None)
|
|
404
|
+
if rows.shape[1] != length:
|
|
405
|
+
raise ValueError("past_covariates must have the same history length")
|
|
406
|
+
parts.append(
|
|
407
|
+
_encode_covariates(
|
|
408
|
+
rows, categorical_past, history, "categorical_past", encode_categoricals
|
|
409
|
+
)
|
|
410
|
+
)
|
|
411
|
+
elif categorical_past:
|
|
412
|
+
raise ValueError("categorical_past requires past_covariates")
|
|
413
|
+
return torch.cat(parts, dim=0), future
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _predict(
|
|
417
|
+
model: nn.Module,
|
|
418
|
+
context: Tensor,
|
|
419
|
+
future: Tensor,
|
|
420
|
+
n_targets: int,
|
|
421
|
+
horizon: int,
|
|
422
|
+
tta: Mapping[str, float],
|
|
423
|
+
lengths: Tensor | None = None,
|
|
424
|
+
) -> Tensor:
|
|
425
|
+
"""Forecast ``[samples, channels, time]`` groups with shared sign preprocessing."""
|
|
426
|
+
device = context.device
|
|
427
|
+
samples = len(context)
|
|
428
|
+
history = context[:, :n_targets].flatten(0, 1)
|
|
429
|
+
if lengths is None:
|
|
430
|
+
lengths = torch.full((samples,), context.shape[-1], dtype=torch.long, device=device)
|
|
431
|
+
sign_flip = bool(tta["postprocess"] and tta["tta_sign_flip"])
|
|
432
|
+
with torch.autocast(device.type, dtype=torch.bfloat16, enabled=bool(tta["bfloat16"])):
|
|
433
|
+
pred = _forecast_once(
|
|
434
|
+
model, context, future, n_targets, horizon, tta, lengths, sign_flip=sign_flip
|
|
435
|
+
)
|
|
436
|
+
if sign_flip:
|
|
437
|
+
positive, negative = pred.chunk(2)
|
|
438
|
+
# Loading validates a strictly sorted, symmetric grid centred on the median.
|
|
439
|
+
pred = (positive - negative.flip(2)) / 2
|
|
440
|
+
pred = pred.float().flatten(0, 1)
|
|
441
|
+
observed = torch.isfinite(history)
|
|
442
|
+
if tta["clip_nonnegative"]:
|
|
443
|
+
nonnegative = observed.any(dim=-1) & ~(observed & (history < 0)).any(dim=-1)
|
|
444
|
+
pred = torch.where(nonnegative[:, None, None], pred.clamp_min(0), pred)
|
|
445
|
+
if tta["integer_round"]:
|
|
446
|
+
integers = observed.any(dim=-1) & ~(observed & (history != history.round())).any(dim=-1)
|
|
447
|
+
pred = torch.where(integers[:, None, None], pred.round(), pred)
|
|
448
|
+
expected = (samples * n_targets, model.get_buffer("quantiles").numel(), horizon)
|
|
449
|
+
if pred.shape != expected or not bool(torch.isfinite(pred).all()):
|
|
450
|
+
raise ValueError("model returned invalid forecast values or shape")
|
|
451
|
+
return pred.reshape(samples, n_targets, -1, horizon).detach().cpu()
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _timestamps(
|
|
455
|
+
timestamps: Tensor | None, freq: str | None, count: int, length: int, horizon: int
|
|
456
|
+
) -> Tensor | None:
|
|
457
|
+
if timestamps is None:
|
|
458
|
+
if freq is not None:
|
|
459
|
+
raise ValueError("freq requires timestamps")
|
|
460
|
+
return None
|
|
461
|
+
if not isinstance(timestamps, Tensor) or timestamps.dtype != torch.int64:
|
|
462
|
+
raise ValueError("timestamps must be an int64 tensor of epoch nanoseconds")
|
|
463
|
+
stamps = timestamps.detach().cpu()
|
|
464
|
+
if stamps.ndim == 1:
|
|
465
|
+
stamps = stamps[None].expand(count, -1)
|
|
466
|
+
if stamps.ndim != 2 or stamps.shape[0] != count or stamps.shape[1] < length + horizon:
|
|
467
|
+
raise ValueError("timestamps must cover history plus horizon for every series")
|
|
468
|
+
stamps = stamps[:, : length + horizon]
|
|
469
|
+
valid = stamps != torch.iinfo(torch.int64).min
|
|
470
|
+
if not valid[:, length - 1 :].all():
|
|
471
|
+
raise ValueError("timestamps must include the forecast origin and every future step")
|
|
472
|
+
start = valid.long().argmax(dim=1)
|
|
473
|
+
if not torch.equal(valid, torch.arange(stamps.shape[1])[None] >= start[:, None]):
|
|
474
|
+
raise ValueError("missing timestamps are only allowed in leading history padding")
|
|
475
|
+
if ((stamps[:, 1:] <= stamps[:, :-1]) & valid[:, :-1]).any():
|
|
476
|
+
raise ValueError("timestamps must be strictly increasing")
|
|
477
|
+
if freq is not None and not isinstance(freq, str):
|
|
478
|
+
raise ValueError("freq must be a frequency string")
|
|
479
|
+
return stamps
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _with_calendar(
|
|
483
|
+
context: Tensor,
|
|
484
|
+
future: Tensor,
|
|
485
|
+
n_targets: int,
|
|
486
|
+
stamps: Tensor | None,
|
|
487
|
+
freq: str | None,
|
|
488
|
+
enabled: bool,
|
|
489
|
+
*,
|
|
490
|
+
cache: dict[bytes, Tensor] | None = None,
|
|
491
|
+
) -> tuple[Tensor, Tensor]:
|
|
492
|
+
if not enabled or stamps is None:
|
|
493
|
+
return context, future
|
|
494
|
+
if freq is None:
|
|
495
|
+
raise ValueError("freq is required to generate calendar covariates")
|
|
496
|
+
from ._calendar import calendar_channels, frequency_step_ns
|
|
497
|
+
|
|
498
|
+
key = stamps.numpy().tobytes()
|
|
499
|
+
calendar = None if cache is None else cache.get(key)
|
|
500
|
+
if calendar is None:
|
|
501
|
+
calendar = torch.from_numpy(calendar_channels(stamps.numpy(), frequency_step_ns(freq)))
|
|
502
|
+
calendar = calendar.to(context)
|
|
503
|
+
if cache is not None:
|
|
504
|
+
cache[key] = calendar
|
|
505
|
+
if not calendar.shape[0]:
|
|
506
|
+
return context, future
|
|
507
|
+
length = context.shape[-1]
|
|
508
|
+
at = n_targets + future.shape[0]
|
|
509
|
+
context = torch.cat([context[:at], calendar[:, :length], context[at:]], dim=0)
|
|
510
|
+
return context, torch.cat([future, calendar[:, length:]], dim=0)
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
@dataclass
|
|
514
|
+
class _Case:
|
|
515
|
+
"""One independent forecast window and the caller's output row indices."""
|
|
516
|
+
|
|
517
|
+
indices: list[int]
|
|
518
|
+
context: Tensor
|
|
519
|
+
future: Tensor
|
|
520
|
+
n_targets: int
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _cohort_covariates(
|
|
524
|
+
value: Tensor | None,
|
|
525
|
+
name: str,
|
|
526
|
+
count: int,
|
|
527
|
+
length: int,
|
|
528
|
+
horizon: int,
|
|
529
|
+
device: torch.device,
|
|
530
|
+
categorical: tuple[int, ...],
|
|
531
|
+
) -> Tensor | None:
|
|
532
|
+
if value is None:
|
|
533
|
+
if categorical:
|
|
534
|
+
raise ValueError(f"categorical indices require {name}")
|
|
535
|
+
return None
|
|
536
|
+
if not isinstance(value, Tensor) or value.ndim not in (2, 3):
|
|
537
|
+
raise ValueError(f"{name} must be a two- or three-dimensional tensor")
|
|
538
|
+
rows = value[None].expand(count, -1, -1) if value.ndim == 2 else value
|
|
539
|
+
if rows.shape[0] != count or rows.shape[1] < 1:
|
|
540
|
+
raise ValueError(f"{name} must have covariates for every series")
|
|
541
|
+
width = length + horizon
|
|
542
|
+
if (horizon and rows.shape[-1] < width) or (not horizon and rows.shape[-1] != width):
|
|
543
|
+
raise ValueError(f"{name} must cover the required time steps")
|
|
544
|
+
rows = rows[..., :width]
|
|
545
|
+
flat = _rows(rows.reshape(-1, width), name, device, dtype=None)
|
|
546
|
+
# Validate indices without fitting an encoding across unrelated series.
|
|
547
|
+
if len(set(categorical)) != len(categorical) or any(
|
|
548
|
+
not isinstance(index, Integral) or isinstance(index, bool) or not 0 <= index < rows.shape[1]
|
|
549
|
+
for index in categorical
|
|
550
|
+
):
|
|
551
|
+
raise ValueError(f"categorical_{name} must contain unique valid covariate row indices")
|
|
552
|
+
return flat.reshape(count, rows.shape[1], width)
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _cohort_cases(
|
|
556
|
+
history: Tensor,
|
|
557
|
+
horizon: int,
|
|
558
|
+
tta: Mapping[str, float],
|
|
559
|
+
past: Tensor | None,
|
|
560
|
+
future: Tensor | None,
|
|
561
|
+
categorical_past: tuple[int, ...],
|
|
562
|
+
categorical_future: tuple[int, ...],
|
|
563
|
+
stamps: Tensor | None,
|
|
564
|
+
freq: str | None,
|
|
565
|
+
series_ids: Sequence[str],
|
|
566
|
+
static_features: Mapping[str, Sequence[object]] | None,
|
|
567
|
+
) -> Iterable[_Case]:
|
|
568
|
+
from ._groups import chunk_groups, joint_neighbour_rows, static_group_rows
|
|
569
|
+
|
|
570
|
+
count, length = history.shape
|
|
571
|
+
if (
|
|
572
|
+
isinstance(series_ids, str)
|
|
573
|
+
or len(series_ids) != count
|
|
574
|
+
or any(not isinstance(name, str) or not name for name in series_ids)
|
|
575
|
+
or len(set(series_ids)) != count
|
|
576
|
+
):
|
|
577
|
+
raise ValueError("series_ids must contain one unique string per history row")
|
|
578
|
+
if static_features is not None and not isinstance(static_features, Mapping):
|
|
579
|
+
raise ValueError("static_features must map column names to per-series values")
|
|
580
|
+
statics = dict(static_features or {})
|
|
581
|
+
for name, values in statics.items():
|
|
582
|
+
if (
|
|
583
|
+
not isinstance(name, str)
|
|
584
|
+
or isinstance(values, (str, bytes, Mapping))
|
|
585
|
+
or not hasattr(values, "__len__")
|
|
586
|
+
or len(values) != count
|
|
587
|
+
):
|
|
588
|
+
raise ValueError("static_features must have one value per series in each column")
|
|
589
|
+
statics[name] = (
|
|
590
|
+
values.detach().cpu().tolist() if isinstance(values, Tensor) else tuple(values)
|
|
591
|
+
)
|
|
592
|
+
if stamps is None:
|
|
593
|
+
stamps = torch.arange(length + horizon)[None].expand(count, -1)
|
|
594
|
+
calendar_stamps = None
|
|
595
|
+
starts = [0] * count
|
|
596
|
+
else:
|
|
597
|
+
calendar_stamps = stamps
|
|
598
|
+
starts = (stamps != torch.iinfo(torch.int64).min).long().argmax(dim=1).tolist()
|
|
599
|
+
order = sorted(range(count), key=lambda i: series_ids[i])
|
|
600
|
+
stamps_np = stamps.numpy()
|
|
601
|
+
bare = not statics and past is None and future is None
|
|
602
|
+
chunk = int(tta["chunk_joint"]) if bare else 0
|
|
603
|
+
static_rows = None
|
|
604
|
+
neighbours = None
|
|
605
|
+
if (statics and tta["static_groups"]) or (
|
|
606
|
+
not statics and not chunk and tta["joint_neighbours"]
|
|
607
|
+
):
|
|
608
|
+
histories = history.detach().double().cpu().numpy()
|
|
609
|
+
histories_in_order = [histories[i, starts[i] :] for i in order]
|
|
610
|
+
times = [stamps_np[i, starts[i] : length] for i in order]
|
|
611
|
+
if statics and tta["static_groups"]:
|
|
612
|
+
columns = [[statics[name][i] for i in order] for name in sorted(statics)]
|
|
613
|
+
static_rows = static_group_rows(
|
|
614
|
+
histories_in_order, times, columns, int(tta["static_groups"])
|
|
615
|
+
)
|
|
616
|
+
elif not statics and not chunk and tta["joint_neighbours"]:
|
|
617
|
+
neighbours = joint_neighbour_rows(histories_in_order, times, int(tta["joint_neighbours"]))
|
|
618
|
+
groups = (
|
|
619
|
+
chunk_groups([int(stamps_np[i, length - 1]) for i in order], chunk)
|
|
620
|
+
if chunk
|
|
621
|
+
else [[i] for i in range(count)]
|
|
622
|
+
)
|
|
623
|
+
calendar_cache: dict[bytes, Tensor] = {}
|
|
624
|
+
for members in groups:
|
|
625
|
+
indices = [order[position] for position in members]
|
|
626
|
+
longest = min(indices, key=lambda i: starts[i])
|
|
627
|
+
start = starts[longest]
|
|
628
|
+
targets = history[indices, start:]
|
|
629
|
+
own_past = None if past is None else past[longest, :, start:]
|
|
630
|
+
own_future = None if future is None else future[longest, :, start:]
|
|
631
|
+
if static_rows is not None:
|
|
632
|
+
extra = torch.from_numpy(static_rows[members[0]]).to(history)
|
|
633
|
+
own_past = extra if own_past is None else torch.cat([own_past, extra])
|
|
634
|
+
if neighbours is not None:
|
|
635
|
+
targets = torch.from_numpy(neighbours[members[0]]).to(history)
|
|
636
|
+
context, revealed = _context(
|
|
637
|
+
targets,
|
|
638
|
+
horizon,
|
|
639
|
+
own_past,
|
|
640
|
+
own_future,
|
|
641
|
+
categorical_past,
|
|
642
|
+
categorical_future,
|
|
643
|
+
bool(tta["target_encode_categoricals"]),
|
|
644
|
+
)
|
|
645
|
+
own_stamps = None if calendar_stamps is None else calendar_stamps[longest, start:]
|
|
646
|
+
context, revealed = _with_calendar(
|
|
647
|
+
context,
|
|
648
|
+
revealed,
|
|
649
|
+
len(targets),
|
|
650
|
+
own_stamps,
|
|
651
|
+
freq,
|
|
652
|
+
bool(tta["calendar_covariates"]),
|
|
653
|
+
cache=calendar_cache,
|
|
654
|
+
)
|
|
655
|
+
yield _Case(indices, context, revealed, len(targets))
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def _packed_predict(
|
|
659
|
+
model: nn.Module, cases: Sequence[_Case], horizon: int, tta: Mapping[str, float]
|
|
660
|
+
) -> Tensor:
|
|
661
|
+
"""Stack equal-width groups without changing group membership or covariate ownership."""
|
|
662
|
+
width = max(case.context.shape[-1] for case in cases)
|
|
663
|
+
context = torch.stack(
|
|
664
|
+
[pad(case.context, (width - case.context.shape[-1], 0), value=torch.nan) for case in cases]
|
|
665
|
+
)
|
|
666
|
+
lengths = torch.tensor([case.context.shape[-1] for case in cases], device=context.device)
|
|
667
|
+
return _predict(
|
|
668
|
+
model,
|
|
669
|
+
context,
|
|
670
|
+
torch.stack([case.future for case in cases]),
|
|
671
|
+
cases[0].n_targets,
|
|
672
|
+
horizon,
|
|
673
|
+
tta,
|
|
674
|
+
lengths,
|
|
675
|
+
).flatten(0, 1)
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def _run_cases(
|
|
679
|
+
model: nn.Module,
|
|
680
|
+
cases: Iterable[_Case],
|
|
681
|
+
count: int,
|
|
682
|
+
horizon: int,
|
|
683
|
+
tta: Mapping[str, float],
|
|
684
|
+
) -> Tensor:
|
|
685
|
+
out = torch.empty(count, model.get_buffer("quantiles").numel(), horizon)
|
|
686
|
+
pending: dict[tuple[int, int, int], list[_Case]] = {}
|
|
687
|
+
rows = 0
|
|
688
|
+
views = 2 if tta["postprocess"] and tta["tta_sign_flip"] else 1
|
|
689
|
+
|
|
690
|
+
def flush() -> None:
|
|
691
|
+
for members in pending.values():
|
|
692
|
+
predictions = _packed_predict(model, members, horizon, tta)
|
|
693
|
+
cursor = 0
|
|
694
|
+
for case in members:
|
|
695
|
+
out[case.indices] = predictions[cursor : cursor + len(case.indices)]
|
|
696
|
+
cursor += case.n_targets
|
|
697
|
+
pending.clear()
|
|
698
|
+
|
|
699
|
+
for case in cases:
|
|
700
|
+
if pending and (rows + len(case.context)) * views > 2048:
|
|
701
|
+
flush()
|
|
702
|
+
rows = 0
|
|
703
|
+
key = (case.n_targets, len(case.future), len(case.context))
|
|
704
|
+
pending.setdefault(key, []).append(case)
|
|
705
|
+
rows += len(case.context)
|
|
706
|
+
if pending:
|
|
707
|
+
flush()
|
|
708
|
+
return out
|