类别不平衡分类建模实战:评估、采样与损失函数优化
2026/9/7 15:40:33 网站建设 项目流程

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, threshold=0.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, average='binary') auc = roc_auc_score(y, y_prob) ap = average_precision_score(y, y_prob) print(f"Confusion 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/(tn+fp):.4f}, Positive Rate={tp/(tp+fn):.4f}") return y_prob, y_pred

每次训练完模型都跑一遍这个函数,把输出贴到实验记录里,你会发现自己对模型的认识会清晰非常多。看到FN高,说明漏得多,需要提高召回率;看到FP高,说明误报多,需要提高精确率。

我强烈建议每个做分类建模的人都养成一个习惯:任何模型评估必看混淆矩阵,不看准确率。

3. 数据处理层面的自救:采样方法的利与弊

3.1 随机过采样与欠采样

最直觉的方法就是调整样本比例。把少数类重复复制(过采样),或者把多数类扔掉一部分(欠采样),让两边数量接近。

随机过采样实现简单,但它有个致命问题:复制出来的样本和原样本完全一样,模型会“背下来”而不是“学会来”,而且容易过拟合。我用过之后发现,虽然训练集上Recall能到95%+,但验证集一测就现原形,降到70%左右,泛化能力很差。

随机欠采样则相反,它的风险是信息丢失。原本10万条多数类样本,直接丢到1万条,等于扔掉了大量决策边界信息。如果多数类内部本身有不同分布,欠采样会让模型对这些子分布的判断失真。

3.2 SMOTE家族的进阶操作

SMOTE(Synthetic Minority Over-sampling TEchnique)的思路比随机复制聪明得多:它在少数类样本之间的连线上随机插值,生成新的合成样本。

from imblearn.over_sampling import SMOTE, BorderlineSMOTE, ADASYN smote = SMOTE(random_state=42, k_neighbors=5) X_resampled, y_resampled = smote.fit_resample(X_train, y_train) # 如果多数类在边界上混在一起,试试 BorderlineSMOTE # borderline = BorderlineSMOTE(random_state=42, kind='borderline-1') # X_res, y_res = borderline.fit_resample(X_train, y_train) # 如果希望生成的样本更关注难分的少数类,试试 ADASYN # adasyn = ADASYN(random_state=42, n_neighbors=5) # 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 混合策略:过采样+欠采样组合拳

单一方法效果有限的时候,可以试混合策略。一个比较实用的管线是:

  1. 先用SMOTE对少数类做适中倍数的过采样(比如把比例从1:50拉到1:10,不追求完全平衡)
  2. 再用NearMiss或RandomUnderSampler对多数类做适度欠采样(把比例进一步压到1:3左右)
  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_estimators=300, learning_rate=0.05, is_unbalance=True, # 自动调整正样本权重 random_state=42 ) # 方法二:手动指定scale_pos_weight # 一般取 负样本数/正样本数 scale = y_train.value_counts()[0] / y_train.value_counts()[1] model = lgb.LGBMClassifier( n_estimators=300, learning_rate=0.05, scale_pos_weight=scale, random_state=42 )

在神经网络里,对应的是在损失函数里给正样本更高的权重:

import torch.nn as nn # 二分类,正样本权重设为负样本的比例 pos_weight = torch.tensor([neg_count / pos_count]) criterion = nn.BCEWithLogitsLoss(pos_weight=pos_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, alpha=0.25, gamma=2.0): super().__init__() self.alpha = alpha self.gamma = gamma def forward(self, logits, targets): bce_loss = F.binary_cross_entropy_with_logits(logits, targets, reduction='none') pt = torch.exp(-bce_loss) # 预测正确的概率 focal_loss = self.alpha * (1 - pt) ** self.gamma * bce_loss return focal_loss.mean()

参数方面,alpha控制正负样本的权重平衡,gamma控制难易样本的调节力度。gamma=0时Focal Loss退化为普通交叉熵带加权;gamma越大,模型越关注难分类样本。我一般从alpha=0.25, gamma=2.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(f"Best 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%。后来改用分层方案:

  1. 先用LightGBM训练一个“是否故障”的二分类器
  2. 对于判为故障的样本,再训练一个“故障类型”的多分类器
  3. 多分类器的样本里,用SMOTE对不同子节点做差异化过采样,对少数子节点过采样倍率更高,对多数子节点只做轻微过采样
  4. 每个子节点单独计算ROC最佳阈值

这样调整以后,冷门故障模式的召回率从20%拉到了65%以上。虽然绝对数字还是不如高频故障,但至少它“能被找到”了,不再是一个完全的黑盒。这种结构化拆解的思路,对任何带层级关系的极端不平衡场景都适用。

6. 常见问题与排查技巧实录

实操中你会反复遇到一些固定的坑,我把自己踩过的列成了一张速查表,方便你对照排查:

现象可能原因排查手段解决方向
训练集AUC很高,测试集掉一半过采样导致过拟合对比采样前后测试集PR-AUC降低过采样倍率,加正则化
准确率98%,但业务完全不可用模型退化为多数类预测器查看混淆矩阵的FN数量换评估指标,调阈值,加权重
调了权重后Precision掉很多权重过大,误报上升画PR曲线看工作点降低scale_pos_weight,或用Focal Loss
SMOTE之后模型反而变差了样本量太少/特征稀疏检查合成样本分布是否合理放弃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_state=42)), ('clf', lgb.LGBMClassifier(n_estimators=200, is_unbalance=True)) ]) skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) scores = cross_val_score(pipeline, X_train, y_train, cv=skf, scoring='roc_auc')

imblearn的Pipeline可以确保SMOTE只在每一折的训练部分内执行,不会泄漏到验证部分。这里的细节至关重要,很多人告诉我他们调了半天都复现不了论文结果,最后发现就是重采样泄漏导致的。

6.2 类别极度不平衡时,先试试“不做任何处理”

这句话可能听起来反直觉,但我是认真的。在动手采样、调权之前,先跑一个完全不做任何处理的基准模型,评估它的PR-AUC和Recall。这一步的目的不是追求最好效果,而是:第一,确认基线在哪里;第二,观察模型在原始分布上的“自然行为”,指引后面的处理方向。

有时候你会惊喜地发现,某些树模型配合调优后的阈值,在不做任何采样的情况下效果已经够用了。毕竟,任何采样方案都是有损的,能不动数据就不动数据。我的原则是:先基线,再加权,再采样,最后才考虑生成式方法。每一步都对比上一步的效果,发现哪一步增益最大、哪一步反而有损,这样你对数据的理解会深很多。

6.3 树模型分对多数类、分错少数类的异常定位

遇到树模型对少数类完全失效的情况,我会做一个特征层面的“差距分析”。把少数类样本和多数类样本在每个特征上的分布拉出来对比,找到差异最大的特征,看是不是某些特征的覆盖范围在少数类上完全不同。同时用SHAP值分析模型对少数类样本的预测逻辑,看是不是模型在用一个无关特征做判断。

这种定位方式不复杂,但很管用。有一次我排查了很久,最后发现少数类样本的某个业务字段因为历史原因从某个月开始大量缺失,模型把“该字段缺失”当成了预测信号,导致新数据上一堆误判。这种问题,不深入看特征分布是永远发现不了的。

7. 最后再聊一点实际体会

写了这么多,其实最想表达的一件事是:没有银弹。SMOTE不是万能的,Focal Loss也不是灵丹妙药,真正有效的是你要对数据、对评估、对业务目标都有一个清醒的认识。

我自己的标准工作流已经固定下来了:

  • 先做EDA,统计类别分布和父子结构占比
  • 跑一个最朴素的基线模型,全面看指标
  • 针对业务确定优化目标——是要召回还是要精确还是F1
  • 按“阈值调优 -> 损失加权 -> 采样平衡 -> 分层建模”的顺序逐步加码
  • 每一层加完后都要用同一套评估体系对比,记录PR-AUC、混淆矩阵、业务指标

整套流程走完,大部分不平衡问题都能被压到一个可接受的范围。剩下的那些“搞不定”的问题,往往是数据本身的信号不够,或者业务定义的类别边界本身就有问题,这不是任何模型技巧能弥补的。

在“dfd黑洞、奇迹与灰洞”这类极端结构的不平衡场景里,我的建议是不要试图用一个巨大模型去解决所有问题,而是把问题拆开:黑洞样本量大但信息量低,适合用一种方式处理;灰洞样本中等,适合用通用模型;奇迹样本极少但关键,可能需要单独维护一套规则或者小模型专门兜底。这种“分而治之”的思路,比任何单一算法都可靠。

最后分享一个实操小技巧:把每次实验的指标、参数、数据版本都记录下来,哪怕只是写在一个txt文件里。你会惊讶地发现,很多调试灵感其实是翻看过去的对比记录时被触发的。模型性能分析这件事,本质上不是在和算法较劲,而是在和“混沌”较劲——而记录,是你对抗混沌的最好工具之一。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询