
如果你正在做风控、反欺诈、故障诊断或者任何跟异常检测沾边的机器学习项目那你大概率遇到过这么一种情况模型在测试集上准确率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个近邻通常k5随机选择一个近邻在两个样本中间随机取一点作为新生成样本。通过插值模型看到的少数类样本不再是简单重复而是分布在一个连续的、更合理的区域里。用Python实现SMOTE非常简单装好imbalanced-learn库就行from imblearn.over_sampling import SMOTE smote SMOTE(random_state42, k_neighbors5) 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_state42), BorderlineSMOTE: BorderlineSMOTE(random_state42), ADASYN: ADASYN(random_state42), } 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 NeighboursENN规则把多数类里的噪音样本删掉——如果一个样本的大多数近邻都属于另一类就把它删掉。from imblearn.combine import SMOTEENN, SMOTETomek smote_enn SMOTEENN(random_state42) X_res, y_res smote_enn.fit_resample(X_train, y_train) smote_tomek SMOTETomek(random_state42) 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_weightbalanced) model.fit(X_train, y_train)class_weightbalanced会自动把权重设为样本总量的倒数乘以类别数量本质上等价于给少数类的每个样本更大的惩罚系数。训练时模型会尽量避免分错少数类样本因为分错它们的损失更大。这里我要多说一句class_weight和重采样并不是互斥关系。实际里两者常常叠加使用但叠加时要注意幅度。比如你已经用SMOTE把正负样本比例调到了1:1再把class_weight设成balanced少数类样本等于被加了双倍权重很容易过拟合。我自己实验中常用的做法是重采样后把class_weight设为{0: 1.0, 1: 1.5}这种较小倍率而不是直接上balanced。4.2 用集成方法搞定极不平衡EasyEnsemble与BalanceCascade当你遇到“正样本只有几百条负样本有几十万条”这种极端情况时单一模型很难吃下全部数据而且学习到的信息也不够准。此时集成方法比单纯重采样更能打。EasyEnsemble的思路是把多数类抽成若干份子集每份子集和少数类组合成一个平衡数据集各自训练一个模型最后把所有模型的预测结果做平均。最常用的实现是imbalanced-learn里的EasyEnsembleClassifierfrom imblearn.ensemble import EasyEnsembleClassifier eec EasyEnsembleClassifier(n_estimators10, random_state42) eec.fit(X_train, y_train)每个子模型只用了一部分多数类样本因此单模型的精度可能不是最高但多个模型集成的方差小整体泛化能力通常优于一个在全部数据上训练但在欠采样后丢失信息的模型。BalanceCascade的思路则更进一步每一轮训练之后把被错误分类的多数类样本保留正确分类的多数类样本从下一轮训练中删掉这样每一轮都集中攻克上一轮的难点。这两种方法在极不平衡数据上比单一模型明显更稳代价是训练时间变长、模型解释性变差。如果业务不需要解释每个预测结果可以优先尝试这一类方法。5. 完整实操演示从零构建一个不平衡二分类Pipeline5.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_samples2000, n_features10, n_informative6, n_redundant2, n_clusters_per_class1, weights[0.95], random_state42, ) X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.3, stratifyy, random_state42 )注意train_test_split里我用了stratifyy这点非常重要。如果不按类别比例分层抽样极端情况下测试集里可能一个正样本都没有后续所有指标都无从谈起。这是一个频率很高、但特别容易犯的错。5.2 建立基线不处理数据直接训练看看差在哪我们先不管不平衡问题直接扔一个随机森林进去作为基线from sklearn.ensemble import RandomForestClassifier from sklearn.metrics import classification_report, confusion_matrix rf_baseline RandomForestClassifier(random_state42) 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_weightbalanced三是BalanceCascade集成。为了相对公平超参数都保持默认只改变数据或权重策略。from imblearn.pipeline import Pipeline as ImbPipeline from imblearn.over_sampling import SMOTE pipe_smote ImbPipeline([ (smote, SMOTE(random_state42)), (rf, RandomForestClassifier(random_state42)), ]) pipe_smote.fit(X_train, y_train) y_pred_smote pipe_smote.predict(X_test) rf_weight RandomForestClassifier(class_weightbalanced, random_state42) rf_weight.fit(X_train, y_train) y_pred_weight rf_weight.predict(X_test) from imblearn.ensemble import BalancedRandomForestClassifier brf BalancedRandomForestClassifier(random_state42, n_estimators100) brf.fit(X_train, y_train) y_pred_brf brf.predict(X_test)我把三种方案的precision、recall、F1结果整理成了一张表方案Precision少数类Recall少数类F1少数类基线随机森林0.710.030.06SMOTE 随机森林0.570.890.69class_weightbalanced0.480.830.61BalancedRandomForest0.510.850.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_state42)), (rf, RandomForestClassifier(random_state42)), ]) scores cross_val_score(pipe_cv, X_train, y_train, cv5, scoringf1) 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务必加stratifyy预测时出现“全是正类”或“全是负类”阈值没调模型输出概率分布偏斜根据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在它们之间插值生成的样本全都落到了特征空间的奇怪位置模型被带偏得厉害。后来我习惯先做一遍离群点处理再进入重采样流程。还有一次我图省事把复制的样本和原始样本合并后直接训练忘了在测试集上做同样处理结果对比基线时数值虚高差点拿着错误结论上线。这种小细节平时不留意真出问题时排查起来极费时间。希望这篇能帮你把不平衡数据集处理从“知道”变成“会用”。如果你手头有具体业务数据可以按这个流程跑一遍效果会直观很多。