类别不平衡这个问题,我一开始真没拿它当回事。直到有一次在模拟项目X里做会员流失预测,训练集中正样本只占4%,我用默认参数跑了一个逻辑回归,准确率96.8%,当时还挺高兴。可把混淆矩阵打印出来一看,真正流失的用户里,模型只找回了不到10%。那一刻我才意识到,数据建模里最不值得信任的数字,就是这种被多数类堆出来的准确率。
这篇笔记,是我在完整复盘那个项目之后整理出来的。我尽量不绕弯子,直接讲清楚类别不平衡会干扰哪些环节、评价指标怎么选、数据侧和算法侧分别有哪些处理手段,以及模型上线后还要注意什么。如果你正在做二分类,而且少数类的占比很低,比如低于10%,那这篇文章应该对你有帮助。读完你可以直接拿里面的思路去检查自己的模型,不用重走我踩过的坑。
1. 类别不平衡问题到底在哪一环开始捣乱
1.1 先定义“不平衡”:比例低到多少才需要专门处理
类别不平衡不是二分类特有的,多分类里也会遇到,但二分类最典型。如果正负样本比例接近,比如1:1或者1:2,通常不需要做额外处理。少数类占比低于10%的时候,就要开始留意了;低于1%属于极端不平衡,处理不当的话,模型即便在验证集上拥有很高的AUC,落地时也可能完全没法用。
为什么会这样?因为绝大多数模型的学习目标是让整体损失最小化。以逻辑回归为例,它的损失函数是所有样本损失的平均值。当负样本占总体96%时,负样本的梯度贡献主导了参数更新,正样本即使全部预测错误,也只会拉扯很小的权重。树模型也一样,分裂时计算信息增益,多数类覆盖的样本多,它天然更关心怎样把多数类分对。这不是模型傻,而是优化目标和业务目标出现了偏差。
可以打个比方:如果一个班级里100个人,只有1个人近视,你让一个统计模型预测谁近视,它只要说“全班都不近视”,准确率就是99%。但如果目的是把近视的人筛选出来去做视力复查,这个模型毫无价值。类别不平衡的本质,就是训练信号被大量多数类样本稀释了。
1.2 一组可以用来跟练的实验数据长什么样
我在模拟项目X里用的是一份会员行为数据,规模约30万条,其中流失用户约1.2万,占比4%。字段包括近30天登录次数、平均订单间隔、优惠券核销数、投诉次数、最近一次购买距今天数、会员等级等。目标变量是未来30天内用户是否流失。
听起来不算太复杂,但这种数据结构在现实中非常常见。多数用户稳定活跃、近期有购买行为,少数用户开始失联、投诉增多,最后悄悄流失。模型要做的,就是从一堆“看起来还健康”的用户里,把即将流失的那一小撮人抓出来。
这里想强调一个初学者容易忽视的点:处理这类数据时,最好不要随机打乱后划分训练集和验证集。流失预测是有时间含义的,模型是用来预测未来,不是预测过去。我先按时间顺序切分,用前80%的月份做训练,后20%的月份做验证,这样得到的指标才反映真实业务场景。另外,同一个用户的行为记录不应该同时出现在训练集和验证集里,否则模型很容易“背答案”。后续我会在验证策略那章再详细展开。
1.3 直接跑一个基线模型,你会看到什么
第一次实验,我没有做任何特殊处理,直接把原始训练集丢给逻辑回归和随机森林。逻辑回归在验证集上的准确率96.7%,随机森林96.8%,看着都很漂亮。但是换成混淆矩阵就不一样了:验证集中1200个真实流失用户,随机森林只抓到了109个,召回率9%。后面一查,几乎所有模型都在预测“不流失”。
这个现象非常典型。在样本占比4%的情况下,模型只要把所有样本都判为多数类,准确率就能到96%。它根本不需要学习任何流失特征,因为学习流失特征反而会在少数样本上犯错,拉低整体收益。所以,你的基线模型分数越高,未必代表越有用,可能只说明你的数据不平衡程度很严重。看到高准确率,先别高兴,去翻一下少数类的召回率,再决定要不要进入下一步处理。
2. 评价指标才是最大的坑:准确率如何骗人
2.1 先把混淆矩阵的四格看懂
很多教程喜欢直接讲指标公式,但我觉得先从混淆矩阵入手更直观。假设二分类问题中,正类是我们要找的流失用户,负类是正常用户。预测结果有四种可能:真阳性TP、假阳性FP、真阴性TN、假阴性FN。这四格几乎可以推导出一切常用指标。
比如准确率等于(TP+TN)/(TP+FP+TN+FN)。在4%正样本的数据里,TP=0、FP=0、TN=28800、FN=1200,准确率是97.6%。公式本身没有错,但它把占大头的TN(正确排除的正常用户)也算进分子,于是模型即便一败涂地,准确率依然非常体面。
所以我的习惯是,每次实验第一件事先打印四类格子,而不是只看一个综合指标。混淆矩阵会告诉你模型到底在哪一侧栽了:是抓不住正样本(FN高),还是误报太多(FP高)。后续所有调优动作,都必须基于其中某一侧的痛点来展开,否则就是在盲调。
2.2 哪些指标值得盯:一个速查表
结合大家常用的指标,我整理了一个对照表,适合类别不平衡场景下快速定位该看什么:
| 指标 | 含义 | 在不平衡数据中的作用 |
|---|---|---|
| 精确率 Precision | TP/(TP+FP),预测为正类的样本中真正类所占比例 | 回答“模型叫出来的用户里,有多少真的流失” |
| 召回率 Recall | TP/(TP+FN),真实正类中被成功预测的比例 | 回答“真实流失用户里,模型抓住了多少” |
| F1 | 精确率与召回率的调和平均 | 用一个数值兼顾精确率与召回率,适合成本不明确时 |
| PR-AUC | 精确率-召回率曲线下面积 | 随少数类表现变差而显著下降,贴近业务痛点 |
| AUC-ROC | ROC曲线下面积 | 衡量排序能力,但对类别分布不敏感,容易被高估 |
| 混淆矩阵 | TP/FP/TN/FN四格 | 用于诊断具体错误类型,是最基础的信息源 |
这里特别想提醒AUC的局限。AUC只看正负样本分数的排序关系,不关注概率绝对值。换句话说,就算模型对所有正样本都给0.49、负样本给0.48,只要排序正确,AUC也可以很高。可在实际业务中,总分0.49根本没法作为决策阈值用。所以我在后面的项目里,逐渐把AUC当作辅助指标,主指标换成PR-AUC和阈值约束下的召回率。
2.3 用业务目标反推指标权重
指标不是选得越全越好,而是要和你能承受的成本绑在一起。在模拟项目X里,业务方的要求很直接:“尽量多找出可能流失的用户,但客服人力有限,打出去的100个电话里,至少要有30个是真正要流失的。”翻译成指标语言就是:召回率尽量高,同时精确率不低于30%。
这个约束一旦定下来,整个建模目标就清晰了。我不再纠结“F1最大”这种模糊目标,而是在验证集上搜索满足精确率条件的最优阈值。简单代码可以这样写:
import numpy as np from sklearn.metrics import precision_recall_curve precision, recall, thresholds = precision_recall_curve(y_val, proba) mask = precision >= 0.3 if mask.any(): best_idx = np.argmax(recall[mask]) best_threshold = thresholds[mask][best_idx] print(f"best threshold = {best_threshold:.3f}")这段代码跑出来的最优阈值在0.18附近,对应召回率约61%、精确率约32%。业务方看到这个结果才觉得模型真的能落地,因为此前默认0.5阈值时的召回率只有9%。同样的模型、同样的验证集,只是把决策边界从0.5改成0.18,业务价值就完全不同。这就是为什么要理解指标背后的业务含义。
3. 从数据侧修不平衡:重采样不是无脑过采样
3.1 下采样:先把训练分布拉平,但别丢弃信息
处理类别不平衡,数据侧最常见的两招是下采样和过采样。先说说下采样,也叫欠采样。它的逻辑很朴素:既然多数类太多,那就从多数类里随机抽出一部分,让训练集正负样本数量接近1:1。
实现上可以借助imblearn库,几行代码就能搞定:
from imblearn.under_sampling import RandomUnderSampler rus = RandomUnderSampler(random_state=42) X_res, y_res = rus.fit_resample(X_train, y_train)随机下采样最大的优点是把训练分布拉平后,模型能充分看到正样本,训练也更快。缺点是随机丢弃大量多数类样本,可能会把一些重要的负样本模式扔掉。比如在流失预测里,很多“最近投诉次数很多但还在活跃”的用户,可能是最容易被误判成流失的负样本。随机下采样如果把这些样本丢了,模型在验证集上的误报就会变高。
缓解办法也很直接:多采几次,每个模型用不同的随机种子欠采样,最后把结果集成起来。这就是EasyEnsemble的核心思想。实际操作时,我大概做10轮随机欠采样,每轮训练一个树模型,最后对概率分数取平均。
models = [] for seed in range(10): rus = RandomUnderSampler(random_state=seed) X_res, y_res = rus.fit_resample(X_train, y_train) clf = RandomForestClassifier(n_estimators=100, random_state=seed) clf.fit(X_res, y_res) models.append(clf) proba = np.mean([m.predict_proba(X_val)[:, 1] for m in models], axis=0)需要注意,欠采样只作用于训练集。验证集和测试集必须保持原始分布,否则你评估出来的召回率和精确率会严重失真。这一点我最初也踩过,后面会专门讲。
3.2 SMOTE与合成样本:边界比想象中容易出问题
下采样牺牲数据量,很多人不满意,于是想用人工合成样本。SMOTE就是最出名的一种。它的原理是:对每个少数类样本,在特征空间中找到若干近邻样本,然后在两者连线上随机插入新样本。这样既不丢失数据,又能增加少数类样本量。
from imblearn.over_sampling import SMOTE smote = SMOTE(random_state=42) X_res, y_res = smote.fit_resample(X_train, y_train)听起来很美好,但我在模拟项目X里试用下来,效果并不好。SMOTE合成的样本在验证集上反而让F1下降了。原因也很有代表性:流失数据里很多特征是稀疏的,比如登录次数、优惠券核销数存在大量0值。SMOTE用欧氏距离找近邻时,很容易在0值密集的区域造出“仿佛活跃又仿佛不活跃”的中间样本,这些合成样本反而成了噪声,让模型学出一套错误的边界。
如果确实想用SMOTE,建议优先尝试Borderline-SMOTE,或者SMOTE-ENN、SMOTE-Tomek这类先合成、再对重叠样本做清洗的组合方法。我后来测试SMOTE+Tomek Links,效果就比单纯SMOTE好一点,但相比下采样集成仍然没有明显优势。
我的经验是:当特征数量多、稀疏程度高、或者正样本本身只有几百条时,不要迷信SMOTE。合成数据和真实数据不同,它没有“业务事实”支撑,只会让模型在某些区域过度自信。
3.3 组合策略的对比:不要指望单次重采样解决一切
为了看清楚不同重采样策略的影响,我把几种常见做法放到同一个验证集上做了比较。验证集始终保持原始4%的分布,下表是我在模拟项目X中得到的一组结果:
| 策略 | AUC | PR-AUC | F1 | 备注 |
|---|---|---|---|---|
| 无处理/随机森林 | 0.71 | 0.16 | 0.18 | 准确率虚高,召回极低 |
| 随机欠采样+Bagging | 0.82 | 0.31 | 0.42 | 多次采样摊平信息损耗 |
| SMOTE | 0.78 | 0.27 | 0.36 | 合成噪声导致指标下降 |
| SMOTE+Tomek Links | 0.80 | 0.29 | 0.38 | 清洗重叠区域后略有提升 |
| 随机欠采样+XGBoost | 0.84 | 0.36 | 0.47 | 树模型+权重调整后最优 |
数据会因为业务场景不同而有差异,但趋势很典型:重采样的作用是改变训练分布,并不等于模型效果一定变好。必须配合阈值调整和合适的算法。我在这个项目里最终采用的是“随机欠采样 + XGBoost”,再往下就会用到类权重和阈值移动,这两部分才是真正把重采样效果放大成业务价值的环节。
4. 算法侧也有牌可打:类权重与阈值移动
4.1 class_weight的真实效果与代价
如果不想破坏训练集,也可以从算法层面给少数类更多“话语权”。最经典的做法就是class_weight。在逻辑回归或树模型里,给少数类的损失乘上一个较大的权重,相当于人为放大小样本错误的代价。
from sklearn.linear_model import LogisticRegression lr = LogisticRegression(class_weight='balanced')class_weight='balanced'会自动根据类别频率分配权重,少数类样本的权重高一些,多数类权重低一些。这样做不会丢弃样本,也保留了完整信息。我在模拟项目X里加了class_weight后,AUC从0.71提升到0.80,看起来很好。但这里有个坑:模型输出的概率整体被抬高了。原本全部样本的平均流失概率可能只有4%,加了权重后,预测概率平均值可能到10%甚至更高。如果还用0.5作为阈值,会出现大量误报。
原因很简单:你用加权损失训练出的模型,学习的并不是真实事件概率,而是一个被扭曲过的风险分数。class_weight对模型的贡献是改善排序能力,帮助把“真流失”排到“不流失”前面,但它并不能直接告诉你应该选哪个概率作为分界。
4.2 阈值移动:把0.5换成业务需要的阈值
这是整个类别不平衡处理链路里,最简单也最容易见效的一步。很多人在训练时做了各种处理,最后却默认用0.5判断,等于前面全白干。正确的做法是:用验证集重新计算不同阈值下的精确率、召回率和F1,再根据业务目标选阈值。
from sklearn.metrics import precision_recall_curve precision, recall, thresholds = precision_recall_curve(y_val, proba) f1 = 2 * precision * recall / (precision + recall + 1e-9) best_idx = np.argmax(f1[:-1]) # 最后一个点对应无穷大阈值,需要去掉 best_threshold = thresholds[best_idx] print(f"best threshold = {best_threshold:.3f}, F1 = {f1[best_idx]:.3f}")如果业务损失不明确,可以直接看F1最大的阈值。但更严谨的做法是把成本量化。假设漏掉一个流失用户带来的损失是100元,误触达一个正常用户的成本是5元。就可以先算所有样本的漏报率和误报率,再算每个阈值下的期望损失,最后选总损失最小的阈值。下面是我当时跑出来的一个示例:
| 阈值 | 召回率 | 精确率 | 期望成本 |
|---|---|---|---|
| 0.5 | 12% | 55% | 5.6元/人 |
| 0.2 | 45% | 38% | 3.1元/人 |
| 0.1 | 68% | 23% | 2.8元/人 |
| 0.05 | 82% | 12% | 4.9元/人 |
成本计算公式其实不复杂:期望成本 = 漏报率 × 正样本占比 × 漏报损失 + 误报率 × 负样本占比 × 误报损失。按这个逻辑,最优阈值落在0.1附近。阈值从0.5下移到0.1,召回率从12%升到68%,业务效果完全不一样。
4.3 集成与scale_pos_weight:算法侧的组合拳
在树模型里,除了class_weight,XGBoost和LightGBM还提供了scale_pos_weight参数。很多新手会把它误当成“调大一点就能提高召回率”的旋钮,其实它背后的含义是正负样本权重比。常见做法是设为负样本数除以正样本数。
import xgboost as xgb model = xgb.XGBClassifier( scale_pos_weight=sum(y_train == 0) / sum(y_train == 1) )在我的模拟项目X里,随机欠采样结合XGBoost和scale_pos_weight,已经比单纯重采样效果好很多。原因在于,梯度提升树在每一轮迭代中会自动聚焦之前分错的样本,而scale_pos_weight进一步放大了正样本的梯度,让树更倾向于分裂出少数类区域。
不过要记住,权重给得太大也会过拟合。常见做法是把scale_pos_weight设为负正比,然后在这个范围附近做网格搜索,比如试0.5倍、1倍、2倍。集成思路也一样,BalancedRandomForest本质上是在每棵树训练时做平衡采样,多个平衡树再聚合,天然适合极端不平衡场景。算法侧和重采样不是互斥的,它们可以组合使用,但所有组合的结果都要回到验证集上,用业务指标来验收。
5. 工程落地时绕不开的细节:验证策略与模型校准
5.1 验证集划分:分层采样不够,要做时间或分组隔离
数据建模做久了会发现,很多模型在离线评估时表现不错,上线后却立刻失效,其中一大原因就是验证集划分没有模拟真实预测场景。类别不平衡问题里,这就更致命了。如果同一用户的行为记录同时出现在训练集和验证集,模型会学到很多“记忆式”规律,召回率看起来很高,但换一批新用户就原形毕露。
所以在模拟项目X里,我坚持按用户ID做分组切割。如果用交叉验证,就用GroupKFold而非普通KFold。代码很简单,逻辑却非常重要:
from sklearn.model_selection import GroupKFold gkf = GroupKFold(n_splits=5) for train_idx, val_idx in gkf.split(X, y, groups=user_id): # train on train_idx, validate on val_idx ...另一个要点是保持验证集原始分布。我见过有同学对全量数据做SMOTE后再划分,验证集里正样本比例也被硬生生拉高到30%,最后F1高得吓人,一到线上就崩。记住:重采样、过采样、SMOTE、邻域清洗,全都只允许作用在训练集。验证集是“未来的新数据”,它必须维持真实比例,这样评估才有意义。
5.2 概率校准:让模型输出可以作为决策依据
模型得分并不等于真实概率,尤其在做过重采样或class_weight之后,输出的概率值会被整体扭曲。如果下游只需要排序,比如按分数从高到低打电话,那校准没那么必要。但如果下游要做预期收益计算、成本估算,或者要把概率交给业务方当作“流失风险百分比”,那就必须做概率校准。
sklearn里提供现成工具:
from sklearn.calibration import CalibratedClassifierCV cal_model = CalibratedClassifierCV(model, method='isotonic', cv=3) cal_model.fit(X_train_cal, y_train_cal) cal_proba = cal_model.predict_proba(X_val)[:, 1]校准方法主要分两种:Platt缩放适合模型曲线较平滑的情况,等渗回归更灵活,但样本量太小时容易过拟合。我一般先用Platt,如果校准曲线明显非线性,再换等渗。注意,校准需要独立数据,不要用训练集校准再用训练集评估,否则会出现“概率很准但实际不靠谱”的假象。
校准后的概率应该比校准前更接近真实比例。比如训练集正样本4%,校准后验证集所有样本的平均预测概率应该也会落在4%附近。这时候前面选的0.1阈值才真正具有业务解释:模型告诉你,一个用户未来30天流失风险超过10%,就进入客服召回名单。
5.3 上线后监控和迭代:分布会漂移
模型上线不是结束,而是监控循环的开始。尤其类别不平衡模型,线上正样本率是漂移的第一信号。比如你现在训练时正样本占比4%,三个月后因为市场环境变化,真实流失率涨到8%,那么模型的预测概率会整体上浮,之前阈值选在0.1,可能不再最优。
我的习惯是每天记录三组数字:线上正样本率、模型预测概率的均值、以及抽样标注的精确率和召回率。一周看一次趋势,如果发现概率均值持续走高,就要重新校准或重训。特征分布也值得关注,常用PSI指标衡量。经验阈值大致是:单个特征PSI低于0.1说明稳定,0.1到0.25之间需要关注,超过0.25说明已明显漂移。
这些监控看起来和建模无关,但恰恰是类别不平衡项目能否稳住的关键。因为不平衡比例本身就不是一成不变的。今天你辛苦找到的阈值,明天可能因为一次活动、一个大版本更新就失效。所以,建模笔记最后一定要补一句:留好数据管道,做好监控,定期重训,比追求某一时刻的最高分更重要。
整套做下来,我自己最深的体会是:类别不平衡问题的核心,其实不是某个算法有多神奇,而是你有没有先把业务损失定义清楚。模型要抓的是哪部分样本?漏掉和误伤的代价分别是什么?这两个问题想明白了,指标选择、阈值移动、校准这些操作才会有的放矢。
最后再分享一个小技巧:无论选择哪种重采样、类权重还是合成样本方案,所有数据加工都只能发生在训练集内部。验证集和测试集始终要维持原始分布。我刚入行时就是对这个细节掉以轻心,拿做过采样的数据做评估,结果离线F1很高,上线后一塌糊涂。现在我把这句话贴在工位上了,每次建模前都会先念一遍。希望你不用再踩这个坑。