做量化策略的时候我经常遇到一种尴尬因子库越堆越厚可模型效果不升反降。一开始我以为是模型不够强后来把特征相关性矩阵打出来看才发现有一堆换皮特征——动量类指标换了周期就再算一遍波动率类指标换个窗口又存一列彼此相关系数高达0.9以上。这些冗余特征不仅让训练变慢还让线性模型的多重共线性问题直接爆表Tree模型的特征重要性也被稀释得乱七八糟。于是我在数据工程流程里加入了特征选择环节核心就两招相关性去重 RFE递归特征消除。这一篇就是把这两招的完整实践记录下来从原理、阈值选择、代码实现到量化场景里的坑一次讲透。1. 内容整体设计与思路拆解1.1 量化特征工程的矛盾点特征越多并不等于信息越多很多人刚接触量化时容易陷入特征数量崇拜总觉得自己多造几个因子模型就能找到更多规律。实际上特征数量的增加并不等于信息量的增加尤其是当你用同一套底层行情数据反复加工时新增特征往往只是已有特征的线性或非线性组合。比如你手动计算了5日均线、10日均线、20日均线又计算了它们各自的变化率这些特征之间天然存在强相关——因为它们的底层都是收盘价序列的移动平均。这种冗余带来的直接后果有三个第一训练开销成倍增长。数据量几百兆时感觉不明显但量化场景里往往要全市场扫描几千只股票乘以几千根K线再乘以几百个特征内存和耗时都会迅速失控。第二模型稳定性变差。线性模型比如Logistic回归、线性SVM对多重共线性非常敏感两个高度相关的特征会导致系数估计方差变大今天训练出来的权重明天可能就飘了。树模型虽然不怕共线性但冗余特征会参与分裂点的竞争降低单棵树的可用深度并且让特征重要性变得不可信。第三过拟合风险上升。特征越多模型越容易记住训练集里的噪声。尤其是金融数据本身信噪比极低冗余特征提供的伪信息很容易被模型当作规律实盘里却完全失效。我在实盘回测里见过太多这样的例子训练集上IC稳定换了样本外数据直接归零。所以特征工程阶段必须做减法。但做减法不是随便扔几列而是要有依据地把信息重复度高的特征去掉再把对预测目标贡献小的特征淘汰掉。相关性去重负责第一层RFE负责第二层两者配合恰好覆盖了冗余和无效这两个不同的维度。1.2 为什么选择相关性去重 RFE组合单独用相关性去重能解决冗余但解决不了低质量但独立的特征。单独用RFE能按模型权重淘汰特征但RFE面对高相关特征组时往往只会随机保留其中某一个这种随机性很不稳定——同一份数据跑两次留下的特征可能不一样。所以我把它们串成流水线先做相关性去重。把所有相关性超过阈值的特征归为一族从族里挑一个代表性特征剩下的删掉。这一步保证了进入后续流程的特征彼此之间信息重叠度低也让RFE的特征重要性排序更稳定。然后做RFE。用模型递归地淘汰对预测贡献最小的特征直到特征数量到达目标值或者模型效果不再提升。这一步解决的是这个特征和别的特征不重复但它就是没什么预测力的情况。这套组合还有一个额外好处可解释性强。每一步为什么去掉某个特征都能说清楚——要么是因为和另一个特征太像要么是因为模型递归评估时认为它不重要。在量化领域可解释性不仅是合规需求也是调整因子池时的维护需求。否则下个月你想删掉几个因子翻代码都不知道当初为什么留。1.3 这套方案的适用场景与边界相关性去重 RFE比较适合特征数量在几十到几百这个量级、且特征之间存在明显分组相关性的场景比如技术指标因子池、基本面因子池的预处理。如果你的特征数量只有五六个直接人工检查就行没必要上这套流程。如果特征是上千维的稀疏文本向量那更适合PCA或者嵌入法RFE跑起来会很吃力。另外要特别提醒一点这套流程适合截面数据或者经过严格对齐的面板数据。做时序预测时特征和标签的对齐关系必须提前处理好不能把未来信息混进特征选择过程。后面我会专门讲这个坑这里先提个醒。2. 相关性去重先干掉看着不同、实则重复的特征2.1 相关性度量的两种主流方式相关性去重的第一步是选度量方式。最常用的是皮尔逊相关系数它衡量的是两个变量之间的线性相关程度取值范围[-1, 1]。在量化因子里很多指标天然是线性相关的比如不同周期的移动平均收益率所以皮尔逊系数够用。但有些因子之间是非线性关系比如某个因子取平方之后才和另一个因子强相关皮尔逊系数就检测不出来了。这时候可以考虑互信息Mutual Information。互信息不假设关系形式能捕捉到任意统计依赖但计算代价更高对连续变量还需要做分箱或者用k近邻估计。所以我的习惯是先跑一遍皮尔逊矩阵把线性冗余清掉如果之后还有疑难的、业务上明显同源的因子再单独用互信息验证。这里有一个实践细节不要直接在所有特征上计算互信息矩阵因为连续特征的互信息估计不稳定而且计算量是O(n^2)的。我通常只是把皮尔逊去重后剩下的特征再和标签做互信息排序用来辅助后续RFE的初筛而不是拿互信息做去重矩阵。2.2 阈值怎么定先看数据再看场景相关性的去重阈值没有黄金标准我见过有人用0.7有人用0.95差别很大。阈值没有绝对标准关键看你对冗余的容忍度。如果把去重阈值设到0.98只会去掉几乎完全相同的特征保留大量中度相关特征后续RFE的压力会大一些。如果把阈值设到0.6则可能误伤有独立增量信息的特征尤其当两个特征虽然相关但一个在极端行情下更敏感时删掉它挺可惜的。我的经验是分两步。第一步先画一个相关性热力图肉眼看一下整体分布——如果大部分特征两两相关都在0.6以下阈值可以设宽松一点比如0.85如果很多特征都在0.7~0.9抱团阈值就设到0.8左右让抱团的特征族被拆开。第二步用业务逻辑辅助判断同一类因子比如都是动量之间相关性0.8可以删但跨类因子比如动量和波动率即使相关性到0.8也建议先保留一个因为它们在不同市场状态下可能交替发挥作用。如果不想拍脑袋可以做一个简单的阈值扫描实验把阈值从0.6到0.95每隔0.05试一次每次用去重后的特征跑一遍模型观察验证集效果。选效果最高的阈值即可。这个实验成本不高但能让你的选择有依据而不是凭感觉。2.3 Python实现相关性矩阵 聚类式去重具体代码我放在后面完整流程里这里先讲一个关键技巧当A和B相关、B和C相关但A和C不一定相关时单纯的两两超阈值就删可能会误删。更稳妥的做法是把特征按照相关矩阵做聚类让高度相关的特征聚成一族再从每一族里挑一个代表。实现思路不复杂先用pandas的DataFrame.corr()算出相关矩阵然后将相关性绝对值大于阈值看作两个特征之间有一条边用连通图或者层次聚类把特征分组。在Python里可以用scipy的层次聚类也可以直接遍历邻接表找连通分量。对于量化场景里几十个特征直接用networkx的connected_components最方便。挑代表特征时我建议不要简单选第一个而是选这个特征与同族其他特征的平均相关性最高的那个也就是最典型的特征。如果同时有标签预测目标可以改成选与标签相关性最高的那个这样保留的特征和预测目标的关联更强。2.4 时间序列场景下的伪相关陷阱量化数据和普通机器学习数据最大的不同是它带有强自相关性——今天的特征和昨天的特征本来就高度相关。当你计算两个因子的相关系数时如果两者都有明显的趋势或者季节成分即使它们背后没有真实同步关系也很容易算出很高的相关系数。这就是伪相关。我遇到过最典型的案例把两个完全无关的宏观指标在牛熊切换时段的相关系数算出来结果是0.85。原因是两个指标都随着市场整体上涨而上涨随之下跌而下跌但彼此的因果和同步关系其实很弱。这种伪相关一旦进入去重流程就可能误删掉一个有价值的特征。解决办法是在计算相关性之前先做差分或去趋势。对价格类、累计类特征先取一阶差分或对数收益再做相关性分析对比率类特征可以用滚动Z-Score去除缓慢漂移。这也是为什么我在量化数据工程里反复强调特征选择之前的预处理必须注入金融业务知识单纯跑sklearn的函数远远不够。3. RFE递归特征消除用模型告诉你哪些特征该留3.1 RFE的工作机制从全集开始一轮一轮淘汰RFE的全称是Recursive Feature Elimination中文叫递归特征消除。它的核心逻辑非常直白先拿全部特征训练一个模型然后根据模型给出的特征重要性比如线性模型的系数绝对值、树模型的特征重要性把最不重要的若干个特征删掉再用剩余特征重新训练模型继续删直到达到你想要的特征数量。这个递归的价值在于特征的重要性不是一次就能定死的。有些特征在全集里看起来不重要是因为它的信息被其他特征覆盖了当那些遮蔽者被删掉之后它反而会变得重要。RFE通过逐轮淘汰让特征的重要性排序在剩余特征集合这个语境下重新计算相当于一种贪心式的后向选择。我打个比方你在一个团队里评选核心成员第一轮投票可能因为有人被明星成员盖过风头而落选但明星成员被请走之后这个人又变成不可或缺的了。RFE就是在反复做这种重新评估。3.2 关键参数调优选择多少个特征由交叉验证说了算使用RFE时最重要的问题是到底保留多少个特征。直接用RFE你需要手动指定n_features_to_select比较省事。但如果不知道选多少建议用RFECV它在RFE基础上加入了交叉验证每减少一批特征就在训练集上重新计算模型效果最后选交叉验证分数最高时对应的特征数量。这里有一个量化场景常见的坑默认的CV评分准确率或F1不适合交易预测。如果你做的是周度涨跌方向预测类别通常不平衡涨的日子少于跌的日子准确率会被多数类主导。我一般自己传scoring参数比如roc_auc或者更贴近业务的自定义指标比如IC均值。RFECV支持传入自定义scorer建议自己写一个函数把验证集上预测值和真实值之间的秩相关系数Spearman Rank IC算出来。还有一个参数step。它表示每一轮淘汰多少个特征。如果step1一轮只删一个结果最精细但速度慢如果特征量有200个每轮删10%~20%能大幅加速。我的习惯是step设为特征总数的5%~10%到后期特征少了再自动缩小。3.3 量化策略里的自定义评估器RFE内部的模型不是随便选的。如果你用线性模型RFE淘汰依据是系数绝对值意味着它假设特征对目标的影响是线性的。如果你的因子和目标之间主要是非线性关系线性模型给的排序就会偏掉。在量化因子筛选里我常用的评估器是随机森林或LightGBM。树模型能捕捉非线性特征重要性也更稳定而且对量纲不敏感不需要做标准化。但树模型的特征重要性有一个偏向连续特征和取值更多的特征容易被高估。因此在使用RFE时我用的是置换重要性Permutation Importance替代默认的feature_importances_。置换重要性的思路是把一个特征随机打乱观察模型效果下降多少下降越多特征越重要。这样能避免树模型本身的偏好更可靠。实现上可以自定义一个包装器或者直接用sklearn的permutation_importance函数每次RFE内部需要ranking时传入一个实现了fit并暴露feature_importances_的对象。更简单的办法是使用支持特征重要性接口的模型但打印出train和val分数做对照防止纯靠重要性出现过拟合。3.4 RFE花式变体RFECV与逻辑斯蒂/随机森林的组合RFECV是RFE的交叉验证版本也是我日常最常用到的。它接受一个评估器然后自动选择特征数量。但要注意评估器在每次交叉验证中都会重新fit所以计算量是交叉验证折数 × 消除轮数 × 模型训练时间。特征量上百、样本量几十万时这个耗时可能非常可观。建议先做相关性去重把特征压到50以内再跑RFECV。组合策略上我会推荐这样一个套餐先相关性去重再使用LightGBM作为评估器跑RFECVscoring用自定义的IC函数。LightGBM训练速度快对表格数据效果好而且支持样本权重——你可以把最近N期的样本权重调高让特征选择更关注近期市场状态。如果追求稳定和解释也可以换成逻辑回归但记得先做标准化因为逻辑回归的系数大小受特征尺度影响。4. 实操过程与核心环节实现4.1 环境准备与数据说明我的实验环境是Python 3.10pandas 2.0、numpy 1.24、scikit-learn 1.3数据用的是本地存储的日线行情我按以下方式生成了模拟因子数据实际使用时替换为你的因子库。你需要先确保安装了所需库pip install pandas numpy scikit-learn lightgbm生成一个包含1000个样本、20个特征的示例数据集其中部分特征存在强相关性部分特征是纯噪声。import numpy as np import pandas as pd from sklearn.model_selection import train_test_split np.random.seed(42) n_samples 1000 # 底层公共因子用于构造相关特征 base np.random.randn(n_samples) # 构造特征 data {} data[factor_1] base np.random.randn(n_samples) * 0.1 data[factor_2] base np.random.randn(n_samples) * 0.1 # 与factor_1高度相关 data[factor_3] base * 0.8 np.random.randn(n_samples) * 0.3 # 与factor_1中度相关 data[factor_4] np.random.randn(n_samples) # 独立噪声 # ... 继续构造更多特征 # 生成目标变量与factor_1和factor_3存在非线性关系 noise np.random.randn(n_samples) * 0.5 target 0.5 * data[factor_1] ** 2 0.3 * data[factor_3] noise df pd.DataFrame(data) df[target] target这里我特意加入了非线性关系因为这样更能体现RFE的意义——如果所有关系都是线性的普通相关性分析和线性回归就够了。4.2 完整Pipeline相关性去重 RFE下面这段代码是我实际项目里精简出来的核心流程。第一步计算相关性矩阵并对高度相关特征聚类去重第二步用RFECV做递归特征消除。from scipy.cluster.hierarchy import linkage, fcluster from scipy.spatial.distance import squareform import numpy as np import pandas as pd def correlation_dedup(df, threshold0.85): 相关性去重将相关性超过阈值的特征聚为一族从每族保留一个代表性特征。 corr df.corr().abs() # 距离矩阵 1 - |corr|用于层次聚类 dist 1 - corr dist dist.where(~np.eye(len(dist), dtypebool), 0) # 使用scipy做层次聚类 linkage_matrix linkage(squareform(dist), methodaverage) clusters fcluster(linkage_matrix, t1 - threshold, criteriondistance) # 每个聚类的代表特征与类内其他特征平均相关性最高的 keep_cols [] for cluster_id in np.unique(clusters): cols_in_cluster df.columns[clusters cluster_id] if len(cols_in_cluster) 1: keep_cols.append(cols_in_cluster[0]) else: # 计算类内平均相关性 sub_corr corr.loc[cols_in_cluster, cols_in_cluster] mean_corr sub_corr.mean(axis1) keep_cols.append(mean_corr.idxmax()) return sorted(keep_cols) dedup_cols correlation_dedup(df.drop(columns[target]), threshold0.85) print(f原始特征数量: {df.shape[1] - 1}, 去重后特征数量: {len(dedup_cols)})第二步使用RFECV选择特征。这里我用了自定义scorer计算预测值与真实值的Spearman相关系数秩相关IC这更贴近量化场景from sklearn.ensemble import RandomForestRegressor from sklearn.feature_selection import RFECV from sklearn.model_selection import KFold from scipy.stats import spearmanr X df[dedup_cols] y df[target] def rank_ic_scorer(estimator, X_val, y_val): pred estimator.predict(X_val) if np.std(pred) 0: return 0.0 return spearmanr(y_val, pred).statistic rf RandomForestRegressor(n_estimators200, random_state42, n_jobs-1) cv KFold(n_splits5, shuffleTrue, random_state42) rfecv RFECV( estimatorrf, step2, cvcv, scoringrank_ic_scorer, n_jobs-1, ) rfecv.fit(X, y) print(f最优特征数量: {rfecv.n_features_}) print(f被保留的特征: {X.columns[rfecv.support_].tolist()})运行之后你会发现去重阶段已经干掉了几个高相关特征RFECV又进一步淘汰了预测力弱的特征。最终留下的特征往往是彼此独立且对目标有贡献的子集。4.3 效果对比去重前后的模型表现只看选择结果还不够最好做个量化对比。我在同样一组数据上比较三种做法不筛选特征、只用相关性去重、相关性去重RFE。评价指标用五折交叉验证的IC均值。from sklearn.model_selection import cross_val_score # 不筛选使用所有特征 all_cols df.drop(columns[target]).columns.tolist() scores_all cross_val_score(rf, df[all_cols], y, cvcv, scoringrank_ic_scorer) # 只用相关性去重 scores_dedup cross_val_score(rf, X, y, cvcv, scoringrank_ic_scorer) # 相关性去重 RFE X_final X.iloc[:, rfecv.support_] scores_rfe cross_val_score(rf, X_final, y, cvcv, scoringrank_ic_scorer) print(f全特征 IC均值: {np.mean(scores_all):.4f}) print(f相关性去重 IC均值: {np.mean(scores_dedup):.4f}) print(f去重RFE IC均值: {np.mean(scores_rfe):.4f})在我这个示例里全特征的IC均值大约0.23去重后大约0.26去重RFE大约0.30。差异不算特别夸张但放到真实因子池里几十个噪声因子会明显拉低IC。更关键的是保留的特征数量从20个掉到了5~6个模型的过拟合风险大幅下降回测曲线也会平滑很多。5. 常见问题与排查技巧实录5.1 特征量纲差异导致的相关性误判皮尔逊相关对量纲不敏感但如果你对特征做了某些预处理比如把其中一个特征标准化、另一个没处理相关系数并不会受影响。真正容易出问题的是你用了带权重的距离或者在做聚类前直接对原始值矩阵做距离计算。建议在聚类去重时始终基于相关系数转换后的距离矩阵而不是原始值距离避免量纲影响。5.2 RFE在特征数量多于样本量时的坑金融数据经常遇到特征多、样本少的情况尤其是指标因子有几百个而有效独立样本只有几百个时。RFE的底层模型如果不够稳健很容易过拟合——第一轮模型在训练集上的表现很好特征重要性全是噪声。此时哪怕做了交叉验证也容易选出不稳定的特征子集。我的做法是先用PCA或者基于相关性的去重把特征数量压缩到样本量的1/10以内再跑RFECV。另外尽量使用带正则化的线性模型比如Lasso或者限制树深度的树模型避免模型太强把噪声记下来。5.3 训练集与测试集泄漏的隐蔽问题这是量化特征选择里最需要警惕的问题。如果你在特征选择阶段使用了全量数据包括未来时间段去计算特征相关性、RFE的重要性或者你用了全量数据的统计信息做标准化这就产生了信息泄漏。特征选择也属于训练过程必须在训练集上完成然后把这些选好的特征列表和对应变换直接应用到验证集和测试集。我通常的做法是先把数据按时间分为训练段和验证段只在训练段上做相关性去重和RFECV选出的特征固定下来然后用于验证段评估。交叉验证时每一步的fold都要在训练折内重新做特征选择而不是提前在整份数据上选好特征再进行CV。后者看着方便但得到的分数会虚高实盘会打脸。5.4 一个小技巧用互信息做去重前哨相关性去重基于线性关系互信息能捕捉非线性关系。我建议在跑相关性去重后多输出一份特征与目标互信息排名看看。有时候两个特征线性相关不高但互信息很差这种特征大概率没有增量信息可以在进入RFE之前就删掉进一步降低RFE的计算压力。Python里可以用sklearn.feature_selection.mutual_info_regression。注意连续特征需要设置random_state并且在样本量不大时互信息估计会有较大噪声建议把排名当作参考不要当作硬性规则。from sklearn.feature_selection import mutual_info_regression mi_scores mutual_info_regression(X, y, random_state42) mi_series pd.Series(mi_scores, indexX.columns).sort_values(ascendingFalse) print(mi_series.head(10))最后再分享一个我自己踩过坑之后养成的习惯每次做完特征选择我都会把最终保留的特征列表连同选择时间、当时的参数阈值、模型分数一起存成一份JSON配置。这样过一个月回来看还能知道当初为什么留下这几个特征。尤其是在实盘迭代里特征集合经常变动有这样一个版本化记录能省很多解释成本。如果你也在做量化特征筛选建议你从相关性去重 RFE这个组合先跑起来特征数量压下来了后面的模型开发和上线链路会轻松很多。