特征工程里有个挺尴尬的现实:很多人花大力气调模型、换算法,最后发现真正拖后腿的是那堆几乎不变化的特征列。方差阈值选择就是专门解决这个问题的——它不看你跟标签的关系,只看特征自己有没有“波动”。这篇文章我会把方差阈值法的原理、参数计算、实操代码、踩坑经验完整拆一遍,适合刚接触特征工程的新手,也适合想把这套流程固化进项目Pipeline的老手。
1. 方差阈值选择到底在解决什么问题
1.1 从“死列”说起:为什么低方差特征该被扔掉
先想一个场景。你手上有一张用户行为表,几百个字段,其中有一列叫“是否中国大陆用户”,结果采样下来99.9%都是1,只有极少数是0。这列特征对模型来说几乎不携带区分信息——因为它几乎不变。类似的还有“app版本号”在某个短周期内全是同一个值、“设备类型”在某个渠道里清一色是安卓。这些列的共同特点就是方差极低。
方差衡量的是数据分布的离散程度。一个特征如果所有样本取值都一样,方差就是0;如果绝大多数样本挤在同一个值附近,方差就非常小。从信息论的角度看,这类特征对区分类别的贡献微乎其微,留着它们只会增加计算量、稀释距离度量、干扰基于分裂的树模型,甚至在某些线性模型里引入共线性噪声。
方差阈值法(Variance Threshold)的核心逻辑非常朴素:设定一个方差下限,低于这个下限的特征直接剔除。它属于过滤式(Filter)特征选择方法,跟卡方检验、互信息、相关系数是一类,但它是唯一一个完全不依赖标签的方法。这意味着它可以在无监督场景下使用,也可以在拿到标签之前就先把数据瘦身一遍。
1.2 它和卡方、互信息、递归消除的本质区别
很多人会把方差阈值和卡方检验混为一谈,其实它们看问题的维度完全不同。卡方检验、互信息、F检验都是有监督的,它们衡量的是“特征和标签之间的关联强度”;而方差阈值是无监督的,它只问一句:“这个特征自己有没有变化?”
这个区别决定了它们的适用边界:
| 方法 | 是否依赖标签 | 衡量对象 | 典型场景 |
|---|---|---|---|
| 方差阈值 | 否 | 特征自身离散度 | 数据清洗、去常量列 |
| 卡方检验 | 是 | 特征与标签相关性 | 文本分类特征筛选 |
| 互信息 | 是 | 特征与标签信息增益 | 非线性关系筛选 |
| 递归特征消除 | 是 | 模型权重贡献 | 小规模高精度筛选 |
方差阈值法的优势在于快、稳、无标签依赖。它的劣势也很明显:一个特征方差很大,不代表它有用。比如“用户ID”这种列,方差巨大,但对预测毫无意义。所以方差阈值从来不是终点,而是特征选择流水线的第一道粗筛。
1.3 什么时候该用它,什么时候别碰
我个人的经验是,下面这几种情况优先上方差阈值:
- 数据里有大量常量列或近常量列,比如埋点表里某些事件字段在某个时间段内几乎不变;
- 做无监督任务(聚类、异常检测),根本没有标签可用;
- 特征维度极高(几千上万列),需要先快速砍掉一批明显无用的,降低后续计算成本;
- 构建可复用的预处理Pipeline,把去低方差列作为标准步骤固化下来。
但下面这些情况要谨慎:
- 特征已经做过标准化,且你用的是基于距离的模型——标准化后方差被统一到1附近,方差阈值几乎筛不掉东西,这时候应该用别的标准;
- 特征本身是稀疏的one-hot编码,大部分取值是0,方差天然就低,但你并不想删掉它们,这时候阈值要设得极低甚至为0;
- 你只有几十个精心设计的特征,每一列都有业务含义,这时候删任何一列都要慎重,方差阈值可能过于粗暴。
提示:方差阈值最适合做“粗筛”,不要指望它帮你选出最优特征子集。把它当成数据清洗的一部分,而不是特征选择的全部。
2. 方差的计算原理与阈值设定逻辑
2.1 方差、标准差、量纲:为什么阈值不能拍脑袋定
方差的计算公式大家都熟:对每个样本值减去均值,平方后求平均。总体方差除以N,样本方差除以N-1。在特征选择场景里,我们通常关心的是样本方差,因为手里的是抽样数据。
这里有个特别容易被忽略的点:方差的量纲是原始单位的平方。如果特征是“年龄”,单位是岁,方差单位就是“岁的平方”;如果特征是“收入”,单位是元,方差单位就是“元的平方”。这两个方差根本没法直接比较,阈值也没法统一。
所以实践中有一个关键决策:要不要先标准化再算方差。标准化(Z-score)之后,每个特征的方差都变成1,这时候方差阈值就失去了区分意义——所有特征方差都是1,你设0.5的阈值一个都筛不掉。反过来说,如果你不标准化,方差阈值就高度依赖量纲,阈值必须针对每个特征单独考虑,或者只用于筛掉那些方差接近0的列。
我的建议是:方差阈值主要用于筛“近常量列”,而不是做精细的方差比较。也就是说,阈值通常设得很小(比如0到0.01之间),目标是干掉那些几乎不变的列,而不是在方差0.5和0.8之间做取舍。
2.2 阈值设定的三种实用策略
阈值到底设多少?没有标准答案,但有三条可操作的路径:
策略一:设0,只删常量列。这是最保守也最安全的做法。方差为0意味着这列所有值完全相同,对任何模型都是纯噪声(除了可能作为“该特征是否存在”的标记,但那是另一回事)。VarianceThreshold(threshold=0)就是干这个的。
策略二:基于分位数动态设定。先算出所有特征的方差,然后取一个低分位数(比如10%分位)作为阈值。这样能自适应地砍掉方差最低的那批特征,而不是拍一个绝对值。代码上就是threshold = np.percentile(variances, 10)。
策略三:针对稀疏数据设极小值。对于one-hot编码后的稀疏矩阵,很多列只有极少数样本为1,方差很小但并非无用。这时候阈值要设得极低,比如1e-6,只删掉真正全0或几乎全0的列。
| 策略 | 阈值 | 适用场景 | 风险 |
|---|---|---|---|
| 设0 | 0 | 通用清洗 | 几乎无风险 |
| 分位数 | 动态 | 高维数据粗筛 | 可能误删有用稀疏特征 |
| 极小值 | 1e-6 | 稀疏one-hot | 需配合业务判断 |
2.3 标准化与方差阈值的顺序陷阱
这是我在实际项目里踩过的一个坑:先标准化再上方差阈值,等于白做。因为标准化会把每个特征的方差强行拉到1,方差阈值就失效了。
正确的顺序应该是:先做方差阈值筛选,再做标准化。或者,如果你确实需要标准化,那就不要用方差阈值,改用别的筛选标准。
还有一种情况是用了MinMaxScaler,它把数据缩放到[0,1]区间。这时候方差会变得非常小(因为值域被压缩了),方差阈值同样会失效。所以记住一句话:任何会改变特征方差量纲的变换,都要放在方差阈值之后。
注意:如果你在Pipeline里把Scaler放在VarianceThreshold前面,一定要检查一下筛选结果,很可能一列都没删掉,你还以为代码生效了。
3. 完整实操:从原始数据到筛选后的特征矩阵
3.1 环境准备与数据构造
我用一个模拟数据集来演示完整流程。这个数据集模拟的是某类用户行为表,包含数值特征、常量列、近常量列和稀疏列,方便观察方差阈值的实际效果。
import numpy as np import pandas as pd from sklearn.feature_selection import VarianceThreshold np.random.seed(42) # 构造模拟数据:200个样本,8个特征 data = { "age": np.random.randint(18, 65, 200), # 正常数值特征 "income": np.random.normal(8000, 3000, 200), # 正常数值特征 "const_zero": np.zeros(200), # 常量列,方差0 "const_five": np.full(200, 5), # 常量列,方差0 "near_const": np.concatenate([np.ones(195), np.zeros(5)]), # 近常量列 "sparse_flag": np.random.binomial(1, 0.02, 200), # 稀疏列,2%为1 "score": np.random.uniform(0, 1, 200), # 正常数值特征 "noise": np.random.normal(0, 0.001, 200), # 极小方差噪声列 } df = pd.DataFrame(data) print(df.var())跑一下df.var(),你会看到类似这样的方差分布:const_zero和const_five方差为0,near_const方差约0.024,sparse_flag方差约0.02,noise方差约1e-6,而age、income、score方差都在正常量级。
3.2 用VarianceThreshold做第一轮筛选
先做最保守的筛选,阈值设0,只删常量列:
selector = VarianceThreshold(threshold=0) X_selected = selector.fit_transform(df) print("原始特征数:", df.shape[1]) print("筛选后特征数:", X_selected.shape[1]) print("保留的特征:", df.columns[selector.get_support()])结果会删掉const_zero和const_five两列,保留6列。这一步几乎没有任何风险,任何项目都可以放心做。
接下来把阈值提高到0.01,看看会删掉什么:
selector = VarianceThreshold(threshold=0.01) X_selected = selector.fit_transform(df) print("阈值0.01筛选后特征数:", X_selected.shape[1]) print("保留的特征:", df.columns[selector.get_support()])这时候noise列(方差1e-6)会被删掉,near_const(方差0.024)和sparse_flag(方差0.02)会被保留,因为它们方差大于0.01。这个结果符合预期。
3.3 基于分位数动态确定阈值
如果特征很多,手动试阈值很麻烦,可以用分位数自动确定:
variances = df.var() threshold = np.percentile(variances, 15) # 取15%分位 print("动态阈值:", threshold) selector = VarianceThreshold(threshold=threshold) X_selected = selector.fit_transform(df) print("动态阈值筛选后特征数:", X_selected.shape[1]) print("保留的特征:", df.columns[selector.get_support()])分位数的选择取决于你想砍掉多少。15%分位意味着砍掉方差最低的15%特征。这个比例没有金标准,我一般会先看方差分布,如果发现有一批特征方差明显低于其他,就在那个“断崖”处设阈值。
3.4 把方差阈值嵌入Pipeline
实际项目里,方差阈值应该作为预处理Pipeline的一环,而不是单独跑一次。下面是一个完整的Pipeline示例:
from sklearn.pipeline import Pipeline from sklearn.preprocessing import StandardScaler from sklearn.linear_model import LogisticRegression pipe = Pipeline([ ("variance_filter", VarianceThreshold(threshold=0.01)), # 先筛 ("scaler", StandardScaler()), # 后标准化 ("clf", LogisticRegression(max_iter=1000)), ]) # 假设 X_train, y_train 已经准备好 # pipe.fit(X_train, y_train)注意顺序:VarianceThreshold必须在StandardScaler之前。如果反过来,标准化之后所有特征方差都是1,方差阈值就完全失效了。这个顺序问题我在至少两个项目里见过有人搞反,排查了半天才发现筛选没生效。
3.5 稀疏矩阵场景的特殊处理
如果你的特征是one-hot编码后的稀疏矩阵,VarianceThreshold默认不支持稀疏输入,需要设置threshold=0并且数据要是密集的,或者用支持稀疏的写法。实际上sklearn的VarianceThreshold对稀疏矩阵的支持有限,处理稀疏数据时更推荐先转成密集再筛,或者用SelectKBest配合其他标准。
对于稀疏场景,我的经验是:不要用方差阈值去筛one-hot列。因为one-hot列的方差天然就低(大部分是0),但它们往往是有意义的。这时候应该用卡方检验或互信息来筛,而不是方差。
4. 常见问题与排查技巧实录
4.1 筛选后特征数没变,是哪里出了问题
这是最高频的问题。原因通常有三个:
原因一:阈值设得太低。如果所有特征方差都大于阈值,自然一列都不删。先打印df.var()看看方差分布,再决定阈值。
原因二:标准化放在了前面。前面强调过,标准化后方差全为1,阈值只要小于1就筛不掉任何东西。检查Pipeline顺序。
原因三:数据里确实没有低方差列。有些数据集本身就很干净,每列都有变化,这时候方差阈值确实筛不掉东西,属于正常现象,不用怀疑代码。
排查方法很简单:先算方差,再对比阈值,最后看get_support()返回的布尔数组。
4.2 误删了有用特征怎么办
方差阈值最大的风险就是误删。比如某个特征在训练集里几乎不变,但在测试集或未来数据里会变化,这时候删掉它就损失了信息。还有一种情况是稀疏特征,方差低但业务含义强。
应对策略:
- 阈值从0开始,逐步提高,观察每次筛选后模型效果的变化,而不是一步到位设个大阈值;
- 保留筛选前后的特征列表,方便回溯;
- 对稀疏特征单独处理,不要和普通数值特征混在一起筛;
- 在交叉验证里评估筛选效果,而不是只看一次训练结果。
我个人的习惯是:方差阈值只用来删方差严格为0的列,以及方差小于1e-6的列。再往上就要结合业务判断了,不会盲目用分位数一刀切。
4.3 方差阈值和后续模型效果的联动观察
方差阈值本身不保证提升模型效果,它的主要价值是降低计算成本、减少噪声。所以评估它的时候,不要只看准确率有没有涨,还要看:
- 训练时间有没有缩短;
- 模型稳定性有没有提升(比如交叉验证方差变小);
- 特征重要性排序有没有变得更清晰。
如果筛完之后模型效果明显下降,说明阈值设高了,误删了有用特征,需要回调。
4.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方式 |
|---|---|---|---|
| 筛选后特征数不变 | 阈值过低或已标准化 | 打印方差分布 | 提高阈值或调整顺序 |
| 模型效果下降 | 误删有用特征 | 对比筛选前后特征列表 | 降低阈值 |
| 稀疏特征被删 | 阈值对稀疏列过高 | 单独查看稀疏列方差 | 对稀疏列设极低阈值 |
| Pipeline报错 | 稀疏矩阵不支持 | 检查输入类型 | 转密集或换筛选方法 |
| 结果不可复现 | 未固定随机种子 | 检查数据生成 | 设置random_state |
4.5 几个我踩过的坑
第一个坑是在标准化之后做方差阈值,筛了半天发现一列没删,还以为是数据太干净。后来打印方差才发现全是1,白白浪费半小时。
第二个坑是对one-hot矩阵用方差阈值,结果把一些低频但重要的类别特征删了,模型效果反而下降。后来改成只删全0列,问题解决。
第三个坑是阈值设了0.1,在某个数据集上效果不错,换到另一个数据集直接把一半特征删了,因为两个数据集的特征量纲完全不同。这让我意识到方差阈值高度依赖数据分布,不能跨数据集复用阈值。
提示:方差阈值是一个“数据相关”的操作,阈值没有通用值。每次换数据集都要重新看方差分布,重新定阈值。
5. 进阶用法:结合其他筛选方法构建特征选择流水线
5.1 方差阈值 + 卡方检验的两级筛选
方差阈值做粗筛,卡方检验做精筛,这是我用得最多的组合。流程是:先用方差阈值删掉常量列和近常量列,再用卡方检验选出与标签最相关的K个特征。
from sklearn.feature_selection import SelectKBest, chi2 pipe = Pipeline([ ("variance_filter", VarianceThreshold(threshold=0)), ("chi2_select", SelectKBest(chi2, k=50)), ("clf", LogisticRegression(max_iter=1000)), ])这样既保证了去掉无变化列,又保证了保留与标签相关的特征。注意卡方检验要求特征非负,如果数据有负值,需要先做适当变换。
5.2 方差阈值 + 递归特征消除的精度路线
如果追求精度而不是速度,可以在方差阈值之后接递归特征消除(RFE)。RFE会用模型权重反复训练、逐步删特征,计算量大但筛选精度高。方差阈值在这里的作用是先把明显无用的列砍掉,减少RFE的搜索空间。
from sklearn.feature_selection import RFE pipe = Pipeline([ ("variance_filter", VarianceThreshold(threshold=0)), ("rfe", RFE(estimator=LogisticRegression(max_iter=1000), n_features_to_select=20)), ("clf", LogisticRegression(max_iter=1000)), ])这个组合适合特征数在几百到几千、且对精度要求较高的场景。如果特征上万,RFE会非常慢,这时候更适合用方差阈值加卡方或互信息。
5.3 在无监督场景下的独立价值
方差阈值最独特的价值在于无监督可用。聚类、异常检测、降维这些任务没有标签,卡方和互信息都用不了,但方差阈值可以用。比如在做用户分群之前,先用方差阈值删掉那些在所有用户上取值都一样的字段,能让聚类结果更聚焦于真正有差异的维度。
我在一个无监督异常检测项目里,原始特征有300多列,其中近一半是各种配置项,在很多样本上取值相同。用方差阈值删掉这些列之后,特征降到150列左右,孤立森林的训练时间缩短了将近一半,检测效果基本持平。这就是方差阈值的典型价值:不提升效果,但显著降低成本。
5.4 阈值选择的自动化尝试
如果你想让阈值选择更自动化,可以写一个简单的循环,遍历一组候选阈值,观察筛选后特征数和模型交叉验证分数的变化:
from sklearn.model_selection import cross_val_score thresholds = [0, 1e-6, 1e-4, 1e-3, 1e-2, 0.05, 0.1] for t in thresholds: pipe = Pipeline([ ("vf", VarianceThreshold(threshold=t)), ("scaler", StandardScaler()), ("clf", LogisticRegression(max_iter=1000)), ]) scores = cross_val_score(pipe, X_train, y_train, cv=5) print(f"threshold={t}, features={pipe.named_steps['vf'].fit(X_train).get_support().sum()}, cv_score={scores.mean():.4f}")跑完这个循环,你就能看到阈值和特征数、模型分数之间的关系,从而选一个平衡点。注意这个循环里每次都要重新fit,因为不同阈值下保留的特征不同。
我个人在实际操作中的体会是,方差阈值这个工具看起来简单,但真正用好需要理解它的边界:它只负责删“没变化”的列,不负责选“有用”的列。把它放在流水线的最前面做粗筛,配合后面的有监督筛选方法,才能发挥最大价值。阈值不要拍脑袋定,先看方差分布,再从0开始逐步提高,观察特征数和模型效果的变化。最后再分享一个小技巧:如果你的数据里有很多稀疏的one-hot列,方差阈值只用来删全0列就够了,不要用分位数去筛,否则很容易误删低频但重要的类别特征。