File size: 6,934 Bytes
2c0a24a | 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 | """Figure for Methodology 3.5: the three chronological splitting schemes.
Dates are computed the way the experiments compute them, not typed in:
A purged walk-forward (regime ladder, scripts/run_ablation_study.py):
the longest contiguous run of 31,234 hours ending 16 Oct 2026, cut into
6 equal blocks; fold k trains on blocks 1..k (last fifth = validation),
skips 24 hours, and tests block k+1
B hold-out (deployed regime model, train_vae_transformer_artifacts.py):
the same run, first 85% for training, 24 hours purged, last 15% tested
C quarterly walk-forward (price range, backtest_improvements.py): each
quarter Jan 2023 - Sep 2026 tested by a fit on the two years ending
72 hours before it
Usage:
python scripts/make_split_diagram.py
"""
from __future__ import annotations
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.dates as mdates
import matplotlib.pyplot as plt
import pandas as pd
from matplotlib.patches import Patch
ROOT = Path(__file__).resolve().parents[1]
SURFACE, INK, INK_2, GRID = "#fcfcfb", "#0b0b0b", "#52514e", "#e6e5e1"
TRAIN, VALID, TEST, GAP = "#2a78d6", "#a9cbf0", "#eb6834", "#0b0b0b"
END = pd.Timestamp("2026-10-16 23:00")
ROWS = 31_234
START = END - pd.Timedelta(hours=ROWS - 1)
H = pd.Timedelta(hours=1)
def bar(ax, y, a, b, color, h=0.62, **kw):
if color == GAP:
# 24-72 hours is invisible on a six-year axis: draw it as a marker
# a few weeks wide, centred on the real gap, and say so in the legend.
mid = a + (b - a) / 2
a, b = mid - pd.Timedelta(days=9), mid + pd.Timedelta(days=9)
h = h * 1.25
ax.barh(y, (b - a) / pd.Timedelta(days=1), left=mdates.date2num(a), height=h,
color=color, edgecolor=SURFACE, linewidth=0.6, zorder=3 if color == GAP else 2, **kw)
def main() -> None:
plt.rcParams.update({"font.family": "DejaVu Sans", "text.color": INK})
fig, axes = plt.subplots(3, 1, figsize=(13, 9.6), facecolor=SURFACE,
gridspec_kw={"height_ratios": [5, 1.4, 5.6]})
fig.subplots_adjust(left=0.13, right=0.98, top=0.86, bottom=0.07, hspace=0.75)
lo, hi = pd.Timestamp("2020-07-01"), pd.Timestamp("2028-03-01")
# A: purged walk-forward, 5 folds
a = axes[0]
edges = [START + (END - START) * k / 6 for k in range(7)]
for f in range(5):
y = 4 - f
tr_end = edges[f + 1] - 24 * H # 24h purge before the test block
cut = START + (tr_end - START) * 0.8
bar(a, y, START, cut, TRAIN)
bar(a, y, cut, tr_end, VALID)
bar(a, y, tr_end, edges[f + 1], GAP, h=0.62)
bar(a, y, edges[f + 1], edges[f + 2], TEST)
a.text(mdates.date2num(edges[f + 2]) + 12, y, f"test {edges[f + 1]:%b %Y} โ {edges[f + 2]:%b %Y}",
va="center", fontsize=8.5, color=INK_2)
a.set_yticks(range(5))
a.set_yticklabels([f"fold {5 - i}" for i in range(5)], fontsize=9)
a.set_title("A Purged walk-forward, 5 folds โ comparing the 14 regime models",
loc="left", fontsize=11.5, fontweight="bold", pad=20)
a.text(0, 1.015, "Each model is tested on five different later periods; the training block grows each time. "
"Its last fifth (validation) is used for early stopping and the decision cut-off.",
transform=a.transAxes, fontsize=8.8, color=INK_2, va="bottom")
# B: hold-out
b = axes[1]
cut = START + (END - START) * 0.85
bar(b, 0, START, cut - 24 * H, TRAIN)
bar(b, 0, cut - 24 * H, cut, GAP)
bar(b, 0, cut, END, TEST)
b.text(mdates.date2num(START) + 20, 0, "train on the first 85%", va="center", fontsize=8.5, color="white")
b.text(mdates.date2num(END) + 12, 0, f"test the last 15%\n({cut:%b} โ {END:%b %Y}, 4,686 h)",
va="center", fontsize=8.5, color=INK_2)
b.set_yticks([0])
b.set_yticklabels(["deployed model"], fontsize=9)
b.set_ylim(-0.6, 0.6)
b.set_title("B Hold-out โ the final VAE + Transformer that runs live", loc="left",
fontsize=11.5, fontweight="bold", pad=20)
b.text(0, 1.04, "One fit on all earlier data, scored once on the most recent period it never saw.",
transform=b.transAxes, fontsize=8.8, color=INK_2, va="bottom")
# C: quarterly walk-forward
c = axes[2]
quarters = pd.date_range("2023-01-01", "2026-07-01", freq="QS")
shown = list(quarters[:3]) + [None] + list(quarters[-2:])
labels = []
for i, q in enumerate(shown):
y = len(shown) - 1 - i
if q is None:
c.text(mdates.date2num(pd.Timestamp("2024-09-01")), y, "โฎ one row per quarter, 15 in total โฎ",
va="center", ha="center", fontsize=9, color=INK_2)
labels.append("")
continue
q_end = q + pd.offsets.QuarterBegin(1, startingMonth=1)
bar(c, y, q - pd.DateOffset(years=2) - 72 * H, q - 72 * H, TRAIN)
bar(c, y, q - 72 * H, q, GAP)
bar(c, y, q, q_end, TEST)
labels.append(f"Q{(q.month - 1) // 3 + 1} {q.year}")
c.set_yticks(range(len(shown)))
c.set_yticklabels(labels[::-1], fontsize=9)
c.set_title("C Quarterly walk-forward โ the price range, exactly as it refits live",
loc="left", fontsize=11.5, fontweight="bold", pad=20)
c.text(0, 1.015, "Every quarter from Jan 2023 to Sep 2026 is forecast by a model fitted on the two years "
"before it (โ 32,700 forecasts per horizon).",
transform=c.transAxes, fontsize=8.8, color=INK_2, va="bottom")
for ax in axes:
ax.set_facecolor(SURFACE)
ax.set_xlim(mdates.date2num(lo), mdates.date2num(hi))
ax.xaxis.set_major_locator(mdates.YearLocator())
ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y"))
ax.grid(axis="x", color=GRID, linewidth=0.8)
ax.set_axisbelow(True)
for s in ("top", "right", "left"):
ax.spines[s].set_visible(False)
ax.spines["bottom"].set_color(GRID)
ax.tick_params(colors=INK_2, labelsize=8.5, length=0)
handles = [Patch(color=TRAIN, label="training (past)"), Patch(color=VALID, label="validation"),
Patch(color=GAP, label="gap / purge: 24h (A, B) or 72h (C), drawn wider to be visible"), Patch(color=TEST, label="test (later, unseen)")]
fig.legend(handles=handles, loc="upper left", bbox_to_anchor=(0.13, 0.925), ncol=4, frameon=False, fontsize=9)
fig.suptitle("Figure 3.2 Three chronological splitting schemes: train on the past, test on the future",
x=0.02, ha="left", fontsize=14, fontweight="bold", y=0.975)
out = ROOT / "outputs" / "figures" / "fig20_data_splitting.png"
fig.savefig(out, dpi=170, facecolor=SURFACE)
plt.close(fig)
print(f"written: {out.relative_to(ROOT)} (contiguous run {START:%Y-%m-%d} โ {END:%Y-%m-%d})")
if __name__ == "__main__":
main()
|