这篇打卡笔记的第16天,本来只想随手整理一下"不平衡数据集的处理"这个主题,结果越写越长。起因是前几天帮一位学弟排查一个分类模型,他在某高校的数据挖掘课程里复现了一个异常检测任务,正样本占比不到2%,模型验证集准确率跑到98%,他以为大功告成,结果换到真实测试数据上几乎抓不到正样本。这个场景我太熟了——准确率虚高、少数类全被吞、上线即翻车,基本每个做过不平衡任务的人都经历过。
这篇文章就围绕不平衡数据集的处理展开,重点拆解数据侧重采样、算法侧代价敏感、评估指标校准、阈值调整这几条路线的原理和落地细节,最后是我在项目里踩过的一些坑。适合正在做分类任务的算法工程师、数据分析师,也适合刚入门机器学习、被"99%准确率"骗过的同学。
1. 先认清问题:不平衡数据集的判定不能只看样本比例
1.1 一个最典型的"99%准确率翻车"案例
假设我要做一个设备故障预测,采集了1万个样本,其中正常样本9950个,故障样本50个。我偷懒不处理类别不平衡,模型把所有样本都预测成"正常",那我训练集准确率是多少?99.5%。这个数字在汇报时很好看,可实际业务里真正的目标是把那50个故障找出来,全预测成正常等于这模型毫无用处。
这就是不平衡分类最大的坑——准确率这个指标,在正样本占比极低的时候已经完全失真。模型不需要学习任何规律,只要无脑输出多数类,就能拿到很高的准确率,而你的业务核心恰恰集中在少数类上。
1.2 "不平衡"不只是一个比例问题,更是一个代价问题
很多人一上来就问:正负样本比例多少算不平衡?1:1算平衡,1:10算不平衡吗?我个人的看法是,比例只是表象,真正的判断标准是漏检代价。
同样是1:99的不平衡:
- 如果是判断一封邮件是不是垃圾邮件,少数类漏掉一些,最多是用户看到几封广告邮件,影响不大;
- 如果是诊断某种罕见病,漏掉一个正样本就是一次医疗事故;
- 如果是信用卡欺诈检测,漏掉一笔欺诈交易意味着直接的资金损失。
后面这两种场景,即使比例是10:1,只要少数类的业务价值极高,处理难度就一点不比100:1低。
所以拿到一个不平衡数据集,我建议你第一件事不是急着调模型,而是先和业务对齐一个问题:**一个正样本被漏掉,损失是多少?一个负样本被误判,损失又是多少?**这个答案决定了整个方案怎么选。
常规机器学习算法为什么在少数类上翻车,原因也不难理解:模型在训练时通过损失函数衡量预测和真实标签的差异,多数类样本量大,它们的损失在总损失里占绝对主导。梯度下降过程中,模型只要把多数类拟合好,总损失就能降得很低,少数类那点样本根本"说不上话"。树模型类似,分裂点的选择主要被多数类分布主导,少数类在特征空间里很容易被当成噪声埋掉。
要解决这个问题,常规路线有三条:数据侧重采样、算法侧代价调整、评估侧指标校准。这三条不是互斥的,实际上我在项目里经常两三条叠加使用,后面分别展开。
2. 数据侧重采样:SMOTE及其家族的正确打开方式
2.1 最朴素的过采样和欠采样,为什么都不够用
数据侧最简单的方法就两个:随机过采样和随机欠采样。
随机过采样:把少数类样本复制几份,让它的数量跟多数类接近。这种做法在实践里最大的问题是非常容易过拟合。因为复制的样本没有任何新信息,模型等于在反复学习同一批样本,特征空间里少数类区域被"重复记忆",泛化能力很差。
随机欠采样:从多数类里随机抽掉一部分,让两类数量接近。问题也很明显:你扔掉了一堆可能的有效信息。特别是多数类内部也有各种子模式时,欠采样很容易让模型只看到多数类的"局部",导致整体精度下降。如果原始数据本来就不多,欠采样基本没法用。
那是不是不能用?不是。我的经验是:如果数据集非常大,比如多数类有几十万甚至上百万条,随机欠采样反而是个性价比很高的方案,因为多数类本身严重冗余,丢一部分不影响大局。但如果数据总量不大,或者多数类多样性很重要,就别用纯随机欠采样。
2.2 SMOTE的原理:在少数类样本之间做"合法插值"
SMOTE(Synthetic Minority Over-sampling Technique)是目前最常用的过采样增强方法,核心思想不是复制样本,而是在少数类样本之间合成新样本。
具体步骤是:
- 对少数类里每个样本,找到它的k个近邻;
- 从k个近邻里随机选一个邻居;
- 在"这个样本"和"选中的邻居"的连线上,随机取一个点,作为新生成的样本。
用数学一点的话说:如果少数类样本是a,选中的邻居是b,那么新样本就是a + λ * (b - a),其中λ是0到1之间的随机数。
这样一来,新样本不再是原样本的复制,而是继承了相邻样本的特征,理论上能缓解随机过采样的过拟合问题。目前处理不平衡分类任务,SMOTE系列是我默认的第一选择。
我用的是imbalanced-learn这个库,跟sklearn的接口非常接近。这里要注意一点,新版本的用法是fit_resample:
from imblearn.over_sampling import SMOTE from sklearn.model_selection import train_test_split X_train, X_val, y_train, y_val = train_test_split( X, y, test_size=0.2, random_state=42, stratify=y ) smote = SMOTE(random_state=42, k_neighbors=5) X_train_resampled, y_train_resampled = smote.fit_resample(X_train, y_train) print(X_train_resampled.shape) print(y_train_resampled.value_counts())注意我在这里只对训练集做了fit_resample,验证集和测试集保持原始分布。这是一个极其关键的细节,后面会单独说。
2.3 进阶变体怎么选:Borderline-SMOTE、ADASYN、SMOTE-ENN
SMOTE虽然好用,但不是万能的。样本分布重叠严重、噪声多的时候,线性插值很容易把噪声也放大。于是又衍生出一批变体。
我按自己的使用频率做了个对比表格:
| 方法 | 核心思路 | 适合场景 | 主要风险 |
|---|---|---|---|
| SMOTE | 随机选少数类近邻做插值 | 一般不平衡,噪声较少 | 类别重叠时生成模糊样本 |
| Borderline-SMOTE | 只对"边界附近"的少数类样本做插值 | 少数类内部有明显边界分布 | 边界样本定义对参数敏感 |
| ADASYN | 按难度加权生成样本,难分类的生成更多 | 少数类内部难度差异大 | 对噪声极度敏感 |
| SMOTE-ENN | SMOTE生成样本后再用ENN删除多数类"附近"的噪声 | 噪声多、边界重叠严重 | 计算量大 |
| SMOTE-Tomek | SMOTE生成后结合Tomek Links清洗边界 | 希望得到更干净的类别边界 | 清洗过度时信息损失 |
我在实践里的选型经验偏保守:
- 数据噪声不多,先试标准SMOTE;
- 少数类都在边界上,试Borderline-SMOTE;
- 数据特别乱、重叠严重,直接上SMOTE-ENN,因为生成之后再清洗,能去掉不少"假样本";
- 高维稀疏数据,比如文本TF-IDF特征,SMOTE系列就不太合适,因为稀疏空间里插值会产生大量不合理取值,这种情况我更倾向于用算法侧方案而不是数据侧方案。
2.4 交叉验证里的重采样泄露,这个坑比想象中更隐蔽
我要特意提一下重采样的数据泄露问题。
很多人知道"验证集不能重采样",但很容易忽略另一个场景:交叉验证中,如果先把整个数据集重采样,再做K折划分,就泄露出去了。因为重采样生成的合成样本,等于已经包含了其他折的信息,验证折里的样本可能跟训练折里生成的合成样本有"血缘关系",评估结果会虚高得离谱。
正确做法是把重采样放进每个折的循环内部:每一折先划分训练集和验证集,只对当折的训练集做fit_resample,验证集始终保持原始分布。
from imblearn.over_sampling import SMOTE from sklearn.model_selection import StratifiedKFold from sklearn.ensemble import RandomForestClassifier from sklearn.metrics import f1_score skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) for train_idx, val_idx in skf.split(X, y): X_train_fold, y_train_fold = X[train_idx], y[train_idx] X_val_fold, y_val_fold = X[val_idx], y[val_idx] smote = SMOTE(random_state=42) X_train_resampled, y_train_resampled = smote.fit_resample(X_train_fold, y_train_fold) model = RandomForestClassifier(random_state=42) model.fit(X_train_resampled, y_train_resampled) y_pred = model.predict(X_val_fold) print(f"fold F1: {f1_score(y_val_fold, y_pred):.4f}")我见过不止一个人在这种地方翻车:全局重采样之后再做交叉验证,F1看着很高,换到真实测试集立刻打回原形。这不是模型不行,是评估流程就错了。
3. 算法侧改造:类别权重与代价敏感的落地姿势
3.1 class_weight的本质:让少数类"犯错代价"变大
数据侧重采样的思路是改变样本分布,算法侧的思路则是不改数据,改学习目标——让模型在训练时更重视少数类。
最直接的实现就是类别权重。逻辑回归、决策树、随机森林、XGBoost、LightGBM这些模型基本都支持class_weight参数。它的本质很简单:少数类样本在计算损失时,乘一个更大的因子,相当于人为放大了少数类的"犯错代价"。
sklearn里计算类别权重的默认公式是:
weight = n_samples / (n_classes * np.bincount(y))举一个直观例子:1000个样本里正类100个,负类900个。
- 正类权重 = 1000 / (2 * 100) = 5.0
- 负类权重 = 1000 / (2 * 900) = 0.56
正样本的权重接近负样本的9倍,训练时梯度更新方向就会向少数类偏移。
实际代码里其实不用自己算,直接用class_weight='balanced':
from sklearn.linear_model import LogisticRegression model = LogisticRegression(class_weight='balanced', max_iter=1000) model.fit(X_train, y_train)3.2 从权重到代价矩阵:两类错误的代价本来就不相等
类别权重虽然好用,但它只做了"少数类比多数类重要"这一件事。真实业务里,少数类内部也不是铁板一块,多数类内部的错误同样有不同代价。
这就引出了代价敏感学习。核心是构建一个代价矩阵:
| 预测\真实 | 正样本 | 负样本 |
|---|---|---|
| 预测为正 | 0 | 代价FN(漏报) |
| 预测为负 | 代价FP(误报) | 0 |
注意我这里故意把矩阵方向写反了,通常查漏报是"真实为正、预测为负",代价远高于误报。在欺诈检测里,漏掉一笔欺诈交易可能损失几万块,误判一个正常用户最多是客服申诉成本。在重症筛查里,漏报堪比事故。
如果业务方已经给了明确代价,比如漏报一次损失10块,误报一次损失1块,那我建议不要只调class_weight,而是把这个代价比例直接算到训练样本权重里去,或者更稳妥的做法是:先不做任何处理训练一个概率模型,然后在阈值选择阶段,用代价函数去搜索最优阈值。阈值这部分我后面单独讲。
3.3 Focal Loss:极度不平衡和难易样本问题
如果你用的是深度学习模型,数据量又很大,可以关注一下Focal Loss。它最早出现在目标检测场景,处理的是"一张图里绝大多数区域是背景,目标只占很少像素"这样的极端不平衡。
Focal Loss在标准交叉熵基础上加了一个调制因子。公式大意是:
FL = -α * (1 - p)^γ * log(p)其中α是类别权重,γ是聚焦参数,p是模型对真实类别的预测概率。核心逻辑是:对已经预测得很好的样本,降低它的损失贡献;对预测得不好的难样本,保留甚至放大它的损失贡献。这样模型就不会在大量"简单易分"的多数类样本上自我满足。
我的建议是:如果你的模型是用PyTorch这类框架自己训练的深度模型,Focal Loss值得试;如果用的是XGBoost、LightGBM这类梯度提升树,优先考虑scale_pos_weight或者调整样本权重就够了,不太需要自己去实现Focal Loss,树模型本身就是一种对特征空间做分割的方法,损失函数的改造收益没有神经网络那么明显。
4. 评估体系校准:别让准确率成为唯一尺子
4.1 准确率为什么骗人,一个算例讲清楚
这里我用一个最极端的例子帮大家形成直觉:
假设数据集有1000个样本,其中只有1个正样本,999个负样本。
模型A:什么都学不会,只输出"负样本",准确率99.9%。 模型B:模型学了一些规律,能勉强发现那1个正样本,准确率99.8%。
模型A的准确率居然比模型B还高。如果我拿准确率当作模型好坏的唯一标准,就会得到一个荒谬的结论——这个"复读机"模型比真正做了学习的模型更好。
这个例子看着极端,但放到真实的欺诈检测、故障预测里,原理完全一样。多数类占比越大,准确率的欺骗性越强。
4.2 该看什么:Precision、Recall、F1和PR曲线
在不平衡分类里,我默认至少要看这几个指标:
- Precision(精确率):预测成正类的样本里,有多少是真正类。衡量的是"抓得准不准"。
- Recall(召回率):所有真实正类里,有多少被模型找出来了。衡量的是"抓得全不全"。
- F1-score:两者的调和平均,兼顾准和全。
- PR曲线:横轴Recall,纵轴Precision,曲线下面积叫AP(Average Precision)。
这里我要多说一句PR曲线和ROC曲线的区别。
ROC曲线在正负样本比例悬殊时依然会显得很乐观,因为它的横纵轴同时考虑了正类和负类,负样本多,模型只要把负样本压得好,ROC曲线就会好看。但PR曲线只关注正类的Precision和Recall,它对少数类的变化更敏感。同样是模型从"漏报一半正类"提升到"只漏报两成正类",PR曲线的变化会比ROC曲线明显得多。
所以在不平衡任务里,我的习惯是:ROC曲线可以画,但做模型选择时以PR曲线为主。
4.3 评估指标的Python落地
sklearn里这些指标都有现成实现,直接看代码:
from sklearn.metrics import classification_report, average_precision_score, precision_recall_curve import matplotlib.pyplot as plt y_pred = model.predict(X_val) y_prob = model.predict_proba(X_val)[:, 1] print(classification_report(y_val, y_pred)) ap = average_precision_score(y_val, y_prob) print(f"AP: {ap:.4f}") precision, recall, thresholds = precision_recall_curve(y_val, y_prob) plt.plot(recall, precision) plt.xlabel("Recall") plt.ylabel("Precision") plt.title("PR Curve") plt.show()如果项目里希望保留少数类的同时控制误报,可以用F1作为优化目标,在验证集上搜索最佳阈值。这个操作本身也比较简单:
import numpy as np precision, recall, thresholds = precision_recall_curve(y_val, y_prob) f1_scores = 2 * precision[:-1] * recall[:-1] / (precision[:-1] + recall[:-1]) best_idx = np.argmax(f1_scores) best_threshold = thresholds[best_idx] print(f"best threshold: {best_threshold:.4f}, best F1: {f1_scores[best_idx]:.4f}")这里注意precision_recall_curve返回的precision和recall数组长度要比thresholds多1,所以索引要取[:-1],否则长度对不上会报错。
5. 阈值调整与概率校准:有时候不碰数据也能救
5.1 0.5不是天经地义的阈值
很多模型默认把输出概率大于0.5判成正类。这个0.5本质上是"正负两类对称"的假设,在不平衡数据上根本不成立。
举个例子:正样本占比只有1%,模型输出概率0.3。如果按0.5的阈值,这个样本被判成负类;但在多数类占主导的分布下,0.3这个概率已经偏离先验很多了,很可能意味着模型发现了一些"类似正类"的特征,值得仔细考察。
阈值怎么定?原则是回到业务目标:
- 如果任务要求尽量不放过正样本(比如故障预警),把阈值往低调,比如0.2甚至0.1,Recall会上去,Precision会降下来;
- 如果任务要求预测成正类的样本尽量准确(比如精准营销),把阈值往高调,比如0.7、0.8,Precision会上去,Recall会降下来。
调阈值本质上就是在Precision和Recall之间做权衡,没有标准答案,只有业务答案。
我项目里常用的一种方式,是在验证集上计算PR曲线之后,根据业务目标定阈值。比如目标"欺诈召回率不低于90%",那就在验证集上找到召回率不低于90%时,最高精度对应的阈值:
target_recall = 0.9 valid_indices = np.where(recall[:-1] >= target_recall)[0] best_precision_idx = valid_indices[np.argmax(precision[valid_indices])] threshold_for_target = thresholds[best_precision_idx]5.2 概率校准:让模型输出不只是"相对分数"
很多模型输出的概率并不是真实概率,更像一个排序分数。XGBoost和逻辑回归输出的0.8不代表真的有80%的概率是正类。如果我们拿这个概率直接做业务决策,比如估算预期收益,那是有风险的。
这时候可以用概率校准,sklearn提供了CalibratedClassifierCV:
from sklearn.calibration import CalibratedClassifierCV calibrated_model = CalibratedClassifierCV(model, method='sigmoid', cv=5) calibrated_model.fit(X_train, y_train) y_prob_calibrated = calibrated_model.predict_proba(X_val)[:, 1]method='sigmoid'对应Platt scaling,适合模型概率单调但不够准确的场景;method='isotonic'是非参数校准,数据量大时效果更好,但需要独立验证集,不能直接用在交叉验证里,容易过拟合。
有一点得提醒:校准不会改变AUC、AP这类排序指标,它只改变概率值本身更接近真实概率。如果你的目标只是选阈值、算F1,那校准不校准对阈值选择的影响有限;如果模型输出概率要直接用于成本核算、风险定价,那建议做一下。
而且要注意:做概率校准要单独切一份数据,不能用训练时的交叉验证结果直接校准,否则又会引入乐观偏差。
6. 项目实战中我踩过的坑:一份可以直接照抄的检查清单
6.1 高发坑位一览
我把这几年处理不平衡数据集踩过的坑统一列出来,每一条都是真金白银换来的:
| 坑 | 表现 | 正确做法 |
|---|---|---|
| 只看准确率 | 验证集99%准确率,上线抓不到正样本 | 改用F1、AP、PR曲线评估 |
| 全局重采样再交叉验证 | 内部验证指标虚高,外部测试崩盘 | 重采样只放进训练集,且必须在CV循环内部 |
| 欠采样扔掉太多数据 | 结果方差很大,每次训练不一样 | 数据量少时优先过采样或类别权重 |
| 稀疏特征上硬套SMOTE | 生成大量无意义样本,效果不升反降 | 高维稀疏场景改用class_weight或代价敏感 |
| 忽略分层采样 | K折划分后某些折里正样本为0 | 用StratifiedKFold或train_test_split(stratify=y) |
| 阈值死守0.5 | 少数类召回率极低 | 在验证集上用PR曲线搜索阈值 |
| 只调采样不调超参 | 调了几轮指标上不去 | 采样方法和模型超参一起网格搜索 |
6.2 我现在的标准处理流程
经过这些坑之后,我现在拿到不平衡数据集的固定套路大概是这样的:
- 先定业务指标。明确漏报一次损失多少,误报一次损失多少,确定优先优化F1还是限定Recall。
- 用分层划分切出验证集。
train_test_split一定要加stratify=y,交叉验证用StratifiedKFold。 - 先跑一个baseline。不做任何处理,用逻辑回归或随机森林先看一版指标,弄清楚当前主要矛盾是分类器太弱,还是数据不平衡导致的偏置。
- 小规模试采样/权重方案。数据量小、特征稠密,试SMOTE;数据量大,试随机欠采样或类别权重;树模型优先试
class_weight。 - 统一用PR曲线和AP评估。记录Precision、Recall、F1、AP,不单独看准确率。
- 在验证集上搜最优阈值。根据业务目标选阈值,而不是默认0.5。
- 上线前再看一次错误分布。把误差分析里误报、漏报的样本各抽几十个出来人工看一遍,这一步经常能发现特征工程的新思路。
说一个比较反直觉的体会:在很多项目里,特征工程和业务规则的改进,比任何采样算法带来的提升都大。比如故障预测任务里,如果能构造出"最近十分钟内传感器变化率"这类强特征,正负样本的区分度会大幅提升,SMOTE和类别权重反而只是锦上添花。所以别把所有希望都寄托在采样方法上,先问自己:有没有特征是被我漏掉的?
另外一个容易忽略的小技巧是,如果少数类的样本本身质量很差,比如标签标注错误、特征字段大量缺失,那重采样只会把噪声放大。我习惯先做一轮异常值清洗和缺失值处理,再进入重采样环节。我的经验是,对于不平衡数据,干净的小样本通常好过污染的大样本。
最后说一句:做不平衡分类没有所谓"万能模板"。有些项目里SMOTE效果显著,有些项目里纯调阈值就够用了,关键是你得建立一套自己的评估和判断流程。这篇笔记算是给我第16天打卡的一个阶段总结,如果对你也有一点点帮助,那今天的整理就没白写。