
做机器学习项目最烦的事情之一就是特征一多模型就跑不动跑得动的时候精度还往下掉。尤其是那种动辄几百上千列的数据表里面真正有用的可能就那二三十个。这几年我在实际项目里反复验证过一件事把特征选择做扎实往往比堆模型更提效果。这篇文章想跟你好好聊聊机器学习中常用的特征选择方法重点是用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_size0.2, random_state42, stratifyy ) scaler StandardScaler() X_train_scaled scaler.fit_transform(X_train) X_test_scaled scaler.transform(X_test)注意一点这里必须是先切分、再做标准化和特征选择。如果先把全量数据标准化再切分测试集信息会在训练阶段泄露后面评估出来的准确率会虚高这是个很隐蔽的坑。先看方差阈值。它的逻辑非常简单如果一个特征在所有样本上的取值几乎不变那它根本没有区分能力删掉不亏。核心代码只有一行selector_vt VarianceThreshold(threshold0.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, k10) 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, k10, random_state42) 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(penaltyl1, solverliblinear, C0.5, random_state42) 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_estimators200, random_state42) 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还是取1S(v) 1 / (1 exp(-v))if random() S(v): x 1 else: x 0注意这里的细节速度v本身仍然可以取任意实数只是不能直接加到位置上而是折算成概率。v越大取1的概率越接近1v越小取1的概率越接近0。这种设计保留了粒子在连续空间里飞行的全部优点同时让最终解始终是合法的0/1组合。另外要控制v的大小如果v太大会让sigmoid进入饱和区概率值几乎变成0或者1粒子就失去了随机探索的能力。我的经验是把v限制在[-4, 4]之间比较稳。3.3 适应度函数怎么定才不翻车BPSO里最关键、也最容易被忽视的就是适应度函数。它直接定义了“什么样的特征子集是好的”。最简单的做法是拿分类准确率当适应度每个粒子表示一个特征子集用这个子集训练模型交叉验证得到准确率就是该粒子的适应度。但只用准确率有一个问题模型可能会钻空子为了多提高一点点准确率而保留很多冗余特征这跟特征选择的目标背道而驰。所以我在适应度函数里加了一个特征数量惩罚项fitness accuracy - alpha * n_selected / n_totaln_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_particles20, max_iter30, w0.7, c11.5, c21.5, alpha0.01, random_state42): 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_splits3, shuffleTrue, random_stateself.random_state) scores cross_val_score(estimator, X_selected, y, cvcv, scoringaccuracy) 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(fIteration {it 1}/{self.max_iter}, fBest Fitness{self.gbest_score:.4f}, fSelected 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(kernelrbf, C1.0, random_state42) selector_bpso BPSOFeatureSelector( n_particles20, max_iter30, w0.7, c11.5, c21.5, alpha0.01 ) selector_bpso.fit(X_train_scaled, y_train, estimatorsvm) 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(fBPSOSVM测试集准确率: {acc_bpso:.4f})在同一套评估流程下常见的实验结果大概是这样的全特征 SVM30个特征测试集准确率约0.974互信息Top10 SVM10个特征测试集准确率约0.956L1筛选 SVM约10-15个特征测试集准确率约0.965BPSO 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调得太大把适应度函数放宽一点再跑一次。特征选择是个反复迭代的活第一次跑出来的结果很少是终版多跑几轮你会越来越了解这份数据里哪些特征是真正有用的。