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.
Files changed (30) hide show
  1. continuousintelligence/__init__.py +9 -0
  2. continuousintelligence/_data/NOTICE +16 -0
  3. continuousintelligence/_data/bike_sharing.csv +145 -0
  4. continuousintelligence/_example.py +20 -0
  5. continuousintelligence/_loader.py +140 -0
  6. continuousintelligence/_plot.py +158 -0
  7. continuousintelligence/_vendor/__init__.py +1 -0
  8. continuousintelligence/_vendor/mamba3/LICENSE +201 -0
  9. continuousintelligence/_vendor/mamba3/__init__.py +6 -0
  10. continuousintelligence/_vendor/mamba3/angle_dt.py +213 -0
  11. continuousintelligence/_vendor/mamba3/layernorm_gated.py +115 -0
  12. continuousintelligence/_vendor/mamba3/mamba3_mimo_fwd.py +483 -0
  13. continuousintelligence/_vendor/mamba3/mamba3_mimo_utils.py +112 -0
  14. continuousintelligence/_vendor/mamba3/utils.py +25 -0
  15. continuousintelligence/ci_proto/__init__.py +5 -0
  16. continuousintelligence/ci_proto/_calendar.py +153 -0
  17. continuousintelligence/ci_proto/_checkpoint.py +106 -0
  18. continuousintelligence/ci_proto/_cuda.py +135 -0
  19. continuousintelligence/ci_proto/_forecast.py +708 -0
  20. continuousintelligence/ci_proto/_groups.py +198 -0
  21. continuousintelligence/ci_proto/_mamba.py +190 -0
  22. continuousintelligence/ci_proto/_model.py +489 -0
  23. continuousintelligence-0.1.0.dist-info/METADATA +29 -0
  24. continuousintelligence-0.1.0.dist-info/RECORD +30 -0
  25. continuousintelligence-0.1.0.dist-info/WHEEL +5 -0
  26. continuousintelligence-0.1.0.dist-info/licenses/LICENCE +95 -0
  27. continuousintelligence-0.1.0.dist-info/licenses/NOTICE +33 -0
  28. continuousintelligence-0.1.0.dist-info/licenses/src/continuousintelligence/_data/NOTICE +16 -0
  29. continuousintelligence-0.1.0.dist-info/licenses/src/continuousintelligence/_vendor/mamba3/LICENSE +201 -0
  30. 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