Spaces:
Running
Running
File size: 7,977 Bytes
0c9cbf3 15836c8 0c9cbf3 15836c8 0c9cbf3 15836c8 0c9cbf3 15836c8 0c9cbf3 15836c8 0c9cbf3 15836c8 0c9cbf3 15836c8 0c9cbf3 15836c8 | 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 | """
Walk-forward validation engine — the trust backbone.
For a fixed config, roll the *same* ensemble forecast the dashboard makes across many
recent origins, hold out the truth each time, and measure how good (and how honest) the
forecast actually is on THIS ticker:
* accuracy : MAPE / MAE / DirAcc of the ensemble median
* skill : MASE and skill-vs-random-walk (does it beat "tomorrow = today"?)
* calibration : the 10-90% band's real coverage, and a multiplicative band-scale that
corrects it to a target coverage (split-conformal, CQR-style)
* validity : mean range_ratio of the fed windows (Kronos normalisation sanity)
Everything downstream (autoconfig, the trust card) is computed from this one pass so a
config is judged by evidence, not by guesswork.
"""
import time
import numpy as np
# predict_us lives one level up when imported as `us.validation`, or flat in the Space.
try:
from us.predict_us import fetch_ohlcv, report_metrics, baseline_skill # type: ignore
except Exception: # flat context (Space) or run from inside us/
from predict_us import fetch_ohlcv, report_metrics, baseline_skill # type: ignore
_COLS = ["open", "high", "low", "close", "volume"]
def make_windows(n_bars: int, lookback: int, pred_len: int, n_windows: int) -> list:
"""End-indices (exclusive) for rolling origins, spread across the recent slice."""
span = lookback + pred_len
first = span + pred_len # leave room for one extra step back
last = n_bars
if first >= last:
first = span
return [int(x) for x in np.linspace(first, last, n_windows, dtype=int)]
def _draw_paths(predictor, hist, fut_ts, pred_len, T, top_p, top_k, n_paths, seed):
import torch
paths = []
x_df = hist[_COLS].reset_index(drop=True)
x_ts = hist["timestamps"].reset_index(drop=True)
for i in range(n_paths):
torch.manual_seed(seed + i); np.random.seed(seed + i)
pred_df = predictor.predict(
df=x_df, x_timestamp=x_ts, y_timestamp=fut_ts, pred_len=pred_len,
T=T, top_k=top_k, top_p=top_p, sample_count=1, verbose=False,
)
paths.append(pred_df["close"].values.astype(float))
return np.stack(paths, axis=0) # (n_paths, pred_len)
def _best_band_scale(median, lo, hi, truth, target, grid=None):
"""Multiplicative scale s so [median±s·halfwidth] hits `target` coverage (CQR-style)."""
if grid is None:
grid = np.round(np.arange(0.3, 6.001, 0.05), 3)
best_s, best_cov, best_gap = 1.0, float("nan"), 1e9
for s in grid:
lo_s = median - s * (median - lo)
hi_s = median + s * (hi - median)
cov = float(np.mean((truth >= lo_s) & (truth <= hi_s)))
gap = abs(cov - target)
# prefer the tightest band whose coverage is closest to target
if gap < best_gap - 1e-9 or (abs(gap - best_gap) <= 1e-9 and s < best_s):
best_s, best_cov, best_gap = float(s), cov, gap
return best_s, best_cov
def _pearson(a, b):
a = np.asarray(a, float); b = np.asarray(b, float)
if len(a) < 3 or np.std(a) < 1e-12 or np.std(b) < 1e-12:
return float("nan")
return float(np.corrcoef(a, b)[0, 1])
def compute_ic(records):
"""Information Coefficient: correlation of predicted vs realised step-returns, pooled
across windows. This is how the Kronos paper judges skill (direction/ranking is what a
generative price model can actually capture; absolute price level is near-random).
Returns (IC=Pearson, RankIC=Spearman)."""
pr, tr = [], []
for w in records:
if len(w["median"]) > 1:
pr.append(np.diff(w["median"])); tr.append(np.diff(w["truth"]))
if not pr:
return float("nan"), float("nan")
pr = np.concatenate(pr); tr = np.concatenate(tr)
ic = _pearson(pr, tr)
rank = lambda x: np.argsort(np.argsort(x)).astype(float)
ric = _pearson(rank(pr), rank(tr))
return ic, ric
def run_windows(predictor, df, lookback, pred_len, T=0.7, top_p=0.9, top_k=0,
n_paths=10, windows=None, n_windows=6, seed=123):
"""Draw the ensemble at each rolling origin; return a per-window record list (raw
arrays + point metrics). Explicit `windows` lets callers share identical origins
across configs (needed for a fair nested inner/outer split)."""
if windows is None:
windows = make_windows(len(df), lookback, pred_len, n_windows)
records = []
for end in windows:
hist = df.iloc[end - lookback - pred_len:end - pred_len]
fut = df.iloc[end - pred_len:end]
if len(hist) < lookback or len(fut) < pred_len:
continue
fut_ts = fut["timestamps"].reset_index(drop=True)
paths = _draw_paths(predictor, hist, fut_ts, pred_len, T, top_p, top_k, n_paths, seed)
median = np.median(paths, axis=0)
lo = np.quantile(paths, 0.1, axis=0)
hi = np.quantile(paths, 0.9, axis=0)
truth = fut["close"].values.astype(float)
hist_close = hist["close"].values.astype(float)
wmin, wmax = float(hist_close.min()), float(hist_close.max())
rec = {"median": median, "lo": lo, "hi": hi, "truth": truth,
"range_ratio": wmax / wmin if wmin > 0 else float("inf")}
rec.update(report_metrics(truth, median))
rec.update(baseline_skill(hist_close, truth, median))
records.append(rec)
return records
def aggregate(records, target_coverage=0.8, band_scale=None) -> dict:
"""Summarise a set of window records. If band_scale is None, FIT the conformal scale
to hit target coverage on these records (calibration set). If a band_scale is given,
APPLY it and just measure the resulting coverage (test set) — this is how the nested
outer set reports coverage using a scale fitted only on the inner set."""
if not records:
raise ValueError("No valid windows — not enough history for this lookback/pred_len.")
keys = ["MAE", "RMSE", "MAPE%", "DirAcc%", "MASE", "skill_vs_rw"]
agg = {k: float(np.nanmean([w[k] for w in records])) for k in keys}
ic, ric = compute_ic(records)
agg["IC"] = ic; agg["RankIC"] = ric
med = np.concatenate([w["median"] for w in records])
lo = np.concatenate([w["lo"] for w in records])
hi = np.concatenate([w["hi"] for w in records])
tr = np.concatenate([w["truth"] for w in records])
cov_raw = float(np.mean((tr >= lo) & (tr <= hi)))
if band_scale is None:
bs, cov_cal = _best_band_scale(med, lo, hi, tr, target_coverage)
else:
bs = float(band_scale)
lo_s = med - bs * (med - lo); hi_s = med + bs * (hi - med)
cov_cal = float(np.mean((tr >= lo_s) & (tr <= hi_s)))
rr = [w["range_ratio"] for w in records]
return {
"n_windows": len(records),
"metrics": {k: (round(v, 4) if v == v else None) for k, v in agg.items()},
"calibration": {"target_coverage": target_coverage,
"coverage_raw": round(cov_raw, 4),
"band_scale": round(float(bs), 3),
"coverage_calibrated": round(cov_cal, 4)},
"validity": {"range_ratio_mean": round(float(np.mean(rr)), 3),
"range_ratio_max": round(float(np.max(rr)), 3)},
}
def evaluate(predictor, df, lookback, pred_len, T=0.7, top_p=0.9, top_k=0,
n_paths=10, n_windows=6, seed=123, target_coverage=0.8) -> dict:
"""Single-pass walk-forward for one config (non-nested; used for quick checks)."""
t0 = time.time()
records = run_windows(predictor, df, lookback, pred_len, T=T, top_p=top_p, top_k=top_k,
n_paths=n_paths, n_windows=n_windows, seed=seed)
out = aggregate(records, target_coverage)
out["config"] = {"lookback": lookback, "pred_len": pred_len, "T": T, "top_p": top_p,
"top_k": top_k, "n_paths": n_paths}
out["secs"] = round(time.time() - t0, 1)
return out
|