UncleanCode's picture
Upload via Autoresearch export_data
51bb0b7 verified
Raw History Blame Contribute Delete
6.89 kB
"""Test-time ensembling for Modular-TS (v3): context-length ensembles, temporal-hierarchy
(multi-resolution) reconciliation and in-context backtest weighting. Inference only.
Members (spec string, comma separated):
cN - the base forecaster with max_context N (e.g. c8192, c2048, c512)
dK - downsampled member: context mean-pooled by K (aligned to the forecast origin, so it
sees up to K x the history at the same token budget), forecast ceil(H/K) coarse
steps, step-upsampled back. Used as a *level corrector* for the fine forecasts
(temporal hierarchies / THieF, Athanasopoulos et al. 2017): fine + a*(coarse - pool(fine))
instead of being averaged in directly, so intra-period shape comes from full res.
Combination of the c-members: quantile averaging (Vincentisation), uniform or, with
backtest=True, per-series softmax weights from each member's scaled MAE on the last H
observed steps of the context (a zero-shot, in-context model selection - it only uses the
series' own history).
fc = EnsembleForecaster(TimestralForecaster.from_pretrained(exp), "c8192,c2048,d4", alpha=0.5)
"""
from __future__ import annotations
import math
from dataclasses import dataclass
import numpy as np
import torch
@dataclass
class _F:
quantiles: torch.Tensor
def _pool(x: np.ndarray | None, k: int) -> np.ndarray | None:
"""(B,V,T) NaN-aware mean pool by k aligned to the end."""
if x is None:
return None
B, V, T = x.shape
n = T // k
if n == 0:
return None
y = x[..., T - n * k:].reshape(B, V, n, k)
with np.errstate(all="ignore"):
cnt = np.isfinite(y).sum(-1)
s = np.nansum(y, -1)
out = np.where(cnt > 0, s / np.maximum(cnt, 1), np.nan)
return out.astype(np.float32)
class EnsembleForecaster:
def __init__(self, fc, spec: str = "c8192", alpha: float = 0.5, backtest: bool = False, tau: float = 0.5, bt_windows: int = 1, prior: float = 0.0,
min_coarse_ctx: int = 256, min_extra: float = 1.5, min_ratio: float = 8.0):
self.fc = fc
self.members = [m.strip() for m in spec.split(",") if m.strip()]
cl = fc.model.config.context_length # never exceed the trained context (RoPE does not extrapolate)
self.ctxs = [min(int(m[1:]), cl) for m in self.members if m[0] == "c"] or [fc.max_context]
self.ks = [int(m[1:]) for m in self.members if m[0] == "d"]
self.alpha, self.backtest, self.tau = alpha, backtest, tau
self.bt_windows, self.prior = bt_windows, prior # prior: logit bonus for the first (longest) member
self.min_coarse_ctx, self.min_extra, self.min_ratio = min_coarse_ctx, min_extra, min_ratio # member ctx >= min_ratio*H
self.base_ctx = fc.max_context
self.max_context = max(self.ctxs + [self.base_ctx * k for k in self.ks] + [self.base_ctx])
self.quantile_levels = fc.quantile_levels
self.mi = list(fc.quantile_levels).index(0.5) if 0.5 in list(fc.quantile_levels) else len(fc.quantile_levels) // 2
def _one(self, ctx, h, past, mc):
self.fc.max_context = mc
try:
return self.fc.forecast(ctx, h, past_covariates=past).quantiles.float().cpu()
finally:
self.fc.max_context = self.base_ctx
def _members(self, ctx, h, past):
T = ctx.shape[-1]
outs, used = [], set()
for i, c in enumerate(self.ctxs):
if i > 0 and c < self.min_ratio * h: # a short context cannot support a long horizon
continue
eff = min(c, T)
if eff in used: # a shorter series makes several members identical
continue
used.add(eff)
outs.append(self._one(ctx[..., -c:], h, None if past is None else past[..., -c:], c))
return outs
def forecast(self, context, prediction_length, past_covariates=None):
ctx = np.asarray(context, np.float32)
if ctx.ndim == 2:
ctx = ctx[:, None]
past = None if past_covariates is None else np.asarray(past_covariates, np.float32)
h = prediction_length
T = ctx.shape[-1]
outs = self._members(ctx, h, past)
if len(outs) == 1:
q = outs[0]
elif self.backtest and T - h >= 2 * 32:
E, ok = 0.0, True
nw = 0
for wdw in range(1, self.bt_windows + 1):
if T - wdw * h < 2 * 32:
break
cut = T - wdw * h
bt = self._members(ctx[..., :cut], h, None if past is None else past[..., :cut])
if len(bt) != len(outs):
ok = False
break
y = torch.from_numpy(ctx[..., cut:cut + h])
hist = torch.from_numpy(ctx[..., :cut])
dd = hist[..., 1:] - hist[..., :-1]
fin = torch.isfinite(dd)
scale = (torch.where(fin, dd.abs(), torch.zeros_like(dd)).sum(-1) / fin.sum(-1).clamp_min(1)).clamp_min(1e-8)
errs = []
for b in bt:
e = (b[..., self.mi] - y).abs()
fe = torch.isfinite(e)
e = torch.where(fe, e, torch.zeros_like(e)).sum(-1) / fe.sum(-1).clamp_min(1) / scale
errs.append(e.mean(-1))
E = E + torch.stack(errs, 0)
nw += 1
if not ok or nw == 0:
q = torch.stack(outs).mean(0)
else:
E = E / nw
logit = -E / (self.tau * E.min(0, keepdim=True).values.clamp_min(1e-3))
logit[0] = logit[0] + self.prior
w = torch.softmax(logit, 0)
q = (torch.stack(outs) * w[:, :, None, None, None]).sum(0)
else:
q = torch.stack(outs).mean(0)
# temporal-hierarchy level correction from downsampled members
for k in self.ks:
hk = math.ceil(h / k)
pooled = _pool(ctx, k)
if pooled is None or pooled.shape[-1] < self.min_coarse_ctx:
continue
# only worth it when the coarse view sees materially more history than the fine one
if T < self.min_extra * self.base_ctx and hk < 2:
continue
pp = _pool(past, k) if past is not None else None
cq = self._one(pooled[..., -self.base_ctx:], hk, None if pp is None else pp[..., -self.base_ctx:], self.base_ctx)
cmed = cq[..., self.mi] # (B,V,hk)
fmed = q[..., self.mi]
pad = hk * k - h
fm = torch.cat([fmed, fmed[..., -1:].expand(*fmed.shape[:-1], pad)], -1) if pad else fmed
fpool = fm.view(*fm.shape[:-1], hk, k).mean(-1)
delta = (cmed - fpool).repeat_interleave(k, -1)[..., :h]
q = q + self.alpha / len(self.ks) * delta[..., None]
return _F(q)