File size: 8,987 Bytes
b428368 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 | """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))
|