# --- # 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 Benjamini–Hochberg 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. # Benjamini–Hochberg 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