做机器学习项目最烦的事情之一,就是特征一多模型就跑不动,跑得动的时候精度还往下掉。尤其是那种动辄几百上千列的数据表,里面真正有用的可能就那二三十个。这几年我在实际项目里反复验证过一件事:把特征选择做扎实,往往比堆模型更提效果。这篇文章想跟你好好聊聊机器学习中常用的特征选择方法,重点是用Python怎么落地,覆盖过滤式和嵌入式两类基础手段,再把很多人感兴趣但不太会写的PSO粒子群优化特征选择完整实例拆开揉碎。内容偏实战,代码可以直接改改跑起来,适合刚开始做特征工程的初学者,也适合遇到维度爆炸问题想找新思路的从业者。
1. 先搞清楚特征选择在解决什么问题
1.1 特征太多为什么反而坏事
很多人刚入门时有个误区,觉得特征越多,模型能用的信息就越多,效果一定越好。实际情况恰恰相反。我在试过几十个数据集之后,对“维度灾难”这四个字体会特别深:特征维度上去之后,样本在空间里的分布会变得极其稀疏,基于距离的算法(比如KNN、SVM里的核函数)几乎都会失效。你可以想象一下:在一张白纸上撒10个点,点与点之间的距离还看得出来远近;如果把这个空间扩展到100维,每个点之间的距离都差不多远,最近邻和最远邻的区别就没意义了,分类边界自然就乱了。
更现实的问题是训练开销。特征多,模型要学的参数就多,训练时间呈指数级增长。树模型虽然对维度相对鲁棒,但一旦特征里有大量无关列,分裂点搜索会变得又慢又容易过拟合。还有一类问题是可解释性,业务方问你这个模型为什么判断这个客户是高风险,你对着300个特征根本说不清楚。特征选择要解决的就是这么几个问题:去掉无关特征、去掉冗余特征、降低维度、提升泛化能力,顺带把模型训练速度和可解释性提上来。
1.2 三大类特征选择方法怎么选
业内一般把特征选择分成三类:过滤式、包裹式、嵌入式。过滤式方法不依赖任何模型,先对特征算统计指标再排序筛选,典型的有方差阈值、卡方检验、互信息、相关系数。包裹式方法把模型性能当成目标函数,直接去搜索最优特征子集,典型代表是递归特征消除(RFE),以及今天要重点讲的PSO。嵌入式方法把特征选择和模型训练融合在一起,训练完模型自然就知道哪些特征有用,典型代表是L1正则化和树模型的特征重要性。
三者的核心差异我用一张表来对比:
- 过滤式:计算开销低,不依赖模型,效果上限中等,适合特征数量极大的初筛阶段
- 包裹式:计算开销高,强依赖模型,效果上限高,适合特征量中等、追求精度的场景
- 嵌入式:计算开销中等,依赖特定模型,效果上限较高,适合有明确模型假设的场景
实战里我通常这样组合:先用过滤式做一次粗筛,把明显没用的特征踢掉,把维度降低到可控范围;然后用嵌入式或者包裹式做细筛。直接一上来就跑PSO,特征几百个的话时间成本很吓人。前一阵我在一个客户画像项目里就是这么干的,原始特征400多维,先按互信息和方差筛到60维,再用BPSO筛到18维,模型AUC反而从0.87涨到了0.91,这就是特征选择的典型收益。
2. 快速上手:用Python实现过滤式和嵌入式特征筛选
2.1 过滤式:方差阈值、卡方检验、互信息
先统一准备数据集和训练测试切分,后面所有代码都在这套基础上跑。我用的是sklearn自带的乳腺癌数据集,30个特征、569个样本,既不会太大导致演示耗时,又能清楚体现特征选择的作用。
加载数据、划分训练集和测试集的代码如下:
import numpy as np import pandas as pd from sklearn.datasets import load_breast_cancer from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler, MinMaxScaler from sklearn.feature_selection import VarianceThreshold, SelectKBest, chi2, mutual_info_classif from sklearn.linear_model import LogisticRegression from sklearn.ensemble import RandomForestClassifier from sklearn.svm import SVC from sklearn.model_selection import cross_val_score, StratifiedKFold from sklearn.metrics import accuracy_score data = load_breast_cancer() X, y = data.data, data.target X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42, stratify=y ) scaler = StandardScaler() X_train_scaled = scaler.fit_transform(X_train) X_test_scaled = scaler.transform(X_test)注意一点:这里必须是先切分、再做标准化和特征选择。如果先把全量数据标准化再切分,测试集信息会在训练阶段泄露,后面评估出来的准确率会虚高,这是个很隐蔽的坑。
先看方差阈值。它的逻辑非常简单:如果一个特征在所有样本上的取值几乎不变,那它根本没有区分能力,删掉不亏。核心代码只有一行:
selector_vt = VarianceThreshold(threshold=0.01) X_train_vt = selector_vt.fit_transform(X_train_scaled) X_test_vt = selector_vt.transform(X_test_scaled) print("原始特征数:", X_train_scaled.shape[1]) print("方差阈值筛选后特征数:", X_train_vt.shape[1])threshold要结合特征本身的量纲选,如果之前做过标准化,特征方差都比较接近,threshold设太大会误删,设太小又没效果,一般从0.01试起,观察删除了哪些特征再调整。
再来看卡方检验。卡方检验检验的是特征与标签之间的独立性,统计量越大说明越不独立,也就越有用。需要注意,卡方检验要求输入特征是非负的,而标准化后的数据会有负值,所以需要用MinMaxScaler把特征归一化到非负区间:
mm = MinMaxScaler() X_train_mm = mm.fit_transform(X_train) X_test_mm = mm.transform(X_test) selector_chi2 = SelectKBest(chi2, k=10) X_train_chi2 = selector_chi2.fit_transform(X_train_mm, y_train) X_test_chi2 = selector_chi2.transform(X_test_mm)选10个还是选15个,这个k需要结合特征总量和业务场景去试。卡方检验对线性关系敏感,但如果特征和标签的关系是非线性的,它的效果就会打折扣。这时候可以换互信息方法,互信息可以捕捉变量之间的非线性关联,不需要特征非负,适用范围更广:
selector_mi = SelectKBest(mutual_info_classif, k=10, random_state=42) X_train_mi = selector_mi.fit_transform(X_train_scaled, y_train) X_test_mi = selector_mi.transform(X_test_scaled)sklearn里的互信息估计器本身带随机性,所以记得传random_state,否则结果每次可能不一样。
2.2 嵌入式:L1正则化与随机森林重要性
嵌入式方法我平时用得最多的是两个:L1正则化和树模型重要性。
L1正则化之所以能做特征选择,是因为它在损失函数后面加了一项参数绝对值之和。优化这个目标函数时,决策边界会比较“尖锐”,容易把某些特征的系数直接压成0。相比之下,L2正则只是把系数缩小但不归零。用逻辑回归加L1惩罚,训练完之后,系数不为0的特征就是留下来的特征:
l1_logit = LogisticRegression(penalty='l1', solver='liblinear', C=0.5, random_state=42) l1_logit.fit(X_train_scaled, y_train) selected_mask = np.abs(l1_logit.coef_[0]) > 1e-6 selected_idx_l1 = np.where(selected_mask)[0] print("L1选择出的特征数:", len(selected_idx_l1)) print("特征名称:", data.feature_names[selected_idx_l1])C是正则强度的倒数,C越小惩罚越重,被删掉的特征越多。实际项目中需要跑一组C值,看不同C下保留的特征数量以及对应的交叉验证指标,选一个平衡点。如果两个强相关特征同时存在,L1有可能随机保留其中一个,这是L1的一个特性,不算bug,但你要知道。
随机森林的特征重要性则完全是另一种思路:训练一棵多棵树,统计每个特征在所有树中分裂时带来的杂质的减少量,归一化后就是feature_importances_。直接取前k个即可:
rf = RandomForestClassifier(n_estimators=200, random_state=42) rf.fit(X_train_scaled, y_train) importance = rf.feature_importances_ top_idx_rf = np.argsort(importance)[::-1][:10] print("随机森林特征重要性Top10:", data.feature_names[top_idx_rf])随机森林的树数量要足够多,特征重要性才稳定,我一般至少设200棵。还要注意,如果有两个高度相关的特征,重要性会在这两个特征之间分摊,导致单个特征的重要性被低估,这属于树模型的常见局限。
2.3 这些基础方法各自的短板在哪
用这些方法踩了几次坑之后,我总结出了它们的共同问题:过滤式方法完全脱离模型,只看特征和标签之间的单变量关系,识别不了“单个特征一般但组合起来很能打”的情况;嵌入式方法虽然结合了模型,但强烈依赖模型假设,比如线性模型选出来的特征对树模型未必是最好的,而且L1处理共线性特征时会有随机性;包裹式里的RFE是贪心策略,每次删掉最不重要的一个特征再重新训,容易掉进局部最优的坑里。
正是这些局限,让我开始关注启发式搜索方法,尤其是PSO。思路很简单:干脆把“选哪些特征”当成一个组合优化问题,用粒子群在特征子集空间里全局搜索,用模型效果当评分标准。这样既不用假设特征和标签之间是什么关系,又能跳出贪心算法的局部最优陷阱。
3. 核心重点:PSO粒子群优化做特征选择的设计思路
3.1 粒子群算法讲人话版
粒子群优化(PSO)是Kennedy和Eberhart在1995年提出的一种群体智能算法,灵感来自鸟群觅食。我把这些鸟想象成一个探险队,食物(最优解)藏在某个未知位置,每只鸟不知道食物在哪,但能评估自己当前所在位置离食物有多远(适应度值),还能知道队里目前谁离食物最近。于是每只鸟决策下一步往哪飞时,会综合三方面信息:自己当前的速度惯性、自己历史上到过的最好位置(个体最优pbest)、整个队伍里最好那只鸟的位置(全局最优gbest)。
对应到PSO的数学表达,每个粒子有位置和速度两个属性。位置代表当前解,速度代表下一步变化的幅度和方向。每次迭代更新速度,再用新速度更新位置:
v = w * v + c1 * r1 * (pbest - x) + c2 * r2 * (gbest - x)
x = x + v
其中w是惯性权重,控制粒子保持原来飞行方向的程度;c1和c2是加速常数,r1和r2是[0,1]之间的随机数。w越大,粒子越倾向于全局探索;c1越大,粒子越相信自己的历史经验;c2越大,粒子越倾向于向群体最优靠近。这三项一起决定了粒子的飞行轨迹。
3.2 从连续优化到二进制:BPSO的关键设计
特征选择是一个典型的组合优化问题,粒子的位置应该是一组0/1向量:第i个位置等于1表示选择第i个特征,等于0表示不选。连续空间里的位置更新公式x = x + v直接套过来不适用,因为x算出来可能变成1.8、-0.3这种没意义的数。
标准做法是Binary PSO,也就是BPSO。它的思路是把速度v先通过sigmoid函数映射成0到1之间的概率值,表示这个位置取1的概率,然后用随机数跟概率比较决定最终取0还是取1:
S(v) = 1 / (1 + exp(-v))
if random() < S(v): x = 1 else: x = 0
注意这里的细节:速度v本身仍然可以取任意实数,只是不能直接加到位置上,而是折算成概率。v越大,取1的概率越接近1;v越小,取1的概率越接近0。这种设计保留了粒子在连续空间里飞行的全部优点,同时让最终解始终是合法的0/1组合。另外要控制v的大小,如果v太大会让sigmoid进入饱和区,概率值几乎变成0或者1,粒子就失去了随机探索的能力。我的经验是把v限制在[-4, 4]之间比较稳。
3.3 适应度函数怎么定才不翻车
BPSO里最关键、也最容易被忽视的就是适应度函数。它直接定义了“什么样的特征子集是好的”。最简单的做法是拿分类准确率当适应度,每个粒子表示一个特征子集,用这个子集训练模型,交叉验证得到准确率,就是该粒子的适应度。
但只用准确率有一个问题:模型可能会钻空子,为了多提高一点点准确率而保留很多冗余特征,这跟特征选择的目标背道而驰。所以我在适应度函数里加了一个特征数量惩罚项:
fitness = accuracy - alpha * n_selected / n_total
n_selected是当前粒子选中的特征数量,n_total是全部特征数量,alpha是惩罚系数。alpha越大,粒子越倾向于少选特征;alpha越小,粒子越倾向于追求准确率。实际项目中alpha一般从0.01开始试,如果发现选择出来的特征数还是太多,就加大;如果发现模型精度掉得厉害,就减小。这个参数对最终结果影响很大,值得花时间单独调。
交叉验证折数我一般选3折或5折。折数越多,适应度评估越稳定,但计算量也成倍上涨。PSO每轮迭代要跑几十个粒子的适应度,每个适应度都是一次交叉验证,如果数据集稍大再加上折数太多,训练时间完全失控。
4. 完整实例:BPSO特征选择代码逐段拆解
4.1 数据集与评估方案
我用前面准备的乳腺癌数据集来跑完整实例。数据是569个样本、30个特征,二分类,样本量适中,很适合做特征选择演示。评估方案很简单:所有方法统一在训练集上做特征选择,再用SVM分类器在同一个测试集上评估,最后比较准确率和选出的特征数量。为什么要统一分类器?因为不同特征选择方法选出来的特征集合不同,如果连分类器都不一样,结果没有可比性。
分类器我选SVM,带RBF核。SVM对特征冗余和维度都比较敏感,特征选择前后的效果差异会体现得很明显。
4.2 BPSO核心代码实现
下面这段代码是我在项目里用的一套BPSO实现,不依赖额外的优化库,纯numpy实现,方便理解原理也方便改造成自己的版本。
class BPSOFeatureSelector: def __init__(self, n_particles=20, max_iter=30, w=0.7, c1=1.5, c2=1.5, alpha=0.01, random_state=42): self.n_particles = n_particles self.max_iter = max_iter self.w = w self.c1 = c1 self.c2 = c2 self.alpha = alpha self.random_state = random_state self.gbest_score = -np.inf self.gbest_position = None def _fitness(self, position, X, y, estimator): if position.sum() == 0: return 0.0 selected_idx = np.where(position == 1)[0] X_selected = X[:, selected_idx] cv = StratifiedKFold(n_splits=3, shuffle=True, random_state=self.random_state) scores = cross_val_score(estimator, X_selected, y, cv=cv, scoring='accuracy') mean_acc = scores.mean() penalty = self.alpha * position.sum() / len(position) return mean_acc - penalty def fit(self, X, y, estimator): n_features = X.shape[1] rng = np.random.RandomState(self.random_state) positions = rng.randint(0, 2, size=(self.n_particles, n_features)).astype(float) velocities = rng.uniform(-0.5, 0.5, size=(self.n_particles, n_features)) pbest_positions = positions.copy() pbest_scores = np.array([self._fitness(p, X, y, estimator) for p in positions]) gbest_idx = np.argmax(pbest_scores) self.gbest_position = pbest_positions[gbest_idx].copy() self.gbest_score = pbest_scores[gbest_idx] for it in range(self.max_iter): for i in range(self.n_particles): r1 = rng.random(n_features) r2 = rng.random(n_features) velocities[i] = (self.w * velocities[i] + self.c1 * r1 * (pbest_positions[i] - positions[i]) + self.c2 * r2 * (self.gbest_position - positions[i])) velocities[i] = np.clip(velocities[i], -4, 4) sig = 1.0 / (1.0 + np.exp(-velocities[i])) rand_vals = rng.random(n_features) positions[i] = (rand_vals < sig).astype(float) if positions[i].sum() == 0: positions[i][rng.randint(0, n_features)] = 1.0 for i in range(self.n_particles): fitness = self._fitness(positions[i], X, y, estimator) if fitness > pbest_scores[i]: pbest_scores[i] = fitness pbest_positions[i] = positions[i].copy() best_idx = np.argmax(pbest_scores) if pbest_scores[best_idx] > self.gbest_score: self.gbest_score = pbest_scores[best_idx] self.gbest_position = pbest_positions[best_idx].copy() if (it + 1) % 5 == 0: print(f"Iteration {it + 1}/{self.max_iter}, " f"Best Fitness={self.gbest_score:.4f}, " f"Selected Features={int(self.gbest_position.sum())}/{n_features}") return self @property def selected_features_(self): return np.where(self.gbest_position == 1)[0]代码里有几个关键设计我要单独说明一下。第一个是fit方法里每个粒子初始化时随机生成0/1向量,这一步模拟了“在特征子集空间里随机撒点”。第二个是速度更新之后用np.clip限制在[-4,4],目的是避免sigmoid饱和,这个前面已经讲过。第三个是一个保护逻辑:如果某个粒子的位置全为0,也就是一个特征都不选,就随机把一个维度置为1,否则这个粒子的适应度永远是0,既没有探索意义,还会干扰全局最优的判断。第四个是pbest和gbest的更新时机:先更新位置,再统一计算适应度,然后更新个体历史最优和全局最优,这个顺序要固定,否则可能用旧位置和旧适应度组合出错误的结果。
4.3 结果对比与解读
主流程代码和结果如下:
svm = SVC(kernel='rbf', C=1.0, random_state=42) selector_bpso = BPSOFeatureSelector( n_particles=20, max_iter=30, w=0.7, c1=1.5, c2=1.5, alpha=0.01 ) selector_bpso.fit(X_train_scaled, y_train, estimator=svm) selected_idx = selector_bpso.selected_features_ print("BPSO选中的特征索引:", selected_idx) print("BPSO选中的特征名称:", data.feature_names[selected_idx]) svm.fit(X_train_scaled[:, selected_idx], y_train) y_pred = svm.predict(X_test_scaled[:, selected_idx]) acc_bpso = accuracy_score(y_test, y_pred) print(f"BPSO+SVM测试集准确率: {acc_bpso:.4f}")在同一套评估流程下,常见的实验结果大概是这样的:
- 全特征 + SVM,30个特征,测试集准确率约0.974
- 互信息Top10 + SVM,10个特征,测试集准确率约0.956
- L1筛选 + SVM,约10-15个特征,测试集准确率约0.965
- BPSO + SVM,约7-10个特征,测试集准确率约0.974
也就是说,PSO用不到三分之一的特征量,拿到了和全特征持平甚至略高的准确率。原因也不难解释:30个特征里本身有不少冗余和噪声,全量特征训练SVM时,这些噪声维度会干扰核函数计算出的样本距离;BPSO把这些噪声特征剔除之后,SVM在低维空间里更容易找到稳定的分类边界,交叉验证的稳定性也更好。
迭代过程中适应度通常呈现出“前期快速上升、后期趋于平稳”的曲线,前10次迭代基本能找到接近最优的特征组合,后面20次是在做精细打磨。如果你的项目里迭代到一半适应度就长期不涨了,可以考虑提前终止,能省下不少时间。
5. 实操中的坑与调参经验
5.1 信息泄露:特征选择必须放在训练集内做
这是我在真实项目里踩过最深的坑,没有之一。很多初学者会先把全部数据标准化、算方差、算卡方统计量,然后切分训练测试集。问题在于,当你用全量数据计算方差阈值或卡方统计量的时候,测试集的分布信息已经参与了“哪些特征该留哪些该删”的决策,等于模型在训练阶段已经偷看过测试集的答案了。这样测出来的准确率会虚高,但模型上了线、遇到新数据立刻现出原形。
正确做法是:先切分训练测试集,然后在训练集上做标准化和特征选择,测试集只用训练集拟合好的scaler和selector做transform。我在第2章给的代码就是这么写的,这套流程建议固化下来,任何项目都按这个顺序走,能避免一大类评估失真问题。
5.2 PSO收敛与稳定性的调参经验
BPSO本身带有随机性,同样一组参数跑两次,选出来的特征可能不同,这个问题在特征数量比较小的时候尤其明显。如果只是自己复现实验,固定random_state就够了;如果是正式项目,我更推荐“多次运行取交集”的策略:把BPSO跑10次,统计每个特征被选中的频次,保留那些被选中次数超过半数的特征。这样能显著提升特征集合的稳定性,代价是计算时间翻了几倍,但换来的是结果可解释、可交付。
关于参数,我整理了一份经验值:
- 粒子数20到50,特征维度高就取上限
- 迭代次数30到50,超过50收益下降明显
- 惯性权重w固定0.7附近,或采用线性递减:w从0.9降到0.4,前期全局探索后期局部精调
- 加速常数c1和c2都在1.5到2之间
- 速度上限限制在[-4,4]
- 惩罚系数alpha从0.01开始调,观察特征数量和准确率的平衡
5.3 常见问题速查表
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
| 收敛到很差的结果 | 惩罚系数alpha太大,导致粒子不敢保留特征 | 调小alpha,观察selected count是否合理 |
| 每次运行结果差异很大 | 没固定random_state,粒子初始化随机 | 固定随机种子,或多次运行取高频特征 |
| 运行时间过长 | 交叉验证折数太多,粒子数太多,分类器太复杂 | 折数降为3折,粒子降到20,优先用线性模型做适应度评估 |
| 选出的特征依然高度相关 | alpha太小,冗余特征没有足够惩罚 | 增大alpha,或在PSO之前先做相关性去冗余 |
| PSO结果不如全特征 | SVM参数没有适配低维特征空间 | 先调好分类器参数,再跑PSO,不要用一辆没调过的车去测试路况 |
| 特征维度几百上千 | 直接在原始维度跑BPSO,搜索空间太大 | 先用过滤式粗筛到50维以内,再套BPSO |
这里特别说一句适应度函数里的分类器选择。实际项目中有人在适应度函数里用随机森林,有人用逻辑回归,都行,但要注意一致性。适应度评估用的分类器,最好和最终模型一致或者性能接近,否则PSO会朝着“对分类器A效果好”的方向搜索,最后在分类器B上评估时结果打折扣。
6. 特征选择技术通常用在哪些实际场景
这几类方法在我的真实项目里各有各的用武之地。过滤式方法最适合维度极高、资源有限的场景,比如文本分类的词袋特征,动辄几万维,这时候根本不可能直接上包裹式,先用卡方或互信息筛到几百维再说。嵌入式方法适合建模流程相对固定的场景,比如线模型或树模型已经是最终方案,直接在训练过程中做L1或重要性筛选,效率高、效果有保障。PSO这类包裹式搜索方法则适合特征量和算力都允许的场景,通常是几十到一两百维,特征之间的关系比较复杂,单变量筛选容易遗漏组合特征,这时候全局搜索的优势就体现出来了。
在生物信息领域,比如基因表达数据,经常是几万个基因、几百个样本,特征数量远远大于样本量。这种场景下直接用PSO原始维度搜索不现实,我的做法是先用方差和互信息粗筛到100维以内,再用BPSO精筛。金融风控里的客户画像特征也有类似问题,几百个衍生变量之间相关性高,业务方还要求模型可解释,特征少一点、逻辑清楚一点,比刷那0.001的AUC重要得多。工业异常检测里,传感器采集的高频特征往往有大量冗余,剔除之后不仅模型更稳,从传感器采集到特征计算的整个链路也省不少资源。
我个人的体会是,特征选择这件事,方法的优先级其实没有绝对答案。更多时候要看你手上有什么数据、有多少算力、模型最后给谁用。但不管场景怎么变,我建议每个做机器学习的人都把过滤式、嵌入式、包裹式这三套套路吃透,再重点掌握一两个能解决组合优化问题的方法,比如PSO或者遗传算法。工具箱全了,遇到特征爆炸才不慌。
最后再分享一个小技巧:BPSO跑完之后,先别急着把其他特征彻底丢掉。把选中的特征和全特征模型的结果都在测试集上对比一下,如果性能差别不大,就放心用精简集;如果掉点严重,也不要盲目加回特征,先看看是不是惩罚系数alpha调得太大,把适应度函数放宽一点再跑一次。特征选择是个反复迭代的活,第一次跑出来的结果很少是终版,多跑几轮,你会越来越了解这份数据里哪些特征是真正有用的。