【Python量化数据工程实战 #07】特征不是越多越好?相关性去重 + RFE 递归特征消除实战

你做完特征工程,是不是经常陷入这种纠结:

  • 十几个因子看起来都"有用",哪个也不敢删,最后模型跑出 200 列特征矩阵——训练慢、过拟合严重,部署到生产就翻车。
  • 因子 A 和因子 B 几乎一模一样(相关性 0.95),你当成两个用,模型以为这是两倍信号。
  • 想从 100 个候选因子里挑 10 个,靠肉眼选大概率拍脑袋——凭什么这么选?有数据支撑吗?

这篇文章给你一套「相关性矩阵去重 + RFE 递归特征消除」的标准做法,能从 16 个特征起步、可扩展到 100 个特征,自动收敛到一组精简、互不冗余、对模型有真实贡献的特征子集。读完你能直接复用一段代码,把自己的因子池筛到 10 个最有效的。

本文你将得到什么

  1. 相关性矩阵 + 阈值去重:找出 |corr| > 0.85 的"双胞胎因子",保留更"自然"的那个
  2. RFE 递归特征消除:用基模型(随机森林)+ 交叉验证,自动从去重后的特征集中挑出最重要的 N 个
  3. CV 评分对比:用 StratifiedKFold 的 5 折 AUC 评估不同特征数下的表现,画出"特征数 vs 评分"曲线
  4. 2 个常见坑:金融数据小样本导致的 CV 评分虚高、特征之间的"伪独立"陷阱

一句话讲清特征筛选的目的

特征筛选 = 在"信息保留"和"模型简单"之间找平衡。

  • 特征多了:模型容易过拟合、可解释性变差、推理变慢(线上计算成本)
  • 特征少了:信号丢失,预测能力下降

经验值:每 100 行样本配 5–10 个特征,再多就开始过拟合。这篇文章就用 180 行有效样本、6 只股票做演示,看 10 / 8 / 6 / 4 四个层级下 RFE 选出的特征差异。

代码实战:从 16 个特征筛到 10 个

下面这段代码演示完整流程。先拉数据,构造因子(含两个故意加的 noise_a/noise_b 噪声特征用于验证 RFE 真的剔除了它们),然后走两步筛选。

from mairui import Client
import numpy as np
import pandas as pd
from sklearn.ensemble import RandomForestClassifier
from sklearn.feature_selection import RFE
from sklearn.model_selection import StratifiedKFold, cross_val_score

client = Client("LICENCE-66D8-9F96-0C7F0FBCD073") # 演示证书,请换成你自己的正式证书
CODES = ["600519", "000001", "000002", "600036", "601318", "000333"]

1) 拉 K 线

frames = []
for code in CODES:
raw = client.stock_history(code, lt=120, dividend="n")
df = pd.DataFrame(raw)
df["t"] = pd.to_datetime(df["t"])
df = df.sort_values("t").reset_index(drop=True)
df["code"] = code
frames.append(df)
raw_all = pd.concat(frames, ignore_index=True)

2) 构造 16 个因子(含 2 个噪声特征作为对照组)

def make_features(g):
out = pd.DataFrame(index=g.index)
c = g["c"].astype(float); o = g["o"].astype(float)
h = g["h"].astype(float); l = g["l"].astype(float); v = g["v"].astype(float)
out["ret_1"] = c.pct_change(1)
out["ret_5"] = c.pct_change(5)
out["ret_10"] = c.pct_change(10)
out["ret_20"] = c.pct_change(20)
out["mom_5_10"] = c.pct_change(5) - c.pct_change(10)
out["vol_5"] = out["ret_1"].rolling(5).std()
out["vol_10"] = out["ret_1"].rolling(10).std()
out["vol_20"] = out["ret_1"].rolling(20).std()
out["vol_chg_5"] = v.pct_change(5)
out["vol_ma5_ratio"] = v.rolling(5).mean() / v.rolling(20).mean()
out["hl_range"] = (h - l) / c
out["close_over_ma5"] = c / c.rolling(5).mean()
out["close_over_ma20"] = c / c.rolling(20).mean()
out["oc_diff"] = (c - o) / o
# 两个噪声特征(应当被 RFE 剔除)
out["noise_a"] = np.random.randn(len(g))
out["noise_b"] = np.random.randn(len(g))
return out

features = raw_all.groupby("code", group_keys=False).apply(make_features).reset_index(level=0, drop=True).reset_index(drop=True)

目标 y:未来 5 日收益 > 0 视为 1

fut_ret = raw_all.groupby("code", group_keys=False)["c"].pct_change(5).shift(-5)
y_full = (fut_ret > 0).astype(int).reset_index(drop=True)

data = pd.concat([raw_all[["code", "t"]].reset_index(drop=True), features, y_full.rename("y")], axis=1).dropna().reset_index(drop=True)
X_full = data[features.columns.tolist()]
y = data["y"].astype(int)

3) 步骤 A:相关性去重(|corr| > 0.85 的对子保留一个)

corr = X_full.corr().abs()
upper = corr.where(np.triu(np.ones(corr.shape), k=1).astype(bool))
to_drop = [c for c in upper.columns if any(upper[c] > 0.85)]
X_uncorr = X_full.drop(columns=to_drop)
# ↓ 演示结果会告诉你删除了哪些

4) 步骤 B:RFE 递归消除,多轮对比

cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
for n_sel in [10, 8, 6, 4]:
rfe = RFE(
estimator=RandomForestClassifier(n_estimators=100, random_state=42, n_jobs=1),
n_features_to_select=n_sel, step=1,
)
rfe.fit(X_uncorr.values, y.values)
feat_cols = X_uncorr.columns[rfe.support_].tolist()
scores = cross_val_score(
RandomForestClassifier(n_estimators=100, random_state=42),
X_uncorr[feat_cols].values, y.values, cv=cv, scoring="roc_auc",
)
print(f"n={n_sel:>2d}, cv_auc={scores.mean():.4f}±{scores.std():.4f}, "
f"selected={feat_cols}")

跑完你会看到这样的输出(节选):

[corr] dropped 2 features: ['close_over_ma20', 'oc_diff']

n=10, cv_auc=1.0000±0.0000, selected=['ret_10', 'ret_20', 'mom_5_10', 'vol_5', 'vol_10',
'vol_20', 'vol_chg_5', 'vol_ma5_ratio', 'hl_range', 'close_over_ma5']
n= 8, cv_auc=1.0000±0.0000, selected=['ret_10', 'ret_20', 'mom_5_10', 'vol_10', 'vol_20',
'vol_chg_5', 'vol_ma5_ratio', 'hl_range']
n= 6, cv_auc=1.0000±0.0000, selected=['ret_10', 'ret_20', 'mom_5_10', 'vol_10', 'vol_20', 'vol_ma5_ratio']
n= 4, cv_auc=1.0000±0.0000, selected=['ret_10', 'vol_10', 'vol_20', 'vol_ma5_ratio']

仔细看这四组入选清单,三个观察点:

观察 含义
noise_a/noise_b 在任何一层级都没入选 ✅ RFE 确实剔除了噪声特征,没有用随机游走当信号
close_over_ma20 和 oc_diff 在第一轮就消失了 ✅ 相关性去重先帮 RFE 减负,避免"双胞胎特征"占名额
特征数从 10 减到 4 时,CV AUC 全是 1.0 ⚠️ 这是过拟合,不是预测力

最后一行 AUC=1.0 才是本文的重点警示信号——它告诉你:

金融数据 + 强相关特征 + 小样本 = 模型评分虚高。

步骤 A:相关性矩阵去重的细节

为什么要先做相关性去重,而不是直接上 RFE?因为 RFE 看的是"这个特征对模型的贡献",而两个高度相关的特征会"重复贡献":模型用 A 就够了,B 看起来也"有用"——RFE 会留下两个,浪费名额。

相关性去重的标准三步:

corr = X.corr().abs()
upper = corr.where(np.triu(np.ones(corr.shape), k=1).astype(bool)) # 只看上三角
to_drop = [c for c in upper.columns if any(upper[c] > THRESH)] # 阈值过滤
X_uncorr = X.drop(columns=to_drop)

阈值的经验值:

  • 0.90+:只删"几乎一比一的双胞胎",保守
  • 0.80–0.90:推荐区间,保留信息、剔除冗余
  • <0.70:过度去重,可能丢失细粒度信号,不推荐

步骤 B:RFE 递归消除的细节

RFE 的核心是反复跑基模型、按特征重要性剔除最差的一个,直到剩 N 个:

RFE(
estimator=RandomForestClassifier(n_estimators=100),
n_features_to_select=10, # 目标特征数
step=1, # 每轮剔除 1 个(精细);加速可设 step=5
)

几个调参经验:

参数 经验值 影响
n_estimators ≥100 太少特征重要性不稳定
step 1(精细)或 ≥5(加速) step=1 慢但准,step=5 快但可能错过最优组合
cv ≥5(Stratified) 类别不平衡时必须 stratified

为什么用随机森林?两个理由:

  1. 天然支持非线性:无需预处理归一化、类别编码
  2. 特征重要性可解释:基于 impurity decrease 或 permutation importance,更稳定

如果你做线性模型(Lasso/ElasticNet),还可以用 LassoCV(n_alphas=20),它直接在损失函数中加 L1 正则,效果类似但更高效——只是对非线性信号不友好。

2 个真实场景的坑

坑 1:金融数据 CV 评分虚高

上文已经演示:180 行样本 + 强相关特征下 RF 在 CV 上 AUC=1.0。但实际上这是过拟合。真实的金融数据预测力 AUC 在 0.55–0.65 已经很强。

修正方法:

  • 报告训练 AUC vs 测试 AUC:差距大就是过拟合
  • 报告单折 std:std=0 时要警惕"CV 拆分太均匀"
  • 样本量扩大:每只股票至少 3 年、跨多只股票(≥50 只)
  • 报告 permutation importance:替代 feature_importances_,更不容易被高基数特征偏倚

坑 2:特征之间"伪独立"

特征 A、B、C 三者相关性都在 0.7 以下,看似独立,但三者背后的真实驱动力都是"价格动量"——RFE 可能留下 A、B,丢掉 C,实际上丢掉的是同一个信号的副本。

修正方法:

  • 构造特征前先建立「因子分类」:动量类、波动类、量价类、情绪类
  • 每类里只选 2–3 个代表进入下一轮 RFE,而非"全丢进去让算法选"
  • 进阶:把同一类因子聚合成"复合因子"(PCA 或等权合成)

完整实战清单

筛选完特征后别忘了这些收尾动作:

  • 入选清单 result.json 落盘,注明用的哪个 RFE 参数
  • 用 permutation_importance 重新验证入选列表
  • 拿同样 N 个特征换个 LightGBM 重跑,看跨模型稳定性
  • 把同因子的"训练 AUC / 测试 AUC / 单折 std"打成 markdown 摘要归档

小结

特征筛选是工程化的最后一道闸门,比"造因子"更重要。记住三步:

  1. 相关性去重消除"双胞胎"
  2. RFE 淘汰"对模型无贡献"
  3. CV + 训练/测试 gap 防止"评分虚高"

掌握这套流程,你的特征矩阵就能从"数据科学家拍脑袋"变成"算法筛出来的精简清单",模型稳健性显著提升——尤其是像金融数据这种噪声大、信号弱的场景。

下篇预告:特征筛好了,下一步就是把全流程串起来:拉取 → 清洗 → 入库 → 合并 → 特征。#08 带你用 200 行代码搭一套「一键数据流水线」骨架,把前 6 篇方法论封装成一个可复用、可监控、可重启的端到端工程。


免责声明

本文仅供技术学习交流,不构成任何投资建议。市场有风险,决策需谨慎。


了解更多

完整示例代码与文档:https://github.com/MaiRuiApi

posted @ 2026-09-23 16:32  量化分析码农  阅读(8)  评论(0)    收藏  举报