ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

类别不平衡分类建模实战:评估、采样与损失函数优化

类别不平衡分类建模实战:评估、采样与损失函数优化 1. 为什么“模型效果不错”是一种错觉先聊一个我在实际项目里反复见过的现象分类模型训练完了准确率97%领导很满意结果上线第一天就被业务方骂回来。为什么因为正样本只占3%模型只要把所有样本都判成负类准确率就已经97%了。这不是模型在“学习”这是模型在“偷懒”。类别不平衡class imbalance就是这么阴险的问题。它不像代码报错那样直接给你弹红字而是让模型在你看不见的地方悄悄退化成“赌徒策略”——永远押大概率那一方。你如果只盯着准确率看根本发现不了模型已经废了。这个问题的本质是模型在优化目标时被多数类支配了。绝大多数分类模型的损失函数是所有样本损失的平均值。当负样本占97%时把负样本分对带来的收益远大于把正样本分对梯度下降自然会把精力全放在“讨好”多数类上。正样本那3%的贡献就像在菜市场里喊一嗓子直接被淹没在人声鼎沸中。更麻烦的是很多场景恰恰是少数类才重要。比如风控场景里欺诈交易是少数类但抓不到它就要赔钱医疗场景里患病样本是少数类漏诊是要出医疗事故的推荐场景里用户真正点击的商品是少数类猜不中就意味着推荐无效工业质检里缺陷品是少数类漏检直接影响出厂质量如果出现“父子图不平衡”这种结构性问题——父节点样本充足、子节点样本稀疏传统的全局采样策略就更难覆盖到那些冷门的细分分支模型在这些分支上的表现会呈断崖式下跌。我在后面的内容里会逐一拆解为什么常规评估手段会被干扰哪些处理手段真正有效以及在“dfd黑洞、奇迹与灰洞”这类极端场景下怎么让模型不彻底摆烂。这些方法不是我坐在办公室里拍脑袋想出来的是从多个实际项目里踩坑踩出来的。2. 先搞清楚模型到底“废”在哪评估指标是第一步2.1 准确率陷阱最漂亮的数字最骗人二分类问题的准确率Accuracy在类别不平衡下几乎没有任何参考价值。你的模型98%准确率可能对少数类的召回率是0%。这种事情我见过太多次了。举个真实例子。之前做一个设备故障预测模型故障率大约2%。第一版模型上线后测试集准确率98.5%看起来非常漂亮。但拉出混淆矩阵一看故障样本的召回率只有6.3%——100个真正要坏的设备模型只抓到了6个剩下94个全都漏了。这模型上线等于没上线而且还给业务方一种“我们已经做了预测性维护”的错觉。真正要看的是混淆矩阵里的四个格子真正例TP、假正例FP、真负例TN、假负例FN。尤其是FN——在多数实际场景里假阴性的代价远比假阳性高。2.2 该用哪些指标Precision、Recall、F1、PR曲线、AUC我把不平衡场景下真正有用的指标列一下指标计算公式关注点适用场景PrecisionTP / (TP FP)预测为正的里面有多少是对的误报代价高的场景RecallTP / (TP FN)真正的正样本被抓住多少漏报代价高的场景F1-score2PR / (P R)精确率和召回率的平衡两者的代价差不多时PR-AUC精确率-召回率曲线下面积阈值变化下的综合表现极端不平衡场景最推荐ROC-AUCTP Rate vs FP Rate曲线下面积排序能力的整体评估平衡或轻度不平衡场景Lift / KS累计正样本占比对比业务提升度风控建模常用关键的区别在于PR曲线和ROC曲线的差异。ROC曲线对不平衡不敏感因为它的两个轴TPR和FPR都是按比例算的负样本再多FPR的分母变大但比例变化不大。而PR曲线直接关注正样本的精确率和召回率负样本数量一变曲线形状就明显变化。所以在极度不平衡场景下PR曲线比ROC曲线更能暴露模型的真实水平。我个人的习惯是全指标都打印出来看。不要只挑好看的发到周报里你要对自己诚实。2.3 泄漏出的真相用混淆矩阵定位错误模式光看汇总指标还不够要把混淆矩阵列出来逐个格子看。我自己常用的做法是写一个简单的评估函数把所有指标一次性打出来from sklearn.metrics import confusion_matrix, precision_recall_fscore_support, roc_auc_score, average_precision_score def evaluate_binary(model, X, y, threshold0.5): y_prob model.predict_proba(X)[:, 1] y_pred (y_prob threshold).astype(int) cm confusion_matrix(y, y_pred) tn, fp, fn, tp cm.ravel() precision, recall, f1, _ precision_recall_fscore_support(y, y_pred, averagebinary) auc roc_auc_score(y, y_prob) ap average_precision_score(y, y_prob) print(fConfusion Matrix:) print(f TN{tn}, FP{fp}) print(f FN{fn}, TP{tp}) print(f Precision{precision:.4f}, Recall{recall:.4f}, F1{f1:.4f}) print(f ROC-AUC{auc:.4f}, PR-AUC{ap:.4f}) print(f Negative Rate{tn/(tnfp):.4f}, Positive Rate{tp/(tpfn):.4f}) return y_prob, y_pred每次训练完模型都跑一遍这个函数把输出贴到实验记录里你会发现自己对模型的认识会清晰非常多。看到FN高说明漏得多需要提高召回率看到FP高说明误报多需要提高精确率。我强烈建议每个做分类建模的人都养成一个习惯任何模型评估必看混淆矩阵不看准确率。3. 数据处理层面的自救采样方法的利与弊3.1 随机过采样与欠采样最直觉的方法就是调整样本比例。把少数类重复复制过采样或者把多数类扔掉一部分欠采样让两边数量接近。随机过采样实现简单但它有个致命问题复制出来的样本和原样本完全一样模型会“背下来”而不是“学会来”而且容易过拟合。我用过之后发现虽然训练集上Recall能到95%但验证集一测就现原形降到70%左右泛化能力很差。随机欠采样则相反它的风险是信息丢失。原本10万条多数类样本直接丢到1万条等于扔掉了大量决策边界信息。如果多数类内部本身有不同分布欠采样会让模型对这些子分布的判断失真。3.2 SMOTE家族的进阶操作SMOTESynthetic Minority Over-sampling TEchnique的思路比随机复制聪明得多它在少数类样本之间的连线上随机插值生成新的合成样本。from imblearn.over_sampling import SMOTE, BorderlineSMOTE, ADASYN smote SMOTE(random_state42, k_neighbors5) X_resampled, y_resampled smote.fit_resample(X_train, y_train) # 如果多数类在边界上混在一起试试 BorderlineSMOTE # borderline BorderlineSMOTE(random_state42, kindborderline-1) # X_res, y_res borderline.fit_resample(X_train, y_train) # 如果希望生成的样本更关注难分的少数类试试 ADASYN # adasyn ADASYN(random_state42, n_neighbors5) # X_res, y_res adasyn.fit_resample(X_train, y_train)实际使用中SMOTE有几个非常关键的坑必须先切分训练集和测试集再对训练集做SMOTE。如果先做SMOTE再切分合成样本和测试样本之间会有信息泄漏验证结果会虚高。对高维稀疏特征做SMOTE效果很差。文本TF-IDF、用户ID类One-Hot特征插值出来的是“四不像”样本。超参数k_neighbors影响很大。k设太小容易生成过于相似的样本k设太大容易跨类边界。我一般从5开始调看验证集PR-AUC的变化。3.3 混合策略过采样欠采样组合拳单一方法效果有限的时候可以试混合策略。一个比较实用的管线是先用SMOTE对少数类做适中倍数的过采样比如把比例从1:50拉到1:10不追求完全平衡再用NearMiss或RandomUnderSampler对多数类做适度欠采样把比例进一步压到1:3左右最后加上后续在损失函数层面做处理完全1:1不一定最优。我做过对比实验在某些数据集上1:5的PR-AUC比1:1更高。因为完全平衡会过度改变原始分布让模型对先验概率的判断失真推理时遇到真实分布反而水土不服。具体哪个比例好只能是拿着验证集一遍遍试。3.4 什么时候采样法会失效采样不是万能的。有两个场景我一般不建议用采样一是样本量本身就极少的情况。比如正样本总共只有30条SMOTE再怎么插值也变不出信息量合成出来的样本都是对那30条样本的“重复变形”本质上是过度自信地外推反而容易引入噪声干扰模型。二是数据本身带有时间序列特性。比如欺诈检测里欺诈手法会随季节、活动、舆论变化直接用SMOTE插值可能把不同时期的数据混在一起生成一个根本不存在的“时间混合体”让模型学到错误的时序依赖。4. 训练与算法层面的降维打击4.1 损失函数加权最简单的有效方案比起动数据直接告诉模型“正样本错了代价更大”往往更高效。具体做法就是对少数类的损失乘一个权重。import lightgbm as lgb # 方法一LightGBM自带的is_unbalance参数 model lgb.LGBMClassifier( n_estimators300, learning_rate0.05, is_unbalanceTrue, # 自动调整正样本权重 random_state42 ) # 方法二手动指定scale_pos_weight # 一般取 负样本数/正样本数 scale y_train.value_counts()[0] / y_train.value_counts()[1] model lgb.LGBMClassifier( n_estimators300, learning_rate0.05, scale_pos_weightscale, random_state42 )在神经网络里对应的是在损失函数里给正样本更高的权重import torch.nn as nn # 二分类正样本权重设为负样本的比例 pos_weight torch.tensor([neg_count / pos_count]) criterion nn.BCEWithLogitsLoss(pos_weightpos_weight)这个方案的好处是不改变原始数据分布训练和推理的分布保持一致不会出现“训练时分布和线上分布不一样”的问题。骨架模型用树模型时LightGBM的is_unbalance参数实测效果非常稳我大多数项目第一步都是直接开它试试。4.2 Focal Loss让模型聚焦“难啃的硬骨头”Focal Loss最初是目标检测领域为了解决正负样本极端不平衡提出的后来被搬到分类任务上也好用。它的核心思想是不仅给少数类加权还给“难分样本”加权。具体来说通过一个调制因子(1 - p_t)^γ让模型把注意力放在那些预测概率不高、经常被分错的样本上而不是已经学得很好的简单样本上。import torch import torch.nn.functional as F class FocalLoss(nn.Module): def __init__(self, alpha0.25, gamma2.0): super().__init__() self.alpha alpha self.gamma gamma def forward(self, logits, targets): bce_loss F.binary_cross_entropy_with_logits(logits, targets, reductionnone) pt torch.exp(-bce_loss) # 预测正确的概率 focal_loss self.alpha * (1 - pt) ** self.gamma * bce_loss return focal_loss.mean()参数方面alpha控制正负样本的权重平衡gamma控制难易样本的调节力度。gamma0时Focal Loss退化为普通交叉熵带加权gamma越大模型越关注难分类样本。我一般从alpha0.25, gamma2.0起步这是原论文的推荐值但实际要根据数据分布自己调。4.3 阈值调整别被默认的0.5绑架很多人在模型输出概率后默认用0.5作为分类阈值。但在不平衡场景下这个默认值几乎从来不是最优值。模型的输出概率本身是有偏的或者分布整体偏向低概率区间0.5可能根本不在一个合理的工作点上。正确的做法是把阈值当做超参数来调。用验证集遍历不同的阈值找到PR曲线上的最优工作点from sklearn.metrics import f1_score import numpy as np y_prob model.predict_proba(X_val)[:, 1] best_threshold 0.5 best_f1 0 for threshold in np.arange(0.1, 0.9, 0.02): y_pred (y_prob threshold).astype(int) current_f1 f1_score(y_val, y_pred) if current_f1 best_f1: best_f1 current_f1 best_threshold threshold print(fBest threshold: {best_threshold}, Best F1: {best_f1})如果业务对召回率有硬性要求比如“故障设备至少要抓出80%”那阈值就按达到这个召回率的最低阈值来定。这种情况下阈值不是“最优”而是“满足业务约束的可用”。4.4 树模型 vs 深度模型的差异心得我在实践里观察到树模型对类别不平衡的“耐受力”其实比深度学习更强。因为决策树在做分裂时用的是样本分割后的纯度增益天然对异常比例没那么敏感。加上LightGBM/XGBoost都支持样本权重和内置的不平衡处理机制所以在表格数据上我个人优先推荐直接用树模型scale_pos_weight简单有效。深度模型则更容易被不平衡干掉。因为神经网络的梯度是通过反向传播累积的多数类的梯度会完全淹没少数类的贡献而且Batch训练时一个Batch里可能根本没有正样本导致某些步数的梯度对正样本“一无所知”。用深度模型时建议配合加权采样器WeightedRandomSampler或者Focal Loss一起上单纯堆数据还不如改损失函数来得快。5. 极端场景实战黑洞、奇迹与灰洞中的“父子图不平衡”5.1 什么是“dfd黑洞、奇迹与灰洞”这个说法来自数据流分析Data Flow Diagram的实践观察。简单来说在一个复杂的数据流链路或者树状层级结构里不同节点之间的数据流量差异巨大黑洞Black Hole流量巨大但信息量极低的大量普通数据节点占据了绝大多数样本奇迹Miracle样本量极少但信息量极高、能直接改变判断结论的少数关键节点灰洞Gray Hole介于两者之间、信息量中等但偶尔也会踩到的“中间态”节点这种“父子图不平衡”本质上是一种多层级、结构化的类别极度不平衡。比如在反欺诈链路里90%的交易走普通路径黑洞9%的交易走风险路径灰洞只有1%的交易会触发欺诈特征奇迹。模型如果只看全量数据训练奇迹节点那部分模式几乎学不到。5.2 为什么这种不平衡比普通不平衡更难搞普通的不平衡只是“正负样本数量差距大”而父子图不平衡多了两个维度第一个维度是结构位置的不平衡。少数类不是随机分布的而是集中在某个父节点的特定子分支里。如果特征工程没有把这种“父子关系”编码进去模型根本看不到这个结构信息再采样都没用。第二个维度是不平衡程度随着层级加深而加剧。越往叶子节点走样本越稀疏到最底层可能只有个位数的样本。这种近似“长尾中的长尾”用全局采样方法根本救不回来因为对每一个尾部分支来说单纯上采样都像是在拿几十个样本“硬造”一片森林。5.3 分层建模与局部平衡策略处理这类问题我实际验证过几个有效的手段第一把“父节点类别”本身作为一个特征喂给模型。不要只给模型叶子标签把父路径信息拼进特征里。树模型能自动切分不同父节点下的不同分布相当于让模型先分组再学习。第二对每个父节点分支单独做阈值调整。因为不同父节点下的正样本比例差异很大统一的全局阈值必然顾此失彼。按父节点分组统计分布每组设一个单独的工作阈值实际效果提升非常明显。第三父节点层级的分类和叶子层级的分级拆成两步模型。第一步判断“会不会踩到风险路径”第二步再在风险路径内判断“是灰洞还是奇迹”。分层建模把极度不平衡的单一问题拆成了两个相对平衡的子问题模型在每一层上的学习压力都小很多。5.4 真实案例设备故障树场景的父-子不平衡之前做过一个工业设备的故障定位模型。设备有多个子系统父节点每个子系统下面还有若干故障模式子节点。数据情况是主控制器故障占了样本的85%而某个冷门传感器故障只有0.3%的样本。第一版模型只做了全局SMOTE加权效果不尽人意主控制器的高频故障模式识别得很好但冷门传感器故障的召回率不到20%。后来改用分层方案先用LightGBM训练一个“是否故障”的二分类器对于判为故障的样本再训练一个“故障类型”的多分类器多分类器的样本里用SMOTE对不同子节点做差异化过采样对少数子节点过采样倍率更高对多数子节点只做轻微过采样每个子节点单独计算ROC最佳阈值这样调整以后冷门故障模式的召回率从20%拉到了65%以上。虽然绝对数字还是不如高频故障但至少它“能被找到”了不再是一个完全的黑盒。这种结构化拆解的思路对任何带层级关系的极端不平衡场景都适用。6. 常见问题与排查技巧实录实操中你会反复遇到一些固定的坑我把自己踩过的列成了一张速查表方便你对照排查现象可能原因排查手段解决方向训练集AUC很高测试集掉一半过采样导致过拟合对比采样前后测试集PR-AUC降低过采样倍率加正则化准确率98%但业务完全不可用模型退化为多数类预测器查看混淆矩阵的FN数量换评估指标调阈值加权重调了权重后Precision掉很多权重过大误报上升画PR曲线看工作点降低scale_pos_weight或用Focal LossSMOTE之后模型反而变差了样本量太少/特征稀疏检查合成样本分布是否合理放弃SMOTE改用加权损失函数线上效果和验证集差距大数据分布漂移对比训练/线上特征分布定期做分布监测增量训练负样本太多训练太慢多数类占比过高统计各类别样本量对多数类做适度的随机欠采样6.1 采样后一定不要交叉验证错要分层交叉验证很多人用SMOTE重采样后直接做普通的K-Fold交叉验证结果严重虚高。因为SMOTE是在整个训练集上做的某条合成样本的“父母”可能来自训练折而它自己却跑到了验证折里评估时模型相当于“见过”了验证集附近的信息。正确的做法是用分层K-Fold并且把重采样步骤放在每一折内部独立进行from sklearn.model_selection import StratifiedKFold from imblearn.pipeline import Pipeline as ImbPipeline from imblearn.over_sampling import SMOTE pipeline ImbPipeline([ (smote, SMOTE(random_state42)), (clf, lgb.LGBMClassifier(n_estimators200, is_unbalanceTrue)) ]) skf StratifiedKFold(n_splits5, shuffleTrue, random_state42) scores cross_val_score(pipeline, X_train, y_train, cvskf, scoringroc_auc)用imblearn的Pipeline可以确保SMOTE只在每一折的训练部分内执行不会泄漏到验证部分。这里的细节至关重要很多人告诉我他们调了半天都复现不了论文结果最后发现就是重采样泄漏导致的。6.2 类别极度不平衡时先试试“不做任何处理”这句话可能听起来反直觉但我是认真的。在动手采样、调权之前先跑一个完全不做任何处理的基准模型评估它的PR-AUC和Recall。这一步的目的不是追求最好效果而是第一确认基线在哪里第二观察模型在原始分布上的“自然行为”指引后面的处理方向。有时候你会惊喜地发现某些树模型配合调优后的阈值在不做任何采样的情况下效果已经够用了。毕竟任何采样方案都是有损的能不动数据就不动数据。我的原则是先基线再加权再采样最后才考虑生成式方法。每一步都对比上一步的效果发现哪一步增益最大、哪一步反而有损这样你对数据的理解会深很多。6.3 树模型分对多数类、分错少数类的异常定位遇到树模型对少数类完全失效的情况我会做一个特征层面的“差距分析”。把少数类样本和多数类样本在每个特征上的分布拉出来对比找到差异最大的特征看是不是某些特征的覆盖范围在少数类上完全不同。同时用SHAP值分析模型对少数类样本的预测逻辑看是不是模型在用一个无关特征做判断。这种定位方式不复杂但很管用。有一次我排查了很久最后发现少数类样本的某个业务字段因为历史原因从某个月开始大量缺失模型把“该字段缺失”当成了预测信号导致新数据上一堆误判。这种问题不深入看特征分布是永远发现不了的。7. 最后再聊一点实际体会写了这么多其实最想表达的一件事是没有银弹。SMOTE不是万能的Focal Loss也不是灵丹妙药真正有效的是你要对数据、对评估、对业务目标都有一个清醒的认识。我自己的标准工作流已经固定下来了先做EDA统计类别分布和父子结构占比跑一个最朴素的基线模型全面看指标针对业务确定优化目标——是要召回还是要精确还是F1按“阈值调优 - 损失加权 - 采样平衡 - 分层建模”的顺序逐步加码每一层加完后都要用同一套评估体系对比记录PR-AUC、混淆矩阵、业务指标整套流程走完大部分不平衡问题都能被压到一个可接受的范围。剩下的那些“搞不定”的问题往往是数据本身的信号不够或者业务定义的类别边界本身就有问题这不是任何模型技巧能弥补的。在“dfd黑洞、奇迹与灰洞”这类极端结构的不平衡场景里我的建议是不要试图用一个巨大模型去解决所有问题而是把问题拆开黑洞样本量大但信息量低适合用一种方式处理灰洞样本中等适合用通用模型奇迹样本极少但关键可能需要单独维护一套规则或者小模型专门兜底。这种“分而治之”的思路比任何单一算法都可靠。最后分享一个实操小技巧把每次实验的指标、参数、数据版本都记录下来哪怕只是写在一个txt文件里。你会惊讶地发现很多调试灵感其实是翻看过去的对比记录时被触发的。模型性能分析这件事本质上不是在和算法较劲而是在和“混沌”较劲——而记录是你对抗混沌的最好工具之一。
返回列表