"""Shared visualization helpers for model_analysis notebooks. Each function renders one figure and optionally prints summary statistics. All functions accept pre-computed data (from model_analysis.py helpers) and produce matplotlib figures. The notebooks provide the narrative context; these functions handle the rendering. """ from __future__ import annotations import matplotlib.pyplot as plt import numpy as np import polars as pl from utils.style import COLORS # --------------------------------------------------------------------------- # Figure 1: Cross-Validation Timeline # --------------------------------------------------------------------------- def plot_cv_timeline( fold_ranges: pl.DataFrame, n_splits: int, holdout_start: str | None = None, date_col: str = "timestamp", ) -> None: """Plot walk-forward fold validation windows as horizontal bars.""" if fold_ranges.height == 0: return fig, ax = plt.subplots(figsize=(12, max(4, n_splits * 0.6))) for row in fold_ranges.iter_rows(named=True): fold = row["fold_id"] vs = row["val_start"] ve = row["val_end"] ax.barh( fold, (ve - vs).days, left=vs, height=0.6, color=COLORS.get("amber", "#F59E0B"), alpha=0.8, label="Validation" if fold == 0 else "", ) ax.set_xlabel("Date") ax.set_ylabel("Fold") ax.set_yticks(range(n_splits)) ax.set_yticklabels([f"Fold {i}" for i in range(n_splits)]) ax.invert_yaxis() ax.set_title("Walk-Forward Cross-Validation Design") if holdout_start: import pandas as pd ax.axvline( pd.Timestamp(holdout_start), color="gray", linestyle="--", linewidth=1, label="Holdout start", ) handles, labels = ax.get_legend_handles_labels() ax.legend( dict(zip(labels, handles, strict=False)).values(), dict(zip(labels, handles, strict=False)).keys(), loc="lower right", ) fig.tight_layout() fig.show() # --------------------------------------------------------------------------- # Figure 2: Fold-by-Model Performance Heatmap # --------------------------------------------------------------------------- def plot_fold_heatmap( fold_ic: pl.DataFrame, ) -> tuple[list[str], list[str], np.ndarray]: """Plot fold × model IC heatmap with mean annotations. Returns (model_labels, fold_cols, matrix) for downstream use. """ if fold_ic.height == 0: return [], [], np.array([]) pivot = fold_ic.pivot(on="fold_id", index="model_label", values="ic_mean") model_labels = pivot["model_label"].to_list() fold_cols = [c for c in pivot.columns if c != "model_label"] matrix = pivot.select(fold_cols).to_numpy() row_means = np.nanmean(matrix, axis=1) n_models = len(model_labels) n_folds = len(fold_cols) fig, ax = plt.subplots(figsize=(max(8, n_folds * 1.2), max(4, n_models * 0.8))) vmax = max(abs(np.nanmin(matrix)), abs(np.nanmax(matrix)), 0.01) im = ax.imshow(matrix, cmap="RdYlBu", vmin=-vmax, vmax=vmax, aspect="auto") for i in range(n_models): for j in range(n_folds): val = matrix[i, j] if not np.isnan(val): color = "white" if abs(val) > vmax * 0.6 else "black" ax.text(j, i, f"{val:.3f}", ha="center", va="center", fontsize=8, color=color) ax.set_xticks(range(n_folds)) ax.set_xticklabels([f"Fold {c}" for c in fold_cols], rotation=45, ha="right") ax.set_yticks(range(n_models)) ax.set_yticklabels(model_labels) ax.set_title("Validation IC by Model Family and Fold") for i, mean in enumerate(row_means): ax.text( n_folds + 0.3, i, f"{mean:+.3f}", ha="left", va="center", fontsize=9, fontweight="bold", ) ax.text(n_folds + 0.3, -0.7, "Mean", ha="left", va="center", fontsize=9, fontweight="bold") fig.colorbar(im, ax=ax, label="IC", shrink=0.8) fig.show() return model_labels, fold_cols, matrix # --------------------------------------------------------------------------- # Figure 3: Fold Performance Distribution Boxplot # --------------------------------------------------------------------------- def plot_fold_boxplot(fold_ic: pl.DataFrame) -> None: """Boxplot with jittered scatter of fold-level IC per model family.""" if fold_ic.height == 0: return families = fold_ic["model_label"].unique().sort().to_list() n_families = len(families) fig, ax = plt.subplots(figsize=(max(8, n_families * 1.5), 5)) bp_data = [] for fam in families: vals = fold_ic.filter(pl.col("model_label") == fam)["ic_mean"].to_numpy() bp_data.append(vals) bp = ax.boxplot( bp_data, positions=list(range(n_families)), widths=0.5, patch_artist=True, showmeans=True, meanprops=dict(marker="D", markerfacecolor="white", markeredgecolor="black"), ) palette = list(COLORS.values())[:n_families] for patch, color in zip(bp["boxes"], palette, strict=False): patch.set_facecolor(color) patch.set_alpha(0.6) rng = np.random.default_rng(42) for i, (fam, vals) in enumerate(zip(families, bp_data, strict=False)): jitter = rng.uniform(-0.15, 0.15, size=len(vals)) ax.scatter( np.full_like(vals, i) + jitter, vals, color=palette[i % len(palette)], alpha=0.7, s=30, zorder=5, ) ax.axhline(0, color="gray", linestyle="--", linewidth=0.8, alpha=0.5) ax.set_xticks(list(range(n_families))) ax.set_xticklabels([l.split("/")[0] for l in families], rotation=30, ha="right") ax.set_ylabel("Mean IC per Fold") ax.set_title("Fold Performance Distribution by Model Family") fig.tight_layout() fig.show() # --------------------------------------------------------------------------- # Figure 4: Prediction Bucket Monotonicity # --------------------------------------------------------------------------- def plot_bucket_monotonicity( bucket_results: dict[str, pl.DataFrame], n_buckets: int, unconditional_mean: float | None = None, label_name: str = "Forward Return", cost_range: list[int] | None = None, ) -> None: """Plot mean return per prediction bucket for each model family.""" if not bucket_results: return fig, ax = plt.subplots(figsize=(10, 6)) palette_items = list(COLORS.items()) for i, (label, buckets) in enumerate(bucket_results.items()): color = palette_items[i % len(palette_items)][1] x = buckets["bucket"].to_numpy() y = buckets["mean_return"].to_numpy() ax.plot(x, y, marker="o", label=label, color=color, linewidth=2) if unconditional_mean is not None: ax.axhline( unconditional_mean, color="gray", linestyle="--", linewidth=0.8, label=f"Unconditional mean ({unconditional_mean:.4f})", ) ax.set_xlabel(f"Prediction Bucket (1 = lowest, {n_buckets} = highest)") ax.set_ylabel(f"Mean Realized {label_name}") ax.set_title("Do Higher Predictions Correspond to Higher Realized Returns?") ax.legend(loc="upper left", fontsize=8) fig.tight_layout() fig.show() # Cost context if cost_range: print( f"\nTop-bottom bucket spread vs trading costs ({cost_range[0]}–{cost_range[1]} bps per leg):" ) for label, buckets in bucket_results.items(): top = buckets.filter(pl.col("bucket") == n_buckets)["mean_return"][0] bottom = buckets.filter(pl.col("bucket") == 1)["mean_return"][0] spread = top - bottom spread_bps = spread * 10000 cost_ratio_low = spread_bps / (2 * cost_range[0]) cost_ratio_high = spread_bps / (2 * cost_range[1]) print( f" {label:20s} spread={spread_bps:+.0f} bps " f"edge/cost={cost_ratio_low:.1f}–{cost_ratio_high:.1f}x" ) # --------------------------------------------------------------------------- # Figure 5: Prediction Correlation Heatmap # --------------------------------------------------------------------------- def plot_correlation_matrix( corr_matrix: np.ndarray, labels: list[str], ) -> None: """Plot pairwise prediction correlation heatmap.""" if corr_matrix.size == 0 or len(labels) < 2: return n = len(labels) fig, ax = plt.subplots(figsize=(max(6, n * 1.2), max(5, n))) im = ax.imshow(corr_matrix, cmap="Blues", vmin=0, vmax=1) for i in range(n): for j in range(n): val = corr_matrix[i, j] color = "white" if val > 0.7 else "black" ax.text(j, i, f"{val:.2f}", ha="center", va="center", fontsize=9, color=color) short_labels = [l.split("/")[0] for l in labels] ax.set_xticks(range(n)) ax.set_xticklabels(short_labels, rotation=45, ha="right") ax.set_yticks(range(n)) ax.set_yticklabels(short_labels) ax.set_title("Pairwise Prediction Rank Correlation") fig.colorbar(im, ax=ax, shrink=0.8) fig.show() off_diag = corr_matrix[np.triu_indices(n, k=1)] print(f"\nAverage pairwise correlation: {off_diag.mean():.2f}") print(f"Range: {off_diag.min():.2f} to {off_diag.max():.2f}") # --------------------------------------------------------------------------- # Figure 6: Learning Curves # --------------------------------------------------------------------------- def plot_learning_curves( cp_data: pl.DataFrame, cp_families: list[str], ) -> None: """Plot IC vs checkpoint for each config within each family.""" if not cp_families or cp_data.height == 0: return n_panels = len(cp_families) fig, axes = plt.subplots(n_panels, 1, figsize=(12, 4 * n_panels), squeeze=False) for idx, family in enumerate(sorted(cp_families)): ax = axes[idx, 0] fam_data = cp_data.filter(pl.col("family") == family) for config in sorted(fam_data["config_name"].unique().to_list()): cfg_data = fam_data.filter(pl.col("config_name") == config).sort("checkpoint_value") x = cfg_data["checkpoint_value"].to_numpy() y = cfg_data["ic_mean"].to_numpy() ax.plot(x, y, marker=".", label=config, linewidth=1.5) if "ic_std" in cfg_data.columns: y_std = cfg_data["ic_std"].to_numpy() valid = ~np.isnan(y_std) if valid.any(): ax.fill_between(x[valid], (y - y_std)[valid], (y + y_std)[valid], alpha=0.15) ax.axhline(0, color="gray", linestyle="--", linewidth=0.5) ax.set_xlabel("Checkpoint (epoch / trees)") ax.set_ylabel("Mean IC (across folds)") ax.set_title(f"Learning Curve: {family}") ax.legend(fontsize=7, loc="lower right") fig.tight_layout() fig.show() # --------------------------------------------------------------------------- # Figure 7: Feature Importance Stability Heatmap # --------------------------------------------------------------------------- def plot_feature_importance_heatmap( importance_df: pl.DataFrame, top_n: int = 15, ) -> None: """Plot feature importance (normalized) across folds as a heatmap.""" if importance_df is None or importance_df.height == 0: return pivot = ( importance_df.group_by(["feature", "fold_id"]) .agg(pl.col("importance_norm").mean()) .pivot(on="fold_id", index="feature", values="importance_norm") ) fold_cols = [c for c in pivot.columns if c != "feature"] features = pivot["feature"].to_list() imp_matrix = pivot.select(fold_cols).to_numpy() mean_imp = np.nanmean(imp_matrix, axis=1) sort_idx = np.argsort(mean_imp)[::-1] n_show = min(top_n, len(features)) features_sorted = [features[i] for i in sort_idx[:n_show]] matrix_sorted = imp_matrix[sort_idx[:n_show]] fig, ax = plt.subplots(figsize=(max(8, len(fold_cols)), max(6, n_show * 0.4))) im = ax.imshow(matrix_sorted, cmap="YlOrRd", aspect="auto", vmin=0, vmax=1) for i in range(n_show): for j in range(len(fold_cols)): val = matrix_sorted[i, j] if not np.isnan(val): ax.text(j, i, f"{val:.2f}", ha="center", va="center", fontsize=7) ax.set_xticks(range(len(fold_cols))) ax.set_xticklabels([f"Fold {c}" for c in fold_cols], rotation=45, ha="right") ax.set_yticks(range(n_show)) ax.set_yticklabels(features_sorted) ax.set_title("Feature Importance Stability Across Folds") fig.colorbar(im, ax=ax, shrink=0.8) fig.show() # Recurrence summary n_total_folds = importance_df["fold_id"].n_unique() top5_per_fold = ( importance_df.sort(["fold_id", "importance_norm"], descending=[False, True]) .group_by("fold_id") .head(5) ) recurrence = ( top5_per_fold.group_by("feature") .agg(pl.len().alias("n_top5")) .sort("n_top5", descending=True) ) persistent = recurrence.filter(pl.col("n_top5") >= n_total_folds * 0.75) if persistent.height > 0: print(f"\nPersistent features (top-5 in ≥75% of folds): {persistent['feature'].to_list()}") # --------------------------------------------------------------------------- # Figure 8: Regime-Conditional Performance Bars # --------------------------------------------------------------------------- def plot_regime_bars( regime_df: pl.DataFrame, ) -> None: """Grouped bar chart of IC by volatility regime per family.""" if regime_df.height == 0: return regimes = sorted(regime_df["regime"].unique().to_list()) families = sorted(regime_df["family"].unique().to_list()) n_fam = len(families) fig, ax = plt.subplots(figsize=(max(8, n_fam * 2), 5)) x = np.arange(n_fam) width = 0.35 colors_regime = { "low_vol": COLORS.get("blue", "#3B82F6"), "high_vol": COLORS.get("amber", "#F59E0B"), } has_hac = "ic_se_hac" in regime_df.columns for i, regime in enumerate(regimes): regime_data = regime_df.filter(pl.col("regime") == regime) ics, ses = [], [] for fam in families: fam_data = regime_data.filter(pl.col("family") == fam) if fam_data.height > 0: # Prefer HAC SE when the daily-uncertainty backfill ran. # Fall back to fold-std/sqrt(n) only when HAC is missing. if has_hac and fam_data["ic_se_hac"][0] is not None: ic = ( fam_data.get_column("ic_mean_daily")[0] if "ic_mean_daily" in fam_data.columns else fam_data["ic_mean"][0] ) se = fam_data["ic_se_hac"][0] else: ic = fam_data["ic_mean"][0] std = fam_data["ic_std"][0] n = fam_data["n_periods"][0] se = std / np.sqrt(max(n, 1)) ics.append(ic) ses.append(se) else: ics.append(0) ses.append(0) offset = (i - 0.5) * width bars = ax.bar( x + offset, ics, width, yerr=ses, label=regime.replace("_", " ").title(), color=colors_regime.get(regime, f"C{i}"), alpha=0.8, capsize=3, ) for j, (bar, ic) in enumerate(zip(bars, ics, strict=False)): ax.text( bar.get_x() + bar.get_width() / 2, bar.get_height() + 0.001, f"{ic:.3f}", ha="center", va="bottom", fontsize=7, ) ax.axhline(0, color="gray", linestyle="--", linewidth=0.8) ax.set_xticks(x) ax.set_xticklabels(families, rotation=30, ha="right") ax.set_ylabel("Mean IC") ax.set_title("Model Performance by Volatility Regime") ax.legend() fig.tight_layout() fig.show() # --------------------------------------------------------------------------- # HAC-CI leaderboard + rolling daily-IC plot # --------------------------------------------------------------------------- def plot_hac_ci_leaderboard( metrics: pl.DataFrame, *, label_col: str = "config_name", family_col: str = "family", ic_col: str = "ic_mean_daily", lo_col: str = "ic_ci_lo", hi_col: str = "ic_ci_hi", boot_lo_col: str = "ic_boot_lo", boot_hi_col: str = "ic_boot_hi", title: str = "Daily-pooled IC ± HAC 95% CI", top_n: int | None = 25, ) -> None: """Dot-plot leaderboard of daily-pooled IC with HAC CIs. Each row is one model config; the dot is the daily-IC point estimate, the thick bar is the HAC 95% CI, and a faint outer bar is the bootstrap CI when present. Configs with overlapping HAC CIs are visually clustered by a faint shaded band so the reader sees which gaps are within noise. """ if metrics.height == 0 or ic_col not in metrics.columns: return df = metrics.sort(ic_col, descending=True, nulls_last=True) if top_n is not None and df.height > top_n: df = df.head(top_n) n = df.height fig, ax = plt.subplots(figsize=(8, max(3.5, n * 0.28))) family_order = list(dict.fromkeys(df[family_col].to_list())) palette = { f: COLORS.get(c, f"C{i}") for i, (f, c) in enumerate( zip(family_order, ("blue", "amber", "emerald", "violet", "rose", "teal"), strict=False) ) } y = np.arange(n)[::-1] # top-to-bottom highest-IC-first has_boot = boot_lo_col in df.columns and boot_hi_col in df.columns # Indistinguishable-CI shading: bands of overlapping CIs. if {lo_col, hi_col}.issubset(df.columns): ic_vals = df[ic_col].to_numpy() lo_vals = df[lo_col].to_numpy() hi_vals = df[hi_col].to_numpy() running_lo = float("inf") band_start = None band_idx = 0 for k in range(n): lo, hi = lo_vals[k], hi_vals[k] if not (np.isfinite(lo) and np.isfinite(hi)): continue if band_start is None: band_start = k running_lo = lo continue if hi >= running_lo: running_lo = max(running_lo, lo) else: if k - band_start >= 2: ax.axhspan( y[k - 1] - 0.45, y[band_start] + 0.45, color=("0.92" if band_idx % 2 == 0 else "0.96"), zorder=0, ) band_idx += 1 band_start = k running_lo = lo if band_start is not None and n - band_start >= 2: ax.axhspan( y[n - 1] - 0.45, y[band_start] + 0.45, color=("0.92" if band_idx % 2 == 0 else "0.96"), zorder=0, ) for k in range(n): row = df.row(k, named=True) fam = row.get(family_col, "?") col = palette.get(fam, "0.4") ic = row.get(ic_col) lo = row.get(lo_col) hi = row.get(hi_col) if ic is None or not np.isfinite(ic): continue if has_boot: blo = row.get(boot_lo_col) bhi = row.get(boot_hi_col) if blo is not None and bhi is not None: ax.hlines(y[k], blo, bhi, color="0.7", linewidth=1.0, zorder=2) if lo is not None and hi is not None: ax.hlines(y[k], lo, hi, color=col, linewidth=2.5, zorder=3) ax.plot(ic, y[k], "o", color=col, markersize=5, zorder=4) ax.axvline(0, color="0.5", linestyle="--", linewidth=0.8, zorder=1) ax.set_yticks(y) ax.set_yticklabels( [f"{r[family_col]} / {r[label_col]}" for r in df.iter_rows(named=True)], fontsize=7, ) ax.set_xlabel("Daily-pooled IC") ax.set_title(title) ax.grid(axis="x", alpha=0.3, zorder=0) fig.tight_layout() fig.show() def plot_label_horizon_forest( metrics: pl.DataFrame, *, families: list[str] | None = None, labels: list[str] | None = None, label_display: dict[str, str] | None = None, family_display: dict[str, str] | None = None, ic_col: str = "ic_mean_daily", lo_col: str = "ic_ci_lo", hi_col: str = "ic_ci_hi", family_col: str = "family", label_col: str = "label", title: str = "", ) -> None: """Small-multiples forest of rank-1 IC ± HAC 95% CI per (family, label). Each subplot is one label/horizon; within a subplot, families occupy fixed y positions in caller-supplied order. Tiles where a (family, label) pair has no run are drawn as a gray "no run" stub at zero so the gap is visible. CIs that straddle zero render in muted gray; CIs that exclude zero render in the family color from :data:`utils.style.COLORS`. Parameters ---------- metrics Long-format frame with one row per (family, label) rank-1 config. Columns required: ``family_col``, ``label_col``, ``ic_col``, ``lo_col``, ``hi_col``. families Display order for families along the y-axis. Defaults to the unique family list as seen in ``metrics`` (sorted). labels Display order for labels across subplots. Defaults to the unique label list as seen in ``metrics`` (sorted). """ if metrics is None or metrics.height == 0 or ic_col not in metrics.columns: return fams = list(families) if families else sorted(metrics[family_col].unique().to_list()) lbls = list(labels) if labels else sorted(metrics[label_col].unique().to_list()) n_lab = len(lbls) n_fam = len(fams) if n_lab == 0 or n_fam == 0: return family_palette = { "linear": COLORS.get("blue", "C0"), "gbm": COLORS.get("orange", "C1"), "deep_learning": COLORS.get("green", "C2"), "tabular_dl": COLORS.get("purple", "C3"), "latent_factors": COLORS.get("red", "C4"), "causal": COLORS.get("brown", "C5"), "causal_dml": COLORS.get("brown", "C5"), "benchmark": COLORS.get("gray", "C7"), } label_display = label_display or {} family_display = family_display or {} fig, axes = plt.subplots( 1, n_lab, figsize=(3.2 * n_lab + 0.5, max(2.5, 0.45 * n_fam + 1.2)), sharey=True, constrained_layout=True, ) if n_lab == 1: axes = [axes] y_pos = np.arange(n_fam) for ax, lbl in zip(axes, lbls): sub = metrics.filter(pl.col(label_col) == lbl) sub_map = {r[family_col]: r for r in sub.iter_rows(named=True)} for i, fam in enumerate(fams): row = sub_map.get(fam) if row is None or row.get(ic_col) is None or not np.isfinite(row.get(ic_col)): ax.text( 0.0, y_pos[i], "no run", ha="center", va="center", fontsize=7, color="0.55", style="italic", ) continue ic = float(row[ic_col]) lo = row.get(lo_col) hi = row.get(hi_col) ci_valid = lo is not None and hi is not None and np.isfinite(lo) and np.isfinite(hi) crosses_zero = bool(ci_valid and lo <= 0 <= hi) color = ( "#999999" if (not ci_valid or crosses_zero) else family_palette.get(fam, "#444444") ) if ci_valid: ax.plot([lo, hi], [y_pos[i], y_pos[i]], color=color, linewidth=2.0, alpha=0.85) ax.plot(ic, y_pos[i], marker="o", color=color, markersize=6, zorder=3) ax.axvline(0.0, color="black", linestyle="--", linewidth=0.8, alpha=0.5) ax.set_title(label_display.get(lbl, lbl), fontsize=10) ax.grid(True, axis="x", linestyle=":", alpha=0.3) axes[0].set_yticks(y_pos) axes[0].set_yticklabels( [family_display.get(f, f) for f in fams], fontsize=9, ) axes[0].invert_yaxis() fig.supxlabel("Information Coefficient (daily-pooled, 95% HAC CI)", fontsize=9) if title: fig.suptitle(title, fontsize=11) fig.show() def plot_rolling_daily_ic( daily_metrics: pl.DataFrame, *, window: int = 63, label: str = "", ) -> None: """Plot rolling mean of daily IC with a faint shaded band for daily noise. Expects a frame with columns ``[fold_id, date, ic, n_obs]`` (the `daily_metrics.parquet` written by the backfill). Pools across folds by sorting on ``date`` and computing the rolling mean. """ if daily_metrics is None or daily_metrics.height == 0 or "ic" not in daily_metrics.columns: return df = daily_metrics.drop_nulls("ic").sort("date") if df.height < window: window = max(5, df.height // 4) dates = df["date"].to_numpy() ic = df["ic"].to_numpy() fig, ax = plt.subplots(figsize=(8, 3.2)) ax.plot(dates, ic, color="0.7", linewidth=0.4, alpha=0.6, label="Daily IC") if window > 1 and df.height >= window: roll_mean = ( df.with_columns(pl.col("ic").rolling_mean(window).alias("__roll")) .get_column("__roll") .to_numpy() ) ax.plot( dates, roll_mean, color=COLORS.get("blue", "#3B82F6"), linewidth=1.6, label=f"Rolling mean ({window}d)", ) ax.axhline(0, color="0.5", linestyle="--", linewidth=0.8) ax.set_xlabel("Date") ax.set_ylabel("Cross-sectional IC") ax.set_title(f"Daily IC time series{(' — ' + label) if label else ''}") ax.legend(loc="best", fontsize=8) fig.tight_layout() fig.show()