multimodalart's picture
multimodalart HF Staff
Upload folder using huggingface_hub
ba6c090 verified
Raw History Blame Contribute Delete
29.2 kB
"""The ci-proto preprocessing and quantile forecasting path.
Includes postprocessing adapted by Continuous Intelligence from TiRex-2:
https://github.com/NX-AI/tirex-2/blob/bb5427f4a97379a17756e67bc846c4f4b569eb48/src/tirex2/model/component/postprocessor.py
Copyright (c) NXAI GmbH.
Upstream portions are licensed under Apache-2.0; see NOTICE.
"""
from __future__ import annotations
import json
from collections.abc import Callable, Iterable, Mapping, Sequence
from dataclasses import dataclass
from numbers import Integral
import torch
from torch import Tensor, nn
from torch.nn.functional import pad
from ._model import Model, forecast_layout
class Forecaster:
"""ci-proto with preprocessing and calibration."""
def __init__(self, model: Model, tta: dict[str, float]) -> None:
self._model = model
self._tta = tta
self._long_model: Callable[[], Forecaster] | None = None
self._info: dict | None = None
@property
def device(self) -> torch.device:
"""Device used for the neural network."""
return next(self._model.parameters()).device
@property
def quantiles(self) -> Tensor:
"""CPU tensor of quantile levels, shape ``[quantiles]``.
ci-proto returns nine quantiles between 0.1 and 0.9 in increments of 0.1.
"""
return self._model.quantiles.detach().cpu().clone()
@property
def info(self) -> dict | None:
"""Model description from the bundle's ``config.json``: name, parameter count, quantile
levels and licence. ``None`` when loaded without one."""
return None if self._info is None else json.loads(json.dumps(self._info))
@property
def backend(self) -> str:
"""Selected inference backend: ``mamba3`` (fused CUDA) or ``torch`` (portable)."""
return getattr(self._model, "backend", "torch")
def forecast(
self,
history: Tensor,
horizon: int = 320,
*,
past_covariates: Tensor | None = None,
future_covariates: Tensor | None = None,
categorical_past: tuple[int, ...] = (),
categorical_future: tuple[int, ...] = (),
timestamps: Tensor | None = None,
freq: str | None = None,
series_ids: Sequence[str] | None = None,
static_features: Mapping[str, Sequence[object]] | None = None,
) -> Tensor:
"""Forecast joint variables, or independent series identified by ``series_ids``.
``history``: float tensor ``[variables, time]``; NaN denotes missing data.
``past_covariates``: ``[past_variables, time]``.
``future_covariates``: ``[known_variables, time + horizon]``; extra future
steps are ignored. Only supply values known at forecast time. Categorical
row indices refer to numeric category codes in the corresponding tensor.
``timestamps``: int64 epoch nanoseconds over history plus horizon, shared
``[time + horizon]`` or per-series ``[variables, time + horizon]``. Supply
``freq`` to generate ci-proto's calendar features. Without timestamps,
calendar features are omitted. Leading padding uses int64's minimum value.
``series_ids`` enables cohort policies for independent univariate rows.
Supply the complete cohort for a window. ``static_features`` maps column
names to values in input row order. Cohort covariates can also be shaped
``[variables, covariates, time]``, including the horizon for known values.
Returns CPU float32 quantiles ``[variables, quantiles, horizon]``, in
original units, with levels given by ``self.quantiles``. ci-proto returns
nine quantiles between 0.1 and 0.9 in increments of 0.1. Horizon is any
positive integer and defaults to 320.
"""
model, tta = self._model, self._tta
if type(horizon) is not int or horizon < 1:
raise ValueError("horizon must be a positive integer")
if (
self._long_model is not None
and isinstance(history, Tensor)
and history.ndim == 2
and history.shape[-1] > model.config.context_len
):
long = self._long_model()
model, tta = long._model, long._tta
device = next(model.parameters()).device
history = _rows(history, "history", device)
count, length = history.shape
stamps = _timestamps(timestamps, freq, count, length, horizon)
if stamps is not None and tta["calendar_covariates"] and freq is None:
raise ValueError("freq is required to generate calendar covariates")
if series_ids is not None:
past = _cohort_covariates(
past_covariates, "past_covariates", count, length, 0, device, categorical_past
)
future = _cohort_covariates(
future_covariates,
"future_covariates",
count,
length,
horizon,
device,
categorical_future,
)
cases = _cohort_cases(
history,
horizon,
tta,
past,
future,
categorical_past,
categorical_future,
stamps,
freq,
series_ids,
static_features,
)
return _run_cases(model, cases, count, horizon, tta)
if static_features is not None:
raise ValueError("static_features requires series_ids")
if stamps is not None:
if not torch.equal(stamps, stamps[:1].expand_as(stamps)):
raise ValueError("different timestamp axes require series_ids")
stamps = stamps[0]
start = int((stamps != torch.iinfo(torch.int64).min).long().argmax())
history, stamps = history[:, start:], stamps[start:]
if past_covariates is not None:
past_covariates = past_covariates[:, start:]
if future_covariates is not None:
future_covariates = future_covariates[:, start:]
context, future = _context(
history,
horizon,
past_covariates,
future_covariates,
categorical_past,
categorical_future,
bool(tta["target_encode_categoricals"]),
)
context, future = _with_calendar(
context, future, count, stamps, freq, bool(tta["calendar_covariates"])
)
return _predict(model, context[None], future[None], count, horizon, tta)[0]
def _rows(
value: Tensor,
name: str,
device: torch.device,
*,
dtype: torch.dtype | None = torch.float32,
length: int | None = None,
) -> Tensor:
if not isinstance(value, Tensor) or value.ndim != 2 or min(value.shape) < 1:
raise ValueError(f"{name} must be a nonempty [variables, time] tensor")
value = value[:, :length]
if value.is_complex() or bool(torch.isinf(value).any()):
raise ValueError(f"{name} must contain real finite values or nan")
result = value.to(device=device, dtype=dtype)
if bool(torch.isinf(result).any()):
raise ValueError(f"{name} exceeds the float32 range")
return result
def _target_encode(context: Tensor, codes: Tensor) -> Tensor:
"""Replace categories with smoothed target means fitted only on history."""
if codes.dtype == torch.bool:
codes = codes.to(torch.int64)
rows, past = context.shape
observed = torch.isfinite(context)
target = torch.where(observed, context.to(torch.float64), 0.0)
count = observed.sum(dim=-1)
mean = torch.where(count > 0, target.sum(dim=-1) / count.clamp(min=1), 0.0)
missing = torch.isnan(codes)
categories = torch.unique(codes[~missing])
found = torch.searchsorted(categories, codes.nan_to_num().contiguous())
slots = torch.where(missing, categories.numel(), found)
width = categories.numel() + 1
offsets = torch.arange(rows, device=codes.device)[:, None] * width
index = (offsets + slots[:, :past]).flatten()
sums = torch.bincount(index, weights=target.flatten(), minlength=rows * width)
counts = torch.bincount(
index, weights=observed.to(torch.float64).flatten(), minlength=rows * width
)
lookup = (mean[:, None] + sums.view(rows, width)) / (1.0 + counts.view(rows, width))
return lookup.gather(1, slots).to(torch.float32)
def _encode_covariates(
rows: Tensor, indices: tuple[int, ...], history: Tensor, name: str, encode: bool
) -> Tensor:
if len(set(indices)) != len(indices) or any(
not isinstance(index, Integral) or isinstance(index, bool) or not 0 <= index < rows.shape[0]
for index in indices
):
raise ValueError(f"{name} must contain unique valid covariate row indices")
encoded = rows.to(torch.float32, copy=True)
if indices and encode:
index = [int(value) for value in indices]
context = torch.nanmean(history.to(torch.float64), dim=0, keepdim=True)
encoded[index] = _target_encode(context.expand(len(index), -1), rows[index])
if bool(torch.isinf(encoded).any()):
raise ValueError("numeric covariates exceed the float32 range")
return encoded
def _difference_mask(
rows: Tensor, config: Mapping[str, float], lengths: Tensor | None = None
) -> Tensor:
"""Detect strong linear trends and monotone running totals on observed history."""
total = rows.shape[-1]
window = (
max(1, round(config["trend_window"] * total))
if lengths is None
else (lengths.double() * config["trend_window"]).round().clamp_min(1)
)
values = rows.to(torch.float64)
index = torch.arange(total, dtype=torch.float64, device=rows.device).expand_as(values)
start = total - window if lengths is None else total - window[:, None]
observed = torch.isfinite(values) & (index >= start)
v = torch.where(observed, values, torch.zeros_like(values))
t = torch.where(observed, index, torch.zeros_like(index))
n = observed.sum(dim=-1).to(torch.float64)
n_safe = n.clamp(min=2.0)
var_t = ((t * t).sum(dim=-1) - t.sum(dim=-1) ** 2 / n_safe).clamp(min=1e-12)
var_v = (v * v).sum(dim=-1) - v.sum(dim=-1) ** 2 / n_safe
cov_tv = (t * v).sum(dim=-1) - t.sum(dim=-1) * v.sum(dim=-1) / n_safe
deviation = torch.sqrt((var_v / (n_safe - 1.0)).clamp(min=0.0))
score = (cov_tv / var_t / deviation.clamp(min=1e-12)).abs() * window
r_squared = cov_tv**2 / (var_t * var_v.clamp(min=1e-12))
trending = (
(score >= config["trend_threshold"])
& (r_squared >= config["trend_min_r2"])
& (n >= 4)
& (deviation > 1e-12)
)
if not config["tta_running_totals"]:
return trending
observed = torch.isfinite(rows)
index = torch.arange(total, device=rows.device).expand_as(rows)
latest = torch.where(observed, index, torch.zeros_like(index)).cummax(dim=-1).values
filled = rows.gather(-1, latest)
steps = filled[..., 1:] - filled[..., :-1]
finite = torch.isfinite(steps)
rising = (~finite | (steps >= 0)).all(dim=-1)
falling = (~finite | (steps <= 0)).all(dim=-1)
counted = finite & observed[..., 1:]
moving = (counted & (steps != 0)).sum(dim=-1)
share = moving / counted.sum(dim=-1).clamp(min=1)
running = (observed.sum(dim=-1) >= 4) & (moving > 0) & (share >= 0.5)
return trending | (running & (rising | falling))
def _last_observed(history: Tensor) -> Tensor:
observed = torch.isfinite(history)
last_index = observed.long().cumsum(dim=-1).argmax(dim=-1)
return torch.nan_to_num(history.gather(-1, last_index[:, None]).squeeze(-1), nan=0.0)
def _calibrate(
pred: Tensor,
history: Tensor,
differenced: Tensor,
config: Mapping[str, float],
*,
anchors: Tensor | None = None,
has_trend: bool | None = None,
) -> Tensor:
median = pred[:, pred.shape[1] // 2 : pred.shape[1] // 2 + 1]
steps = torch.arange(1, pred.shape[-1] + 1, dtype=pred.dtype, device=pred.device)
factor = config["raw_band_scale"] ** 0.5 * steps ** (config["raw_band_exponent"] - 0.5)
calibrated = median + factor * (pred - median)
if bool(differenced.any()) if has_trend is None else has_trend:
selected = pred[differenced]
middle = selected[:, selected.shape[1] // 2 : selected.shape[1] // 2 + 1]
offset = selected - middle
band = offset.sum(dim=-1, keepdim=True).sign() * torch.sqrt(
torch.cumsum(offset * offset, dim=-1)
)
band = band * steps ** (config["diff_band_exponent"] - 0.5)
if anchors is None:
anchors = _last_observed(history)
calibrated[differenced] = (
torch.cumsum(middle, dim=-1)
+ config["diff_band_scale"] ** 0.5 * band
+ anchors[differenced, None, None]
)
return calibrated.sort(dim=1).values
def _forecast_once(
model: nn.Module,
context: Tensor,
future: Tensor,
n_targets: int,
horizon: int,
tta: Mapping[str, float],
lengths: Tensor,
*,
sign_flip: bool = False,
) -> Tensor:
"""Forecast equal-width groups, optionally including their independent negative views."""
cfg = model.config
patch = cfg.output_patch_size
window_len = max(cfg.future_len, -(-horizon // patch) * patch)
samples, channels, width = context.shape
history = context[:, :n_targets].flatten(0, 1)
row_lengths = lengths.repeat_interleave(n_targets)
differenced = (
_difference_mask(history, tta, row_lengths)
if tta["postprocess"] and tta["tta_diff"] and horizon <= tta["differencing_max_horizon"]
else torch.zeros(len(history), dtype=torch.bool, device=history.device)
)
has_trend = bool(differenced.any())
anchors = _last_observed(history) if has_trend else None
if has_trend:
diff = torch.cat([torch.zeros_like(history[:, :1]), history[:, 1:] - history[:, :-1]], -1)
start = width - row_lengths
valid = torch.arange(width, device=history.device) >= start[:, None]
diff = torch.where(valid, diff, torch.nan).scatter(1, start[:, None], 0.0)
targets = torch.where(differenced[:, None], diff, history).reshape(
samples, n_targets, width
)
context = torch.cat([targets, context[:, n_targets:]], dim=1)
n_known = future.shape[1]
window = context.new_full((samples, channels, window_len), torch.nan)
window[:, n_targets : n_targets + n_known, :horizon] = future
observed = torch.isfinite(context).flatten(0, 1).any(0)
span = width - int(observed.int().argmax()) if bool(observed.any()) else 1
context_len = max(patch, 1 << (span - 1).bit_length())
if cfg.context_len is not None:
context_len = min(context_len, cfg.context_len)
x = torch.cat([context[..., -context_len:], window], dim=-1)
x = pad(x, (context_len + window_len - x.shape[-1], 0), value=torch.nan)
if sign_flip:
x = torch.cat([x, -x])
differenced = differenced.repeat(2)
if anchors is not None:
anchors = torch.cat([anchors, -anchors])
layout = forecast_layout(len(x), channels, n_targets, n_known, x.device)
batch = {
"x": x.flatten(0, 1),
"group_vector": layout.groups,
"target_mask": layout.targets,
"future_covariate_mask": layout.future,
"past_covariate_mask": layout.past,
}
with torch.no_grad():
pred = model(batch, future_len=window_len, horizon=horizon, layout=layout)
pred = pred.reshape(len(x), channels, -1, horizon)[:, :n_targets].flatten(0, 1)
if tta["postprocess"]:
pred = _calibrate(pred, history, differenced, tta, anchors=anchors, has_trend=has_trend)
return pred.reshape(len(x), n_targets, -1, horizon)
def _context(
history: Tensor,
horizon: int,
past_covariates: Tensor | None,
future_covariates: Tensor | None,
categorical_past: tuple[int, ...],
categorical_future: tuple[int, ...],
encode_categoricals: bool,
) -> tuple[Tensor, Tensor]:
device = history.device
n_targets, length = history.shape
parts = [history]
future = history.new_empty((0, horizon))
if future_covariates is not None:
rows = _rows(
future_covariates, "future_covariates", device, dtype=None, length=length + horizon
)
if rows.shape[1] < length + horizon:
raise ValueError("future_covariates must cover history plus horizon")
rows = _encode_covariates(
rows, categorical_future, history, "categorical_future", encode_categoricals
)
parts.append(rows[:, :length])
future = rows[:, length:]
elif categorical_future:
raise ValueError("categorical_future requires future_covariates")
if past_covariates is not None:
rows = _rows(past_covariates, "past_covariates", device, dtype=None)
if rows.shape[1] != length:
raise ValueError("past_covariates must have the same history length")
parts.append(
_encode_covariates(
rows, categorical_past, history, "categorical_past", encode_categoricals
)
)
elif categorical_past:
raise ValueError("categorical_past requires past_covariates")
return torch.cat(parts, dim=0), future
def _predict(
model: nn.Module,
context: Tensor,
future: Tensor,
n_targets: int,
horizon: int,
tta: Mapping[str, float],
lengths: Tensor | None = None,
) -> Tensor:
"""Forecast ``[samples, channels, time]`` groups with shared sign preprocessing."""
device = context.device
samples = len(context)
history = context[:, :n_targets].flatten(0, 1)
if lengths is None:
lengths = torch.full((samples,), context.shape[-1], dtype=torch.long, device=device)
sign_flip = bool(tta["postprocess"] and tta["tta_sign_flip"])
with torch.autocast(device.type, dtype=torch.bfloat16, enabled=bool(tta["bfloat16"])):
pred = _forecast_once(
model, context, future, n_targets, horizon, tta, lengths, sign_flip=sign_flip
)
if sign_flip:
positive, negative = pred.chunk(2)
# Loading validates a strictly sorted, symmetric grid centred on the median.
pred = (positive - negative.flip(2)) / 2
pred = pred.float().flatten(0, 1)
observed = torch.isfinite(history)
if tta["clip_nonnegative"]:
nonnegative = observed.any(dim=-1) & ~(observed & (history < 0)).any(dim=-1)
pred = torch.where(nonnegative[:, None, None], pred.clamp_min(0), pred)
if tta["integer_round"]:
integers = observed.any(dim=-1) & ~(observed & (history != history.round())).any(dim=-1)
pred = torch.where(integers[:, None, None], pred.round(), pred)
expected = (samples * n_targets, model.get_buffer("quantiles").numel(), horizon)
if pred.shape != expected or not bool(torch.isfinite(pred).all()):
raise ValueError("model returned invalid forecast values or shape")
return pred.reshape(samples, n_targets, -1, horizon).detach().cpu()
def _timestamps(
timestamps: Tensor | None, freq: str | None, count: int, length: int, horizon: int
) -> Tensor | None:
if timestamps is None:
if freq is not None:
raise ValueError("freq requires timestamps")
return None
if not isinstance(timestamps, Tensor) or timestamps.dtype != torch.int64:
raise ValueError("timestamps must be an int64 tensor of epoch nanoseconds")
stamps = timestamps.detach().cpu()
if stamps.ndim == 1:
stamps = stamps[None].expand(count, -1)
if stamps.ndim != 2 or stamps.shape[0] != count or stamps.shape[1] < length + horizon:
raise ValueError("timestamps must cover history plus horizon for every series")
stamps = stamps[:, : length + horizon]
valid = stamps != torch.iinfo(torch.int64).min
if not valid[:, length - 1 :].all():
raise ValueError("timestamps must include the forecast origin and every future step")
start = valid.long().argmax(dim=1)
if not torch.equal(valid, torch.arange(stamps.shape[1])[None] >= start[:, None]):
raise ValueError("missing timestamps are only allowed in leading history padding")
if ((stamps[:, 1:] <= stamps[:, :-1]) & valid[:, :-1]).any():
raise ValueError("timestamps must be strictly increasing")
if freq is not None and not isinstance(freq, str):
raise ValueError("freq must be a frequency string")
return stamps
def _with_calendar(
context: Tensor,
future: Tensor,
n_targets: int,
stamps: Tensor | None,
freq: str | None,
enabled: bool,
*,
cache: dict[bytes, Tensor] | None = None,
) -> tuple[Tensor, Tensor]:
if not enabled or stamps is None:
return context, future
if freq is None:
raise ValueError("freq is required to generate calendar covariates")
from ._calendar import calendar_channels, frequency_step_ns
key = stamps.numpy().tobytes()
calendar = None if cache is None else cache.get(key)
if calendar is None:
calendar = torch.from_numpy(calendar_channels(stamps.numpy(), frequency_step_ns(freq)))
calendar = calendar.to(context)
if cache is not None:
cache[key] = calendar
if not calendar.shape[0]:
return context, future
length = context.shape[-1]
at = n_targets + future.shape[0]
context = torch.cat([context[:at], calendar[:, :length], context[at:]], dim=0)
return context, torch.cat([future, calendar[:, length:]], dim=0)
@dataclass
class _Case:
"""One independent forecast window and the caller's output row indices."""
indices: list[int]
context: Tensor
future: Tensor
n_targets: int
def _cohort_covariates(
value: Tensor | None,
name: str,
count: int,
length: int,
horizon: int,
device: torch.device,
categorical: tuple[int, ...],
) -> Tensor | None:
if value is None:
if categorical:
raise ValueError(f"categorical indices require {name}")
return None
if not isinstance(value, Tensor) or value.ndim not in (2, 3):
raise ValueError(f"{name} must be a two- or three-dimensional tensor")
rows = value[None].expand(count, -1, -1) if value.ndim == 2 else value
if rows.shape[0] != count or rows.shape[1] < 1:
raise ValueError(f"{name} must have covariates for every series")
width = length + horizon
if (horizon and rows.shape[-1] < width) or (not horizon and rows.shape[-1] != width):
raise ValueError(f"{name} must cover the required time steps")
rows = rows[..., :width]
flat = _rows(rows.reshape(-1, width), name, device, dtype=None)
# Validate indices without fitting an encoding across unrelated series.
if len(set(categorical)) != len(categorical) or any(
not isinstance(index, Integral) or isinstance(index, bool) or not 0 <= index < rows.shape[1]
for index in categorical
):
raise ValueError(f"categorical_{name} must contain unique valid covariate row indices")
return flat.reshape(count, rows.shape[1], width)
def _cohort_cases(
history: Tensor,
horizon: int,
tta: Mapping[str, float],
past: Tensor | None,
future: Tensor | None,
categorical_past: tuple[int, ...],
categorical_future: tuple[int, ...],
stamps: Tensor | None,
freq: str | None,
series_ids: Sequence[str],
static_features: Mapping[str, Sequence[object]] | None,
) -> Iterable[_Case]:
from ._groups import chunk_groups, joint_neighbour_rows, static_group_rows
count, length = history.shape
if (
isinstance(series_ids, str)
or len(series_ids) != count
or any(not isinstance(name, str) or not name for name in series_ids)
or len(set(series_ids)) != count
):
raise ValueError("series_ids must contain one unique string per history row")
if static_features is not None and not isinstance(static_features, Mapping):
raise ValueError("static_features must map column names to per-series values")
statics = dict(static_features or {})
for name, values in statics.items():
if (
not isinstance(name, str)
or isinstance(values, (str, bytes, Mapping))
or not hasattr(values, "__len__")
or len(values) != count
):
raise ValueError("static_features must have one value per series in each column")
statics[name] = (
values.detach().cpu().tolist() if isinstance(values, Tensor) else tuple(values)
)
if stamps is None:
stamps = torch.arange(length + horizon)[None].expand(count, -1)
calendar_stamps = None
starts = [0] * count
else:
calendar_stamps = stamps
starts = (stamps != torch.iinfo(torch.int64).min).long().argmax(dim=1).tolist()
order = sorted(range(count), key=lambda i: series_ids[i])
stamps_np = stamps.numpy()
bare = not statics and past is None and future is None
chunk = int(tta["chunk_joint"]) if bare else 0
static_rows = None
neighbours = None
if (statics and tta["static_groups"]) or (
not statics and not chunk and tta["joint_neighbours"]
):
histories = history.detach().double().cpu().numpy()
histories_in_order = [histories[i, starts[i] :] for i in order]
times = [stamps_np[i, starts[i] : length] for i in order]
if statics and tta["static_groups"]:
columns = [[statics[name][i] for i in order] for name in sorted(statics)]
static_rows = static_group_rows(
histories_in_order, times, columns, int(tta["static_groups"])
)
elif not statics and not chunk and tta["joint_neighbours"]:
neighbours = joint_neighbour_rows(histories_in_order, times, int(tta["joint_neighbours"]))
groups = (
chunk_groups([int(stamps_np[i, length - 1]) for i in order], chunk)
if chunk
else [[i] for i in range(count)]
)
calendar_cache: dict[bytes, Tensor] = {}
for members in groups:
indices = [order[position] for position in members]
longest = min(indices, key=lambda i: starts[i])
start = starts[longest]
targets = history[indices, start:]
own_past = None if past is None else past[longest, :, start:]
own_future = None if future is None else future[longest, :, start:]
if static_rows is not None:
extra = torch.from_numpy(static_rows[members[0]]).to(history)
own_past = extra if own_past is None else torch.cat([own_past, extra])
if neighbours is not None:
targets = torch.from_numpy(neighbours[members[0]]).to(history)
context, revealed = _context(
targets,
horizon,
own_past,
own_future,
categorical_past,
categorical_future,
bool(tta["target_encode_categoricals"]),
)
own_stamps = None if calendar_stamps is None else calendar_stamps[longest, start:]
context, revealed = _with_calendar(
context,
revealed,
len(targets),
own_stamps,
freq,
bool(tta["calendar_covariates"]),
cache=calendar_cache,
)
yield _Case(indices, context, revealed, len(targets))
def _packed_predict(
model: nn.Module, cases: Sequence[_Case], horizon: int, tta: Mapping[str, float]
) -> Tensor:
"""Stack equal-width groups without changing group membership or covariate ownership."""
width = max(case.context.shape[-1] for case in cases)
context = torch.stack(
[pad(case.context, (width - case.context.shape[-1], 0), value=torch.nan) for case in cases]
)
lengths = torch.tensor([case.context.shape[-1] for case in cases], device=context.device)
return _predict(
model,
context,
torch.stack([case.future for case in cases]),
cases[0].n_targets,
horizon,
tta,
lengths,
).flatten(0, 1)
def _run_cases(
model: nn.Module,
cases: Iterable[_Case],
count: int,
horizon: int,
tta: Mapping[str, float],
) -> Tensor:
out = torch.empty(count, model.get_buffer("quantiles").numel(), horizon)
pending: dict[tuple[int, int, int], list[_Case]] = {}
rows = 0
views = 2 if tta["postprocess"] and tta["tta_sign_flip"] else 1
def flush() -> None:
for members in pending.values():
predictions = _packed_predict(model, members, horizon, tta)
cursor = 0
for case in members:
out[case.indices] = predictions[cursor : cursor + len(case.indices)]
cursor += case.n_targets
pending.clear()
for case in cases:
if pending and (rows + len(case.context)) * views > 2048:
flush()
rows = 0
key = (case.n_targets, len(case.future), len(case.context))
pending.setdefault(key, []).append(case)
rows += len(case.context)
if pending:
flush()
return out