做数据分析时最头疼的一种场景,就是数据里塞了几百上千个特征,真正有用的可能只有一小撮。这时候跑出来的模型,看起来训练集得分很高,一到验证集就崩。这类问题靠手筛特征不现实,于是基于遗传算法的特征选择出现了:它把特征子集当成一个个体,用自然选择过程去迭代,最终逼近最优特征集。这篇文章会讲清楚原理、给出能直接跑的Python代码,并把我在实践中踩过的坑一起整理出来,适合正在和特征工程较劲的建模工程师和数据科学初学者。
1. 高维数据下的特征选择,为什么偏偏选遗传算法
1.1 特征选择到底在解决什么问题
先回忆一下建模流程。很多时候我们拿到的数据不是精心设计过的实验数据,而是从多个业务系统里拼出来的宽表。比如某银行的风控项目,原始特征可能有几百个,其中包含客户收入、消费行为、历史逾期次数、申请渠道、设备信息等。这些特征里,有一批是强相关的,有一批是纯噪声,还有一批逻辑上重复。直接丢进模型,不仅训练时间翻倍,还可能让模型把噪声当成规律去拟合,最终在验证集上暴露问题。
特征选择的目标,就是从 p 个特征里挑出一个子集,使得模型在这个子集上的泛化能力最优。听起来简单,做起来难。因为候选特征子集的数量是 2 的 p 次方,p 哪怕只有 50,子集数量也超过 10 的 15 次方。穷举搜索根本不现实,所以特征选择本质上是一个组合优化问题,而且是一个 NP 难问题。
这类高维场景里,特征选择的价值不只是提升准确率,还包括降低训练开销、提升模型稳定性、减少部署特征依赖,以及让业务方更容易理解模型。很多时候模型上线后才发现,某些特征只在训练集上表现好,上线后数据口径一变,线上效果直接崩盘。提前做一轮特征选择,至少能把这种风险控制在一定范围内。
1.2 过滤式、包裹式、嵌入式为什么不够用
业内常规做法大致分三类。第一类是过滤式,比如卡方检验、互信息、相关系数。它把每个特征单独拿出来和标签算相关性,按得分排序取前 k 个。优势是速度快、适合超高维数据,缺点也很明显:完全忽略特征之间的交互。两个特征单独看和标签相关性都弱,组合在一起却可能非常强;反过来,两个强相关特征在一起只会互相干扰。过滤式处理不了这种组合效应。
第二类是包裹式,比如递归特征消除。它直接以模型性能为目标,逐步删掉最不重要的特征。效果通常比过滤式好,但是每一步都要重新训练模型,计算开销大,而且它是一个贪心过程,每一步都只做局部最优决策。一旦在某个节点删掉了一个“当时看起来没用、后面却和其他特征组合很有用”的特征,后面再也找不回来。
第三类是嵌入式,比如 L1 正则、树模型的特征重要性。它把特征选择揉进模型训练过程,效率高,但依赖模型假设。L1 正则面对高度相关特征时,往往只留下其中一个,把其他全压成 0;树模型的重要性打分又容易偏向数值型特征和高基数特征。而且嵌入式方法一般只给一个特征重要度排序,最终阈值怎么定,依然是个问题。
遗传算法做的特征选择,本质上属于包裹式的思路:用模型性能来评价一组特征,但搜索策略不是贪心,而是用自然选择过程去试探整个子集空间。它不要求特征之间独立,也不要求模型是可导的线性模型,只要你能给出一个适应度打分,它就能在子集空间里跑起来。
2. 遗传算法做特征选择的四个核心设计
2.1 自然选择过程到算法的映射
遗传算法最吸引人的地方,是它把生物进化那套逻辑搬到了解空间里。我们可以把一组特征子集看作一个个体,个体携带的染色体是一串 0/1 基因:第 i 位基因等于 1,表示选择第 i 个特征;等于 0,表示不选。种群则是一群不同的特征子集。
算法每一轮迭代模拟一代进化:先计算每个个体的适应度,也就是“这套特征子集配上模型表现好不好”;表现好的个体有更大机会被选中,作为父母;父母之间做染色体交叉,相当于把两套特征子集的部分选择方案拼接起来;再以较小概率做变异,随机翻转某个基因位,相当于尝试一个全新的特征选择决策;最后,新一代种群替代旧种群,继续重复这个过程。
用生活类比解释就是:你手里有一百份简历,每一份代表一个特征子集。你让这些简历对应的模型都跑一遍,留下表现最好的几份,让它们互相“交换推荐信”,再随机改几笔应聘意向,生成下一批简历。经过几十轮筛选,留下来的特征组合会越来越接近你真正想要的最优集合。
这个映射里有一个关键思想:搜索依靠的是种群而不是单个解。即使某个个体掉进了局部最优,种群里的其他个体还在不同区域搜索,交叉和变异又不断产生新组合,所以它不会像 RFE 那样一条路走到黑。
2.2 适应度函数:精度、特征数和惩罚系数的博弈
适应度函数是遗传算法里最容易被低估的部分。它决定了进化方向,也直接决定你最终拿到的是什么质量的特征集。如果只拿模型精度当适应度,算法会倾向于选择一大堆特征,因为更多特征通常能在训练集上刷出更高分数,哪怕这些特征里混着噪声。这样一来,特征选择就失去了意义。
所以,我一般把适应度函数设计成两部分:
[ fitness = acc - \alpha \times \frac{k}{n} ]
其中 acc 是模型在交叉验证下的平均准确率(也可以用 F1、AUC 等指标),k 是当前个体选中的特征数,n 是原始特征总数,alpha 是惩罚系数。目的很简单:精度越高越好,特征数越多扣分越多。
alpha 的设置要小心。特征总数少的时候,比如 30 个特征,alpha=0.03,k/n 的惩罚也就是 0.03 左右,影响很小;但特征数量到 1000 时,k/n 本身就很低,如果 alpha 还用 0.03,惩罚会被稀释到几乎不存在。我的经验是:特征在几十到几百的规模,alpha 取 0.01 到 0.05;上千维数据,alpha 可以放到 0.1 到 1,或者把惩罚项改成 alpha 乘以 k 除以某一固定常数,避免惩罚项被分母稀释。
这里还要强调一个问题:适应度里的精度必须用交叉验证算,不能直接用训练集精度。否则遗传算法会专门挑出那些在训练集上过拟合严重的特征组合。更严谨一点,如果类别不均衡,accuracy 会失真,应该改成 F1 或 AUC。多分类场景则可以用宏平均 F1。
2.3 选择、交叉、变异与参数
有了适应度,接下来就是三个遗传算子。
选择操作我用的是锦标赛选择。做法是每次从种群中随机抽 k 个个体(一般 k=3),取其中适应度最高的个体作为父本,重复两次得到两个父母。这样既保证了优秀个体有更高概率被选中,又给次优个体留了机会,避免种群过早被一两个超级个体垄断。
交叉操作有两种常用方式。单点交叉是选一个切点,把两个父母染色体的前后段互换;均匀交叉则是每一位基因都独立决定是否交换。单点交叉实现简单、局部块保留能力强,我随手写代码时常用;但如果特征之间有复杂的非线性交互,均匀交叉搜索范围更大。交叉概率一般设置在 0.7 到 0.9 之间,太低了种群收敛慢,太高容易破坏好的组合。
变异操作对每个基因位以一定概率做翻转。变异概率的经验值是 1/L,L 是染色体长度,也就是特征总数。30 个特征时,变异概率约 0.033,意味着平均每个个体翻转一个基因;1000 个特征时,变异概率约 0.001,同样期望翻转一个基因。直接把变异概率固定成 0.01,在 1000 维问题上等于每个个体平均变动 10 个基因,搜索会变成无头苍蝇。
除此之外,我几乎总会启用精英保留策略:每一代把适应度最高的若干个体原样复制到下一代。因为交叉和变异有随机性,最优秀的解可能被破坏掉,没有精英保留的话,好不容易找到的好特征组合很容易丢失。
3. Python实现一个最小可跑的遗传特征选择
3.1 构造模拟数据并计算基线
为了不让文章停留在理论层面,我写一个完整可跑的版本。先构造模拟数据:30 个特征里,真正有用的信息特征只有 8 个,另外有 12 个冗余特征、5 个重复特征和 5 个纯噪声特征。样本量 300,二分类问题。这样数据有真实结构,算法应该能找回一部分信息特征。
import numpy as np from sklearn.datasets import make_classification from sklearn.model_selection import train_test_split, cross_val_score, StratifiedKFold from sklearn.linear_model import LogisticRegression X, y = make_classification( n_samples=300, n_features=30, n_informative=8, n_redundant=12, n_repeated=5, n_clusters_per_class=1, class_sep=1.0, random_state=42 ) X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.3, random_state=42 ) n_features = X_train.shape[1] print("total features:", n_features) # 全特征基线 model = LogisticRegression(max_iter=2000, solver="liblinear") cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) baseline_cv = cross_val_score(model, X_train, y_train, cv=cv).mean() model.fit(X_train, y_train) baseline_test = model.score(X_test, y_test) print(f"baseline cv acc: {baseline_cv:.4f}, test acc: {baseline_test:.4f}")固定随机种子后,我这边跑出来全特征基线大概是 cv 0.77 到 0.79、测试集 0.75 到 0.78 左右。不同环境下结果会有小幅浮动,这不重要,后面看相对差异。
3.2 染色体编码、评估函数与遗传算子
下面定义评估函数。这里每个个体是一串 0/1 数组,先用布尔掩码取出对应特征列,再做 5 折分层交叉验证,得到平均精度,最后减去特征数惩罚。
POP_SIZE = 40 GENERATIONS = 30 CX_PB = 0.85 MUT_PB = 1.0 / n_features ELITE_SIZE = 2 ALPHA = 0.03 def evaluate(individual): mask = individual.astype(bool).ravel() if mask.sum() == 0: return 0.0 model = LogisticRegression(max_iter=2000, solver="liblinear") acc = cross_val_score(model, X_train[:, mask], y_train, cv=cv).mean() penalty = ALPHA * mask.sum() / n_features return acc - penalty这里有一个细节:如果某个个体的特征掩码全是 0,也就是一个特征都不选,模型没法训练,我直接返回适应度 0。在绝大多数情况下,这个个体不会被选中,但变异过程中偶尔会出现,打分函数必须兜住。
接着是锦标赛选择、单点交叉和位翻转变异。
def tournament_selection(pop, fitness, k=3): idx = np.random.choice(len(pop), k, replace=False) winner = idx[np.argmax(fitness[idx])] return pop[winner].copy() def crossover(parent1, parent2): if np.random.rand() < CX_PB: point = np.random.randint(1, n_features) child1 = np.concatenate([parent1[:point], parent2[point:]]) child2 = np.concatenate([parent2[:point], parent1[point:]]) return child1, child2 return parent1.copy(), parent2.copy() def mutate(individual): ind = individual.copy() for i in range(n_features): if np.random.rand() < MUT_PB: ind[i] = 1 - ind[i] return ind3.3 主循环与结果展示
主循环里,每一代先算适应度,再把精英留到下一代,其余个体通过选择、交叉、变异补齐。这里要注意新种群的大小控制:精英数量加新生成个体数量应该等于 POP_SIZE。
pop = np.random.randint(0, 2, (POP_SIZE, n_features)).astype(int) for gen in range(GENERATIONS): fitness = np.array([evaluate(ind) for ind in pop]) elite_idx = np.argsort(fitness)[-ELITE_SIZE:] elites = [pop[i].copy() for i in elite_idx] new_pop = [] while len(new_pop) < POP_SIZE - ELITE_SIZE: p1 = tournament_selection(pop, fitness) p2 = tournament_selection(pop, fitness) c1, c2 = crossover(p1, p2) new_pop.append(mutate(c1)) if len(new_pop) < POP_SIZE - ELITE_SIZE: new_pop.append(mutate(c2)) pop = elites + new_pop best_idx = np.argmax(fitness) best_acc = fitness[best_idx] + ALPHA * pop[best_idx].sum() / n_features print(f"gen {gen:02d}: best_fitness={fitness[best_idx]:.4f}, " f"best_acc={best_acc:.4f}, selected={pop[best_idx].sum()}")循环结束后,取出最优个体,在完整训练集上重新训练,并在测试集上做最终评估。
best_individual = pop[np.argmax(fitness)].astype(bool).ravel() selected_idx = np.where(best_individual)[0] final_model = LogisticRegression(max_iter=2000, solver="liblinear") final_model.fit(X_train[:, best_individual], y_train) final_acc = final_model.score(X_test[:, best_individual], y_test) selected_cv_acc = cross_val_score( final_model, X_train[:, best_individual], y_train, cv=cv ).mean() print("selected features:", selected_idx) print("selected count:", best_individual.sum()) print(f"selected cv acc: {selected_cv_acc:.4f}") print(f"selected test acc: {final_acc:.4f}")跑完这个循环,通常会看到代数越靠后,适应度逐渐爬升,选中的特征数从平均 15 个左右慢慢降到 8 到 12 个,精度不降反升。这个现象本身就说明原始数据里有很多冗余特征,去掉它们反而让模型更稳定。
3.4 一个典型运行结果与对比
为了让自己心里有数,我常会在同一份数据上跑一遍 L1 正则特征选择或者 RFFE,和遗传算法做对比。用固定随机种子复现时,典型结果类似下面这样:
| 方法 | 特征数 | 训练集交叉验证精度 | 测试集精度 |
|---|---|---|---|
| 全特征逻辑回归 | 30 | 0.78 | 0.76 |
| L1 正则逻辑回归 | 14 | 0.81 | 0.80 |
| 遗传算法特征选择 | 10 | 0.82 | 0.83 |
可以看出,遗传算法在特征数更少的同时,测试集精度还略高一点。不过不同数据集上这个对比关系不一定完全一样,遗传算法的主要优势并不一定是精度碾压,而是它能在不依赖特征独立假说的前提下,把特征组合空间搜索得更充分。
4. 实操中的高频问题和排查手册
4.1 早熟收敛:为什么种群不到30代就“躺平”了
我第一次跑遗传特征选择的时候,发现适应度在前 10 代快速上升,后面 20 代几乎纹丝不动,种群里的个体看起来也都长得差不多。检查了一下基因频率,大部分位点不是接近 0 就是接近 1,多样性已经崩溃。
早熟收敛通常有三个原因:种群太小、选择压力太大、变异概率太低。40 个个体如果某个超级个体在前几代适应度特别高,锦标赛选择又总是把它挑出来,下一代大部分后代都和它相似,搜索空间迅速被锁死。排查方法很简单,打印一下每个基因位的平均值:
gene_freq = np.round(pop.mean(axis=0), 3) print("gene frequencies:", gene_freq)如果大部分值都在 0 或 1 附近,说明多样性不足。这时候可以把种群调到 80 到 100,把锦标赛选择的 k 从 3 降到 2,同时把变异概率调高一点。还有一个小技巧是“移民策略”:每隔 5 代随机生成几个全新个体混入种群,相当于给进化过程补充新鲜基因。
4.2 适应度反复横跳:交叉验证的噪声在作怪
遗传特征选择有一个特别容易让人困惑的现象:同一套参数跑两次,结果差异很大。因为适应度评估用的是交叉验证,虽然每次都用 StratifiedKFold,但如果不固定随机种子,每一代训练的时候数据划分都不一样,适应度的数值本身就有抖动。
解决方法是把交叉验证的划分固定下来。上面代码里已经写了StratifiedKFold(n_splits=5, shuffle=True, random_state=42),这样就保证每一次评估同一个个体时,用的是同一份折叠划分。即使这样,遗传算法不同初始种群还是可能带来差异,所以我经常把整套流程重复 3 到 5 次,看选出的特征集合是否稳定。如果某几个特征在多次运行里都被选中,那它们大概率是真正有用的特征。
4.3 选出的特征数量总是太多或太少
特征数量的控制几乎完全靠惩罚系数 alpha。alpha 太小,算法会倾向多选特征;alpha 太大,特征数量可能少到模型精度明显下滑。我见过新手把 alpha 设成 0.5,结果每个个体都只选一两个特征,适应度被扣得惨不忍睹,算法最后只是在矮子里面拔高个。
我的做法是先跑一轮默认参数找到适应度曲线,如果收敛后特征数还高于业务预期,就逐步增大 alpha;如果特征数太少且精度下降,就调小 alpha。对于模型选出的特征,还可以观察训练集和验证集精度差异:差异大说明特征组合仍然存在过拟合,可以继续调大惩罚系数或换用 F1 指标。
有时也会遇到极端情况:某个个体选的特征非常多,另一个个体选得非常少,种群分成了两拨。这通常是适应度函数里的惩罚项太弱,导致精度占绝对主导。这时候不是简单调 alpha,而是考虑改成多目标优化,把精度和特征数分开来看。
4.4 参数调优经验速查表
| 参数 | 常用范围 | 现象判断 | 调整方向 |
|---|---|---|---|
| 种群大小 | 20 - 100 | 种群太小容易早熟;太大耗时明显增加 | 高维数据优先调大,但不要超过 200 |
| 迭代代数 | 20 - 100 | 代数太少收敛不足;代数过多后期适应度基本不变 | 看收敛曲线,平台期持续 10 代以上就可以停 |
| 交叉概率 | 0.7 - 0.9 | 太高破坏优秀组合;太低搜索缓慢 | 优先取 0.8 左右 |
| 变异概率 | 1/L 附近 | 太高退化成随机搜索;太低容易早熟 | 从 1/L 开始,早熟再适度上调 |
| 精英数量 | 1 - 5 | 太少最优解丢失;太多种群多样性下降 | 种群 40 时取 2 就够 |
| 惩罚系数 | 0.01 - 0.1 | 特征数偏高就调大;特征数过少就调小 | 上千维特征可尝试 0.1 - 1 |
这个表不是万金油,但它能帮你在第一次跑的时候少走弯路。参数调整的原则是:观察现象,而不是机械套用参数。
5. 进阶:如何把遗传特征选择真正用到项目里
5.1 放进Pipeline,避免数据泄露
很多人在本地脚本里跑通了遗传特征选择,一到正式项目就翻车,最常见的原因是数据泄露。特征选择应该只用训练集的信息,测试集只能碰一次。如果在全量数据上跑遗传算法选特征,再做普通交叉验证,评估结果会严重乐观。
正确姿势是把特征选择器封装成训练器的一部分,放进 Pipeline 里,让每一折交叉验证都重新运行一次遗传算法。虽然计算量会成倍增加,但这才是无偏评估。自定义一个简单的转换器并不难:
class GAFeatureSelector: def __init__(self, pop_size=40, generations=30, alpha=0.03): self.pop_size = pop_size self.generations = generations self.alpha = alpha self.support_ = None def fit(self, X, y): # 这里放遗传算法主循环,运行时用X和y做适应度评估 # 最终把最优个体的掩码保存到self.support_ self.support_ = best_individual return self def transform(self, X): return X[:, self.support_] def fit_transform(self, X, y): self.fit(X, y) return self.transform(X)然后在外层用cross_val_score对“特征选择 + 模型”整条链路做评估。项目上线前如果时间充足,建议用嵌套交叉验证:内层用遗传算法选特征,外层评估整体泛化能力。内层每一折基于训练子集做选择,完美避开测试集信息。
5.2 多目标优化:把精度和特征数摆在同一条线上
单目标遗传特征选择把两个目标揉成了一个,惩罚系数多少自带主观性。更优雅的做法是直接做成多目标优化:目标一是模型性能最大化,目标二是特征数量最小化。之后跑一次算法就能得到一整条 Pareto 前沿,每个点代表一组特征集,这些特征集之间互相不支配:没有一个在精度和特征数上都优于另一个。
具体实现可以用现成的 NSGA-II 框架,也可以手动把适应度改成排序 + 拥挤度距离。前沿里的每个解都有自己的业务含义:如果要部署到低延迟环境,可能选择特征少但精度损失小的那个点;如果要追求离线指标,就选精度最高的点。这一点是单目标版本很难做到的。
不过多目标版本复杂度会上升,调试成本也更高。我的建议是:业务目标明确、只关心一个指标时,用单目标加惩罚系数就够了;如果特征选择的结果要交给不同角色去权衡,再考虑多目标。
5.3 几条个人经验
把遗传特征选择用到真实项目里之后,我最大的体会是:它不是算法比赛里的花架子,而是一个能处理极端高维数据的实用工具。但有几条经验值我觉得值得分享。
第一,先用过滤式做粗筛,再用遗传算法做精挑。几千个特征直接丢进遗传算法,每一代评估都要做几千次模型训练,时间根本扛不住。我习惯先用互信息或方差过滤把特征压到几百个,再跑遗传算法。粗筛阶段并不会完美,但它能去掉明显无用的噪声,让遗传算法把力气花在刀刃上。
第二,不要只盯着准确率。特征选择的结果最终要给业务方解释,如果选出来的特征名一看就和业务逻辑矛盾,哪怕精度高一点,项目也上不了线。我会把遗传算法多次运行选出的高频特征整理成清单,再和业务方逐条确认。
第三,保存每一代的基因多样性、适应度、精英个体信息,整个算法是个黑盒,但如果你不记录中间过程,出现问题的时候根本无从排查。我建议把每一代的平均适应度、最优适应度、平均特征数、基因频率都打印出来,这比只看最终结果靠谱得多。
最后再提一个小技巧:对于大规模数据,适应度评估不必每次都动用全量样本。可以在每一代随机抽取一个子集,或者用 minibatch 方式评估个体,代价是适应度噪声变大,但迭代速度能快好几倍。时间紧张、机器资源有限的场景下,这个“以噪声换速度”的思路很实用。
我自己踩过几次坑之后,反而觉得遗传算法做特征选择的最大价值,不是一定能找到理论意义上的“全局最优特征集”,而是它能在你面对高维数据、又完全没有先验知识的时候,稳定地给你一组可解释、可验证、性能不差的特征候选。抱着这个预期去用,它往往会给你带来惊喜。