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

673 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ---
# 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]
# # Feature Selection and Deduplication
#
# **Chapter 8: Feature Engineering**
# **Section Reference**: 8.6 — Combining Features and Controlling Search
#
# **Docker image**: `ml4t`
#
# ## Purpose
#
# A feature engineering pipeline produces many candidates — different lookbacks,
# transforms, and interaction variants. This notebook demonstrates how to reduce
# that set to a focused, production-ready collection using systematic selection
# and deduplication.
#
# ## Learning Objectives
#
# 1. Compute cross-sectional IC and rank features by predictive power
# 2. Apply correlation filtering to remove redundant features
# 3. Cluster near-duplicate features and select representatives
# 4. Use BenjaminiHochberg FDR to control false discovery across multiple tests
# 5. Assess feature stability via bootstrap IC
# 6. Compare IC-based and ML-based (LightGBM) importance rankings
#
# ## Prerequisites
#
# - Run [`03_financial_features`](../case_studies/etfs/03_financial_features.ipynb)
# to produce `financial.parquet`
# - Requires `ml4t-diagnostic` and `ml4t-engineer` libraries
#
# ## References
#
# - Harvey, Liu, and Zhu (2016) — Multiple testing in factor research
# - Meinshausen and Bühlmann (2010) — Stability selection
#
# **Output**: Selected feature list for downstream Chapter 9 use
# %% [markdown]
# ## Setup
# %%
"""Feature Selection and Deduplication — reduce feature candidates to a focused production-ready set."""
import warnings
import matplotlib.pyplot as plt
import numpy as np
import polars as pl
import seaborn as sns
import statsmodels.api as sm
from ml4t.diagnostic.metrics import pooled_ic
from scipy.cluster.hierarchy import fcluster, leaves_list, linkage
from scipy.spatial.distance import squareform
warnings.filterwarnings("ignore")
from data import load_etfs
from utils.paths import get_case_study_dir, get_output_dir
from utils.reproducibility import set_global_seeds
from utils.style import COLORS
# %% tags=["parameters"]
START_DATE = "2006-01-01"
N_BOOTSTRAP = 50
MAX_SYMBOLS = 0
SEED = 42
# %%
set_global_seeds(SEED)
# %% [markdown]
# ## 1. Load Features from ETF Case Study
#
# The ETF case study produced features in `case_studies/etfs/features/`.
# %%
CASE_DIR = get_case_study_dir("etfs")
FEATURES_PATH = CASE_DIR / "features" / "financial.parquet"
if not FEATURES_PATH.exists():
raise FileNotFoundError(
f"Features file not found at {FEATURES_PATH}. "
"Please run case_studies/etfs/03_financial_features.py first."
)
features_df = pl.read_parquet(FEATURES_PATH)
prices_df = load_etfs()
# Apply date filter
features_df = features_df.filter(pl.col("timestamp") >= pl.lit(START_DATE).str.to_date())
prices_df = prices_df.filter(pl.col("timestamp") >= pl.lit(START_DATE).str.to_date())
if MAX_SYMBOLS > 0:
top_symbols = (
features_df.group_by("symbol")
.len()
.sort("len", descending=True)
.head(MAX_SYMBOLS)["symbol"]
)
features_df = features_df.filter(pl.col("symbol").is_in(top_symbols))
prices_df = prices_df.filter(pl.col("symbol").is_in(top_symbols))
# Compute forward returns on-demand
labels_df = (
prices_df.sort(["symbol", "timestamp"])
.with_columns(
(pl.col("close").shift(-21).over("symbol") / pl.col("close") - 1).alias("fwd_return_1m")
)
.select(["timestamp", "symbol", "fwd_return_1m"])
.drop_nulls()
)
print(f"Features: {features_df.shape}")
print(f"Labels: {labels_df.shape}")
# %%
all_feature_cols = [c for c in features_df.columns if c not in ["timestamp", "symbol"]]
print(f"Available features: {len(all_feature_cols)}")
for i, col in enumerate(all_feature_cols, 1):
print(f" {i:2d}. {col}")
# %% [markdown]
# ## 2. Compute Information Coefficient (IC)
#
# IC measures the Spearman rank correlation between features and forward returns.
# We compute IC **cross-sectionally** (per date, then average). Pooled IC
# conflates time-series drift with cross-sectional predictive power.
# %%
# Merge features with forward returns
analysis = features_df.join(
labels_df.select(["timestamp", "symbol", "fwd_return_1m"]),
on=["timestamp", "symbol"],
how="inner",
).drop_nulls(subset=["fwd_return_1m"])
print(f"Analysis dataset: {analysis.shape}")
# %%
# Compute cross-sectional IC per date
ic_by_date = analysis.group_by("timestamp").agg(
[pl.corr(col, "fwd_return_1m", method="spearman").alias(col) for col in all_feature_cols]
)
# Summary statistics. The daily IC series is serially correlated (overlapping
# information sets, slow-moving common factors). We report both the i.i.d.
# t-stat and a Newey-West HAC t-stat from regressing the IC time series on a
# constant. HAC is the headline used for the BH-FDR step in §5.
NW_MAXLAGS = 12
ic_results = {}
for col in all_feature_cols:
daily_ics = ic_by_date[col].drop_nulls().to_numpy()
if len(daily_ics) < 10:
ic_results[col] = {"ic": np.nan, "n": len(daily_ics)}
continue
mean_ic = np.mean(daily_ics)
std_ic = np.std(daily_ics, ddof=1)
# Yield NaN on both branches when std_ic == 0 so the iid and HAC outputs
# are internally consistent for degenerate series.
if std_ic > 0:
t_stat_iid = mean_ic / (std_ic / np.sqrt(len(daily_ics)))
nw = sm.OLS(daily_ics, np.ones(len(daily_ics))).fit(
cov_type="HAC", cov_kwds={"maxlags": NW_MAXLAGS}
)
t_stat_nw = float(nw.tvalues[0])
else:
t_stat_iid = np.nan
t_stat_nw = np.nan
ic_results[col] = {
"ic": mean_ic,
"ic_std": std_ic,
"t_stat_iid": t_stat_iid,
"t_stat_NW": t_stat_nw,
"n": len(daily_ics),
}
ic_df = (
pl.DataFrame(
[
{
"feature": k,
"ic": v["ic"],
"t_stat_iid": v.get("t_stat_iid"),
"t_stat_NW": v.get("t_stat_NW"),
"n_obs": v["n"],
}
for k, v in ic_results.items()
]
)
.with_columns(pl.col("ic").abs().alias("ic_abs"))
.sort("ic_abs", descending=True)
)
print(f"\nFeature IC Rankings (top 15) — Newey-West with {NW_MAXLAGS} lags:")
ic_df.head(15)
# %%
# IC bar chart
fig, ax = plt.subplots(figsize=(10, 8))
ic_pd = ic_df.to_pandas().sort_values("ic_abs", ascending=True)
colors = [COLORS["positive"] if ic > 0 else COLORS["negative"] for ic in ic_pd["ic"]]
ax.barh(ic_pd["feature"], ic_pd["ic"], color=colors)
ax.axvline(0, color="black", linewidth=0.5)
ax.axvline(0.02, color="orange", linestyle="--", alpha=0.7, label="IC threshold (0.02)")
ax.axvline(-0.02, color="orange", linestyle="--", alpha=0.7)
ax.set_xlabel("Information Coefficient (Spearman)")
ax.set_title("Feature IC Ranking")
ax.legend()
plt.show()
# %% [markdown]
# ## 3. Correlation Filtering
#
# Highly correlated features provide overlapping information. We compute
# correlation on the full panel (all dates × symbols), then remove features
# with |r| > 0.9 — keeping the one with higher IC in each redundant pair.
# %%
feature_matrix = features_df.select(all_feature_cols).drop_nulls()
corr_np = feature_matrix.corr().to_numpy()
print(f"Correlation matrix: {corr_np.shape[0]} × {corr_np.shape[1]} features")
# %% [markdown]
# ### Remove Redundant Features
# Greedily drop the weaker member of each highly correlated pair.
# %%
def filter_correlated_features(
corr_matrix: np.ndarray,
feature_names: list[str],
ic_scores: dict[str, float] | None = None,
threshold: float = 0.9,
) -> tuple[list[str], list[str]]:
"""Remove highly correlated features, keeping the one with higher IC."""
removed = set()
n = len(feature_names)
for i in range(n):
if feature_names[i] in removed:
continue
for j in range(i + 1, n):
if feature_names[j] in removed:
continue
if abs(corr_matrix[i, j]) > threshold:
if ic_scores:
ic_i = abs(ic_scores.get(feature_names[i], 0))
ic_j = abs(ic_scores.get(feature_names[j], 0))
to_remove = feature_names[j] if ic_i >= ic_j else feature_names[i]
else:
to_remove = feature_names[j]
removed.add(to_remove)
kept = [f for f in feature_names if f not in removed]
return kept, list(removed)
# %%
ic_scores = {row["feature"]: row["ic"] for row in ic_df.to_dicts()}
kept_after_corr, removed_by_corr = filter_correlated_features(
corr_matrix=corr_np,
feature_names=all_feature_cols,
ic_scores=ic_scores,
threshold=0.9,
)
print("Correlation Filtering (threshold=0.9):")
print(f" Before: {len(all_feature_cols)} features")
print(f" After: {len(kept_after_corr)} features")
print(f" Removed: {removed_by_corr}")
# %% [markdown]
# ## 4. Clustering and Deduplication
#
# Even after removing pairs above 0.9, many features remain near-duplicates.
# Hierarchical clustering groups similar features so we can pick one
# representative per cluster — preserving diversity across families while
# removing redundancy within them.
#
# **Linkage choice**: We use **average linkage** (not Ward) because Ward
# assumes Euclidean distance. Correlation-based distances don't satisfy
# this assumption; average and complete linkage work with any distance.
# %%
# Build correlation matrix for surviving features
surv_idx = [all_feature_cols.index(f) for f in kept_after_corr]
surv_corr = corr_np[np.ix_(surv_idx, surv_idx)]
# Distance = 1 - |ρ| (NaN correlations treated as uncorrelated → distance 1.0)
dist_matrix = 1 - np.abs(np.nan_to_num(surv_corr, nan=0.0))
np.fill_diagonal(dist_matrix, 0)
dist_matrix = (dist_matrix + dist_matrix.T) / 2
dist_matrix = np.clip(dist_matrix, 0, 2)
dist_condensed = squareform(dist_matrix, checks=False)
link = linkage(dist_condensed, method="average")
# %%
# Clustered heatmap
leaves = leaves_list(link)
reordered_names = [kept_after_corr[i] for i in leaves]
reordered_corr = surv_corr[np.ix_(leaves, leaves)]
fig, ax = plt.subplots(figsize=(14, 12))
n_feats = len(reordered_names)
sns.heatmap(
reordered_corr,
annot=(n_feats <= 25),
fmt=".2f",
annot_kws={"size": 6},
cmap="RdBu_r",
center=0,
vmin=-1,
vmax=1,
ax=ax,
xticklabels=reordered_names,
yticklabels=reordered_names,
cbar_kws={"label": "Correlation"},
)
ax.set_title("Feature Correlation (Clustered, Average Linkage)")
ax.tick_params(axis="both", labelsize=8)
plt.setp(ax.get_xticklabels(), rotation=60, ha="right")
plt.show()
# %% [markdown]
# The block structure reveals which features are essentially measuring the
# same thing. Within each block, correlations are high (>0.7), confirming
# that one representative per cluster is sufficient. Between blocks,
# correlations are low — genuine diversification.
# %%
# Assign clusters and select representatives by highest |IC|
N_CLUSTERS = 5
clusters = fcluster(link, N_CLUSTERS, criterion="maxclust")
print(f"\n=== Factor Clusters ({N_CLUSTERS} groups) ===\n")
representatives = []
for c in range(1, N_CLUSTERS + 1):
cluster_factors = [kept_after_corr[i] for i, clust in enumerate(clusters) if clust == c]
best = max(cluster_factors, key=lambda f: abs(ic_scores.get(f, 0)))
representatives.append(best)
print(f"Cluster {c}:")
for f in cluster_factors:
marker = " →" if f == best else " "
print(f" {marker} {f}: IC = {ic_scores.get(f, 0):.4f}")
print(f"\nRepresentatives: {representatives}")
# %% [markdown]
# ## 5. Multiple Testing Correction (BH-FDR)
#
# With many features tested, some appear significant by chance.
# BenjaminiHochberg FDR controls the expected false discovery rate.
#
# **Inference**: the p-values fed into BH-FDR come from the **Newey-West HAC**
# t-statistic on each feature's daily IC series (matching the table above and
# the headline measure in `06_robustness_sensitivity.py`). The i.i.d. t-stat
# would overstate significance because daily ICs share slow-moving common
# factors and overlapping information sets.
# %%
from ml4t.diagnostic.evaluation.stats import benjamini_hochberg_fdr
ic_pvalues = []
ic_feature_names = []
for col in all_feature_cols:
daily_ics = ic_by_date[col].drop_nulls().to_numpy()
# Exclude degenerate series (constant or non-finite) so they do not
# contribute NaN p-values, which would still inflate BH's denominator and
# tighten the per-rank threshold for every valid feature. Mirror the same
# std_ic > 0 guard the IC-ranking loop above uses.
if len(daily_ics) < 20 or np.std(daily_ics, ddof=1) == 0 or not np.isfinite(daily_ics).all():
continue
nw = sm.OLS(daily_ics, np.ones(len(daily_ics))).fit(
cov_type="HAC", cov_kwds={"maxlags": NW_MAXLAGS}
)
p_val = float(nw.pvalues[0])
if not np.isfinite(p_val):
continue
ic_pvalues.append(p_val)
ic_feature_names.append(col)
if ic_pvalues:
bh_result = benjamini_hochberg_fdr(ic_pvalues, alpha=0.05, return_details=True)
n_significant_raw = sum(p < 0.05 for p in ic_pvalues)
n_significant_fdr = sum(bh_result["rejected"])
print(f"Features tested: {len(ic_pvalues)}")
print(f"Significant at p<0.05 (raw): {n_significant_raw}")
print(f"Significant after BH-FDR: {n_significant_fdr}")
print(f"False discoveries prevented: {n_significant_raw - n_significant_fdr}")
survivors = [ic_feature_names[i] for i, r in enumerate(bh_result["rejected"]) if r]
if survivors:
print("\nFeatures surviving FDR correction:")
for f in survivors:
print(f" - {f}")
# %% [markdown]
# ## 6. Selection Pipeline
#
# Applying the filters in sequence: correlation filtering → IC filtering →
# top-K selection.
# %%
# IC filtering
IC_THRESHOLD = 0.01
kept_after_ic = [f for f in kept_after_corr if abs(ic_scores.get(f, 0)) >= IC_THRESHOLD]
print(f"IC Filtering (|IC| >= {IC_THRESHOLD}):")
print(f" Before: {len(kept_after_corr)} features")
print(f" After: {len(kept_after_ic)} features")
# %%
# Top-K selection
TOP_K = 10
ic_ranked = sorted(kept_after_ic, key=lambda f: abs(ic_scores.get(f, 0)), reverse=True)
final_features = ic_ranked[:TOP_K]
print(f"\nTop-{TOP_K} Selected Features:")
for i, f in enumerate(final_features, 1):
print(f" {i:2d}. {f} (IC={ic_scores[f]:.4f})")
# %% [markdown]
# ## 7. Stability Selection via Bootstrap IC
#
# Stability selection tests whether features remain important across bootstrap
# samples. Features that rank highly in >80% of samples are considered stable.
#
# > **Caveat**: The bootstrap below samples individual rows (date × symbol),
# > pooling across dates. A more rigorous approach bootstraps by *date*
# > (block bootstrap), preserving cross-sectional structure. The pooled
# > version here is a quick filter; production systems should use
# > time-aware resampling.
# %%
def bootstrap_ic(
df: pl.DataFrame,
feature_cols: list[str],
return_col: str = "fwd_return_1m",
n_bootstrap: int = 50,
sample_frac: float = 0.8,
) -> pl.DataFrame:
"""Compute IC across bootstrap samples to assess stability.
Uses the global numpy seed set in the preamble via ``set_global_seeds(SEED)``.
"""
n_samples = len(df)
sample_size = int(n_samples * sample_frac)
results = {f: [] for f in feature_cols}
for _ in range(n_bootstrap):
indices = np.random.choice(n_samples, size=sample_size, replace=True)
sample = df[indices.tolist()]
y = sample[return_col].to_numpy()
for col in feature_cols:
x = sample[col].to_numpy()
mask = np.isfinite(x) & np.isfinite(y)
if mask.sum() < 30:
results[col].append(np.nan)
continue
ic = pooled_ic(x[mask], y[mask])
results[col].append(ic)
stability_data = []
for col in feature_cols:
ics = np.array(results[col])
valid = ics[~np.isnan(ics)]
if len(valid) == 0:
continue
stability_data.append(
{
"feature": col,
"ic_mean": np.mean(valid),
"ic_std": np.std(valid),
"ic_ir": np.mean(valid) / (np.std(valid) + 1e-8),
"positive_pct": np.mean(valid > 0) * 100,
}
)
if not stability_data:
return pl.DataFrame(
{"feature": [], "ic_mean": [], "ic_std": [], "ic_ir": [], "positive_pct": []}
)
return pl.DataFrame(stability_data).sort("ic_ir", descending=True)
# %%
stability = bootstrap_ic(df=analysis, feature_cols=final_features, n_bootstrap=N_BOOTSTRAP)
print(f"Stability Selection ({N_BOOTSTRAP} bootstrap samples):")
stability
# %%
fig, ax = plt.subplots(figsize=(10, 6))
stab_pd = stability.to_pandas()
ax.errorbar(
stab_pd["feature"],
stab_pd["ic_mean"],
yerr=stab_pd["ic_std"],
fmt="o",
capsize=5,
capthick=2,
markersize=8,
)
ax.axhline(0, color="black", linewidth=0.5)
ax.set_xlabel("Feature")
ax.set_ylabel("Mean IC ± Std")
ax.set_title("Feature IC Stability (Bootstrap)")
plt.xticks(rotation=45, ha="right")
plt.show()
# %% [markdown]
# ## 8. ML-Based Feature Importance
#
# Beyond IC ranking, ML models identify features with non-linear predictive
# power. We fit a quick LightGBM model and compare its feature importance
# with the IC rankings above.
# %%
from ml4t.diagnostic.metrics import analyze_ml_importance
ml_data = analysis.select(["timestamp", "symbol"] + final_features + ["fwd_return_1m"]).drop_nulls()
X = ml_data.select(final_features).to_numpy()
y = ml_data["fwd_return_1m"].to_numpy()
if len(X) > 100:
from lightgbm import LGBMRegressor
lgbm = LGBMRegressor(n_estimators=100, max_depth=5, verbose=-1, random_state=SEED)
lgbm.fit(X, y)
importance_result = analyze_ml_importance(
model=lgbm,
X=X,
y=y,
feature_names=final_features,
methods=["mdi", "pfi"],
)
print("=== ML Feature Importance (LightGBM) ===\n")
print(f"Consensus top features: {importance_result['consensus_ranking'][:10]}")
print(f"Methods run: {importance_result['methods_run']}")
if importance_result.get("method_agreement"):
print(f"Method agreement: {importance_result['method_agreement']}")
print(f"\n{importance_result['interpretation']}")
# %% [markdown]
# **Interpretation**: MDI (Mean Decrease in Impurity) measures how much each
# feature reduces prediction error in the tree ensemble. PFI (Permutation
# Feature Importance) measures how much shuffling a feature degrades
# predictions. Features ranking high in both IC and ML importance are the
# strongest candidates for production.
# %% [markdown]
# ## 9. Post-Selection Verification
# %%
# Verify low inter-correlation among selected features
selected_matrix = features_df.select(final_features).drop_nulls()
corr_after = selected_matrix.corr().to_numpy()
fig, ax = plt.subplots(figsize=(10, 8))
mask = np.triu(np.ones_like(corr_after, dtype=bool), k=1)
sns.heatmap(
corr_after,
mask=mask,
annot=True,
fmt=".2f",
cmap="RdBu_r",
center=0,
vmin=-1,
vmax=1,
ax=ax,
xticklabels=final_features,
yticklabels=final_features,
cbar_kws={"label": "Correlation"},
)
ax.set_title("Selected Features — Residual Correlation")
plt.show()
np.fill_diagonal(corr_after, 0)
max_corr = np.abs(corr_after).max()
print(f"Max remaining correlation: {max_corr:.3f}")
# %% [markdown]
# ## 10. Selection Summary and Output
# %%
print("=" * 60)
print("FEATURE SELECTION REPORT")
print("=" * 60)
print(f"\nInitial Features: {len(all_feature_cols)}")
print(f"After Correlation Filter: {len(kept_after_corr)}")
print(f"After IC Filter: {len(kept_after_ic)}")
print(f"Final Selected: {len(final_features)}")
print(f"Removal Rate: {100 * (1 - len(final_features) / len(all_feature_cols)):.1f}%")
print("\n" + "-" * 60)
print("SELECTED FEATURES FOR CHAPTER 9")
print("-" * 60)
for i, f in enumerate(final_features, 1):
ic = ic_scores[f]
stab_row = stability.filter(pl.col("feature") == f)
ic_ir = stab_row["ic_ir"][0] if len(stab_row) > 0 else np.nan
print(f"{i:2d}. {f:30s} IC={ic:+.4f} IC_IR={ic_ir:.2f}")
print("=" * 60)
# %%
# Save selected features for Chapter 9
OUTPUT_DIR = get_output_dir(8, "feature_selection")
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
selected_df = pl.DataFrame(
{"feature": final_features, "ic": [ic_scores[f] for f in final_features]}
)
selected_df.write_parquet(OUTPUT_DIR / "selected_features.parquet")
filtered_features = features_df.select(["timestamp", "symbol"] + final_features)
filtered_features.write_parquet(OUTPUT_DIR / "features_selected.parquet")
print(f"Saved selected features to {OUTPUT_DIR}")
print(f" - selected_features.parquet: {len(final_features)} features")
print(f" - features_selected.parquet: {filtered_features.shape}")
# %% [markdown]
# ## Key Takeaways
#
# 1. **Cross-sectional IC** is the correct method for factor evaluation —
# pooled IC conflates time-series drift with predictive power
# 2. **Correlation filtering** (|r| > 0.9) removes obvious redundancy;
# **clustering** catches subtler near-duplicates within feature families
# 3. **Use average or complete linkage** (not Ward) for correlation distances —
# Ward assumes Euclidean geometry
# 4. **BH-FDR with HAC-adjusted p-values** controls false discovery when
# screening many candidates. The p-values fed into BH-FDR come from the
# Newey-West t-statistic on each feature's daily IC series, not the
# i.i.d. t-stat, because daily ICs are serially correlated. Without
# multiple-testing correction, ~5% of null features appear significant
# at the 5% level by chance alone
# 5. **Bootstrap stability** separates features with robust IC from those
# that depend on a few outlier periods
# 6. Features ranking high in both IC and ML importance are the strongest
# production candidates
#
# **Next**: `06_robustness_sensitivity` — parameter sensitivity and
# regime-conditional analysis