Files
stefan-jansen--machine-lear…/08_financial_features/case_study_feature_summary.py
T
2026-07-13 13:26:28 +08:00

410 lines
13 KiB
Python

# ---
# jupyter:
# jupytext:
# cell_metadata_filter: tags,-all
# text_representation:
# extension: .py
# format_name: percent
# format_version: '1.3'
# jupytext_version: 1.19.3
# kernelspec:
# display_name: Python 3 (ipykernel)
# language: python
# name: python3
# ---
# %% [markdown]
# # Cross-Case-Study Feature Evaluation Summary
#
# **Chapter 8: Feature Engineering**
# **Section Reference**: 8.6 - Combining Features and Controlling Search
# **Docker image**: `ml4t`
#
# ## Purpose
#
# This notebook is the cross-case-study inventory and presentation layer: it
# aggregates engineered features and the best registry IC per case study across
# all 9 asset classes. It surfaces:
# - **Feature counts and families**: how large each case study's feature space is
# - **Best IC per case study** (from the model registry): how predictive the
# strongest family is, by asset class
# - **Cross-asset patterns**: which feature families generalize vs which are asset-specific
#
# The HAC-adjusted significance and BH-FDR survival counts themselves are computed
# upstream, in each case study's `13_model_analysis.py`; this notebook reads and
# presents their results rather than recomputing them.
#
# ## Learning Objectives
#
# 1. Compare feature predictability across diverse asset classes
# 2. Read off each case study's best registry IC and feature-space size
# 3. Identify feature families that generalize vs those that are asset-specific
# 4. Understand how universe size (breadth) interacts with IC magnitude
#
# ## Prerequisites
#
# - Case study feature notebooks must have produced `data/features/financial.parquet`
# - If feature data is missing for some case studies, they show as "no features"
# %%
"""Cross-case-study feature evaluation summary."""
import warnings
import numpy as np
import plotly.graph_objects as go
import polars as pl
from IPython.display import display
warnings.filterwarnings("ignore")
from utils.paths import get_case_study_dir
# %% tags=["parameters"]
# Scale parameters (Papermill overrides for testing; readers see production values)
START_DATE = None # use full dataset
# %% [markdown]
# ## 1. Load Feature Data
#
# Scan all case study `data/features/` directories for the `financial.parquet`
# produced by the feature engineering notebooks. We introspect schemas to count
# features and compare across case studies.
# %%
CASE_STUDIES = [
"etfs",
"crypto_perps_funding",
"nasdaq100_microstructure",
"sp500_equity_option_analytics",
"us_firm_characteristics",
"fx_pairs",
"cme_futures",
"sp500_options",
"us_equities_panel",
]
DISPLAY_NAMES = {
"etfs": "ETFs",
"crypto_perps_funding": "Crypto Perps",
"nasdaq100_microstructure": "NASDAQ-100",
"sp500_equity_option_analytics": "S&P 500 Eq+Opt",
"us_firm_characteristics": "US Firm Chars",
"fx_pairs": "FX Pairs",
"cme_futures": "CME Futures",
"sp500_options": "S&P 500 Options",
"us_equities_panel": "US Equities",
}
# Columns that are identifiers, not features
_ID_COLS = {"timestamp", "symbol", "product", "stock_id", "instrument_id", "date", "asset"}
def load_feature_info(case_study_id: str) -> dict | None:
"""Load feature summary by introspecting financial.parquet schema."""
case_dir = get_case_study_dir(case_study_id)
# Case studies materialize features under <case_dir>/features/ (the
# naming-conventions doc lists this under data/features/, but the
# current case-study layout writes directly under features/).
features_path = case_dir / "features" / "financial.parquet"
if not features_path.exists():
return None
schema = pl.scan_parquet(features_path).collect_schema()
feature_names = [c for c in schema.names() if c not in _ID_COLS]
n_features = len(feature_names)
# Group features into families by prefix (e.g. "mom_", "vol_", "carry_")
family_counts: dict[str, int] = {}
for name in feature_names:
parts = name.split("_")
family = parts[0] if len(parts) > 1 else "other"
family_counts[family] = family_counts.get(family, 0) + 1
return {
"n_features": n_features,
"feature_names": feature_names,
"family_counts": family_counts,
}
# %%
# Load all feature info
all_results: dict[str, dict] = {}
evaluated: dict[str, dict] = {}
awaiting: list[str] = []
for cs in CASE_STUDIES:
result = load_feature_info(cs)
if result is None:
awaiting.append(cs)
continue
all_results[cs] = result
evaluated[cs] = result
print(f"Case studies with features: {len(evaluated)}/{len(CASE_STUDIES)}")
if evaluated:
print(f" Available: {', '.join(DISPLAY_NAMES[cs] for cs in evaluated)}")
if awaiting:
print(f" No features: {', '.join(DISPLAY_NAMES.get(cs, cs) for cs in awaiting)}")
# %% [markdown]
# ## 2. Feature Count Comparison
#
# How many features and feature families does each case study engineer? (The
# multiple-testing survival counts are produced upstream in each case study's
# `13_model_analysis.py`; here we inventory the feature space.)
# %%
if evaluated:
summary_rows = []
for cs, result in evaluated.items():
summary_rows.append(
{
"case_study": DISPLAY_NAMES[cs],
"n_features": result["n_features"],
"n_families": len(result["family_counts"]),
"top_families": ", ".join(
f"{k}({v})"
for k, v in sorted(result["family_counts"].items(), key=lambda x: -x[1])[:5]
),
}
)
summary_df = pl.DataFrame(summary_rows)
display(summary_df)
else:
print("No feature data available yet. Run case study feature notebooks first.")
# %% [markdown]
# ## 3. Feature Count Comparison
#
# How does feature set size vary across case studies? More features provide
# a richer signal space but also increase the multiple testing burden.
# %%
if evaluated:
cs_names = [DISPLAY_NAMES[cs] for cs in evaluated]
n_features = [evaluated[cs]["n_features"] for cs in evaluated]
fig = go.Figure()
fig.add_trace(
go.Bar(
x=cs_names,
y=n_features,
marker_color="#3498db",
text=[str(n) for n in n_features],
textposition="outside",
)
)
fig.update_layout(
title="Number of Financial Features by Case Study",
yaxis_title="Number of Features",
template="plotly_white",
height=450,
)
fig.show()
else:
print("No feature data available.")
# %% [markdown]
# ## 4. Feature Family Distribution
#
# Which feature families are used across asset classes? This heatmap shows
# the number of features per family per case study, revealing cross-asset
# patterns (e.g., momentum features everywhere) vs asset-specific features
# (e.g., carry only in futures/FX).
# %%
if evaluated:
# Collect all family names across case studies
all_families: set[str] = set()
for cs in evaluated:
all_families.update(evaluated[cs]["family_counts"].keys())
all_families_sorted = sorted(all_families)
if all_families_sorted:
heatmap_data = []
for family in all_families_sorted:
row = []
for cs in evaluated:
count = evaluated[cs]["family_counts"].get(family, 0)
row.append(count if count > 0 else float("nan"))
heatmap_data.append(row)
fig = go.Figure(
data=go.Heatmap(
z=heatmap_data,
x=[DISPLAY_NAMES[cs] for cs in evaluated],
y=all_families_sorted,
colorscale="Blues",
text=[
[f"{int(v)}" if not np.isnan(v) else "" for v in row] for row in heatmap_data
],
texttemplate="%{text}",
textfont={"size": 9},
)
)
fig.update_layout(
title="Feature Family Count by Case Study",
template="plotly_white",
height=max(400, len(all_families_sorted) * 30 + 100),
width=max(600, len(evaluated) * 100 + 200),
)
fig.show()
else:
print("No family-level data available.")
else:
print("No feature data available.")
# %% [markdown]
# ## 5. Representative Features Across Case Studies
#
# A sample of each case study's feature space — the first few feature names in
# schema order — to illustrate the engineered inputs. This is an inventory view,
# not an IC ranking (per-feature IC is computed in each case study's evaluation
# notebook).
# %%
if evaluated:
top_features_all = []
for cs in evaluated:
# Show first 5 feature names per case study
for feat_name in evaluated[cs]["feature_names"][:5]:
top_features_all.append(
{
"case_study": DISPLAY_NAMES[cs],
"feature": feat_name,
}
)
if top_features_all:
top_df = pl.DataFrame(top_features_all)
display(top_df)
else:
print("No feature data available.")
# %% [markdown]
# ## 6. Correlation Structure Summary
#
# Feature redundancy across case studies. How many feature pairs
# have correlation above 0.7? High redundancy wastes model capacity.
# %%
if evaluated:
corr_data = []
for cs in evaluated:
corr_data.append(
{
"case_study": DISPLAY_NAMES[cs],
"n_features": evaluated[cs]["n_features"],
"n_families": len(evaluated[cs]["family_counts"]),
}
)
corr_df = pl.DataFrame(corr_data)
display(corr_df)
print("\nNote: For full correlation analysis, run the per-case-study evaluation notebooks.")
else:
print("No feature data available.")
# %% [markdown]
# ## 7. Breadth vs IC: The Fundamental Law Perspective
#
# The Fundamental Law of Active Management says:
#
# $$IR \approx IC \times \sqrt{BR}$$
#
# where $BR$ is the number of independent bets (roughly the universe size).
# A case study with IC = 0.01 and 3,000 stocks achieves IR = 0.55,
# while IC = 0.03 with 20 pairs gives IR = 0.13. Breadth matters enormously.
# %%
from case_studies.utils.analytics import DATASET_META, load_best_ic_per_family
if evaluated:
# Load best IC per family from registry to combine with universe metadata
best_ic_df = load_best_ic_per_family()
if not best_ic_df.is_empty():
# Get best IC per case study (across all families)
best_per_cs = (
best_ic_df.sort("ic_mean", descending=True, nulls_last=True)
.group_by("case_study")
.first()
.select("case_study", "ic_mean")
)
breadth_data = []
for row in best_per_cs.iter_rows(named=True):
cs = row["case_study"]
meta = DATASET_META.get(cs, {})
n_entities = meta.get("entities", 0)
ic = abs(row["ic_mean"]) if row["ic_mean"] is not None else 0.0
if n_entities == 0 or ic == 0.0:
continue
ir_estimate = ic * np.sqrt(n_entities)
breadth_data.append(
{
"case_study": DISPLAY_NAMES.get(cs, cs),
"universe_size": n_entities,
"best_abs_ic": round(ic, 4),
"estimated_ir": round(ir_estimate, 2),
}
)
if breadth_data:
breadth_df = pl.DataFrame(breadth_data).sort("estimated_ir", descending=True)
display(breadth_df)
else:
print("No IC data available from registry.")
else:
print("No model IC data in registry yet.")
else:
print("No feature data available.")
# %%
# Visualize breadth vs IC
if evaluated and "breadth_data" in dir() and breadth_data:
fig = go.Figure()
for brow in breadth_data:
fig.add_trace(
go.Scatter(
x=[brow["universe_size"]],
y=[brow["best_abs_ic"]],
mode="markers+text",
text=[brow["case_study"]],
textposition="top center",
marker=dict(size=brow["estimated_ir"] * 20 + 5),
showlegend=False,
)
)
fig.update_layout(
title="Universe Size vs Best Model IC (bubble size = estimated IR)",
xaxis_title="Universe Size (number of instruments)",
yaxis_title="Best Model |IC|",
xaxis_type="log",
template="plotly_white",
height=450,
)
fig.show()
# %% [markdown]
# ## What the Panels Above Show
#
# The notebook aggregates whatever is present in each case study's
# `data/features/financial.parquet` and the model registry. The substantive
# findings — which feature families have predictive content for which label
# and horizon, how many features survive HAC + BH-FDR, and how breadth
# interacts with IC magnitude — are produced by the per-case-study evaluation
# notebooks (`13_model_analysis.py` in each case study). This summary
# notebook is a cross-case-study inventory and presentation layer; it does
# not itself compute IC or run multiple-testing correction.
#
# **Next**: See `09_model_based_features/case_study_temporal_summary` for the
# temporal/model-based feature companion view.
# **Book**: Chapter 8.6 discusses combining features and controlling the
# search space to avoid data mining.