
我入行做深度学习的第一年就被正负样本比例失衡狠狠教育过一回。当时做一个工业质检项目良品和次品的比例大约是1000比1模型跑出来准确率99.8%看起来漂亮得不像话结果放到生产线上根本没法用——因为它把所有的次品全部判成了良品准确率全靠把绝大多数样本猜对撑起来。后来我才明白在极度不平衡的分类任务里准确率就是最大的骗局。这个坑几乎每个做分类任务的人都会踩。欺诈检测、医疗诊断、推荐系统、故障预测凡是真实业务里“坏事”很少发生的场景都逃不过这个问题。如果你正在被这类问题折磨或者想在面试里把这个问题讲透这篇文章会给你一套完整的解题思路结合我实际跑过的代码和踩过的坑把五种最实用的方案一次说清楚。1. 正负样本失衡的本质和它带来的连锁反应1.1 失衡到什么程度才算“问题严重”很多初学者觉得样本比例不平衡是个非黑即白的问题其实不然。我自己总结了一个粗线条的判断标准虽然不严谨但非常实用比例小于10比1模型通常还能扛得住通过调整阈值或稍微改一下训练方式就能解决。比例在10比1到100比1之间必须主动干预不然模型会明显偏向多数类。比例超过100比1甚至到1000比1常规手段基本失效需要组合拳还要重新设计训练目标和评估方式。关键不是那个比例数字本身而是少数类样本的绝对数量。如果你的正样本只有几百条那不管负样本是几万还是几十万问题的性质都差不多——你面临的核心困难是“正样本太少模型学不到足够泛化的特征”。反之如果正样本本身有几万条那即使比例是100比1问题也远远没那么可怕。1.2 为什么模型会“偷懒”选择全部预测为多数类从损失函数的角度来看这个问题的逻辑非常直白。拿二元交叉熵来说模型优化的目标是让整体损失最小化。当负样本占据99%的时候把负样本预测对的收益远大于去费力区分那1%的正样本。在梯度下降的过程中多数类的梯度占据了主导地位少数类的贡献直接被淹没了。打个比方一个班级里99个学生都选了A选项只有1个学生选了B选项。老师如果只想让全班平均分最高最省力的策略就是告诉所有人“都选A”这样平均分也能达到99分。但这不是学习这是作弊。神经网络在数据不平衡时做的事情本质上就是这个“偷懒策略”。1.3 评估指标在这里会彻底失效失衡任务里最迷惑人的就是准确率。准确率99%听起来完美但如果正样本只占0.1%那模型只需要把所有人都判负准确率就有99.9%。你看着 loss 一直在降准确率一直在涨model 权重保存得开开心心实际上模型是个“废人”。所以在处理失衡问题之前先把评估指标换成这些Precision精确率预测为正类的样本里有多少是真正的正类。Recall召回率真正的正类样本里有多少被成功找出来了。F1-Score精确率和召回率的调和平均两者兼顾。PR曲线下的面积AUC-PR比ROC更适用于极端不平衡场景。AUC-ROC虽然常用但在极度不平衡时可能过于乐观建议配合PR曲线一起看。这些概念不复杂但它们决定了你后续所有方案的目标方向。你是更在乎“宁可错杀三千不可放过一个”还是更在乎“找出来的尽量都是对的”会直接影响阈值怎么调、loss怎么设计。2. 方案一数据层面的重采样技巧2.1 随机欠采样简单直接但别乱用欠采样的思路很朴素——从多数类里随机抽掉一部分样本让正负样本比例接近1比1或者2比1。它的优点是计算开销小、训练速度快缺点是扔掉了很多可能有用的数据尤其是当多数类内部本身也存在多种子类型时欠采样会让模型丢失对多数类复杂分布的刻画。实际操作里我会用imbalanced-learn这个库代码很简单from imblearn.under_sampling import RandomUnderSampler from collections import Counter rus RandomUnderSampler(sampling_strategy0.5, random_state42) X_resampled, y_resampled rus.fit_resample(X_train, y_train) print(f重采样前的样本分布: {Counter(y_train)}) print(f重采样后的样本分布: {Counter(y_resampled)})这里的sampling_strategy0.5表示让少数类与多数类的数量比为1比2也就是负样本保留两倍于正样本的数量。需要注意欠采样必须在划分完训练集和验证集之后进行而且验证集千万不能动否则验证集的分布会被污染评估结果就失真了。2.2 随机过采样小心过拟合陷阱过采样就是从少数类里重复抽样把正样本复制多份喂给模型。最原始的随机过采样就是简单复制但这种做法很容易让模型对同一批样本“背答案”导致严重的过拟合。我见过有人直接把正样本重复了20倍喂给模型训练集 loss 漂亮得不行验证集上一塌糊涂。原因很简单模型把重复样本的噪声也当成了规律来学习。所以现在做过采样我更推荐用SMOTESynthetic Minority Over-sampling Technique。它的思想不是简单复制而是在正样本的近邻之间插值合成新样本代码实现如下from imblearn.over_sampling import SMOTE smote SMOTE(sampling_strategy0.3, k_neighbors5, random_state42) X_smote, y_smote smote.fit_resample(X_train, y_train) print(fSMOTE后的样本分布: {Counter(y_smote)})原理上SMOTE 会为每个少数类样本找 k 个最近邻默认5然后随机选一个近邻在两个样本的连线上随机取一点作为新样本。这样合成的样本不是已有样本的复制能在一定程度上缓解过拟合问题。2.3 数据增强的边界问题SMOTE虽然好用但我提醒一句它假设样本特征空间是连续的、有意义的。如果是高维稀疏特征比如文本的TF-IDF向量、推荐系统里的离散ID特征SMOTE生成的插值样本可能会落在没有任何实际意义的特征组合上反而引入噪声。我实际项目里就吃过这个亏。当时做文本分类特征用的是 TF-IDF正样本太少就用 SMOTE 去扩结果生成的样本完全不像自然语言的特征分布模型效果反而变差了。对这类场景更务实的做法是如果正样本本身是图片用图像增强旋转、裁剪、色彩抖动来扩充如果是表格数据用 SMOTE 之前先跑一个简单模型验证下特征连续性如果是文本可以考虑回译翻译成另一种语言再翻译回来或者基于语言模型的同义替换。2.4 数据重采样的组合策略现实中单一重采样方式往往不够。我自己用过的组合套路是先 SMOTE 过采样把正负比例拉到1比3左右再配合少量欠采样把多数类压到合理范围。不要追求正负比例恰好1比1在多数情况下1比2到1比3反而更稳因为完全均衡会丢掉多数类的分布信息而且会让训练样本数量变得非常大。另外imbalanced-learn里还提供了一些进阶方法BorderlineSMOTE只对边界区的少数类样本合成新样本。ADASYN根据学习难度自适应决定生成多少个新样本。SMOTEENN、SMOTETomekSMOTE和清洗方法的组合。其中SMOTETomek我用的频率最高因为它在合成样本之后会用 Tomek Links 去清理类间重叠的噪声样本效果比纯 SMOTE 更干净。代码from imblearn.combine import SMOTETomek smt SMOTETomek(sampling_strategy0.5, random_state42) X_res, y_res smt.fit_resample(X_train, y_train)3. 方案二在损失函数上做文章3.1 类别权重加权最容易被忽视的有效手段很多人一上来就想到调数据其实最简单高效的方法往往就在眼皮底下——直接告诉模型“正样本错了要付出更大代价”。这就是类别加权本质是对损失函数里的不同类别样本赋予不同权重。在 scikit-learn 里很多分类器自带class_weight参数一行代码就能搞定from sklearn.linear_model import LogisticRegression model LogisticRegression(class_weightbalanced) model.fit(X_train, y_train)balanced会自动根据样本比例反推权重公式是weight n_samples / (n_classes * np.bincount(y))。也就是说样本数量越少的类别单个样本的权重越高。在 Keras/TensorFlow 里可以通过class_weight参数传入fit函数import numpy as np from tensorflow.keras.models import Sequential from tensorflow.keras.layers import Dense # 假设正样本是1负样本是0 total len(y_train) neg_count np.sum(y_train 0) pos_count np.sum(y_train 1) weight_for_0 (1 / neg_count) * total / 2.0 weight_for_1 (1 / pos_count) * total / 2.0 class_weight {0: weight_for_0, 1: weight_for_1} model Sequential([ Dense(64, activationrelu, input_shape(X_train.shape[1],)), Dense(32, activationrelu), Dense(1, activationsigmoid) ]) model.compile(optimizeradam, lossbinary_crossentropy) model.fit(X_train, y_train, epochs20, batch_size32, class_weightclass_weight)3.2 Focal Loss让模型聚焦难分类样本Focal Loss 最初是目标检测领域为了解决一阶段检测器正负样本极度不平衡提出的被 RetinaNet 带火之后现在几乎成了不平衡分类任务的标配方案之一。Focal Loss 的公式是在标准交叉熵上乘了一个调制因子[ FL(p_t) -\alpha_t (1 - p_t)^\gamma \log(p_t) ]这里的 (p_t) 是模型对正确类别的预测概率。(p_t) 越大说明样本越容易被分类正确((1-p_t)^\gamma) 就越小意味着这个样本的loss贡献被降权。而难分类样本 (p_t) 小loss 权重保留得就多。(\gamma) 是聚焦参数常见取值是0.5到2之间。我自己的经验是轻度失衡用0.5到1重度失衡用1到2。(\alpha_t) 是类别权重用来平衡正负样本的总体比例通常取0.25到0.75之间。用 PyTorch 实现 Focal Loss 非常简单下面是我常用的版本import torch import torch.nn as nn import torch.nn.functional as F class FocalLoss(nn.Module): def __init__(self, alpha0.75, gamma2.0, reductionmean): super(FocalLoss, self).__init__() self.alpha alpha self.gamma gamma self.reduction reduction def forward(self, inputs, targets): # inputs: [batch_size, 1] 或 [batch_size, 2] # targets: [batch_size] 取值为0或1 BCE_loss F.binary_cross_entropy_with_logits(inputs.squeeze(), targets.float(), reductionnone) pt torch.exp(-BCE_loss) # 防止数值溢出用exp(-loss) focal_loss self.alpha * (1 - pt) ** self.gamma * BCE_loss if self.reduction mean: return focal_loss.mean() elif self.reduction sum: return focal_loss.sum() else: return focal_loss3.3 自定义损失函数的注意事项自己写损失函数时最容易出的问题有两个第一个是数值稳定性。直接计算 (\log(p_t)) 时如果 (p_t) 接近0log会趋向负无穷。正确做法是使用log_softmax配合nll_loss或者用binary_cross_entropy_with_logits这样的内置函数让框架在内部做好数值稳定处理。第二个是alpha和gamma的配合。很多同学直接网上抄一个alpha0.25, gamma2就当万能键用这其实是目标检测的默认参数不一定适合你的场景。实际调参时我的建议是先固定 gamma扫一组 alpha 值0.1到0.9看看 PR 曲线面积的变化再固定效果最好的 alpha扫 gamma0.5到3观察趋势。另外一个要注意的是Focal Loss 对噪声标签比较敏感。因为难分类样本既包含真正有价值的困难样本也可能包含标注错误的样本Focal Loss 会把两者的权重都放大。如果数据集标签质量本身不高使用时要格外谨慎。3.4 其他值得尝试的损失函数除了 Focal Loss我在工作中还会根据场景交替使用以下损失Weighted BCE就是给标准二分类交叉熵加一个类别权重实现最简单作为基线非常合适。Dice Loss直接优化 Dice 系数语义分割领域常用对小目标友好但训练时梯度不够平滑。Asymmetric LossASL对正负样本的梯度衰减采用不对称策略在很多多标签分类任务上效果比 Focal Loss 更好。这些损失函数本质都是重新分配样本对梯度的贡献理解了这一点就不会觉得它们是什么玄学。你选择哪个取决于你的数据分布和任务目标——如果只追求快速出效果类别权重就够了如果想要更精细的控制再上 Focal Loss 或 ASL。4. 方案三利用数据增强和难例挖掘4.1 针对少数类做特征空间增强对于表格数据SMOTE 这种基于特征插值的方法有用但天花板很低。真正的突破往往来自对少数类样本做合理扰动——关键在于“合理”这两个字。以图像分类为例正样本少的时候我们不会直接复制图片而是用旋转、翻转、缩放、颜色抖动等方式制造变体。这些变体保留了原图的核心语义但增加了视角、光照等维度的多样性让模型学到更鲁棒的特征。4.2 OHEM在线难例挖掘的暴力美学难例挖掘的思路是训练过程中模型对某些少数类样本的预测置信度很低这些就是“难例”。把这些难例挑出来单独加大权重或者反复训练让模型把火力集中在这些薄弱环节上。OHEM 的实现逻辑分为三步正常前向传播得到每个样本的 loss。按 loss 从大到小排序取出前 N 个样本通常是batch的一半。只用这些难例的梯度做反向传播。这样模型每次更新的“教材”都是当前最不会做的题训练效率非常高。用 PyTorch 实现的核心片段如下# 伪展示 OHEM 的核心逻辑 outputs model(batch_x) loss_per_sample F.binary_cross_entropy_with_logits( outputs.squeeze(), batch_y.float(), reductionnone ) # 从大到小排序取前keep_ratio比例的样本 keep_num int(loss_per_sample.size(0) * keep_ratio) _, indices torch.topk(loss_per_sample, keep_num) # 只用难例的loss均值做反向传播 loss loss_per_sample[indices].mean() loss.backward() optimizer.step()我在实际使用 OHEM 时有几个心得keep_ratio一般在 0.25 到 0.5 之间太小会让模型训练不稳定太大就失去难例挖掘的意义。OHEM 和类别不平衡是天然搭档。因为正样本本来就少它们天然更容易成为难例所以 OHEM 相当于隐式地提高了正样本的权重。前期不要急着开 OHEM。我习惯先用普通训练跑10个epoch左右等模型有一定分辨能力之后再开启难例挖掘不然早期模型的误判噪声会污染训练。4.3 自监督预训练给少数类“开小灶”当少数类样本非常少比如只有几百张图的时候常规的模型从头训练基本都会过拟合。有一个更高级的打法先用所有数据做自监督预训练比如 SimCLR、MAE让模型先学会通用的特征表示然后再在少量标注数据上微调。这种方式在医疗影像领域非常常见。比如一个病灶检测任务标注的阳性样本只有200张阴性样本有2000张。直接用有监督学习训练模型很快就会过拟合。但如果先在2000多张图不管标注上用对比学习做预训练让模型学到“什么样的CT影像长什么样”然后再用200张阳性样本去微调效果会提升非常多。我做过一个实验在同样200个正样本的条件下有自监督预训练的模型比从头训练的模型 A U C 高出大约15个百分点。这个方案实现起来有一定门槛但在样本极度稀缺的场景下收益是最大的。5. 方案四用集成学习训练多个模型投票5.1 Bagging 欠采样让每个模型看到不同数据单个模型在极度不平衡的数据上学习总觉得“巧妇难为无米之炊”。集成学习的思路是把数据切分成多个不同的子集训练多个模型最后投票融合这样每个模型虽然都有偏科但合起来能取长补短。比较常用的做法是 EasyEnsemble对多数类做多次欠采样每次采出和少数类数量差不多的子集分别训练模型最后对结果取平均。我用imbalanced-learn里的BalancedBaggingClassifier实现过代码非常简洁from imblearn.ensemble import BalancedBaggingClassifier from sklearn.tree import DecisionTreeClassifier bbc BalancedBaggingClassifier( estimatorDecisionTreeClassifier(max_depth8), n_estimators20, sampling_strategyauto, random_state42 ) bbc.fit(X_train, y_train) y_pred_proba bbc.predict_proba(X_test)[:, 1]这种方式的好处是每个子模型都在相对均衡的数据上训练从源头上缓解了偏差多个模型的集成又降低了方差。缺点是训练时间长要训练多个模型、推理时间也线性增加。适合在数据量中等、对实时性要求不高的场景使用。5.2 Balanced Random Forest随机森林本身就是多个决策树的集成。Balanced Random Forest 的核心改动是每棵决策树训练时从多数类中有放回抽取和少数类等量的样本而不是从全量数据里抽样。这样就保证了每一棵树看到的数据都是平衡的。这个方案我用的频率很高因为它几乎不需要调参而且天然支持并行训练。imbalanced-learn里也有现成实现from imblearn.ensemble import BalancedRandomForestClassifier brf BalancedRandomForestClassifier( n_estimators200, max_depth15, random_state42 ) brf.fit(X_train, y_train)5.3 异质模型融合的实战心得上面提到的都是同质模型集成。在实际业务里我更倾向于做异质模型融合——把不同的模型架构、不同的采样策略、不同的特征工程组合起来效果往往比单一模型好得多。我最近一个贷前风控项目就是这么干的三个基础模型分别是用 SMOTE 扩样后的 XGBoost用类别权重的 LightGBM用 Focal Loss 的神经网络。三个模型各自预测概率最终用加权平均融合。权重不是固定的而是在验证集上用最简单的网格搜索来确定。融合后的模型比最优单模型的 AUC 高出约2个百分点F1 提升了接近4个百分点尤其在极端正样本上稳定性明显增强。融合的教训是基础模型之间的差异越大融合效果越好。如果三个模型用的都是相同的采样策略、相同的特征、相同的损失函数那融合基本不会带来提升因为它们的错误是高度相关的。6. 方案五阈值调整和评估策略优化6.1 为什么默认阈值0.5不适合你很多深度学习框架训练完模型后直接用output 0.5来判定正负类。这个0.5隐含的假设是正负样本的先验概率相等。当你面对100比1的不平衡数据时模型输出的概率分布整体会偏向低值0.5这个阈值已经完全不适用了。正确的做法是训练完模型后在验证集上搜索最优阈值。目标函数可以根据业务需求设定——如果追求 F1 最大化就搜 F1 最大的阈值如果业务要求召回率不低于90%就搜满足约束条件下精确率最高的阈值。用 sklearn 实现阈值搜索非常简单import numpy as np from sklearn.metrics import f1_score, precision_recall_curve # y_valid是验证集真实标签y_pred_proba是模型输出的概率 precision, recall, thresholds precision_recall_curve(y_valid, y_pred_proba) f1_scores 2 * precision * recall / (precision recall 1e-9) best_idx np.argmax(f1_scores) best_threshold thresholds[best_idx] print(f最优阈值: {best_threshold:.4f}) print(f最优F1: {f1_scores[best_idx]:.4f}) # 用最优阈值重新预测 y_pred (y_pred_proba best_threshold).astype(int)我习惯在验证集上做这个搜索然后把这个阈值应用到测试集。一个真实的案例某项目默认0.5阈值时模型召回率只有32%——也就是68%的正样本全被漏掉了。把阈值调到0.12之后召回率升到78%虽然精确率有所下降但综合业务收益提升了不止一个量级。6.2 PR曲线才是你的“照妖镜”ROC 曲线在类别不平衡场景下会严重“虚报军情”。原因在于 ROC 的横轴是假正率FP / (FP TN)分母里包含了大量负样本。当负样本很多的时候即使假阳性数量很大假正率依然可以很小所以 ROC 曲线看起来非常漂亮。PR 曲线则不同。它的横轴是召回率纵轴是精确率两条轴都直接和正样本相关。当正样本很少的时候PR 曲线能更敏感地反映模型找正样本的能力。这就是为什么业内人士普遍认为不平衡分类任务看 PR 曲线平衡任务看 ROC 曲线。6.3 分层采样保证验证集分布一致还有一个非常容易忽略的细节数据划分时一定要用分层采样。如果用普通的随机划分在正样本只有5%的数据集上有一定概率验证集里正样本的比例只有2%甚至没有正样本。这样一来你的验证结果不仅不稳定而且完全不可信。sklearn 的train_test_split只需加一个参数from sklearn.model_selection import train_test_split X_train, X_val, y_train, y_val train_test_split( X, y, test_size0.2, stratifyy, random_state42 )stratifyy会保证训练集和验证集里正负样本比例和原始数据一致。这个习惯我用了五年从来没有出过岔子。7. 实战复盘一个真实项目中的五种方案对比7.1 实验设置和数据集说明以前段时间我做过的一个信用卡欺诈检测项目为例数据分布是正样本欺诈284条负样本正常284315条比例约为1比1000。特征共有28个经过PCA降维的数值特征经典的Credit Card Fraud Detection数据集另外还有Time和Amount两个原始字段。我把数据按7比3分层划分为训练集和验证集固定随机种子确保可复现。基线模型是一个不加任何处理的神经网络结构是三层MLP激活函数ReLU输出层sigmoid优化器Adam学习率1e-3训练30个epoch早停机制patience5。7.2 各种方案在验证集上的表现方案精准率召回率F1AUC-PR基线无处理0.860.210.340.42类别权重加权0.720.860.780.75SMOTE过采样0.730.840.780.73Focal Lossalpha0.75, gamma20.700.880.780.76OHEM 类别权重0.740.850.790.77集成Balanced RF 加权XGB0.780.890.830.81可以看到基线模型的召回率惨不忍睹只有0.21。用了任何一种处理后召回率都能提升到0.85左右。多方案融合的效果最好F1从0.34提升到了0.83提升幅度非常可观。7.3 关键结论和选择建议结合实验结果我总结出了几条相对稳定的选择逻辑如果你只有半天时间先给模型加类别权重再在验证集上搜索最优阈值。这两个改动加起来不超过20行代码通常能把 F1 从0.3拉到0.7以上。如果你有时间精调用 Focal Loss 或者 OHEM配合网格搜索调 gamma 和 alpha同时做阈值搜索。这一步能把 F1 再提升5个百分点。如果你要上线模型追求极致效果做集成融合用不同采样策略和不同模型结构制造差异性把基础模型的输出做概率融合通常还能再提升3到5个百分点。如果正样本只有几十条别挣扎了单靠这些方案救不了你。赶紧先去搞数据想想能不能用预训练模型做迁移学习或者直接换一个任务定义方式。这些方案不是互相排斥的我在实际项目里通常会用至少两种组合。比如类别权重 阈值搜索是最常见的起步套装数据量允许的话SMOTE Focal Loss 阈值搜索效果更强集成融合通常是压轴大招。7.4 后续还可以怎么扩展如果你在这个方向想继续深入有几个进阶课题可以考虑图神经网络 节点不平衡分类在社交网络、金融网络中少数类样本往往涉及节点间的结构关系单纯在特征维度上做重采样会丢失结构信息。用 GraphSMOTE 这类方法在图上直接合成少数类节点是这几年比较热门的方向。半监督和伪标签如果无标注数据很多可以让模型先对无标注数据预测把高置信度的少数类预测结果当作伪标签加入训练集不断迭代。异常检测视角当正样本少到近乎没有的时候可以把问题从二分类切换成单类分类One-Class Classification用 Isolation Forest、Deep SVDD 这类模型直接描述正常样本分布偏离分布的就是异常。每一个方向都能单独写一篇长文。这篇文章写到的五种方案更多是一个基础框架帮你在面对不平衡问题时先有一套可以落地的武器库。等你在真实场景里把每一种方案的坑都踩一遍之后对“模型为什么学不好少数类”这件事会有远比公式更深的理解。我个人在实际操作中的体会是没有银弹组合拳才是王道。数据层面改分布损失层面改权重训练层面改采样推理层面改阈值评估层面换指标——每一层都贡献一点叠起来就是天壤之别。别指望靠一招一式解决所有问题把每个环节都做到60分整体效果一定会超过单一环节做到90分。