Download scripts/make_thesis_figures.py from chonthichar/crypto-forecasting-ai: direct link, hf CLI and curl.
- Browser
- Download file 12.7 kB
-
https://huggingface.co/spaces/chonthichar/crypto-forecasting-ai/resolve/main/scripts/make_thesis_figures.py
- Command line
-
hf download hf://spaces/chonthichar/crypto-forecasting-ai/scripts/make_thesis_figures.py
-
curl -L -o make_thesis_figures.py https://huggingface.co/spaces/chonthichar/crypto-forecasting-ai/resolve/main/scripts/make_thesis_figures.py
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() | |