crypto-forecasting-ai / scripts /make_thesis_figures.py
chonthichar's picture
Claude Opus 5
Record the twelve-rung ablation results and redraw the ladder figure
9f9eb0f
Raw History Blame Contribute Delete
12.7 kB
"""Figures for the thesis, drawn from the measured results rather than retyped.
Every figure reads a JSON written by the experiment that produced it, so a
re-run updates the plots and no number is transcribed by hand.
The design rule throughout: **a bar is never shown without its baseline.** Each
chart draws the naive comparison as a line or a paired bar, because a figure
showing seven architectures at 65-75% with no baseline tells a reader nothing
about whether any of them works, and that is the failure this project documents
in the literature.
Usage:
python scripts/make_thesis_figures.py
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt # noqa: E402
import numpy as np # noqa: E402
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
OUT = ROOT / "outputs" / "figures"
OUT.mkdir(parents=True, exist_ok=True)
plt.rcParams.update({
"figure.dpi": 150, "savefig.dpi": 300, "savefig.bbox": "tight",
"font.size": 9, "axes.titlesize": 10, "axes.labelsize": 9,
"axes.spines.top": False, "axes.spines.right": False,
"axes.grid": True, "grid.alpha": 0.25, "grid.linewidth": 0.5,
})
MODEL = "#2f6f9f"
BASE = "#b0b7bd"
WARN = "#c8912f"
GOOD = "#3f8f5f"
def load(name: str):
path = ROOT / "outputs" / name
if not path.exists():
print(f" skipped: outputs/{name} not found")
return None
return json.loads(path.read_text(encoding="utf-8"))
# ------------------------------------------------------------------ figure 1
def figure_target_screen():
"""Margin over the binding baseline, for every target screened.
Plotted as margin rather than accuracy on purpose: accuracy would rank a
94.71% target top when it loses to its own majority class.
"""
first, second = load("prediction_target_screen.json"), load("change_target_screen.json")
if not first or not second:
return
rows = []
for source in (first, second):
for asset, targets in source.items():
if asset.startswith("_"):
continue
for name, row in targets.items():
binding = max(row["majority"], row["persistence"])
rows.append((name, asset.replace("USDT", ""),
row["accuracy"] - binding, row["roc_auc"]))
names = sorted({r[0] for r in rows},
key=lambda n: np.mean([r[2] for r in rows if r[0] == n]))
assets = ["BTC", "ETH", "ADA"]
fig, ax = plt.subplots(figsize=(7.2, 5.4))
height = 0.26
for i, asset in enumerate(assets):
values = [next((r[2] * 100 for r in rows if r[0] == n and r[1] == asset), np.nan)
for n in names]
ax.barh(np.arange(len(names)) + (i - 1) * height, values, height,
label=asset, color=[MODEL, "#7aa7c7", "#aecbe0"][i])
ax.axvline(0, color="#333", linewidth=1)
ax.set_yticks(range(len(names)))
ax.set_yticklabels(names)
ax.set_xlabel("accuracy minus the harder of (majority, persistence), percentage points")
ax.set_title("Sixteen candidate targets, ranked by margin over baseline\n"
"Zero is the line a target must clear to be worth modelling")
ax.legend(title="asset", loc="lower right", frameon=False)
fig.savefig(OUT / "fig1_target_screen.png")
plt.close(fig)
print(" fig1_target_screen.png")
# ------------------------------------------------------------------ figure 2
def figure_ablation():
"""The architecture ladder, all assets, naive rungs in grey.
Drawn as margin over the majority class rather than accuracy: accuracy
alone puts the naive rungs at a respectable-looking 50% and hides that they
are the floor rather than a result.
"""
data = load("ablation_study.json")
if not data:
return
assets = [a for a in ("BTCUSDT", "ETHUSDT", "ADAUSDT") if a in data]
order = ["majority", "persistence", "logistic", "gradient boosting", "MLP",
"CNN", "LSTM", "GRU", "Transformer", "CNN+Transformer",
"VAE+Transformer", "CNN+VAE+Transformer"]
names = [n for n in order if any(n in data[a]["architecture"] for a in assets)]
fig, ax = plt.subplots(figsize=(8.8, 5.2))
width = 0.26
for i, asset in enumerate(assets):
arch = data[asset]["architecture"]
values = [arch[n]["margin"] * 100 if n in arch else np.nan for n in names]
ax.bar(np.arange(len(names)) + (i - 1) * width, values, width,
label=asset.replace("USDT", ""),
color=[MODEL, "#7aa7c7", "#aecbe0"][i])
ax.axhline(0, color="#333", linewidth=1)
# The band the non-linear architectures occupy. This is the figure's point:
# nine of them sit inside one shaded strip, while the step up from the
# naive rungs on the left is an order of magnitude larger.
band = [data[a]["architecture"][n]["margin"] * 100
for a in assets for n in names
if n in data[a]["architecture"]
and n not in ("majority", "persistence", "logistic")]
ax.axhspan(min(band), max(band), color=GOOD, alpha=0.10, zorder=0,
label="nine non-linear architectures ({:.1f} to {:.1f} pts)".format(
min(band), max(band)))
ax.set_xticks(range(len(names)))
ax.set_xticklabels(names, rotation=28, ha="right")
ax.set_ylabel("margin over majority class, percentage points")
ax.set_title("Twelve rungs, three assets, identical folds and features"
"\nNine non-linear architectures share one band; the gap that "
"matters is to the left of it")
ax.legend(frameon=False, loc="lower right", fontsize=8)
fig.savefig(OUT / "fig2_ablation_ladder.png")
plt.close(fig)
print(" fig2_ablation_ladder.png")
# ------------------------------------------------------------------ figure 3
def figure_har():
"""R-squared and QLIKE against HAR-RV. Two panels because they disagree.
QLIKE is drawn with the axis inverted so that "higher is better" holds in
both panels; a reader comparing them should not have to remember that one
is a loss.
"""
data = load("har_benchmark.json")
if not data:
return
keys, r2, ql, har_r2, har_ql = [], [], [], [], []
for asset, horizons in data.items():
for label, rows in horizons.items():
if "HAR-RV" not in rows:
continue
best = max((v for k, v in rows.items() if k != "HAR-RV"),
key=lambda v: v["r2"])
cheapest = min((v for k, v in rows.items() if k != "HAR-RV"),
key=lambda v: v["qlike"])
keys.append(f"{asset.replace('USDT','')} {label}")
r2.append(best["r2"]); har_r2.append(rows["HAR-RV"]["r2"])
ql.append(cheapest["qlike"]); har_ql.append(rows["HAR-RV"]["qlike"])
x = np.arange(len(keys))
fig, (left, right) = plt.subplots(1, 2, figsize=(10, 4))
left.bar(x - 0.19, har_r2, 0.38, label="HAR-RV", color=BASE)
left.bar(x + 0.19, r2, 0.38, label="best model here", color=MODEL)
left.set_xticks(x); left.set_xticklabels(keys, rotation=30, ha="right")
left.set_ylabel("R² (higher is better)")
left.set_title("Explained variance")
left.legend(frameon=False)
right.bar(x - 0.19, har_ql, 0.38, label="HAR-RV", color=BASE)
right.bar(x + 0.19, ql, 0.38, label="best model here", color=MODEL)
right.set_xticks(x); right.set_xticklabels(keys, rotation=30, ha="right")
right.set_ylabel("QLIKE (lower is better)")
right.set_yscale("log")
right.set_title("QLIKE, the loss this literature reports")
right.legend(frameon=False)
# the caption is derived, not asserted: whether the panels disagree is a
# property of the data, and on some assets the same model wins both
disagree = sum(1 for a, b in zip(r2, ql) if not np.isclose(a, b))
fig.suptitle("Against HAR-RV on identical folds, R² and the loss this "
"literature actually reports", y=1.02)
fig.savefig(OUT / "fig3_har_benchmark.png")
plt.close(fig)
print(" fig3_har_benchmark.png")
# ------------------------------------------------------------------ figure 4
def figure_sentiment():
"""Change in ROC-AUC from adding external data. Zero is the only story."""
data = load("sentiment_new_targets_screen.json")
if not data:
return
labels, deltas = [], []
for asset, rows in data.items():
for key, row in rows.items():
if row["feature_set"] == "own 33" or row.get("delta_auc") is None:
continue
labels.append(f"{asset.replace('USDT','')} · {row['target']}\n{row['feature_set']}")
deltas.append(row["delta_auc"])
order = np.argsort(deltas)
labels = [labels[i] for i in order]
deltas = [deltas[i] for i in order]
fig, ax = plt.subplots(figsize=(7.2, max(3.5, 0.32 * len(labels))))
ax.barh(range(len(deltas)), deltas,
color=[GOOD if d > 0.005 else BASE for d in deltas])
ax.axvline(0, color="#333", linewidth=1)
ax.axvline(0.005, color=WARN, linestyle="--", linewidth=1,
label="+0.005, the smallest change worth claiming")
ax.set_yticks(range(len(labels)))
ax.set_yticklabels(labels, fontsize=7)
ax.set_xlabel("change in ROC-AUC from adding sentiment or on-chain data")
ax.set_title("Historic data versus sentiment: every comparison\n"
"Both sources lagged a full day, so nothing describes the hour it scores")
ax.legend(frameon=False, loc="lower right")
fig.savefig(OUT / "fig4_sentiment.png")
plt.close(fig)
print(" fig4_sentiment.png")
# ------------------------------------------------------------------ figure 5
def figure_horizon():
"""Margin by forecast horizon. 24h, which every deployed model used, is worst."""
data = load("horizon_spillover_screen.json")
if not data:
return
fig, ax = plt.subplots(figsize=(6.4, 4))
for asset, rows in data.items():
own = [(v["horizon"], (v["accuracy"] - v["majority"]) * 100)
for k, v in rows.items() if v["feature_set"] == "own 33"]
own.sort()
ax.plot([h for h, _ in own], [m for _, m in own], marker="o",
label=asset.replace("USDT", ""))
ax.axvline(24, color=WARN, linestyle="--", linewidth=1,
label="24h, the horizon every deployed model inherited")
ax.set_xscale("log")
ax.set_xticks([1, 6, 12, 24, 48])
ax.get_xaxis().set_major_formatter(matplotlib.ticker.ScalarFormatter())
ax.set_xlabel("forecast horizon, hours")
ax.set_ylabel("margin over majority class, percentage points")
ax.set_title("Forecast horizon was never tested until now\n"
"BTC and ETH peak at 12h; ADA degrades monotonically")
ax.legend(frameon=False)
fig.savefig(OUT / "fig5_horizon.png")
plt.close(fig)
print(" fig5_horizon.png")
# ------------------------------------------------------------------ figure 6
def figure_direction():
"""The negative result: direction against its baselines, measured three ways."""
labels = ["direction 1h", "direction 6h", "direction 24h",
"triple-barrier 1%", "triple-barrier 2.5%", "triple-barrier 5%"]
auc = [0.503, 0.474, 0.490, 0.4989, 0.5167, 0.5265]
fig, ax = plt.subplots(figsize=(6.8, 3.8))
ax.bar(labels, auc, color=[WARN if a < 0.5 else BASE for a in auc], width=0.6)
ax.axhline(0.5, color="#333", linestyle="--", linewidth=1.2, label="chance (0.50)")
ax.axhline(0.8286, color=GOOD, linestyle=":", linewidth=1.4,
label="volatility rises, same features (0.8286)")
ax.set_ylim(0.4, 0.88)
ax.set_ylabel("ROC-AUC")
ax.set_title("Direction is not predictable from these features\n"
"The dotted line is the positive control: same data, same pipeline")
ax.legend(frameon=False, fontsize=8)
plt.setp(ax.get_xticklabels(), rotation=20, ha="right")
fig.savefig(OUT / "fig6_direction_negative.png")
plt.close(fig)
print(" fig6_direction_negative.png")
def main() -> None:
print(f"writing to {OUT.relative_to(ROOT)}/")
for fn in (figure_target_screen, figure_ablation, figure_har,
figure_sentiment, figure_horizon, figure_direction):
try:
fn()
except Exception as exc: # noqa: BLE001
print(f" {fn.__name__} failed: {exc}")
print("done")
if __name__ == "__main__":
main()