cascade-toto2-4m-best-bench / forecast_wrapper.py
tensorlink-dev's picture
cascade r8-challenger checkpoint: best public-bench artifact (GIFT-Eval CRPS 0.6009)
b428368 verified
Raw
History Blame Contribute Delete
8.99 kB
"""Auto-generated by cascade Toto2Trainer. Loads the trained checkpoint and
decodes the full horizon in one forward pass via contiguous patch masking
(CPM) β€” no autoregressive sampling. Exposes:
forecast(history, horizon, num_samples) -> (1, num_samples, horizon)
the cascade validator contract β€” sample paths drawn once from the
decoded quantiles (seeded per window for validator consensus).
forecast_quantiles(history, horizon) -> (1, horizon, num_q)
forecast_quantiles_batch(histories, horizon) -> (B, horizon, num_q)
the quantile head directly β€” what benchmark CRPS consumes; batched
across series so eval sweeps amortize the forward passes.
"""
from __future__ import annotations
import hashlib
import importlib.util
import json
import sys
from pathlib import Path
import numpy as np
import torch
# Single-pass CPM decoding is stable to ~768 steps (Toto 2.0 tech report);
# longer horizons block-decode: commit the median per block, then continue.
STABLE_DECODE_STEPS = 768
def _load_model_module(d: Path):
spec = importlib.util.spec_from_file_location("cascade_ckpt_model", d / "model.py")
mod = importlib.util.module_from_spec(spec)
# Register before exec: model.py defines an @dataclass, and the dataclass
# machinery does sys.modules.get(cls.__module__).__dict__ during class
# creation β€” which is None (AttributeError) unless the module is registered.
sys.modules[spec.name] = mod
spec.loader.exec_module(mod)
return mod
class Wrapper:
def __init__(self, checkpoint_dir, device: str = "cpu"):
d = Path(checkpoint_dir)
self.device = device
cfg_obj = json.loads((d / "config.json").read_text())
self.m = _load_model_module(d)
self.cfg = self.m.Toto2Config(**cfg_obj["toto2"])
self.quantile_levels = [float(v) for v in cfg_obj["quantile_levels"]]
self.levels = torch.tensor(self.quantile_levels, dtype=torch.float32, device=device)
self.model = self.m.Toto2Model(self.cfg).to(device).eval()
from safetensors.torch import load_file
state = load_file(str(d / "weights.safetensors"))
self.model.load_state_dict(state)
# ── CPM decoding ──────────────────────────────────────────────────────────
def _prep(self, histories):
"""Left-pad (with the first value) or truncate each 1-D history to the
context window. Returns the real-space context ``(B, window_len)`` in
float64 β€” standardization happens per decode block, from full
precision, so large-level series keep their fluctuations."""
ps = self.cfg.patch_size
n_ctx = max(2, self.cfg.context_length // ps)
window_len = n_ctx * ps
rows = []
for h in histories:
h = np.asarray(h, dtype=np.float64).reshape(-1)
if h.shape[0] < window_len:
pad = np.full(window_len - h.shape[0], h[0] if h.size else 0.0)
h = np.concatenate([pad, h])
else:
h = h[-window_len:]
rows.append(h)
return torch.as_tensor(np.stack(rows), dtype=torch.float64, device=self.device)
@torch.no_grad()
def _decode_block_z(self, z, block: int):
"""One CPM forward pass: append ``block`` masked patches to the
normalized context ``(B, L)`` and read their z-space quantiles
``(B, block*patch_size, num_q)``."""
ps = self.cfg.patch_size
# keep as much context as the positional table allows
ctx_p = min(z.shape[1] // ps, self.cfg.max_patches - block)
ctx = z[:, -ctx_p * ps :].view(z.shape[0], ctx_p, ps)
filler = torch.zeros(z.shape[0], block, ps, dtype=ctx.dtype, device=self.device)
mask = torch.zeros(z.shape[0], ctx_p + block, dtype=ctx.dtype, device=self.device)
mask[:, ctx_p:] = 1.0
pred = self.model(torch.cat([ctx, filler], dim=1), mask=mask)
# position i predicts patch i+1 β†’ the horizon patches come from
# positions ctx_p-1 .. ctx_p+block-2.
q = pred[:, ctx_p - 1 : ctx_p + block - 1] # (B, block, ps, nq)
q, _ = torch.sort(q, dim=-1) # prevent quantile crossing
return q.reshape(z.shape[0], block * ps, -1)
@torch.no_grad()
def _decode_quantiles(self, x, horizon: int):
"""Block-decode real-space quantiles ``(B, horizon, num_q)`` from the
real-space context ``x`` ``(B, L)``.
Each block re-runs the causal scaler over history + committed medians
and unscales with the resulting end-of-context anchor. Committed
patches are *observed* context for later blocks, and in training the
causal stats advance through every observed patch β€” so the anchor must
advance with them; reusing the pre-horizon anchor would feed blocks β‰₯ 2
a scale/location regime the model never sees in training. Clamp bounds
are fixed from the original context (min/max Β± 1e4x anchor scale, per
the report) so committed medians can't widen them.
"""
ps = self.cfg.patch_size
stable = max(1, min(STABLE_DECODE_STEPS // ps, self.cfg.max_patches - 2))
remaining = -(-int(horizon) // ps)
lo = hi = None
out = []
while remaining > 0:
block = min(remaining, stable)
z, loc_t, scale_t = self.m.causal_standardize(x)
loc = loc_t[:, -1:].double().unsqueeze(-1) # (B, 1, 1)
scale = scale_t[:, -1:].double().unsqueeze(-1)
if lo is None:
lo = x.min(dim=-1, keepdim=True).values.unsqueeze(-1) - 1e4 * scale
hi = x.max(dim=-1, keepdim=True).values.unsqueeze(-1) + 1e4 * scale
qz = self._decode_block_z(z.to(torch.float32), block)
q = torch.sinh(qz.double()) * scale + loc # (B, block*ps, nq)
q = torch.clamp(q, min=lo, max=hi)
out.append(q)
remaining -= block
if remaining > 0:
x = torch.cat([x, q[..., q.shape[-1] // 2]], dim=1)
return torch.cat(out, dim=1)[:, : int(horizon)]
# ── quantile head (benchmark path) ────────────────────────────────────────
@torch.no_grad()
def forecast_quantiles_batch(self, histories, horizon: int) -> np.ndarray:
"""Decode ``len(histories)`` series in one batch β†’ real-space quantiles
``(B, horizon, num_q)`` at ``self.quantile_levels``. arcsinh + affine
are monotone increasing, so quantiles map pointwise."""
q = self._decode_quantiles(self._prep(list(histories)), horizon)
return q.detach().cpu().numpy().astype(np.float64)
def forecast_quantiles(self, history, horizon: int) -> np.ndarray:
return self.forecast_quantiles_batch([history], horizon)
# ── validator contract (sample paths) ─────────────────────────────────────
@torch.no_grad()
def forecast(self, history, horizon: int, num_samples: int) -> np.ndarray:
hist = np.asarray(history, dtype=np.float64).reshape(-1)
# Deterministic per-window sampling: seed from the (raw history, horizon,
# num_samples) so every validator computes identical scores and king vs
# challenger share the uniform draws (paired Monte-Carlo).
seed_src = hist.tobytes() + int(horizon).to_bytes(8, "big") + int(num_samples).to_bytes(8, "big")
seed = int.from_bytes(hashlib.sha256(seed_src).digest()[:8], "big") & ((1 << 63) - 1)
generator = torch.Generator(device=self.device)
generator.manual_seed(seed)
q = self._decode_quantiles(self._prep([hist]), horizon)[0] # (h, nq) real-space
# One draw per step per path via the piecewise-linear inverse CDF of the
# decoded quantiles (already clamped and monotone in the level).
# Quantiles decode once; samples never feed back.
nq = q.shape[-1]
levels = self.levels
u = torch.rand(int(num_samples), int(horizon), device=self.device, generator=generator)
idx = torch.searchsorted(levels, u.clamp(levels[0].item(), levels[-1].item()))
idx = idx.clamp(1, nq - 1)
i_lo = idx - 1
i_hi = idx
qe = q.unsqueeze(0).expand(u.shape[0], -1, -1) # (ns, h, nq)
vl = torch.gather(qe, -1, i_lo.unsqueeze(-1)).squeeze(-1)
vh = torch.gather(qe, -1, i_hi.unsqueeze(-1)).squeeze(-1)
ql = levels[i_lo].double(); qh = levels[i_hi].double()
frac = ((u.double() - ql) / (qh - ql).clamp_min(1e-8)).clamp(0, 1)
out = vl + frac * (vh - vl) # (ns, h)
return out.detach().cpu().numpy().reshape(1, int(num_samples), int(horizon))