特征选择利器:卡方检验与互信息原理与实战
特征选择这活儿干过几轮建模的人都会明白它的重要性。数据清洗完之后面前摆着一堆特征有的和标签强相关有的纯粹是噪声有的彼此之间还高度共线。全塞进模型轻则训练慢重则过拟合、解释性崩塌。而把特征筛一遍这事最常用、也最容易上手的就是过滤式Filter方法其中卡方检验和互信息又是出场率最高的两把尺子。这篇文章就围绕这两者的原理、Python实现、实际应用中的坑和搭配策略展开。适合刚入门特征工程、想搞懂过滤式特征选择原理的读者也适合已经用过但没深究过为什么这么筛的人。我会把代码、原理、实测经验放在一起说争取让你看完直接能在自己项目里上手。1. 先搞清楚选特征时卡方和互信息各自管什么1.1 特征选择的场景什么时候轮到它们上场先说一个常见误区很多人一上来就抱着L1正则、随机森林重要性去做特征筛选但这属于嵌入式Embedded或包装式Wrapper方法它们依赖具体模型算出来的特征重要性是在某个模型视角下的重要性。而卡方检验和互信息属于过滤式方法不依赖任何模型只依靠特征和标签之间的统计关联来做判断。这意味着它们跑得快、结果可复现、也不容易被模型偏好带偏。什么时候优先用它们我总结三个典型场景特征数量巨大比如上万维模型还没法快速迭代先用过滤式方法粗筛一轮把明显没用的特征踢掉再去跑后面的精细选择。需要稳定、可解释的特征排名比如做报告或者给业务方解释为什么选这些特征。特征工程阶段想快速验证一批新构造的特征是否和标签有关联。在这类场景下卡方检验和互信息就像两台不同类型的探测器卡方检验专门查离散特征和离散标签是否独立互信息则是任意特征和任意标签之间的关联强度探测器。1.2 卡方检验专治离散特征-离散标签的独立性怀疑卡方检验Chi-Square Test本质上是一个独立性检验。它的原假设是特征和标签之间相互独立。如果计算出来的卡方统计量很大对应的p值很小就拒绝原假设认为特征和标签之间存在显著的关联。它在特征选择中的角色非常明确处理离散特征。比如文本分类里的词频特征、用户画像里的职业类别、商品类目等等这些特征本身就是类别型或计数型的标签也是离散类别卡方检验就是最自然的适配工具。在sklearn里chi2函数会为每个特征和标签计算卡方统计量和p值统计量越高说明关联越强。它内部做的事情本质上就是把特征和标签做成列联表然后比较实际观测频数和假设独立时的期望频数相差多大。1.3 互信息捕捉非线性关联的通用探测器互信息Mutual Information来自信息论度量的是知道特征X之后标签Y的不确定性减少多少。它的优势在于不限定特征和标签的类型离散、连续都能用。能捕捉非线性的关联。皮尔逊相关系数只能管线性关系互信息连那种U型周期性的奇怪关系都能探测到。这在实际项目中很重要因为真实数据的关联形态五花八门。比如某个特征和标签的关系是特征值处于中间区间时标签为1两端为0这种关系皮尔逊相关系数几乎测不出来但互信息能明显捕捉到。1.4 两者的核心差异对比对比项卡方检验互信息适用特征类型离散/计数型离散、连续均可适用标签类型离散分类离散、连续均可核心思想观察频数与期望频数的偏差信息不确定性减少是否捕捉非线性只能捕捉离散关联中的偏差对连续非线性关系无能为力能且是强项对样本量要求要求足够的样本量否则列联表单元格期望频数过低小样本下估计偏差较大输出含义统计量 p值信息量值互相信息量无量纲工具实现sklearn.feature_selection.chi2sklearn.feature_selection.mutual_info_classif一句话总结如果特征是离散的、标签是分类的卡方检验是第一选择如果特征连续、或者怀疑关系是非线性的直接上互信息两者不是互斥关系完全可以串联使用。后面我会专门讲怎么打组合拳。2. 原理不太难这两把尺子到底在量什么2.1 卡方检验的数学直觉观察与期待的偏差卡方检验的计算过程其实不复杂。假设有一个特征是否点击过某个页面取值0或1标签是是否转化也取0或1。把数据整理成一个2x2的列联表转化0转化1合计点击0abab点击1cdcd合计acbdn如果特征和标签独立那么每个单元格的期望频数就是该行合计乘以该列合计再除以总样本数。比如左上角单元格的期望频数就是 (ab)(ac)/n。卡方统计量的公式是χ² Σ (观测频数 - 期望频数)² / 期望频数对所有单元格求和。这个值越大说明观测和期望偏差越大独立性假设越可疑特征与标签的关联越强。sklearn里chi2函数返回的评分本质上就是这个值对每个特征单独计算同时还会返回p值。p值越小代表显著性越高但特征选择中我们一般只看统计量排序p值当参考。2.2 互信息的熵视角知道X之后Y的不确定性减少多少互信息的定义是I(X; Y) H(Y) - H(Y | X)其中H(Y)是标签的熵H(Y|X)是在已知特征X的条件下Y的条件熵。翻译成人话没看X之前Y对我来说有多少不确定性看了X之后Y的不确定性还剩多少差值就是X提供的关于Y的信息量。如果X和Y完全独立H(Y|X) H(Y)互信息为0。如果X能完全决定YH(Y|X)0互信息等于H(Y)。互信息不对称于相关系数的正是这一点它可以衡量任意函数关系下的依赖程度。比如下面这种关系X np.random.uniform(-1, 1, 1000) Y np.where(np.abs(X) 0.5, 1, 0)这种中间是1、两边是0的关系皮尔逊相关系数几乎为0但互信息值会明显大于0。实际业务里这种情况太多了所以互信息是过滤式特征选择里地位不可撼动的原因就在这。2.3 和相关系数的本质区别为什么不能用皮尔逊一把梭很多教程会拿皮尔逊相关系数来做特征选择它确实是快速了解特征与目标线性关系的好工具。但问题是皮尔逊相关系数只能捕捉线性关系或者经过变换后才变成线性的关系。它测不出单调但非线性的关系更别说非单调的关系。互信息和卡方检验在处理非线性和离散关联上天然有优势。卡方检验基于频数分布和特征数值的线性趋势无关互信息基于熵和概率分布同样不关心数值的形状。所以如果项目里面特征和标签的关系形态未知优先用这两把尺子比直接用相关系数靠谱得多。3. Python实操从数据到特征排名的完整链路3.1 环境准备与数据模拟实操前先把环境准备说一下。需要用到numpy、pandas数据处理scipy.stats卡方检验的底层实现sklearn.feature_selectionchi2、mutual_info_classif、SelectKBestmatplotlib 或 seaborn可视化排名先模拟一份数据方便演示完整流程。假设是一个二分类问题有6个特征其中3个和标签有真实关联另外3个是纯噪声。import numpy as np import pandas as pd from sklearn.feature_selection import SelectKBest, chi2, mutual_info_classif from sklearn.preprocessing import MinMaxScaler import matplotlib.pyplot as plt np.random.seed(42) n_samples 2000 # 三个与标签有关的离散特征 x1 np.random.randint(0, 5, n_samples) x2 np.random.randint(0, 4, n_samples) x3 np.random.randint(0, 3, n_samples) # 三个纯噪声特征 noise1 np.random.randint(0, 10, n_samples) noise2 np.random.randint(0, 10, n_samples) noise3 np.random.randint(0, 10, n_samples) # 构造标签x1和x2的取值组合影响标签概率 prob 1 / (1 np.exp(-(0.8 * x1 - 0.5 * x2 0.3 * (x3 1)))) y np.random.binomial(1, prob) X pd.DataFrame({ x1: x1, x2: x2, x3: x3, noise1: noise1, noise2: noise2, noise3: noise3 })这里故意不把影响设得太强模拟真实场景中特征有用但关联没那么显著的情况。3.2 卡方检验的三种落地方式第一种方式是直接用sklearn的chi2。注意chi2要求输入特征非负这是它计算列联表时的隐含要求。如果特征里有负数需要先做处理。from sklearn.feature_selection import chi2 chi2_stats, chi2_pvals chi2(X, y) chi2_res pd.DataFrame({ feature: X.columns, chi2_stat: chi2_stats, p_value: chi2_pvals }).sort_values(chi2_stat, ascendingFalse) print(chi2_res)运行后你会看到x1、x2的卡方统计量明显比其他特征高noise特征们的统计量很低p值也很大。这正是我们想要的效果。第二种方式是用scipy自己算。sklearn的chi2是对每个特征分别和标签做独立性检验如果你想知道某个特征在不同取值水平下的具体分布可以自己用chi2_contingency。from scipy.stats import chi2_contingency import pandas as pd # 对x1和y做列联表卡方检验 contingency_table pd.crosstab(X[x1], y) chi2_stat, p_val, dof, expected chi2_contingency(contingency_table) print(fchi2_stat{chi2_stat:.4f}, p_value{p_val:.6f}) print(期望频数矩阵) print(expected)expected这个输出很有意思它展示了假设x1和y独立时每个单元格应有的大致频数。对比观测频数contingency_table和期望频数能直观看出哪些单元格贡献了最大的偏差。第三种方式是自己手写卡方统计量。这主要是为了加深理解实际项目中没必要。但如果想验证sklearn结果或者想扩展成加权卡方理解公式会有帮助def manual_chi2(feature, label): table pd.crosstab(feature, label) row_totals table.sum(axis1).values col_totals table.sum(axis0).values total table.values.sum() expected np.outer(row_totals, col_totals) / total chi2_stat ((table.values - expected) ** 2 / expected).sum() return chi2_stat for col in X.columns: print(f{col}: manual chi2 {manual_chi2(X[col], y):.4f})对比一下就会看到手动计算结果和sklearn返回的统计量一致。这个手写版本在理解算法细节、或者处理特殊业务口径时很有用。3.3 互信息的两种实现连续特征不能直接丢给卡方检验但可以丢给互信息。sklearn提供了两个函数mutual_info_classif针对分类标签mutual_info_regression针对回归标签使用方式from sklearn.feature_selection import mutual_info_classif mi_scores mutual_info_classif(X, y, random_state42, n_neighbors3) mi_res pd.DataFrame({ feature: X.columns, mi_score: mi_scores }).sort_values(mi_score, ascendingFalse) print(mi_res)需要强调一点这里的X可以全部是连续特征也可以混合离散连续不会报错。我的建议是先把所有特征都放进去跑一遍看整体排名再结合特征类型决定最终怎么取舍。如果想自己实现互信息基础版本可以对连续特征做分箱、然后基于离散化后的概率分布计算熵和互信息def entropy(labels, baseNone): _, counts np.unique(labels, return_countsTrue) probs counts / counts.sum() if base is not None: return -(probs * np.log(probs) / np.log(base)).sum() return -(probs * np.log(probs)).sum() def mutual_info_discrete(x, y, bins10): # 对连续特征x做分箱 x_binned np.digitize(x, binsnp.percentile(x, np.linspace(0, 100, bins 1)[1:-1])) # 联合熵 xy np.stack([x_binned, y], axis1) joint_entropy entropy(xy) # H(x) H(y) - H(x,y) mi entropy(x_binned) entropy(y) - joint_entropy return mi for col in X.columns: mi_val mutual_info_discrete(X[col].values, y, bins10) print(f{col}: manual MI {mi_val:.4f})注意手动分箱的版本和sklearn的k近邻估计值会有些差异这是正常的。sklearn内部用的是基于k近邻的估计器精度更高对连续变量的处理也更细腻。手动版只适合理解原理和教学场景。3.4 可视化排名结果特征选择做完不可视化总觉得少点东西。把两个评分画在一起看能直观发现哪些特征是两边都认可的哪些存在分歧。fig, axes plt.subplots(1, 2, figsize(12, 5)) # 卡方得分 chi2_res_sorted chi2_res.sort_values(chi2_stat, ascendingTrue) axes[0].barh(chi2_res_sorted[feature], chi2_res_sorted[chi2_stat], colorskyblue) axes[0].set_title(Chi-Square Scores) # 互信息得分 mi_res_sorted mi_res.sort_values(mi_score, ascendingTrue) axes[1].barh(mi_res_sorted[feature], mi_res_sorted[mi_score], colorsalmon) axes[1].set_title(Mutual Information Scores) plt.tight_layout() plt.show()在我的模拟数据里两种方法都会把x1、x2排在前面噪声特征们得分很低。但如果你自己构造的数据里含有一个和标签有复杂非线性关系的连续特征你就会看到互信息给了高分而卡方检验如果把该特征也强行放进去的话得分很低——这就是方法选型差异的直观体现。4. 实战中的坑这些错误我见人踩过无数回4.1 卡方检验拿连续特征硬上这是初学者最容易犯的错。chi2函数跑起来不会报错但它的计算逻辑是为非负离散特征设计的。你如果直接把连续特征丢进去会得到什么它会把连续特征本身当作离散取值来构建列联表每个取值一个类别结果就是期望频数极低列联表稀疏得可怕。卡方统计量被严重放大或者严重失真排名毫无意义。正确的做法是连续特征要么分箱离散化后再跑卡方要么直接用互信息。像这样分箱后跑卡方是可以的X[x1_binned] pd.cut(X[x1], bins5, labelsFalse) chi2(X[[x1_binned]], y)但说句实话如果特征本身是连续的我会直接选择互信息不要绕一圈去做分箱。分箱操作本身就带了信息损失而且分箱边界的选择会影响结果稳定性。注意sklearn的chi2要求输入特征非负因为它内部假设这些值是频数或类似频数的计数。如果你的特征是0-1编码的词频、TF-IDF值的一部分非负或者类别编码都没问题但如果是标准化后带有负值的特征直接跑会报错。4.2 互信息的小样本陷阱与k近邻参数互信息估计有一个很隐蔽的问题样本量小或者特征取值特别稀疏的时候估计值会系统性偏高。比如你只有50个样本特征是100维每一维都和标签做了互信息计算那结果里很容易出现不少虚高的分数——这不是特征真的有用而是估计器在小样本下没法准确建模联合分布。另外sklearn的mutual_info_classif有个n_neighbors参数默认是3。这个参数控制k近邻算法中邻居的数量。调参会直接影响结果取太小比如1估计方差大结果不稳定。取太大估计偏差增大容易把有用的关联磨平。实践中3到5是比较稳妥的范围样本量大的时候可以试试5。random_state也一定要固定。互信息估计里涉及随机性不固定种子的话同一份数据每次跑出来的结果可能有细微差异。我在项目里习惯把random_state42固定确保特征排名可复现。4.3 离散化带来的信息损耗无论是卡方检验还是手动实现互信息涉及分箱时都要小心。分箱太粗会丢失太多信息分箱太细又会让每个箱子里的样本太少、统计不稳定。这是个典型的偏差-方差权衡。我常用的分箱策略是场景建议探索性分析用分位数分箱比如10分位或5分位特征选择前预处理4到6个箱是比较折中的选择样本量很大10万可以放宽到10个箱以上样本量很小10003到4个箱再多就不稳了4.4 稀疏矩阵的处理差异文本数据是卡方检验和互信息的经典应用场景。在这种场景下特征矩阵通常是一个巨大的稀疏矩阵比如TF-IDF矩阵或者词频矩阵。sklearn的chi2对稀疏矩阵的支持非常好它底层有些优化直接传入csr_matrix就能快速计算。但mutual_info_classif就没有那么友好了——sklearn的互信息实现需要把稀疏矩阵转成密集矩阵这在高维场景下会直接内存爆炸。所以文本场景下我的习惯是先用卡方检验做一轮粗筛把特征维度从几万降到几千这一步在稀疏矩阵上很快。再用互信息在降维后的稠密子集上做精排此时数据量已经可控。这个顺序充分利用了两者的特性也是后面要说的组合拳的一种具体形态。5. 组合拳打法卡方与互信息配合使用的完整策略5.1 过滤式特征选择的标准Pipeline单独用某一种方式都容易有盲区。我实践下来最顺手的组合策略是这样的先把特征按类型分组离散特征一组连续特征一组。离散特征组用卡方检验计算得分。连续特征组用互信息计算得分分类问题用mutual_info_classif回归问题用mutual_info_regression。归一化对齐卡方统计量和互信息值尺度不同没法直接比较。先把两组分数各自转成排名或百分位再按业务需求加权合并。合并后做Top-K筛选或者结合阈值筛选。这个方案的好处是两种方法的盲区都被对方填补。卡方检验漏掉的连续非线性关系会被互信息抓到互信息在离散特征上偶尔会出现过拟合式的高估卡方检验会给出更保守的参照。5.2 从Top-K到阈值选择选多少特征合适特征选择之后到底保留多少个特征这是另一个让人纠结的点。Top-K里的K怎么定没有绝对标准但我有几个参考维度模型复杂度线性模型对特征数不那么敏感可以保留多些树模型要看树的深度和特征数的匹配。特征间相关性如果选出来的特征彼此高度相关实际有效信息量没那么多。业务可解释性如果需要向业务方解释模型一般控制在10到20个以内比较合理。阈值选择也有两种常用做法。一种是直接卡百分位比如保留得分排前20%的特征。另一种是看得分分布的肘部——画出得分排序图找到那个明显拐点保留拐点之前的特征。5.3 一个实际案例文本分类中的特征筛选拿一个具体的文本二分类任务举例判断一段用户反馈是投诉还是咨询。原始特征有TF-IDF词项特征8000维、反馈字数、标点数量、情感得分、历史交互次数等非文本特征十来个。我的处理流程是第一步把TF-IDF矩阵和标签丢给chi2选出卡方得分前500的词特征。这一步直接在稀疏矩阵上操作秒出结果。第二步把选出来的500个词特征和其它十几个非文本特征合并成一份稠密特征表每行样本一个向量。第三步对这份合并后的特征表跑mutual_info_classif得到所有特征的互信息得分。第四步把卡方排名和互信息排名做个简单平均得到最终的综合排名取前200个词特征和全部非文本特征。最后进模型训练效果比单独用卡方选出的800个特征或者单独用互信息选出的800个特征都要好。原因也不难理解卡方擅长挑出离散词项特征里的强关联项互信息擅长挑出非文本特征里那些非线性有效项两种能力互补。5.4 与其它特征选择方法的串联过滤式方法通常不做最终决定它只是粗筛。我的一般流程是过滤式卡方互信息先粗筛把维度降下来。再用嵌入式的带L1惩罚的逻辑回归或者树模型的重要性做二次筛选。最后如果有精力在小规模特征集上用包装式方法做最终确认。这样做的好处是效率和安全兼顾。纯包装式方法在高维场景下计算开销大到不可接受纯过滤式方法又忽略了特征之间的交互作用串起来各取所长。6. 个人的实际体会和几点建议6.1 两种方法对特征类型的适配有一个最佳甜点区我做过不少次实验一个比较稳定的观察是卡方检验在词频、类别计数这类离散特征上非常可靠而互信息在处理连续特征、尤其是存在非线性关联的连续特征时明显强于卡方。两者配合使用时最好按照特征的自然类型分工不要强行把连续特征离散化后交给卡方也不要把所有离散特征都丢给互信息。6.2 固定随机种子是必须养成的工作习惯互信息估计器里有随机性固定random_state不仅是为了结果可复现也方便团队里其他人复核你的实验。我在每个项目的特征选择环节都会在代码注释里写上random_state42固定结果可复现。这个小习惯在合作开发时能省很多沟通成本。6.3 特征选择结果一定要和业务解释对齐卡方检验和互信息给出的都是统计关联统计关联不等于因果关联更不等于业务上有意义。我见过不止一次某个特征在统计上得分很高但业务方一看就觉得不合理——这时候一定要回到数据里检查看看是不是存在数据泄露、样本偏差或者编码错误。特征选择工具是帮你缩小关注范围而不是替你做最终决策。6.4 最后分享一个小的调试技巧当你对特征选择结果不放心的时候做一个简单验证把选出来的特征集和随机抽样的同等数量特征集分别训练同一个模型比较验证集表现。如果选出来的特征集没有明显优势那说明你的特征选择过程可能有问题——要么方法用错了要么特征本身信息量就有限。这一步虽然笨但非常有效。