做深度学习分类任务这么久最让我头疼的不是网络结构选不出来而是正负样本比例失衡。银行风控里欺诈样本往往不到1%医疗影像里病灶区域可能只占几十个像素工业质检中次品率长期在0.5%以下——这些场景下模型训练出来看loss好像在收敛精度能到98%甚至99%但一上线全完了模型把所有样本都预测成负类关键业务指标直接归零。如果你也遇到过这种情况那这篇文章就是写给你的。我会从实际问题出发把深度学习分类任务中应对正负样本比例失衡的5种实战解决方案全部拆开讲清楚包括每种方案的原理、适用场景、完整可运行的代码还有我在实际项目中踩过的坑和调参经验。内容不绕弯子直接讲怎么做、为什么这么做。在开始之前先说个前提无论用哪种方案第一步一定是把数据集划分做好。必须用分层采样也就是sklearn里的train_test_split(..., stratifyy)保证训练集、验证集、测试集中少数类占比一致。否则验证集里可能一条正样本都没有后面的评估全部失真。1. 失衡问题全景认知先想清楚再做方案1.1 为什么失衡会让普通模型“罢工”很多人不理解比例失衡为什么会让模型失效。其实深度学习和机器学习模型在训练时默认是在最小化整体损失。少数类样本数量少它们对总损失的贡献占比就小模型只要把所有样本都预测成多数类就能把整体损失压得很低。举个例子正负样本比例1:99模型全预测成负类准确率也有99%。看起来很漂亮但对业务毫无用处。所以处理失衡问题第一个要改的不是模型而是评估指标不要盯着accuracy看要看Precision、Recall、F1-score、AUC-ROC、AUC-PR。尤其是极度失衡场景PR曲线比ROC曲线更能反映模型真实水平因为PR曲线不受大量负样本影响能直接体现少数类的查全和查准能力。1.2 失衡的程度决定方案选择不同失衡程度方案选择的优先级完全不同。我一般把失衡状况分为三个等级轻度失衡1:2到1:10调整评估指标适当调一下决策阈值或者简单加权损失就够了。中度失衡1:10到1:100建议用SMOTE过采样、加权损失函数或者两者结合。重度失衡1:100以上这时候单一方案往往不够需要过采样、Focal Loss、阈值调整组合使用极端情况下可以考虑换思路把少数类当成异常点用异常检测来解决问题。这个分级不是死规矩但它能帮你快速找到方向而不是一上来就堆一大堆方案最后不知道是哪个在起效果。2. 方案一从多数类里做减法——下采样三大变体2.1 随机下采样最简单但别乱用下采样的思路很直接让多数类样本数量变少与少数类大致持平。随机下采样就是从多数类里随机抽一部分样本和少数类拼成新的训练集。from imblearn.under_sampling import RandomUnderSampler from collections import Counter # 假设 X_train, y_train 是训练数据和标签 # sampling_strategyauto 表示采样后所有类别的样本数都与最少类一致 rus RandomUnderSampler(sampling_strategyauto, random_state42) X_resampled, y_resampled rus.fit_resample(X_train, y_train) print(采样前分布:, Counter(y_train)) print(采样后分布:, Counter(y_resampled))随机下采样的优点是快、简单缺点是它盲目丢弃大量多数类样本很可能把多数类里的重要信息也一起丢掉了。如果多数类内部本身有复杂的子结构——比如正常用户也分不同行为模式——随机丢弃就容易让模型对多数类的判断能力变差。所以我的建议是随机下采样只适合数据量足够大、多数类冗余度极高的场景。如果你手里总共就几万条数据慎重。2.2 NearMiss用近邻信息挑“有价值的”多数类既然随机丢不靠谱那就按“信息量”来丢。NearMiss的思想是只保留那些与少数类距离最近的多数类样本因为这些样本最容易被模型混淆是决策边界附近的“关键样本”。from imblearn.under_sampling import NearMiss # version2 表示保留离少数类最近的多数类样本 nm NearMiss(version2, n_neighbors3, sampling_strategyauto) X_resampled, y_resampled nm.fit_resample(X_train, y_train)NearMiss有三个版本实际项目里我用version2最多。version1选的是离少数类最近的多数类样本version2选的是离所有少数类样本平均距离最近的多数类样本version3则是两边都想着保留距离近但又不扎堆的样本。用NearMiss的好处是模型能把有限的容量集中在决策边界附近坏处是它计算距离消耗大数据量大时非常慢而且对异常值敏感。2.3 ClusterCentroids用聚类中心替代样本簇如果再高级一点可以用聚类对多数类做降维式采样。ClusterCentroids先把多数类聚成K个簇然后用每个簇的中心点代替这个簇的多条样本保留多数类的整体分布结构又不会丢掉太多个性化信息。from imblearn.under_sampling import ClusterCentroids cc ClusterCentroids(sampling_strategyauto, random_state42) X_resampled, y_resampled cc.fit_resample(X_train, y_train)这个方法相比随机下采样信息保留度更高相比NearMiss速度更快。但要注意聚类中心是“合成点”不再来自原始样本空间如果后续要解释模型、看具体的样本长什么样可能会别别扭扭的。一般我会在特征标准化之后再做ClusterCentroids否则聚类结果容易被量纲大的特征带跑。2.4 下采样方案的使用心得下采样最大优点是训练速度显著加快因为数据量变小了。但它有个天然短板把data搞得越来越少对深度神经网络这种“吃数据”的模型不太友好。我的经验是下采样更适合浅层模型、树模型或者数据规模本身很大的深度学习任务如果你只有1万条数据还去做下采样那基本等于自废武功。顺带说一句无论用哪种下采样都只能作用在训练集上验证集和测试集必须保持真实分布。否则你用测试集评估出来的指标是假的上线就现原形。3. 方案二给少数类“造数据”——SMOTE及其进化版本3.1 SMOTE核心原理在样本之间插值下采样是做减法过采样则是做加法。简单复制少数类样本容易过拟合SMOTESynthetic Minority Over-sampling Technique聪明的地方在于它会在两个近邻少数类样本之间做插值生成新的、逻辑上合理的样本。具体过程是对每个少数类样本xi先找出它的K个近邻默认K5然后从近邻中随机选一个xj在xi和xj的连线上随机取一个点作为新样本。这样生成的样本不是原样本的简单复制而是在特征空间里有实际意义的插值点。from imblearn.over_sampling import SMOTE smote SMOTE(sampling_strategyauto, k_neighbors5, random_state42) X_resampled, y_resampled smote.fit_resample(X_train, y_train) print(SMOTE前分布:, Counter(y_train)) print(SMOTE后分布:, Counter(y_resampled))当少数类样本数量很少时k_neighbors要调小比如少到只有几十条K5可能都找不到足够的近邻这时可以降到3甚至2。3.2 BorderlineSMOTE和ADASYN专治边界难样本SMOTE有个问题它对所有少数类样本一视同仁包括那些远离边界、非常“安全”的样本。但在失衡问题里真正难的是边界上的少数类。于是有了BorderlineSMOTE和ADASYN。BorderlineSMOTE会先区分少数类样本哪些处于“危险区”周围多数类占多数然后专门在这些边界样本附近生成新样本。ADASYN更进一步它会根据每个少数类样本的学习难度动态决定生成多少新样本越难学习的样本生成的新样本越多。from imblearn.over_sampling import BorderlineSMOTE, ADASYN # BorderlineSMOTE: 只向边界困难样本做插值 bsmote BorderlineSMOTE(sampling_strategyauto, k_neighbors5, random_state42) X_res, y_res bsmote.fit_resample(X_train, y_train) # ADASYN: 自适应的按难度生成样本 adasyn ADASYN(sampling_strategyauto, n_neighbors5, random_state42) X_res, y_res adasyn.fit_resample(X_train, y_train)从我实际项目的结果看如果少数类和多数类在特征空间里有明显的重叠区域ADASYN效果通常比普通SMOTE好如果少数类样本本身质量参差不齐、噪声多ADASYN反而会放大噪声这时候普通SMOTE或BorderlineSMOTE更稳。3.3 深度学习中必须注意的数据泄漏陷阱这个问题我必须单独强调因为它太隐蔽了不少人在这里翻车。在传统的机器学习里你可以先对整个训练集做SMOTE再送去训练问题不大。但在深度学习中如果你的数据量很大需要分批batch训练直接在全部训练数据上做SMOTE会导致有的合成样本极小概率和验证集样本的特征空间发生重叠评估结果虚高。更稳妥的做法是把数据增广、过采样放到训练process里只对训练集使用验证集、测试集一律保持原样。如果你用的是PyTorch可以在PyTorch里写一个带过采样的Dataset也可以用imblearn的Pipeline它会把采样器当作数据变换的一环自动只作用在训练集上from imblearn.pipeline import Pipeline from sklearn.svm import SVC pipe Pipeline([ (smote, SMOTE(random_state42)), (clf, SVC()) ]) pipe.fit(X_train, y_train)亲测这样能把数据泄漏风险降到最低。3.4 什么时候SMOTE效果差SMOTE不是万能的有几种情况我劝你放弃稀疏特征场景。比如文本TF-IDF向量绝大多数维度是0SMOTE在0值之间插值生成的全是毫无意义的稀疏向量。特征维度极高的场景。维度一高欧氏距离的区分度急剧下降“近邻”根本不可靠。少数类样本量极少的场景少于5条时做SMOTE基本等于在几个点之间反复内插过拟合风险极大。遇到这几种情况可以考虑用数据增强图像里翻转裁剪、文本里同义替换来代替SMOTE本质上也是一种更合理的“过采样”。4. 方案三加权损失函数让模型主动关注少数类4.1 核心思路把“少数”转换成“权重”既然失衡的本质是少数类对总损失的贡献太小那最直接的办法就是给少数类样本的loss乘以一个更大的系数让它们在梯度更新里占据更多话语权。这种做法就是加权损失函数也叫class_weight。权重怎么定一个常用公式是weight_for_class total_samples / (n_classes * class_count_of_class)比如总样本10000个正样本100个负样本9900个二分类情况下weight_for_positive 10000 / (2 * 100) 50 weight_for_negative 10000 / (2 * 9900) ≈ 0.51也就是说每个正样本在loss里相当于50个负样本的贡献。sklearn里有现成的方法直接算from sklearn.utils.class_weight import compute_class_weight import numpy as np class_weights compute_class_weight( class_weightbalanced, classesnp.unique(y_train), yy_train ) class_weight_dict {i: class_weights[i] for i in range(len(class_weights))} print(class_weight_dict)4.2 Keras/TensorFlow中的加权实现在Keras里只需要给model.fit()传一个class_weight参数import tensorflow as tf model tf.keras.Sequential([ tf.keras.layers.Dense(64, activationrelu, input_shape(X_train.shape[1],)), tf.keras.layers.Dense(32, activationrelu), tf.keras.layers.Dense(1, activationsigmoid) ]) model.compile(optimizeradam, lossbinary_crossentropy, metrics[accuracy]) history model.fit( X_train, y_train, validation_data(X_val, y_val), epochs30, batch_size64, class_weightclass_weight_dict, verbose1 )如果你做的是多分类同样的方式class_weight_dict的key就是类别索引。4.3 PyTorch中的加权实现PyTorch里更直接CrossEntropyLoss自带weight参数import torch import torch.nn as nn class_weights_tensor torch.tensor(class_weights, dtypetorch.float).to(device) criterion nn.CrossEntropyLoss(weightclass_weights_tensor) # 训练循环中正常使用即可 loss criterion(outputs, labels)对于二分类的BCEWithLogitsLoss也同样支持weight参数。4.4 权重设置的经验技巧按公式算出来的权重有时候直接上会“用力过猛”模型会变得过度关注少数类导致大量误报、Precision掉得厉害。我自己踩过这个坑。后来我的做法是把理论权重当作上限实际从理论值的1/2、1/4、1/10开始试观察验证集F1曲线选一个不过拟合的中间值。还有一个更细化的操作是sample_weight它可以精确到每个样本而不是每个类别。比如你怀疑某些少数类样本标签打错了不想让它们参与太大权重就可以把这些样本的sample_weight调低。Keras的fit()同样支持sample_weight参数。加权损失的优势是改动极小、效率高不改变数据不增加训练时间适合快速验证缺点是权重调起来比较费劲而且如果两类在特征空间高度重叠单纯加权并不能帮助模型学到更好的边界。5. 方案四Focal Loss易分样本压制术5.1 为什么分类任务会“淹没”在易分样本里Focal Loss最早来自目标检测领域用来解决正负框比例极度失衡的问题但它完全可以平移到一般的分类任务中。它的核心洞察很深刻即使在加权之后还是会有大量“易分类”的样本——比如多数类中那些远离决策边界、模型很确定它们是负类的样本。这些样本单条loss虽然小但架不住数量多累积起来在梯度中占比巨大把少数类和难分类样本的贡献淹没了。普通交叉熵对这类“已经被分对的样本”没有任何抑制作用模型还会反复学它们。Focal Loss做了一件事给那些已经被正确分类的样本加一个衰减系数让它们的loss迅速变小从而把模型的注意力腾出来聚焦到难分类样本和少数类样本上。5.2 Focal Loss核心公式Focal Loss的定义是FL(p_t) -alpha * (1 - p_t)^gamma * log(p_t)其中p_t表示模型预测到真实类别的概率。如果样本被预测对了且置信度很高p_t接近1(1-p_t)^gamma趋近于0loss被大幅压低如果样本难分p_t接近0衰减系数接近1loss基本不受影响。gamma就是调制因子gamma0时Focal Loss退化为普通交叉熵。gamma越大对易分样本的压制越强。alpha的作用是平衡正负类别的全局权重类似于class_weight。5.3 PyTorch实现完整代码二分类版本的Focal Loss我直接给一份能跑的代码import torch import torch.nn as nn import torch.nn.functional as F class FocalLoss(nn.Module): def __init__(self, alpha1, gamma2, reductionmean): super(FocalLoss, self).__init__() self.alpha alpha self.gamma gamma self.reduction reduction def forward(self, inputs, targets): # inputs: [batch_size, 1]未经过sigmoid的logits # targets: [batch_size]取值为0或1 BCE_loss F.binary_cross_entropy_with_logits(inputs, targets, reductionnone) pt torch.exp(-BCE_loss) # 等价于 sigmoid后预测正确类的概率 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_loss训练时这样调用criterion FocalLoss(alpha0.75, gamma2) logits model(X_batch) loss criterion(logits, y_batch.float().unsqueeze(1))多分类版本需要稍微改一下alpha从标量变成每个类别的权重向量class MultiFocalLoss(nn.Module): def __init__(self, alphaNone, gamma2, reductionmean): super(MultiFocalLoss, self).__init__() self.gamma gamma self.reduction reduction self.alpha alpha # 可传入包含每个类别权重的张量 def forward(self, inputs, targets): ce_loss F.cross_entropy(inputs, targets, reductionnone) pt torch.exp(-ce_loss) if self.alpha is not None: alpha_t self.alpha[targets] focal_loss alpha_t * (1 - pt) ** self.gamma * ce_loss else: focal_loss (1 - pt) ** self.gamma * ce_loss if self.reduction mean: return focal_loss.mean() elif self.reduction sum: return focal_loss.sum() else: return focal_loss如果你用的是Keras可以用tf.keras.layers.Lambda包一层自定义loss函数或者重写train_stepPyTorch这边实现起来更顺手所以我项目里通常会把用Focal Loss的任务单独用PyTorch来做。5.4 Focal Loss调参经验alpha和gamma这对参数我试过很多组合分享一个还算通用的规律gamma从2开始往往是最稳的起点。如果模型对多数类过拟合、少数类被完全忽略可以调大gamma到2.5或3。如果模型变得不稳定、loss震荡、训练初期发飘说明压制过头了降回1.5。alpha可以设置为少数类的比例补偿比如少数类只占1%alpha设为0.75或0.9这里alpha是调制系数方向是让少数类别保留更多loss贡献如果结果误报特别多把alpha往0.5方向调。Focal Loss和class_weight组合使用也可以Focal Loss里的alpha已经处理了类别全局权重再加class_weight可能会导致双重加权、梯度异常。我一般会先单用Focal Loss调gamma不够再加class_weight而不是一上来两个都怼上。6. 方案五调整决策阈值压轴的后处理神器6.1 别默认0.5模型输出概率才是真正的财富不管是深度模型还是传统分类器二分类最终输出的是一个0到1之间的概率。我们通常用0.5作为阈值把概率切成正类和负类这个0.5只是一个数学上的方便默认值它假设“把正类判成负类”和“把负类判成正类”的代价完全一样。但失衡业务里这个假设几乎不成立。欺诈检测里漏掉一笔欺诈可能损失几万元而误报一个正常用户可能只是打个电话确认医疗筛查里漏诊的代价远高于误诊。所以阈值不应该是拍脑袋的0.5而是根据业务取向在验证集上扫出来的。6.2 用验证集扫描最优阈值具体做法是模型训练好之后对验证集输出预测概率然后从0到1遍历所有候选阈值找到让F1或者其他你关心的指标最大化的那个阈值。from sklearn.metrics import precision_recall_curve import numpy as np # model 是训练好的模型X_val 是验证集特征y_val 是验证集标签 proba model.predict_proba(X_val)[:, 1] # 正类概率 precision, recall, thresholds precision_recall_curve(y_val, proba) # 计算每个阈值对应的F1注意thresholds比precision少一个元素 f1_scores 2 * precision[:-1] * recall[:-1] / (precision[:-1] recall[:-1] 1e-12) best_idx np.argmax(f1_scores) best_threshold thresholds[best_idx] print(f最优阈值: {best_threshold:.4f}, 最优F1: {f1_scores[best_idx]:.4f}) # 用最优阈值重新预测 y_pred_new (proba best_threshold).astype(int)如果你追求的是在固定召回率下尽量提高准确率可以用np.argmin(np.abs(recall[:-1] - target_recall))反推阈值这也是一种做法。关键点只有一个阈值必须在验证集上选然后拿到测试集上验证绝不能在测试集上直接调阈值那属于作弊。6.3 阈值调整的实战价值很多人忽略了阈值调整的收益这是因为他们习惯性把模型输出当作“预测类别”而不是“概率”。其实阈值调整可能是所有方案里性价比最高的一步不用改数据、不用改网络结构、不用重新训练只改一个判断边界就能显著改善少数类召回。但要注意阈值调整不能凭空创造信息。如果模型输出的概率完全没有区分度正负类概率分布几乎完全重叠扫出来的阈值也不会有太大帮助。它适合的场景是模型学到了一些区分信号只是默认0.5不合适。6.4 组合使用多方案叠加的正确姿势五种方案不是互斥的实际项目里我通常按这个顺序组合先做分层采样保证验证集可靠。用加权损失函数或Focal Loss重新训练模型二选一别一开始就叠加。在验证集上扫描阈值找到最优切分点。如果效果仍不理想再考虑SMOTE过采样但一定要控制SMOTE比例比如只把少数类扩到占整体的20%-30%而不是硬拉到50%。最后用测试集整体评估记录Precision、Recall、F1、AUC-PR。这种组合思路比单点方案的鲁棒性高很多。7. 几个容易翻车的场景复盘最后聊几个我在项目里真实遇到过的问题给各位提个醒。第一个坑是用了SMOTE之后训练集上F1很高验证集上一塌糊涂。排查了半天发现是SMOTE在交叉验证里泄漏了——SMOTE应该在每一折的fold内部做完整个过采样如果用整个训练集先过采样再交叉验证每个fold的验证集会混入由训练集生成的合成样本指标严重失真。第二个坑是对文本或稀疏特征做SMOTE。有次做文本分类特征是用TF-IDF拼的200维稀疏向量跑完SMOTE之后发现新生成的样本90%的特征值全是0说明书上的“插值”在稀疏空间里生成了大量无意义样本。第三个坑是在pipeline里把下采样和过采样一起用。理论上没问题但顺序搞错会出大问题。正确顺序是先对多数类做下采样再对少数类做SMOTE这样可以避免SMOTE生成样本与后续下采样冲突操作不当会让训练集分布变得非常奇怪。我记得还有一次用了非常极端的类别权重1:100直接怼上去结果训练loss彻底发飘梯度爆炸最后把权重降到1:10配合一个比较小的学习率才稳定下来。所以别迷信公式要多跑几次验证实验看趋势。最后一个建议做重度失衡的深度学习项目不要一头扎进去调模型先用简单的线性和树模型跑一遍baseline算出PR曲线和F1如果baseline完全不能看再思考特征和模型结构的问题而不是把所有锅都甩给“样本失衡”。样本失衡确实碍事但它掩盖不了特征质量低下的问题。正负样本比例失衡没有一劳永逸的银弹但只要你把评估指标定对、把数据泄漏堵死、再把以上五种方案按需组合起来绝大多数业务场景都能扛过去。下一次再遇到99:1的数据集至少你知道该从哪里下手了。
