做分类项目时间稍长一点,一定会撞上一堵墙:数据不平衡。我昨天在调试一个二分类模型的时候,训练集里正样本只有1.5%,默认参数跑完,准确率高得吓人,可业务方真正关心的那部分样本,几乎一个都没挖掘出来。今天这一篇笔记,就集中梳理一下不平衡数据集问题从诊断到处理的全流程,包括重采样技术、损失函数调整、评估指标的选择,以及一个可以直接照做的对比实验。这次内容对做欺诈检测、风控评分、故障预测、医学诊断这类稀有事件场景的同学尤其有用。
1. 先诊断再动手:什么样的不平衡才真正需要处理
很多人一看到“不平衡”三个字就紧张,立刻想到上采样、下采样、SMOTE,恨不得把工具箱里的东西全倒出来。但我的经验是,先别急着动手,先问三个问题:类别比例到底是多少?少数类在业务上到底值多少钱?模型现在真的不行吗?
1.1 不平衡程度的量化分级
不平衡程度不是非黑即白,业内通常按少数类占比分成几个档位。我用一张表概括一下常见情况。
| 程度 | 多数类:少数类 | 典型场景 | 处理策略 |
|---|---|---|---|
| 轻度不平衡 | ≤ 10:1 | 用户流失、营销响应 | 通常不需要专门处理,调阈值即可 |
| 中度不平衡 | 10:1 ~ 100:1 | 信用违约、设备故障 | 建议尝试类别权重或轻量重采样 |
| 高度不平衡 | 100:1 ~ 1000:1 | 欺诈交易、网络入侵 | 需要系统性方案,重采样+算法级调整+阈值移动 |
| 极度不平衡 | > 1000:1 | 罕见病筛查、超大流量异常过滤 | 需要专门设计,常规方法容易失效 |
这个分级没有绝对标准,但能帮你快速判断复杂度的起点。比如10:1的数据,你用默认逻辑回归跑出来,少数类召回率可能已经能接受;但1000:1的数据,不管用什么模型,直接训练几乎必然把所有样本都判成多数类。
1.2 业务目标决定了少数类的价值
这里有个反直觉的点:即使类别比例很悬殊,有些场景仍然不需要处理。比如一个电商平台上“北京用户”和“非北京用户”的比例可能是1:20,但如果业务目标是对所有用户做个性化推荐,品牌方都希望预测得准确,那这个不平衡就没有那么“致命”。因为少数类本身没有更高的业务价值,误判少数类也不会带来额外损失。
真正需要处理不平衡的场景,通常满足两个条件:
- 少数类代表“事件发生”,比如欺诈、故障、患病,漏掉一个的代价极高。
- 模型默认会牺牲少数类来换取整体准确率。
所以,处理不平衡的第一步不是选算法,而是确认“少数类真的值得重视”。如果你担心漏掉欺诈交易,那么召回率就是你最重要的指标;如果你做的是精准营销,希望降低骚扰用户的比例,那精确率就更重要。这两个不同的侧重,会直接决定你后面用哪种手段。
1.3 用基线模型先判断“问题有多严重”
在动手重采样之前,我习惯先跑一个最简单的基线:用默认参数训练一个逻辑回归或随机森林,然后看混淆矩阵。只要少数类召回率惨不忍睹,那就说明模型确实被多数类带偏了。如果连基线都能把少数类识别得七七八八,说明数据分布或者特征本身已经足够可分,这时候强行加SMOTE反而可能画蛇添足。
基线实验同时也是后续所有方案的“锚点”。没有这个锚点,你后面看到的任何指标提升都是没有参照物的浮动。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据层重采样:过采样、欠采样与SMOTE的真实边界
一旦确认问题存在,首先想到的往往就是改变数据的分布,让样本数量看起来平衡一点。这一章我重点讲三条路线:随机欠采样、随机过采样、以及最常用的SMOTE系列。
2.1 随机欠采样:简单直接,但信息丢弃严重
随机欠采样的逻辑很朴素:从多数类里随机抽出一部分样本,使得多数类和少数类数量接近。比如多数类有9000条,少数类1000条,那就从多数类里抽1000条,组成一个各1000条的新训练集。
它的优点很明显:
- 训练速度快,内存占用小。
- 当多数类数据本身有大量冗余时,减少样本量对模型效果影响不大。
- 在某些高噪声数据集上,去掉一部分多数类反而能提升模型对少数类的敏感度。
但缺点也同样致命——随机丢弃多数类样本,就是随机丢弃信息。如果多数类内部有多个不同的子分布,抽样的随机性可能让某些重要模式全部被丢弃。实际项目中,我在一个故障预测数据集上尝试过随机欠采样,把训练集缩小到原来的20%后,多数类的精确率大幅下降,整体效果反而不如不处理。
所以我的建议是:随机欠采样适合“多数类海量且冗余、训练资源紧张”的场景,但不适合样本总量本来就不大的场景。它最大的价值是作为其他重采样方法的对照,而不是终极方案。
2.2 随机过采样:复制少数类会让模型“背答案”
随机过采样的做法更简单:把少数类样本复制几份,直到数量追上多数类。实现上几乎零成本,一行代码就能搞定。但它的问题在于——模型会过度记忆被重复的样本。
为什么?因为模型训练时的损失函数会在每个样本上计算梯度。如果你把同一个少数类样本复制10遍,相当于这个样本的梯度被放大10倍。模型会努力拟合这些重复样本的具体特征,而不是学习少数类的规律。结果就是训练集上的评估指标异常漂亮,一到测试集就原形毕露。
2.3 SMOTE:用插值生成新样本才是更可靠的方式
SMOTE全称是Synthetic Minority Over-sampling Technique,它的核心思路不是复制,而是“在少数类样本之间插值生成新样本”。具体步骤说人话就是:
- 随机选中一个少数类样本,写作 ( x_i )。
- 在它的k个最近邻居(同样属于少数类)中随机挑一个,写作 ( x_{nn} )。
- 在 ( x_i ) 和 ( x_{nn} ) 的连线上随机取一个点,用公式表达就是:
[
x_{new} = x_i + \lambda \times (x_{nn} - x_i)
]
其中 ( \lambda ) 是0到1之间的随机数。
举个具体例子。假设少数类样本 ( x_i = [0.2, 0.5] ),它的邻居 ( x_{nn} = [0.4, 0.7] ),随机取 ( \lambda = 0.3 ),那么新样本就是:
[
x_{new} = [0.2 + 0.3 \times 0.2, 0.5 + 0.3 \times 0.2] = [0.26, 0.56]
]
新样本不落在原样本位置,而是落在两个真实样本之间的“空白地带”,这就人为地扩大了少数类在特征空间里的覆盖范围。
SMOTE系列有很多变体,我比较常用的是这几个:
- Borderline-SMOTE:只对边界区域的少数类样本做插值。边界样本指的是那些容易被多数类混淆的样本,针对它们生成新样本,能让模型更关注分类边界。
- ADASYN:自适应合成采样,对“难学”的少数类样本分配更高的生成权重。每次迭代都会调整权重,让新样本集中在模型难以分类的区域。
- SMOTE-NC:当特征里混合了数值和分类变量时使用,它处理离散特征的方式和连续特征不同,避免生成不存在的类别组合。
在实际工程里,SMOTE通常是重采样工具箱的第一选择,因为它比随机过采样更不容易过拟合,又比随机欠采样保留更多信息。
2.4 最容易翻车的细节:先切分数据,再做重采样
这里有个坑,几乎每个新手甚至一些老手都会踩:拿到数据,先对整个数据集做SMOTE,再train_test_split。这个顺序是错的,而且错得很隐蔽。
原因很直接:如果你在切分之前做重采样,那么SMOTE生成的合成样本会被同时分进训练集和测试集。测试集里出现了“由训练集样本插值生成的样本”,这等于测试集提前看到了训练集的信息,评估结果会呈现一种虚假的乐观。也就是说,线上效果远不如你的验证指标。
正确做法是:先切分训练集和测试集,然后只对训练集做重采样,测试集保持原始分布不变。在交叉验证的场景下,重采样必须在每一折内部单独进行,不能提前在全部训练数据上重采样再分折。
用代码写就是这样:
python复制from imblearn.over_sampling import SMOTE
from sklearn.model_selection import train_test_split
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.3, random_state=42, stratify=y
)
smote = SMOTE(random_state=42)
X_train_resampled, y_train_resampled = smote.fit_resample(X_train, y_train)
在scikit-learn的Pipeline里,需要配合imblearn自带的Pipeline使用,才能让重采样在交叉验证的每一折内部正确执行:
python复制from imblearn.pipeline import Pipeline
from sklearn.ensemble import RandomForestClassifier
pipeline = Pipeline([
('smote', SMOTE(random_state=42)),
('clf', RandomForestClassifier(n_estimators=200, random_state=42))
])
这个细节,如果没注意,会让你的模型评估结果虚高10%甚至更多,但项目上线后立刻现原形。
3. 算法层调整:类别权重、Focal Loss与损失函数改造
数据层处理有它的天花板,特别是当样本总量极小、特征维度很高时,重采样生成的新样本很可能引入噪声。这时候就需要换个思路:不动数据,动模型。让模型在训练时天然地更重视少数类。
3.1 class_weight背后的数学逻辑
最常见的算法层手段,就是给少数类更高的损失权重。以二分类交叉熵损失为例,标准形式是:
[
L = -\frac{1}{N}\sum_{i=1}^{N} \left[ y_i \log(p_i) + (1-y_i) \log(1-p_i) \right]
]
引入类别权重后,每个样本的损失会被乘上对应的权重 ( w_c ):
[
L = -\frac{1}{N}\sum_{i=1}^{N} \left[ w_1 y_i \log(p_i) + w_0 (1-y_i) \log(1-p_i) \right]
]
这样,少数类样本的 ( \log(p_i) ) 被放大了,模型每次预测出错都会受到更重的惩罚,训练时自然会往“更准确预测少数类”的方向调整。
在scikit-learn中,最简单的方式是设置 class_weight='balanced',它的权重公式是:
[
w_c = \frac{N_{total}}{n_classes \times N_c}
]
假设训练集10000条样本,多数类9000条、少数类1000条:
- 多数类权重:
[
w_0 = \frac{10000}{2 \times 9000} \approx 0.556
] - 少数类权重:
[
w_1 = \frac{10000}{2 \times 1000} = 5.0
]
少数类的每个样本在损失中的贡献将近多数类的9倍。在使用时也可以手动传入自定义权重:
python复制clf = RandomForestClassifier(class_weight={0: 1.0, 1: 5.0})
实际使用中,我建议先从 'balanced' 开始跑,看效果再微调权重。权重设得过大,模型会走另一个极端:把大量多数类误判成少数类,精确率暴跌。
PyTorch里面对应的做法是给 CrossEntropyLoss 传 weight 参数:
python复制import torch.nn as nn
weights = torch.tensor([0.556, 5.0])
loss_fn = nn.CrossEntropyLoss(weight=weights)
3.2 从交叉熵到Focal Loss:难样本挖掘的数学直觉
类别权重虽然简单,但它对所有少数类样本一视同仁。问题在于:少数类样本里也有“容易学”和“难学”的区分。有些欺诈样本特征非常明显,模型已经学得不错了;有些欺诈样本和正常交易几乎一模一样,模型怎么也学不会。Focal Loss的思路就是把注意力集中到后者上。
先看标准交叉熵(以二分类为例):
[
CE(p_t) = -\log(p_t)
]
Focal Loss在它前面乘了一个调制因子:
[
FL(p_t) = -(1-p_t)^\gamma \log(p_t)
]
其中 ( \gamma ) 通常取2。现在用具体数值来感受一下:
| 样本情况 | ( p_t ) | 交叉熵损失 | Focal Loss ( \gamma=2 ) | 衰减倍数 |
|---|---|---|---|---|
| 易分类多类样本 | 0.95 | 0.051 | 0.000128 | 约400倍 |
| 中等难度样本 | 0.60 | 0.511 | 0.082 | 约6倍 |
| 难分类少数类样本 | 0.30 | 1.204 | 0.590 | 约2倍 |
看到没有?模型已经很有把握的样本,损失被大幅压低;而模型完全搞不定的样本,损失几乎没怎么减少。这样梯度更新的重点自然就落在了困难样本上。这个机制很像老师上课:优等生已经掌握的知识点,老师不会反复讲;而那些一学就会的同学,老师要反复强调、多花时间,直到他们真的把薄弱环节补上。
Focal Loss最常用于深度模型,尤其是在目标检测领域,它由何恺明团队提出,最初就是为了解决一阶段检测器里正负样本极度不平衡的问题。经典的实现思路是这样的:
python复制def focal_loss(logits, targets, alpha=0.25, gamma=2.0):
ce_loss = F.cross_entropy(logits, targets, reduction='none')
pt = torch.exp(-ce_loss)
focal_loss = alpha * (1 - pt) ** gamma * ce_loss
return focal_loss.mean()
在传统树模型上,Focal Loss不是不能实现,但要改动底层目标函数比较麻烦,性价比不高。因此我通常把它用在神经网络、深度学习那类任务上,而树模型先用类别权重。
3.3 树模型、集成方法与类别权重的搭配思路
随机森林、XGBoost、LightGBM这类集成树模型,对不平衡数据有一定天然鲁棒性,因为每棵树只在随机子样本上训练,少数类在部分树上会有相对更高的比重。但实际效果并不会自动好到哪去,最终的叶子节点预测仍会被多数类主导。
如果你用的是树模型,我的经验是:
- 优先设置
scale_pos_weight(XGBoost/LightGBM)或class_weight(scikit-learn随机森林),这个改动最小、回归风险最低。 - 如果加了权重后少数类召回率还不够,再考虑重采样。
- 还有一种思路是“欠采样集成”:把多数类分成多份,每份和全部少数类组成一个子训练集,分别训练一个模型,最后集成投票。这种方式的优点是既不会丢失多数类信息,又不会因为复制少数类而过拟合,缺点是训练成本成倍增长。
XGBoost中可以直接通过 scale_pos_weight 参数控制正负样本的权重比例,推荐设置值通常是:
[
scale_pos_weight = \frac{N_{negative}}{N_{positive}}
]
也就是把多数类和少数类数量直接作商。
在重度不平衡场景下,我的习惯是同时开类别权重和轻度SMOTE,然后做两轮对比实验。权重负责让模型“重视”少数类,SMOTE负责给少数类提供更多可学习的“素材”,两者不冲突。但切记不要在没做对比实验的情况下叠加过多手段,因为每一种手段都会引入偏差,叠得越多,模型就越可能在训练分布上过拟合。
4. 评估指标不能错:从“准确率幻觉”到PR曲线
数据方法用了、模型调了,最后怎么判断到底有没有效果?这里有个更大的坑——你很可能还在看准确率。
4.1 准确率幻觉:99%的准确率可能说明不了任何问题
还是用欺诈检测来算一笔账。假设测试集有10000条交易,其中欺诈交易100条。如果你的模型把10000条全部预测为“正常”,那准确率是:
[
\frac{9900}{10000} = 99%
]
看起来是个非常漂亮的数字。但那100条真正的欺诈交易,模型一条都没识别出来,召回率是0%。业务上的损失一点没减少,而你却因为“99%准确率”给老板交了一份漂亮的汇报。这就是准确率幻觉。
所以在处理不平衡数据集的任何一个环节,我都建议直接把准确率从核心指标里拿掉,最多让它当一个参考信息。
4.2 混淆矩阵是一切评估的地基
真正该看的第一样东西永远是混淆矩阵。它把预测结果分成四类:
| 预测为少数类 | 预测为多数类 | |
|---|---|---|
| 实际为少数类 | TP(真正例) | FN(假负例) |
| 实际为多数类 | FP(假正例) | TN(真负例) |
基于这四个数,才能定义出真正有意义的指标:
-
精确率(Precision):
[
P = \frac{TP}{TP + FP}
]
含义是:模型预测为少数类的样本里,有多少真的是少数类。精确率低,说明模型为了抓少数类,误伤了一大批多数类用户。 -
召回率(Recall):
[
R = \frac{TP}{TP + FN}
]
含义是:真实的少数类样本里,模型抓到了多少。召回率低,说明模型漏掉了大量关键事件。 -
F1分数:
[
F1 = \frac{2 \times P \times R}{P + R}
]
精确率和召回率的调和平均,用来在两者之间找平衡。
在不同的业务场景里,这两个指标的优先级完全不同。做反欺诈时,漏掉一笔欺诈的代价可能远超误判一个正常用户,所以F1不够,甚至还得看F2分数(给召回率更高权重);做精准推荐时,误判骚扰太多会伤害用户体验,那就要把精确率放在第一位。
4.3 为什么PR曲线比ROC曲线在不平衡场景下更可靠
ROC曲线横轴是假正率(FPR = FP/(FP+TN)),纵轴是真正率(TPR)。问题在于,FPR的分母里有大量真负例(TN),当多数类数量极其庞大时,即使模型产生了不少假正例,FPR也会被压得很低,让ROC曲线看起来很漂亮。
举个例子:模型把1000个多数类样本误判成了少数类,但多数类总量是99000个,FPR只增加了约1%。这个变化在ROC曲线上几乎看不出来,但精确率已经暴跌。这就导致ROC-AUC在极度不平衡的场景下往往是虚高的。
PR曲线的横轴是召回率,纵轴是精确率。它不把真负例纳入计算,完全聚焦在少数类上。当类别极度不平衡时,PR-AUC的数值变化比ROC-AUC敏感得多,也更贴近业务真实效果。
我个人的习惯是:中度不平衡以上,做模型对比时主要看PR-AUC、F1、召回率这三个指标。只有讨论阈值对FPR的影响时,才会去翻ROC曲线。
5. 实验复盘:同一份欺诈数据上跑通三种处理方案
前面讲了理论,这一章我放一个实际的对比实验。数据是模拟的银行交易欺诈场景,但思路和真实项目完全一致。
5.1 数据准备与实验设计
设计如下:
- 总样本量:100000条交易。
- 少数类(欺诈)占比:1%,也就是1000条。
- 特征:8个数值特征,包括交易金额、历史交易频率、交易时段时间编码等(模拟数据,实际项目中还需要做大量特征工程和清洗)。
- 训练测试划分:70%训练、30%测试,按标签分层抽样,保证测试集中欺诈比例仍为1%。
实验目标:找到三种方案里最适合这个场景的建模策略。
三种方案分别设置:
- 方案A:原数据直接训练随机森林,作为基线。
- 方案B:随机森林 +
class_weight='balanced'。 - 方案C:SMOTE重采样 + 随机森林。
评价指标用测试集上的召回率、精确率、F1和PR-AUC,准确率只做参考。
5.2 基线实验:准确率很高,但业务价值接近零
方案A的结果如下:
| 方案 | 准确率 | 精确率 | 召回率 | F1 | PR-AUC |
|---|---|---|---|---|---|
| 基线(默认随机森林) | 99.3% | 40.2% | 18.7% | 25.5% | 0.31 |
准确率99.3%,看起来不错。但召回率只有18.7%,意味着真正欺诈交易里超过80%被漏掉了。这个模型如果直接上线,业务方几乎发现不了异常,欺诈损失照旧。这就是典型的准确率幻觉。
F1只有25.5,PR-AUC只有0.31。这就是“不做任何处理”的真实水平。
5.3 类别权重与SMOTE的对比
方案B和方案C的结果:
| 方案 | 准确率 | 精确率 | 召回率 | F1 | PR-AUC |
|---|---|---|---|---|---|
| 基线 | 99.3% | 40.2% | 18.7% | 25.5% | 0.31 |
| 方案B(类别权重) | 97.8% | 18.6% | 54.3% | 27.7% | 0.44 |
| 方案C(SMOTE) | 97.1% | 15.2% | 61.8% | 24.4% | 0.41 |
这里有一个很典型的取舍:
- 方案B用类别权重,召回率从18.7%提升到54.3%,但精确率从40.2%降到18.6%。F1只涨了一点,PR-AUC提升明显。
- 方案C用SMOTE,召回率提升到61.8%,但精确率进一步掉到15.2%,F1反而比权重方案略低。
从这个结果可以看到,没有“绝对更好”的方案,只有“更符合业务目标”的方案。
如果业务目标是“尽可能把欺诈交易找出来”,那么方案C的61.8%召回率更有吸引力。如果业务目标是“不能大量打扰正常用户”,那么方案B的18.6%精确率已经会让客服部门压力很大了,需要进一步调节。
5.4 位置开关:阈值移动与概率校准
还有一个经常被忽略的手段——阈值移动。上面所有方案,模型输出的是一个概率值,默认情况下概率超过0.5才判定为欺诈。但在这个不均匀的数据集上,0.5显然不是一个合理的决策点。
在不平衡场景中,我们可以把决策阈值调低,比如从0.5降到0.3,甚至0.2。一旦阈值降低,更多的样本会被判为欺诈,召回率会上升,但误伤会增加。
为了找到合适的阈值,我会在验证集上画出PR曲线,然后在上面定位业务要求对应的点。比如业务要求召回率至少要达到70%,那就找到召回率为70%时的阈值,看这个阈值下的精确率能不能接受。这种方式比盲目调参数更可靠。
下面是同一模型在不同阈值下的表现:
| 阈值 | 精确率 | 召回率 |
|---|---|---|
| 0.5 | 18.6% | 54.3% |
| 0.3 | 13.8% | 63.2% |
| 0.2 | 10.1% | 69.5% |
| 0.1 | 6.9% | 75.4% |
可以看到,阈值越低,召回率越高,但精确率下降得非常快。现实中你需要找到一个“性价比”最高的点:既能抓住足够的欺诈交易,又不至于让客服团队收到大量无效告警。
5.5 本次实验的几个经验沉淀
- 不要一上来就用SMOTE。先跑基线,用混淆矩阵判断问题严重程度,再决定要不要重采样。
- 类别权重是最快、风险最低的干预手段,改动半天内能跑完。SMOTE提升召回率的上限更高,但代价是精确率下降更多。
- 如果数据集只有几万条级别,SMOTE的效果通常不稳定,类别权重更稳。如果样本量大且少数类样本数量确实能支撑插值,SMOTE的优势会更明显。
- 一定要把所有实验的指标记录成表格,不要只看一个F1。召回率、精确率、PR-AUC三列要同时观察。
- 最后一步永远是为业务服务,根据业务成本选阈值,不要机械地使用0.5。
我在实际项目里曾踩过一个教训:刚开始做信用风控的时候,见到不平衡就上SMOTE,结果训练集精度很高,测试集F1反而下滑。后来才发现,问题根本不在样本数量,而在于部分特征在少数类里缺失严重。这时候应该做的是特征工程和数据清洗,而不是无脑调采样。所以顺序一定是:先看特征可分性,再看采样策略。
处理不平衡数据集没有一劳永逸的银弹,它更像是一条链路:先诊断问题,再从数据层或算法层干预,最后把评估标准切换到少数类真正关心的指标上。只要这个顺序不乱,模型不会走偏到哪里去。
