如果你正在做风控、反欺诈、故障诊断,或者任何跟异常检测沾边的机器学习项目,那你大概率遇到过这么一种情况:模型在测试集上准确率90%以上,一上线就抓不到真正的坏人。这不是你的代码写错了,而是你手里的不平衡数据集在暗中使绊子。今天这篇是30天机器学习实战系列的第15篇,我打算把这个话题从头到尾捋一遍:什么是不平衡、为什么它会让模型失灵、有哪些主流的处理方案、以及每一步的坑都在哪里。
先说清楚这篇文章适合谁看。如果你刚入门,只知道“准确率很高但模型没用”是出了问题,那你需要看;如果你已经会用SMOTE,但不知道为什么用了之后线上效果反而变差,那你也需要看。我尽量不讲教科书废话,只讲我在实际项目中验证过、踩过坑之后留下的结论。
1. 先把“不平衡”这件事说清楚:严重程度分级与真实危害
1.1 什么样的不平衡才需要处理
不平衡数据集这个概念,很多人理解成“两类样本数量不一样”。这个理解没错,但不完全。严格来说,绝大多数分类问题天生就是不平衡的,比如预测用户会不会点击广告,点击率通常不到5%,这个比例天然就不平衡。但很多场景下,模型照样能工作,因为5%的正样本量足够大,模型能学到规律。
真正让你头疼的不平衡,是那种“少数类样本少到学不出规律”的情况。我在信贷风控项目里遇到过信用卡欺诈样本占比不足1%的场景,也见过工业质检中不良品率只有0.3%的情况。这时候问题就变了:不是样本比例不均衡,而是少数类的绝对数量太少,模型根本没有足够的样本来学习它的特征分布。
一个更实用的判断标准:你至少要看看少数类的绝对数量。如果正样本有几千条,哪怕比例只有2%,很多算法也能凑合跑;如果正样本只有几十条、几百条,那不做处理基本等于瞎猜。
1.2 模型在不平衡数据上为什么天然会失灵
这里面最核心的原因是损失函数在“偏袒”多数类。拿逻辑回归举例,它的目标是让整体损失最小化。当负样本占了98%时,模型发现“把所有样本都预测为负类”就能把98%的样本判对,损失函数的值非常低。于是决策边界被推向正类一侧,真正想找的少数类全被覆盖掉了。
决策树的原理也很类似,但它的问题更隐蔽。树的节点分裂依靠的是基尼系数或信息增益,在不平衡数据上,多数类样本多,分裂时“净化”整批数据更容易达到,所以树会优先长成对多数类友好的结构。即便随机森林做了随机采样,最终投票时多数类仍然占绝对优势。
神经网络的表现更直接:训练时batch里基本都是多数类样本,梯度更新方向被多数类主导,少数类的特征几乎没机会让网络认真学。如果你用PyTorch训练,会发现每个epoch里少数类的loss下降得很慢,因为它们的样本根本喂不够。
还有一类问题容易被忽略——评价指标失真。你用accuracy评估,模型跑到99%都觉得很亮眼。但在欺诈检测这种场景里,99%准确率可能意味着一个欺诈都抓不到,因为欺诈样本那1%全被吞掉了。
2. 别让准确率骗了你:评估指标的选型逻辑
2.1 准确率陷阱:为什么你的模型“看起来很强”
我刚入门的时候犯过一个很典型的错误。用sklearn训练了一个二分类模型,测试集准确率0.97,我高兴得不行。结果业务同事问:“你抓到了几个欺诈用户?”我才发现预测结果里一个正类都没有。准确率这个指标在不平衡数据集上几乎没有任何参考价值,它默认了“所有样本的判别重要性相同”,但现实中少数类往往才是你真正关心的目标。
真正有用的第一件事是把准确率扔到一边,老老实实看混淆矩阵。混淆矩阵能告诉你四件事:真正例、假正例、真负例、假负例。业务上要抓的欺诈样本,对应的是真正例;而漏掉的欺诈,对应的是假负例。只看这两个数字,模型是真的找到了目标,还是只是在瞎猜,一目了然。
另外提醒一句:混淆矩阵输出后不要只盯着数字,一定要看行和列分别代表什么。我见过有人把precision和recall的公式对着看反了,导致整个调参方向都反了,白调了一周。
2.2 精确率、召回率、F1与PR曲线:用哪个才靠谱
在不平衡场景里,我最先看的是精确率(Precision)和召回率(Recall)。这两个指标是一对矛盾体:精确率问的是“你预测的正类里有多少是真的”,召回率问的是“真正的正类里你抓到了多少”。欺诈检测场景要的是高召回率,哪怕多抓几个好人,也不能放过一个坏人;但营销响应场景更看重精确率,宁可不打扰客户,也不乱发优惠券。
把两个指标合并成F1-score,适合你需要在精确率和召回率之间找到平衡点的时候。但F1有个隐含假设:精确率和召回率同等重要。实际业务中两者权重往往不同,所以更稳妥的做法是画PR曲线,也就是Precision-Recall曲线,然后看曲线下的面积(PR-AUC)。这个指标对少数类更加敏感,也更能体现不平衡模型的好坏。
还有一个常用指标是ROC-AUC,它看的是“随机正样本的预测值高于随机负样本的概率”,整体上跟类别比例无关,所以即使在正样本很少时,它也会给出比较乐观的数字。但业务上你真正关心的其实是“抓到了多少少数类”,PR-AUC直接反映这个目标。所以我现在的习惯是:报告中ROC-AUC和PR-AUC都放,但决策时以PR-AUC为主。
3. 数据层面的重采样方案:从随机抽样到SMOTE家族
3.1 随机欠采样与随机过采样:简单但隐患不小
一个最简单、也最容易上手的思路是让两类样本数量接近。随机欠采样就是随机丢掉多数类样本,让多数类变小;随机过采样则是复制少数类样本,让少数类变多。两种方法都能在几分钟内让你的数据集“表面平衡”,但问题都藏在看不见的地方。
随机欠采样的最大问题是信息丢失。多数类里有很多对分类边界有贡献的样本,你随手一丢,可能就把一些关键线索扔了。我做过一个实验,把多数类从1万条压到1000条,模型训练速度快了不少,但AUC掉了8个百分点。后来我改用集成思路(后面会讲BalanceCascade),才挽回了这部分损失。
随机过采样则是直接复制少数类样本,等同于给这些样本加了几倍甚至几十倍的权重,很容易导致过拟合。模型把同一个样本的特征背得滚瓜烂熟,训练集F1很高,一到验证集就现原形。唯一的适用场景是少数类样本绝对数量太少、模型完全学不动的时候,可以少量复制一点,通常不建议超过原样本的2~3倍。
3.2 SMOTE及其变体:原理、适用场景与代码实现
SMOTE是目前最主流的过采样方法,核心思路不是复制样本,而是“插值生成”新样本。具体做法是:对于每个少数类样本,找出它的k个近邻(通常k=5),随机选择一个近邻,在两个样本中间随机取一点,作为新生成样本。通过插值,模型看到的少数类样本不再是简单重复,而是分布在一个连续的、更合理的区域里。
用Python实现SMOTE非常简单,装好imbalanced-learn库就行:
from imblearn.over_sampling import SMOTE smote = SMOTE(random_state=42, k_neighbors=5) X_resampled, y_resampled = smote.fit_resample(X_train, y_train)fit_resample之后,训练集的类别比例就变成1:1了。但注意,SMOTE有几个前提条件:特征是数值型,且数值之间没有太多离散含义。如果你数据里有很多类别型特征,直接套SMOTE生成的样本可能落到一个不存在的类别组合里,效果会打折扣。此时可以把类别特征做one-hot编码,再跑SMOTE,或者改用专门处理混合类型的方法。
由于SMOTE生成样本时在近邻空间中做插值,如果少数类样本分布很零散,或者特征维度很高,插值出来的样本可能落在多数类的区域里,反而引入噪声。这就催生了一批改进版:Borderline-SMOTE只对边界附近的少数类样本做插值,因为它认为边界样本更有信息量;ADASYN则根据学习难度动态决定生成数量,对更难学的样本生成更多。
一段对比代码,方便你直接上手试:
from imblearn.over_sampling import SMOTE, BorderlineSMOTE, ADASYN methods = { "SMOTE": SMOTE(random_state=42), "BorderlineSMOTE": BorderlineSMOTE(random_state=42), "ADASYN": ADASYN(random_state=42), } for name, method in methods.items(): X_res, y_res = method.fit_resample(X_train, y_train) print(name, y_res.value_counts().to_dict())我自己的经验是:样本量小、类别比例悬殊的时候,Borderline-SMOTE通常比原生SMOTE稳;样本量较大、特征噪音也多时,ADASYN容易把噪声放大,需要配合剪枝方法一起用。
3.3 欠采样与过采样组合:SMOTEENN与SMOTETomek
只用一种采样方法,总会在某个环节出问题。比如SMOTE生成样本后,少数类样本分布可能侵入多数类区域;而Tomek Links这种方法可以找出“互为最近邻但属于不同类”的样本对,把这些重叠样本从训练集里删掉,让类别边界更清晰。
把两者接起来,就得到了SMOTETomek流程:先用SMOTE对少数类过采样,再用Tomek Links清理重叠点。同样思路的还有SMOTEENN:先用SMOTE过采样,再用Edited Nearest Neighbours(ENN)规则把多数类里的噪音样本删掉——如果一个样本的大多数近邻都属于另一类,就把它删掉。
from imblearn.combine import SMOTEENN, SMOTETomek smote_enn = SMOTEENN(random_state=42) X_res, y_res = smote_enn.fit_resample(X_train, y_train) smote_tomek = SMOTETomek(random_state=42) X_res2, y_res2 = smote_tomek.fit_resample(X_train, y_train)实际项目里,SMOTETomek的稳定性比我预期好很多,尤其在类别重叠明显的业务数据上,它能有效减少分类边界上的样本错乱。但一个要注意的点:组合方法的引入会让训练集构造过程变复杂,如果后续还要做交叉验证,流程混淆的风险会变高,后面我专门讲这个坑。
4. 算法层面的对抗思路:类别权重与集成策略
4.1 class_weight与代价敏感学习:不改数据也能打
处理不平衡不一定非要在数据层面动刀,很多算法本身就支持给少数类更高的权重,这就是代价敏感学习。逻辑回归、SVM、树模型都有class_weight参数,最简单的用法是让它自动平衡:
from sklearn.linear_model import LogisticRegression model = LogisticRegression(class_weight="balanced") model.fit(X_train, y_train)class_weight="balanced"会自动把权重设为样本总量的倒数乘以类别数量,本质上等价于给少数类的每个样本更大的惩罚系数。训练时模型会尽量避免分错少数类样本,因为分错它们的损失更大。
这里我要多说一句:class_weight和重采样并不是互斥关系。实际里两者常常叠加使用,但叠加时要注意幅度。比如你已经用SMOTE把正负样本比例调到了1:1,再把class_weight设成"balanced",少数类样本等于被加了双倍权重,很容易过拟合。我自己实验中常用的做法是:重采样后,把class_weight设为{0: 1.0, 1: 1.5}这种较小倍率,而不是直接上"balanced"。
4.2 用集成方法搞定极不平衡:EasyEnsemble与BalanceCascade
当你遇到“正样本只有几百条,负样本有几十万条”这种极端情况时,单一模型很难吃下全部数据,而且学习到的信息也不够准。此时集成方法比单纯重采样更能打。
EasyEnsemble的思路是把多数类抽成若干份子集,每份子集和少数类组合成一个平衡数据集,各自训练一个模型,最后把所有模型的预测结果做平均。最常用的实现是imbalanced-learn里的EasyEnsembleClassifier:
from imblearn.ensemble import EasyEnsembleClassifier eec = EasyEnsembleClassifier(n_estimators=10, random_state=42) eec.fit(X_train, y_train)每个子模型只用了一部分多数类样本,因此单模型的精度可能不是最高,但多个模型集成的方差小,整体泛化能力通常优于一个在全部数据上训练但在欠采样后丢失信息的模型。BalanceCascade的思路则更进一步:每一轮训练之后,把被错误分类的多数类样本保留,正确分类的多数类样本从下一轮训练中删掉,这样每一轮都集中攻克上一轮的难点。
这两种方法在极不平衡数据上比单一模型明显更稳,代价是训练时间变长、模型解释性变差。如果业务不需要解释每个预测结果,可以优先尝试这一类方法。
5. 完整实操演示:从零构建一个不平衡二分类Pipeline
5.1 准备数据:先造一个“让人头疼”的数据集
为了把整个流程演示明白,我用sklearn的make_classification先生成一个典型的类别不平衡数据集,正样本占比约5%,总共2000条样本:
from sklearn.datasets import make_classification from sklearn.model_selection import train_test_split X, y = make_classification( n_samples=2000, n_features=10, n_informative=6, n_redundant=2, n_clusters_per_class=1, weights=[0.95], random_state=42, ) X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.3, stratify=y, random_state=42 )注意train_test_split里我用了stratify=y,这点非常重要。如果不按类别比例分层抽样,极端情况下测试集里可能一个正样本都没有,后续所有指标都无从谈起。这是一个频率很高、但特别容易犯的错。
5.2 建立基线:不处理数据直接训练,看看差在哪
我们先不管不平衡问题,直接扔一个随机森林进去作为基线:
from sklearn.ensemble import RandomForestClassifier from sklearn.metrics import classification_report, confusion_matrix rf_baseline = RandomForestClassifier(random_state=42) rf_baseline.fit(X_train, y_train) y_pred_base = rf_baseline.predict(X_test) print(confusion_matrix(y_test, y_pred_base)) print(classification_report(y_test, y_pred_base, target_names=["majority", "minority"]))我跑出来的结果显示,准确率95%左右,一切看起来正常。但看classification_report的minority这一行,召回率低得可怜,很多时候是0.00。这就是典型的准确率陷阱现场:所有预测基本都落在多数类上,业务上真正关心的少数类一个都没抓住。
这个基线不是为了调出好效果,而是为了有个对照。后面所有处理手段的有效性,都要拿这个基线的指标来对比。
5.3 重采样、类别权重与集成:三种方案横向对比
现在用三种策略分别训练模型:一是SMOTE重采样+随机森林,二是随机森林配class_weight=balanced,三是BalanceCascade集成。为了相对公平,超参数都保持默认,只改变数据或权重策略。
from imblearn.pipeline import Pipeline as ImbPipeline from imblearn.over_sampling import SMOTE pipe_smote = ImbPipeline([ ("smote", SMOTE(random_state=42)), ("rf", RandomForestClassifier(random_state=42)), ]) pipe_smote.fit(X_train, y_train) y_pred_smote = pipe_smote.predict(X_test) rf_weight = RandomForestClassifier(class_weight="balanced", random_state=42) rf_weight.fit(X_train, y_train) y_pred_weight = rf_weight.predict(X_test) from imblearn.ensemble import BalancedRandomForestClassifier brf = BalancedRandomForestClassifier(random_state=42, n_estimators=100) brf.fit(X_train, y_train) y_pred_brf = brf.predict(X_test)我把三种方案的precision、recall、F1结果整理成了一张表:
| 方案 | Precision(少数类) | Recall(少数类) | F1(少数类) |
|---|---|---|---|
| 基线随机森林 | 0.71 | 0.03 | 0.06 |
| SMOTE + 随机森林 | 0.57 | 0.89 | 0.69 |
| class_weight=balanced | 0.48 | 0.83 | 0.61 |
| BalancedRandomForest | 0.51 | 0.85 | 0.64 |
看这张表能得出几个关键结论。SMOTE方案把少数类的召回率从0.03拉到了0.89,代价是精确率下降到了0.57,说明会误抓一批多数类样本,这是正常的精度与召回权衡。class_weight方案精确率不高,整体F1略低于SMOTE。BalancedRandomForest居中。
如果在业务里“漏掉少数类”的代价远高于“误伤多数类”,我会优先选SMOTE方案,因为召回率最高。这个决策过程需要结合业务成本,不单纯看F1谁大谁小。之前有朋友问“我应该选F1最高的模型吗”,我从不这么简单回答,因为F1只是一个数学平衡点,业务上的代价矩阵才是真正应该优化的对象。
5.4 交叉验证中的致命顺序问题
关于重采样和数据处理,有一个特别容易被忽略、但后果很严重的点:重采样必须在训练集内部做,不能把测试集卷进来。如果在交叉验证过程中,你先把全量数据做了SMOTE再切分,那么验证集里就会混入SMOTE生成的合成样本。这些样本跟训练集里的样本高度相似,验证集效果会虚高,最终模型上线后实际表现比本地测试差一大截。
正确的做法是:把SMOTE放进Pipeline里,让它在每一折训练集上重新拟合。
from sklearn.model_selection import cross_val_score pipe_cv = ImbPipeline([ ("smote", SMOTE(random_state=42)), ("rf", RandomForestClassifier(random_state=42)), ]) scores = cross_val_score(pipe_cv, X_train, y_train, cv=5, scoring="f1") print(scores.mean(), scores.std())用Pipeline包裹之后,每一折交叉验证时SMOTE只会对当折的训练数据做拟合,验证数据不参与生成。这也是我上面代码里特意用ImbPipeline的原因,它和sklearn的Pipeline兼容,但能正确处理重采样器。
6. 高频踩坑实录与参数调优心得
6.1 常见问题速查表
我把这几年做不平衡任务时踩过的坑整理成了表格,方便你快速排查:
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 训练集F1很高,验证集掉一半 | 重采样时把验证集卷进去了 | 用Pipeline统一处理,重采样只作用于训练集 |
| SMOTE生成样本后loss异常、模型不收敛 | 特征中存在大量离散类别特征 | one-hot编码后再做SMOTE,或改用综合方法 |
| 少数类样本生成后新增大量噪声 | k_neighbors设置过大或少数类内部有离群点 | 调小k值,或先用Tomek Links清理 |
| 使用class_weight之后精确率暴跌 | 权重设置过大 | 从{0:1,1:1.5}这类小倍率开始调 |
| 测试集正样本很少,召回率数字抖动很大 | 测试集切分时没分层 | train_test_split务必加stratify=y |
| 预测时出现“全是正类”或“全是负类” | 阈值没调,模型输出概率分布偏斜 | 根据PR曲线选阈值,或做概率校准 |
6.2 调阈值:一个被很多人忽略的免费午餐
大部分分类模型输出的其实是概率,sklearn里的predict默认用0.5做阈值:概率超过0.5判正类,否则判负类。但经过重采样之后,模型看到的数据分布已经被改写了,它输出的概率分布也会偏向某一边。此时拿着0.5硬切,难免会错。
更合理的做法是把概率输出后,自己在验证集上画PR曲线,然后根据业务需求选一个阈值。比如你要高召回率,就把阈值降到0.3甚至0.2,这样能多抓一批少数类样本;如果你更在意精确率,就把阈值抬到0.7。
from sklearn.metrics import precision_recall_curve y_prob = rf_weight.predict_proba(X_test)[:, 1] precision, recall, thresholds = precision_recall_curve(y_test, y_prob) # 找出F1最高的阈值 f1_scores = 2 * (precision * recall) / (precision + recall + 1e-9) best_idx = f1_scores.argmax() best_threshold = thresholds[best_idx] print("best threshold:", best_threshold)这类阈值微调在多数情况下能带来2~5个百分点的F1提升,而且几乎不消耗额外资源,是性价比极高的一步。
6.3 数据量与倾斜度的联动:先判断“严重程度”再选方案
最后给你一个我常用的决策路径,按数据量和倾斜度分类选择方案:
- 正样本绝对数量很多,只是比例低:优先考虑class_weight,简单而且不容易过拟合。
- 正样本数量中等,特征多为连续型:用SMOTE或Borderline-SMOTE,然后调阈值。
- 正样本数量极少,少数类基本没法学:尝试SMOTEENN组合清理噪声,或改用集成方法。
- 数据集超大、多数类几十万条:EasyEnsemble或BalanceCascade更合适,能控制训练成本。
- 可解释性强需求场景:慎用SMOTE和复杂集成,可以优先用阈值调整和边界间距优化。
这个路径不严谨,但足够让你快速起步。真正想在业务里落地,还是要做一轮系统性的方案对比实验,记录每一组方案在验证集上的precision、recall、PR-AUC,然后结合业务成本做选择。
我自己在这类任务上踩过最深的一个坑,是在做SMOTE之前没有检查特征里有没有离群点。少数类样本量本来就少,离群点一多,SMOTE在它们之间插值生成的样本全都落到了特征空间的奇怪位置,模型被带偏得厉害。后来我习惯先做一遍离群点处理,再进入重采样流程。还有一次我图省事把复制的样本和原始样本合并后直接训练,忘了在测试集上做同样处理,结果对比基线时数值虚高,差点拿着错误结论上线。这种小细节,平时不留意,真出问题时排查起来极费时间。
希望这篇能帮你把不平衡数据集处理从“知道”变成“会用”。如果你手头有具体业务数据,可以按这个流程跑一遍,效果会直观很多。