机器学习的模型训练中,特征 不是越多越好,而是要找到那个 "刚刚好"的子集。
今天介绍的方法 "简单粗暴" ,通过递归的方式,不断淘汰一个个特征,直到找到那个最优的 "特征子集"。
核心思想
核心思想用一句话来说,就是用"淘汰赛"选出最强特征组合。
递归消除的思路特别像公司搞末位淘汰:
plain
1. 把所有"员工"(特征)都拉进来干活
2. 让"经理"(模型)给大家打个绩效(特征重要性)
3. 干掉绩效最差的那位
4. 剩下的人继续干活,重新打绩效
5. 重复,直到剩下的人"够用了"为止
但这里有个关键问题:怎么判断"够了"?
答案是:每一步都做一次交叉验证,记录模型得分。最后看哪一步得分最高,那个特征数量就是最优的。
画个图就是这种感觉:
plain
特征数: 10 9 8 7 6 5 4 3 2 1
得分: 0.82 0.84 0.87 0.89 0.91 0.93 0.92 0.90 0.86 0.78
↑
最优子集
看到没?随着特征减少,得分先升后降------去掉噪音特征时得分上升,去掉有用特征时得分下降。
我们要找的就是那个拐点。
设计思路
代码的整体设计上主要分 4 个部分:
初始化参数
python
def __init__(self, base_model, remove_count, fold_num, metric, seed, ...):
| 参数 | 含义 | 类比 |
|---|---|---|
base_model |
用来评估特征重要性的模型 | "裁判" --- 由它来判断哪些特征有用 |
remove_count |
每轮淘汰几个特征 | 每轮换下几名球员 |
fold_num |
交叉验证折数 | 评估时多试几轮,避免偶然性 |
metric |
评估指标(准确率等) | 用什么标准衡量"踢得好不好" |
early_stop_rounds |
连续几轮变差就提前停止 | 连输几场就不折腾了 |
核心流程 --- run()
这是整个算法的主循环,对应下面这张流程图:
plain
全部 30 个特征
│
▼
┌──────────────────────────────────┐
│ 第 1 轮:30 个特征 │
│ ① 交叉验证 → 得分 0.85 │ ← 记录下来
│ ② 训练模型 → 获取特征重要性 │
│ ③ 移除最差的 1 个特征 │
└──────────────────────────────────┘
│
▼
┌──────────────────────────────────┐
│ 第 2 轮:29 个特征 │
│ ① 交叉验证 → 得分 0.86 │ ← 更好了!
│ ② 训练模型 → 获取特征重要性 │
│ ③ 移除最差的 1 个特征 │
└──────────────────────────────────┘
│
▼
... 重复直到只剩 1 个特征 ...
│
▼
回顾所有轮次,选得分最高的那轮 → 最优特征子集
关键代码对应关系:
python
# 第 3a 步:用当前特征做交叉验证(评估这套阵容的成绩)
eval_result = self._evaluate_subset(model, subset_df, labels)
# 第 3d 步:训练模型,获取特征重要性(让裁判打分)
model.fit(subset_df, labels)
importance_array = self._extract_importance(model, active_features)
# 第 3e 步:移除贡献最低的特征(换下最差的球员)
weakest_indices = np.argsort(importance_array)[:actual_remove]
active_features = [f for f in active_features if f not in weakest_names]
两个重要的内部方法
_evaluate_subset()--- "评委打分"
python
scores = cross_validate(clone(model), X_sub, y, cv=splitter, scoring=self.metric)
用 交叉验证 评估当前特征子集的质量。
为什么要交叉验证而不是直接训练?因为交叉验证把数据分成多份轮流测试,结果更可靠,不容易过拟合。
_extract_importance()--- "判断谁最弱"
python
if hasattr(model, "feature_importances_"): # 树模型(如随机森林)
raw = model.feature_importances_
elif hasattr(model, "coef_"): # 线性模型(如逻辑回归)
raw = np.abs(model.coef_).ravel()
不同模型衡量特征重要性的方式不同:
- 树模型 :自带
feature_importances_,直接告诉你每个特征贡献了多少 - 线性模型 :看系数
coef_的绝对值,系数越大说明这个特征越重要
结果输出
select():用最优特征子集裁剪数据summary_table():输出每轮迭代的得分汇总表plot_curve():画出"特征数量 vs 得分"的曲线图,直观看出哪个特征数量最优
关键细节
注意 _locate_optimum() 方法:
python
def _locate_optimum(self):
best_record = max(self._tracking_log, key=lambda r: r["mean_score"])
它不是选"最后剩下的特征",而是选整个过程中交叉验证得分最高的那一轮的特征。
这意味着:
有可能 10 个特征时得分最高,继续消除反而变差。算法会自动停在"甜蜜点"。
代码实现
完整的代码和模拟数据的测试用例(心脏病风险预测)共享在:
url11.ctfile.com/d/45455611-... (访问密码: 6872)
相关文件是:recursive_feature_eliminator.py 和 test_recursive_feature_eliminator.py。
感兴趣的朋友欢迎下载了自己运行试试看,运行过程中有什么问题也欢迎交流。
测试代码运行效果如下:
plain
----------------------------------------------------------------------
[特征消除] 初始特征数: 30,每轮移除: 1,交叉验证折数: 5,评估指标: accuracy
-----------------------------------------------------------------
特征数 30 | 得分 0.8625 ± 0.0230
特征数 29 | 得分 0.8607 ± 0.0385
特征数 28 | 得分 0.8464 ± 0.0207
特征数 27 | 得分 0.8643 ± 0.0164
特征数 26 | 得分 0.8643 ± 0.0311
特征数 25 | 得分 0.8500 ± 0.0372
特征数 24 | 得分 0.8625 ± 0.0332
特征数 23 | 得分 0.8589 ± 0.0273
特征数 22 | 得分 0.8643 ± 0.0331
特征数 21 | 得分 0.8768 ± 0.0279
特征数 20 | 得分 0.8536 ± 0.0332
特征数 19 | 得分 0.8786 ± 0.0166
特征数 18 | 得分 0.8768 ± 0.0118
特征数 17 | 得分 0.8804 ± 0.0256
特征数 16 | 得分 0.8875 ± 0.0471
特征数 15 | 得分 0.8750 ± 0.0246
特征数 14 | 得分 0.8821 ± 0.0273
特征数 13 | 得分 0.8804 ± 0.0312
特征数 12 | 得分 0.8875 ± 0.0373
特征数 11 | 得分 0.8786 ± 0.0443
特征数 10 | 得分 0.8786 ± 0.0317
特征数 9 | 得分 0.8946 ± 0.0236
特征数 8 | 得分 0.8857 ± 0.0345
特征数 7 | 得分 0.8982 ± 0.0360
特征数 6 | 得分 0.9000 ± 0.0182
特征数 5 | 得分 0.8893 ± 0.0337
特征数 4 | 得分 0.7982 ± 0.0230
特征数 3 | 得分 0.7821 ± 0.0268
特征数 2 | 得分 0.7089 ± 0.0184
-----------------------------------------------------------------
[特征消除] 完成!耗时 32.1s
最优特征数: 6
最优得分: 0.9000
选中特征: ['年龄', '收缩压', '胆固醇', '血糖', '运动频率', '身高']
筛选出的特征: ['年龄', '收缩压', '胆固醇', '血糖', '运动频率', '身高']
测试集准确率: 0.9125
分类报告:
precision recall f1-score support
低风险 0.92 0.90 0.91 120
高风险 0.90 0.93 0.91 120
accuracy 0.91 240
macro avg 0.91 0.91 0.91 240
weighted avg 0.91 0.91 0.91 240
======================================================================
对比总结
======================================================================
方案 A(全部 30 项特征)准确率: 0.8750
方案 B(筛选后 6 项特征)准确率: 0.9125
准确率变化: +0.0375
特征数量减少: 30 -> 6
======================================================================
注意事项
每轮不重新训练模型
一开始我图省事,只在第一轮训练一次模型,后面全用同一个模型的重要性来删特征。
问题:特征之间的交互效应被忽略了。A和B单独看都不重要,但组合起来可能很厉害。你第一轮就把A删了,后面B也跟着"失宠"。
正解 :每删一批特征,必须重新训练模型,重新计算重要性。
忘了做交叉验证
直接用训练集上的得分来评判。
问题:过拟合到训练集上了,选出来的"最优子集"在测试集上表现很差。
正解 :一定要用交叉验证的得分来做判断,这样选出来的子集泛化能力更强。
没 clone 模型
交叉验证的时候,直接传了同一个模型实例进去。
问题 :cross_validate 内部会 fit 模型,如果不 clone,每折之间会互相污染。
正解 :用 sklearn.base.clone() 确保每次评估都是一个全新的模型。
特征太多的时候太慢
有次我拿了200多个特征去跑,remove_count=1,跑了大半天......
正解:
- 特征多时先把
remove_count调大,比如remove_count=5 - 或者先用过滤法(比如相关系数阈值)粗筛一轮,再用 RFE 精筛
- 打开
n_jobs=-1,让交叉验证多核并行
分类不平衡时用了 accuracy
有个二分类数据正样本只占 5%,我用 accuracy 做评估。
问题:模型全预测负类就有 95% 的 accuracy,但毫无意义。
正解 :不平衡数据一定要换 f1、roc_auc 或者自定义的评分函数。
总结
RFE(recursive featureeliminator 递归消除法) 核心就三句话:
- 特征在精不在多------去掉噪声特征,模型反而更好
RFE是"用模型选特征"------让模型自己告诉我们谁重要谁不重要- 交叉验证是裁判------不能光看训练集,要用 CV 得分来判断子集好坏
同时,我们也要清醒认识到 RFE 的局限:
- 贪心策略:每次只删当前最弱的,不保证全局最优
- 计算开销大:特征数 × 交叉验证折数 = 要训练的模型总数
- 依赖基模型:基模型的特征重要性计算方式直接影响结果。换不同的基模型,可能选出不同的子集