1"""Journal-style figures (vector PDF + PNG) drawn only from saved result files. Times/STIX typography, monochrome2marks distinguished by shape and line style, one restrained accent colour, panel labels, inward ticks."""3from __future__ import annotations45import json6from pathlib import Path78import matplotlib9matplotlib.use("Agg")10import matplotlib.pyplot as plt # noqa: E40211import numpy as np # noqa: E40212import pandas as pd # noqa: E4021314INK, INK2, INK3, INK4 = "#000000", "#4d4d4d", "#8c8c8c", "#c8c8c8"15ACCENT = "#1f4e79"16plt.rcParams.update({17 "font.family": "serif", "font.serif": ["Times New Roman", "Times", "STIX Two Text", "DejaVu Serif"], "mathtext.fontset": "stix",18 "font.size": 9, "axes.titlesize": 9, "axes.labelsize": 9, "legend.fontsize": 7.5, "xtick.labelsize": 8, "ytick.labelsize": 8,19 "axes.spines.top": False, "axes.spines.right": False, "axes.edgecolor": INK, "axes.linewidth": 0.6, "xtick.direction": "in",20 "ytick.direction": "in", "xtick.major.size": 3, "ytick.major.size": 3, "axes.grid": False, "legend.frameon": False,21 "lines.linewidth": 1.0, "lines.markersize": 4, "lines.markeredgewidth": 0.8, "figure.dpi": 150, "savefig.bbox": "tight",22 "savefig.pad_inches": 0.02, "pdf.fonttype": 42})23W1, W2 = 3.25, 6.524STYLE = {"naive_rw": dict(marker="o", ls=":", color=INK3), "seasonal_vol": dict(marker="v", ls=":", color=INK3), "ewma": dict(marker="^", ls=":", color=INK3),25 "har_rv": dict(marker="s", ls="--", color=INK2), "garch_t": dict(marker="D", ls="--", color=INK2), "ridge": dict(marker="x", ls="-.", color=INK3),26 "elastic_net": dict(marker="+", ls="-.", color=INK3), "lightgbm": dict(marker="p", ls="-.", color=INK2), "mlp": dict(marker="<", ls="-", color=INK3),27 "tcn": dict(marker=">", ls="-", color=INK2), "mrpt_small_1m_only": dict(marker="s", ls="-", color=INK), "mrpt_small": dict(marker="o", ls="-", color=ACCENT),28 "mrpt_base": dict(marker="D", ls="-", color=ACCENT), "mrpt_base_1m_only": dict(marker="D", ls="--", color=INK), "mrpt_spec": dict(marker="h", ls="-", color=ACCENT)}29LABEL = {"naive_rw": "Random walk", "seasonal_vol": "Seasonal vol.", "ewma": "EWMA-$t$", "har_rv": "HAR-RV", "garch_t": "GARCH(1,1)-$t$", "ridge": "Ridge",30 "elastic_net": "Elastic net", "lightgbm": "LightGBM", "mlp": "MLP (1m)", "tcn": "TCN (1m)", "mrpt_small_1m_only": "MRPT-S 1m only",31 "mrpt_small": "MRPT-S multi-res.", "mrpt_base": "MRPT-B multi-res.", "mrpt_base_1m_only": "MRPT-B 1m only", "mrpt_spec": "MRPT-M multi-res.",32 "mrpt_small_b4k": "MRPT-S ref. (4k)", "mrpt_small_1m_only_b4k": "1m only (4k)", "mrpt_small_1m_5m": "1m+5m", "mrpt_small_cross": "cross-attention fusion",33 "mrpt_small_gaussian": "Gaussian head", "mrpt_small_noregime": "no regime", "mrpt_small_learnedregime": "learned regime", "mrpt_small_returns_only": "returns only",34 "mrpt_small_time_only": "time only", "mrpt_small_ohlcv": "no relative volume", "mrpt_small_shuffled": "shuffled minutes", "mrpt_small_ticker": "ticker embedding",35 "mrpt_small_seed2": "ref. seed 2", "mrpt_small_seed3": "ref. seed 3", "mrpt_small_copula": "copula, joint", "mrpt_small_copula_indep": "copula, independent"}363738def _panel(ax, letter, title):39 ax.set_title(f"Panel {letter}. {title}", loc="left", fontsize=9, fontweight="bold", pad=6)404142def _save(fig, out: Path, name: str):43 out.mkdir(parents=True, exist_ok=True)44 fig.savefig(out / f"{name}.pdf"); fig.savefig(out / f"{name}.png", dpi=220); plt.close(fig)454647def _lab(m):48 base = m.replace("_heldout", "")49 return LABEL.get(base, base.replace("_", " ")) + (" (held-out)" if m.endswith("_heldout") else "")505152def fig_scores_by_horizon(lb: pd.DataFrame, out: Path, name: str, metric: str = "crps_scaled", ref: str = "naive_rw", models=None):53 """Panel A: metric by horizon; Panel B: relative change vs the reference with bootstrap CIs."""54 lb = lb.copy(); lb["base"] = lb["model"].str.replace("_heldout", "", regex=False)55 models = models or [m for m in STYLE if m in set(lb["base"])]56 H = sorted(lb["H"].unique())57 fig, axes = plt.subplots(1, 2, figsize=(W2, 2.6))58 for m in models:59 d = lb[lb["base"] == m].sort_values("H")60 if d.empty:61 continue62 st = STYLE.get(m, dict(marker=".", ls="-", color=INK3))63 axes[0].plot(d["H"], d[metric], label=_lab(m), **st)64 r = lb[lb["base"] == ref].set_index("H")[metric]65 rel = 100 * (d.set_index("H")[metric] / r.loc[d["H"]].values - 1)66 axes[1].plot(d["H"], rel.values, label=_lab(m), **st)67 _panel(axes[0], "A", f"{metric.replace('_', ' ')} by horizon"); axes[0].set_xlabel("Horizon $H$ (minutes)"); axes[0].set_ylabel(metric.replace("_", " "))68 _panel(axes[1], "B", f"relative to {_lab(ref)} (%)"); axes[1].axhline(0, color=INK4, lw=0.6); axes[1].set_xlabel("Horizon $H$ (minutes)"); axes[1].set_ylabel("%")69 for ax in axes:70 ax.set_xticks(H)71 h_, l_ = axes[1].get_legend_handles_labels()72 fig.legend(h_, l_, loc="lower center", ncol=4, bbox_to_anchor=(0.5, -0.36), frameon=False)73 fig.subplots_adjust(wspace=0.3)74 _save(fig, out, name)757677def fig_calibration(lb: pd.DataFrame, out: Path, name: str, models=None):78 """Coverage of 50/80/95 % central intervals at each horizon (nominal = dotted)."""79 lb = lb.copy(); lb["base"] = lb["model"].str.replace("_heldout", "", regex=False)80 models = models or [m for m in STYLE if m in set(lb["base"])]81 H = sorted(lb["H"].unique())82 fig, axes = plt.subplots(1, 3, figsize=(W2, 2.4), sharey=False)83 for ax, lv in zip(axes, (50, 80, 95)):84 for m in models:85 d = lb[lb["base"] == m].sort_values("H")86 if d.empty:87 continue88 ax.plot(d["H"], 100 * d[f"cov_{lv}"], label=_lab(m), **STYLE.get(m, dict(marker=".", ls="-", color=INK3)))89 ax.axhline(lv, color=INK4, lw=0.8, ls=":"); ax.set_xticks(H); ax.set_xlabel("$H$ (min)")90 _panel(ax, "ABC"[(50, 80, 95).index(lv)], f"{lv}% coverage")91 h_, l_ = axes[0].get_legend_handles_labels()92 fig.legend(h_, l_, loc="lower center", ncol=4, bbox_to_anchor=(0.5, -0.34), frameon=False)93 fig.subplots_adjust(wspace=0.35)94 _save(fig, out, name)959697def fig_pit(per: dict[str, np.ndarray], out: Path, name: str, H: int = 60, bins: int = 20):98 """PIT histograms of selected models at one horizon."""99 n = len(per)100 fig, axes = plt.subplots(1, n, figsize=(W2, 1.9), sharey=True)101 axes = np.atleast_1d(axes)102 for ax, (m, u), letter in zip(axes, per.items(), "ABCDEFGH"):103 ax.hist(u, bins=bins, range=(0, 1), density=True, color=INK4, edgecolor=INK, lw=0.4)104 ax.axhline(1, color=ACCENT, lw=0.8, ls="--"); ax.set_xlim(0, 1); ax.set_xlabel("PIT")105 _panel(ax, letter, f"{_lab(m)}, $H={H}$")106 axes[0].set_ylabel("density")107 _save(fig, out, name)108109110def fig_ablation_forest(cmp: pd.DataFrame, out: Path, name: str, ref_label: str, H: int = 60, order=None, labels=None):111 """Relative CRPS change versus a reference with day-cluster bootstrap CIs (forest plot) at one horizon."""112 d = cmp[cmp["H"] == H].copy()113 if order:114 d = d.set_index("model").loc[[o for o in order if o in d["model"].values]].reset_index()115 y = np.arange(len(d))[::-1]116 ref_mean = (d["diff"] / d["rel_diff"]).replace(0, np.nan) # mean reference loss (diff = rel_diff * ref_mean)117 rel, lo, hi = 100 * d["rel_diff"], 100 * d["lo"] / ref_mean, 100 * d["hi"] / ref_mean118 fig, ax = plt.subplots(figsize=(W2, 0.28 * len(d) + 0.9))119 ax.errorbar(rel, y, xerr=[(rel - lo).abs().fillna(0), (hi - rel).abs().fillna(0)], fmt="o", color=INK, ecolor=INK2, capsize=2.5,120 elinewidth=0.7, capthick=0.7, ms=4)121 ax.axvline(0, color=INK4, lw=0.8)122 ax.set_yticks(y); ax.set_yticklabels([(labels or {}).get(m, _lab(m)) for m in d["model"]])123 ax.set_xlabel(f"CRPS change vs {ref_label} at $H={H}$ (%), 95% day-cluster bootstrap CI")124 _save(fig, out, name)125126127def fig_training_curves(logs: dict[str, Path], out: Path, name: str):128 fig, axes = plt.subplots(1, 2, figsize=(W2, 2.4))129 for (m, p), st in zip(logs.items(), [dict(ls="-", color=ACCENT), dict(ls="--", color=INK), dict(ls=":", color=INK2), dict(ls="-.", color=INK3)] * 3):130 rows = [json.loads(l) for l in Path(p).read_text().splitlines() if l.strip()]131 tr = pd.DataFrame([r for r in rows if r.get("event") == "train"]); va = pd.DataFrame([r for r in rows if r.get("event") == "val"])132 if len(tr):133 axes[0].plot(tr["step"], tr["loss"].rolling(10, min_periods=1).mean(), label=_lab(m), lw=0.9, **st)134 if len(va):135 axes[1].plot(va["step"], va["val_nll"], marker="o", ms=3, label=_lab(m), **st)136 _panel(axes[0], "A", "training NLL (moving average)"); _panel(axes[1], "B", "validation NLL")137 for ax in axes:138 ax.set_xlabel("optimiser step")139 axes[1].legend(loc="best")140 _save(fig, out, name)141142143def fig_subgroups(df: pd.DataFrame, out: Path, name: str, col: str, metric_label: str):144 """Relative CRPS change of the main model vs reference by subgroup (bars with CIs)."""145 y = np.arange(len(df))[::-1]146 fig, ax = plt.subplots(figsize=(W1 * 1.6, 0.26 * len(df) + 0.9))147 ax.barh(y, 100 * df["rel"], color=INK4, edgecolor=INK, lw=0.5, height=0.6)148 ax.errorbar(100 * df["rel"], y, xerr=[100 * (df["rel"] - df["lo"]), 100 * (df["hi"] - df["rel"])], fmt="none", ecolor=INK, elinewidth=0.7, capsize=2)149 ax.axvline(0, color=INK2, lw=0.8); ax.set_yticks(y); ax.set_yticklabels([str(v) for v in df[col]]); ax.set_xlabel(metric_label)150 _save(fig, out, name)151