1. 项目概述:当你的模型“偏爱”了多数派
在机器学习的实战里,尤其是分类任务,我们常常会遇到一个令人头疼的“潜规则”:模型会倾向于预测那些在数据集中出现次数更多的类别。比如,在一个99%是正常交易、1%是欺诈交易的信用卡数据集上,一个“傻瓜”模型只要永远预测“正常”,就能轻松获得99%的准确率。这听起来很荒谬,但却是类别不平衡问题最直观的体现。我们真正关心的,往往是那些稀少的“少数派”——欺诈、疾病、故障信号等。如果模型学不会识别它们,那么再高的整体准确率也毫无意义。
“解决类别不平衡问题的方法综述”这个标题,指向的正是机器学习从业者必须掌握的一套核心工具箱。它不是一个单一的技巧,而是一套从数据层面、算法层面到评估层面进行系统性干预的组合拳。我处理过太多因为忽视这个问题而导致项目失败的案例:一个癌症筛查模型因为健康样本过多,对所有新样本都预测为“健康”;一个工业缺陷检测系统对罕见缺陷类型视而不见。因此,深入理解并灵活运用这些方法,是构建一个健壮、公平且真正有用的分类器的关键。无论你是刚入门的数据科学爱好者,还是正在为生产环境模型调优的工程师,这篇文章将带你系统性地拆解各类方法的核心思想、适用场景以及那些只有踩过坑才知道的实操细节。
2. 核心思路拆解:从“数据”到“决策”的全链路视角
解决类别不平衡问题,绝不能只盯着数据本身。一个完整的思路应该贯穿机器学习工作流的始终。我们可以将其分为三个层次:数据层、算法层和评估层。这三者并非互斥,而是常常需要协同使用。
数据层方法的核心思想是“动数据”。既然数据分布不平衡,我们就通过人工手段调整训练集的类别分布,使其趋向平衡,从而让模型在学习时能“公平”地看待每个类别。这是最直观、也最常用的一类方法。
算法层方法的核心思想是“改算法”。我们不改变数据本身,而是通过修改机器学习算法的训练过程或目标函数,让算法自身具备对少数类的“敏感性”。这类方法通常更优雅,但实现复杂度可能更高。
评估层方法的核心思想是“换尺子”。我们承认数据的不平衡性,但不再使用准确率(Accuracy)这种会被多数类主导的指标,转而使用更能反映模型对少数类识别能力的评估指标,如精确率(Precision)、召回率(Recall)、F1-score,特别是面向多类的宏平均(Macro-average)或针对特定少数类的评估。
一个成熟的解决方案,往往是先选择合适的评估指标设定目标(评估层),然后决定是采用重采样技术(数据层),还是使用代价敏感学习等算法(算法层),或是两者结合。例如,在一个极度不平衡的场景下,我可能会先采用SMOTE生成一些少数类样本(数据层),然后使用XGBoost并调整scale_pos_weight参数(算法层),最后以少数类的F1-score作为模型选择的最终标准(评估层)。
3. 数据层方法详解:重采样技术的艺术与陷阱
数据层方法是实践中的第一道防线,主要分为过采样和欠采样。
3.1 过采样:让少数派“发声”
过采样通过增加少数类样本的数量来平衡数据集。最朴素的方法是随机过采样,即随机复制已有的少数类样本。
注意:单纯的随机复制极易导致模型过拟合。因为模型会反复看到完全相同的样本,从而将这些样本的特定噪声也当作规律学习,在未见过的数据上表现会急剧下降。因此,除非作为基线对比,否则不建议在生产中使用纯随机过采样。
为了解决过拟合问题,SMOTE及其衍生算法成为了主流。SMOTE的基本思想不是在现有样本点上复制,而是在特征空间中“创造”新的样本。对于每一个少数类样本,SMOTE会找到它的k个最近邻(同样是少数类),然后在这条连线上随机选择一个点作为新样本。例如,样本A的特征向量是[1, 2],其最近邻B是[2, 3],那么可能会在A和B的连线上生成一个新样本[1.5, 2.5]。
# 使用imbalanced-learn库实现SMOTE的示例 from imblearn.over_sampling import SMOTE from sklearn.datasets import make_classification # 生成一个不平衡数据集 X, y = make_classification(n_samples=1000, weights=[0.95, 0.05], random_state=42) # 应用SMOTE smote = SMOTE(random_state=42) X_resampled, y_resampled = smote.fit_resample(X, y) print(f"原始数据分布: {np.bincount(y)}") print(f"SMOTE后分布: {np.bincount(y_resampled)}")SMOTE有很多变体,如Borderline-SMOTE(只对处于类别边界附近的困难样本进行过采样)、ADASYN(根据样本密度自适应地生成不同数量的新样本,更关注难以学习的样本)。选择哪种变体取决于数据特性:如果少数类样本内部差异大,分布稀疏,ADASYN可能更好;如果分类边界清晰但少数类样本少,Borderline-SMOTE可能更合适。
实操心得:使用SMOTE时,务必在训练集上拟合和转换,然后使用同样的拟合器去转换验证集是错误的。正确的流程是:先划分训练集和测试集,只在训练集上应用SMOTE进行重采样,测试集必须保持原始分布,用于模拟真实场景下的模型性能评估。将重采样技术应用于整个数据集会导致严重的数据泄露,使评估结果过于乐观。
3.2 欠采样:给多数派“瘦身”
欠采样通过减少多数类样本的数量来平衡数据集。最简单的是随机欠采样,即随机丢弃一部分多数类样本。
注意:随机欠采样最大的风险是信息丢失。丢弃的样本中可能包含了对定义分类边界至关重要的信息。如果多数类样本本身就不多,或者数据非常珍贵,欠采样可能不是好选择。
为了更智能地丢弃样本,衍生出了多种方法:
- Tomek Links:找到一对分属不同类别且彼此是最近邻的样本点(称为Tomek Link),通常认为这对点处于分类边界附近且可能是噪声或边界模糊点。常见的策略是移除其中的多数类样本,从而让边界更清晰。
- Edited Nearest Neighbours:对于每个样本,检查其k个最近邻的类别。如果一个多数类样本的大部分邻居都属于少数类,则移除它(认为它是噪声);类似地,如果一个少数类样本被多数类邻居包围,也可能被移除。
- Cluster Centroids:使用聚类算法(如K-Means)对多数类进行聚类,然后用每个簇的质心代表该簇的样本,从而实现大幅度的、有代表性的欠采样。
场景选择:当你的多数类数据量极大,且存在大量冗余或噪声时,欠采样是高效的选择。例如,从数千万条正常日志中筛选出与少数异常日志进行匹配分析时,可以先对正常日志进行聚类欠采样,极大减少计算开销。
3.3 过采样与欠采样的结合:SMOTEENN / SMOTETomek
实践中,单一方法可能有局限。结合过采样和欠采样的混合方法往往能取得更好效果。最经典的是SMOTEENN:先使用SMOTE过采样少数类,然后使用ENN(Edited Nearest Neighbours)清理过采样后可能产生的噪声样本(包括少数类和多数类)。SMOTETomek则是先SMOTE,再移除Tomek Links。
我的经验是,在中等不平衡比例(如1:10到1:100)的数据集上,混合方法通常比单一方法更稳健,因为它同时缓解了过采样可能带来的噪声问题和欠采样可能带来的信息丢失问题。
4. 算法层方法详解:让模型“看见”少数类
如果不想改动数据,或者重采样效果不佳,我们可以从模型本身入手。
4.1 代价敏感学习
这是最核心的算法层思想。其原理是为不同的分类错误赋予不同的“代价”。在类别不平衡问题中,将少数类误分为多数类的代价(False Negative)应该远大于将多数类误分为少数类的代价(False Positive)。
许多算法原生支持代价敏感学习:
- 逻辑回归/支持向量机:可以通过
class_weight参数设置。通常设置为‘balanced’,算法会自动根据类别频率反比计算权重,或者手动指定一个字典,如{0: 1, 1: 10},意味着将类别1误判的代价是类别0的10倍。 - 决策树及其集成算法:如Scikit-learn的
DecisionTreeClassifier、RandomForestClassifier、XGBoost、LightGBM都支持class_weight或类似的参数(如XGBoost的scale_pos_weight)。
参数计算示例:对于二分类,scale_pos_weight的一个常用启发式设置是多数类样本数 / 少数类样本数。如果数据中正样本(少数类)有100个,负样本(多数类)有900个,那么scale_pos_weight可设为9。这告诉模型,每犯一个漏报正样本的错误,其“严重性”相当于犯9个误报负样本的错误。
# 以XGBoost为例 import xgboost as xgb 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) # 计算权重 negative_count = np.sum(y_train == 0) positive_count = np.sum(y_train == 1) scale_pos_weight = negative_count / positive_count model = xgb.XGBClassifier( scale_pos_weight=scale_pos_weight, eval_metric='logloss', # 在不平衡数据中,AUC或F1可能比默认的logloss更好 use_label_encoder=False ) model.fit(X_train, y_train)实操心得:class_weight=‘balanced’是一个很好的起点,但它假设“代价与类别频率成反比”这一先验总是成立。有时,业务上对少数类的重视程度可能远超其频率比例。例如,在金融欺诈中,即使欺诈率只有0.1%,我们可能也愿意承担1000倍甚至更高的误报成本来捕捉它。这时就需要根据业务目标手动调整权重,并通过验证集上的关键指标(如召回率)来精细调优。
4.2 集成学习方法:Bagging与Boosting的变体
集成学习天然适合处理不平衡数据。
- 基于Bagging的变体:如
BalancedRandomForest和BalancedBaggingClassifier(来自imbalanced-learn库)。它们在构建每棵决策树时,不是从原始数据集中随机抽样,而是进行欠采样或过采样,确保每棵树的训练数据是平衡的。这样集成的结果既降低了方差,又缓解了偏差。 - 基于Boosting的变体:如
AdaBoost的改进算法AdaCost,它在每次迭代更新样本权重时,不仅考虑分类对错,还考虑误分类的代价。XGBoost、LightGBM等现代梯度提升框架通过scale_pos_weight等参数也实现了代价敏感,效果通常非常强大。
选择建议:对于结构化表格数据,梯度提升决策树,尤其是XGBoost或LightGBM,配合scale_pos_weight,是我解决不平衡分类问题的首选算法,它们的表现通常优于单纯的采样+简单模型。对于需要模型可解释性或数据量较小的场景,BalancedRandomForest是一个优秀的选择。
4.3 单类学习与异常检测
在极端不平衡的情况下(如1:10000),有时可以换一个思路:不将其视为分类问题,而视为异常检测问题。我们只使用多数类样本(视为“正常”数据)来训练一个模型,学习“正常”数据的分布模式。任何偏离该模式的样本都被视为“异常”(即少数类)。
常用算法包括:
- One-Class SVM:在特征空间中寻找一个能将所有正常样本包含在内的最小超球体。
- 孤立森林:随机选择特征和分割点来“孤立”每一个样本。异常点由于特征值与正常点差异大,通常能被更快地孤立出来(路径更短)。
- 自编码器:训练一个神经网络,学习将正常数据压缩再重构。模型在正常数据上重构误差小,在异常数据上重构误差大,通过设定误差阈值来检测异常。
注意:异常检测方法通常用于无监督或半监督场景,且假设“正常”样本占绝对主导且模式一致。如果多数类内部也存在显著差异,这种方法效果会大打折扣。
5. 评估层方法:抛弃虚假的“准确率”
在不平衡数据上,准确率是最大的“谎言”。我们必须使用更合适的评估指标。
5.1 混淆矩阵及其衍生指标
一切始于混淆矩阵。对于一个二分类问题:
| 实际 \ 预测 | 预测为正 | 预测为负 |
|---|---|---|
| 实际为正 | TP (真正例) | FN (假负例) |
| 实际为负 | FP (假正例) | TN (真负例) |
- 精确率:
Precision = TP / (TP + FP)。在所有预测为正的样本中,有多少是真的正。它衡量的是预测的“准度”。在欺诈检测中,高精确率意味着你发出的警报大部分都是真实的欺诈,减少了误报带来的处理成本。 - 召回率:
Recall = TP / (TP + FN)。在所有实际为正的样本中,有多少被成功预测出来。它衡量的是模型的“查全率”。在疾病筛查中,高召回率意味着尽可能少地漏掉病人。 - F1-Score:
F1 = 2 * (Precision * Recall) / (Precision + Recall)。精确率和召回率的调和平均数。当两者都重要且需要找一个平衡点时使用。 - Fβ-Score:F1的泛化形式,允许你通过β参数来调整对召回率的偏好程度(β>1更看重召回率,β<1更看重精确率)。
5.2 ROC-AUC 与 PR-AUC
- ROC曲线与AUC:绘制真正例率
TPR (Recall)vs. 假正例率FPR (FP / (FP + TN))在不同分类阈值下的曲线。AUC表示模型将随机一个正样本排在随机一个负样本之前的概率。ROC-AUC对类别不平衡相对不敏感,因为它的横纵坐标都是比例。 - PR曲线与AUC:绘制精确率 vs. 召回率在不同分类阈值下的曲线。在不平衡数据中,PR-AUC通常比ROC-AUC更具信息量。因为PR曲线聚焦于正样本(少数类)的性能,而ROC曲线同时受多数类影响。当正样本非常稀少时,一个微小的FP增长会导致精确率大幅下降,这在PR曲线上会非常明显,而在ROC曲线上FPR的变化可能看起来微不足道。
实操建议:在高度不平衡的数据集上,优先使用PR-AUC作为模型选择和调优的核心指标。同时,不要只看曲线下面积,也要观察曲线形状,特别是高召回率区域的精确率,这决定了你在“尽可能多抓”的策略下需要付出多少误报的代价。
5.3 多类别不平衡的评估
当类别超过两个且不平衡时,情况更复杂。Scikit-learn等库提供的precision_score,recall_score,f1_score函数有一个重要的average参数:
macro:计算每个类别的指标,然后取算术平均。它平等看待每个类别,无论其样本多少。因此,少数类的性能会显著影响宏平均指标。这是评估模型是否公平对待所有类别的关键指标。weighted:计算每个类别的指标,然后按每个类别的样本数加权平均。这相当于总体性能,但会被大类别主导。micro:先汇总所有类别的TP, FP, FN, TN,再计算一个全局指标。在多分类中,micro F1实际上等于总体准确率。
报告策略:对于多类不平衡问题,我的标准报告模板是:提供每个类别的精确率、召回率、F1,同时提供宏平均F1和加权平均F1。宏平均F1告诉我模型在最差类别上的表现如何,而加权平均F1更接近整体的业务影响。
6. 实战流程与方案选型指南
理论说了这么多,实战中到底该怎么选、怎么做?下面是一个我常用的决策流程和组合方案。
6.1 诊断与评估基准建立
首先,量化不平衡程度。计算每个类别的样本数、占比。然后,永远从一个简单的、不做任何处理的基准模型开始。例如,使用逻辑回归或随机森林,在原始数据上训练,并使用宏平均F1和少数类的召回率作为评估基准。这个基准有两个作用:1)让你了解问题的难度;2)作为衡量后续所有改进方法的“锚点”。
6.2 分层抽样与交叉验证的陷阱
在划分训练集、验证集和测试集时,必须使用分层抽样,以确保每个集合中各类别的比例与原始数据集大致相同。这保证了评估的公平性。 更重要的是,如果你在交叉验证内部使用重采样技术,必须极其小心。正确的做法是:在交叉验证的每一折,重采样应只应用于该折的训练部分,而不是整个数据。使用Pipeline与imblearn库可以优雅地避免这个陷阱。
from imblearn.pipeline import make_pipeline from sklearn.model_selection import cross_val_score, StratifiedKFold # 创建一个包含SMOTE和分类器的流水线 pipeline = make_pipeline( SMOTE(random_state=42), RandomForestClassifier(n_estimators=100, random_state=42, class_weight='balanced') ) # 使用分层K折交叉验证 cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) scores = cross_val_score(pipeline, X, y, cv=cv, scoring='f1_macro') print(f"交叉验证宏平均F1: {scores.mean():.3f} (+/- {scores.std():.3f})")6.3 方法选型决策树
面对一个具体问题,你可以参考以下决策路径:
数据量评估:
- 多数类海量,少数类稀缺:考虑欠采样(如Cluster Centroids)或转为异常检测思路(如孤立森林)。优先使用代价敏感学习的集成模型(如XGBoost)。
- 数据总量适中:尝试过采样(特别是SMOTE变体)或混合采样。结合代价敏感学习。
问题类型与业务目标:
- 需要高召回率(如癌症筛查、漏洞检测):优先保证召回率。可适当降低分类阈值,使用F2-Score(β=2)作为优化指标。算法上,可以加大少数类的权重或使用Borderline-SMOTE关注边界样本。
- 需要高精确率(如垃圾邮件过滤、推荐系统去重):优先保证精确率。可适当提高分类阈值。算法上,可以清理噪声(如使用ENN欠采样),并使用PR-AUC作为核心指标。
- 需要平衡:以F1-Score或宏平均F1为目标,使用SMOTEENN等混合方法,并调整算法中的类别权重。
计算资源与时效性:
- 资源紧张,需要快速迭代:从代价敏感学习(如设置
class_weight='balanced')开始,这是最轻量级的改动。避免复杂的重采样。 - 资源充足,追求极致性能:构建一个多方法实验框架,系统性地比较不同重采样技术(RandomOverSampler, SMOTE, ADASYN, Tomek Links等)与不同算法(逻辑回归、随机森林、XGBoost、LightGBM)的组合,使用嵌套交叉验证选择最佳组合。
- 资源紧张,需要快速迭代:从代价敏感学习(如设置
6.4 一个综合实战案例:信用卡欺诈检测
假设我们有一个经典的信用卡交易数据集,欺诈率约为0.2%。
- 基准模型:用逻辑回归在原始数据上训练,准确率99.8%,但欺诈类的召回率仅为5%。这说明模型几乎没学到欺诈模式。
- 第一轮改进 - 数据层:在训练集上应用SMOTE,将欺诈类过采样到与正常类相当。重新训练逻辑回归,整体准确率下降到98%,但欺诈类召回率提升到70%,同时精确率也有15%。这是一个巨大进步,但误报仍很多。
- 第二轮改进 - 算法层:换用XGBoost,并设置
scale_pos_weight为正常交易数/欺诈交易数(约500)。在不使用SMOTE的原始数据上训练,欺诈类召回率达到75%,精确率提升到20%。 - 第三轮改进 - 组合与调优:尝试SMOTE + XGBoost的组合。通过网格搜索调整XGBoost的
max_depth,learning_rate以及SMOTE的k_neighbors参数。最终模型在独立测试集上,欺诈类召回率达到85%,精确率为25%。 - 决策与部署:业务方评估后认为,当前误报率(精确率25%)带来的审核成本可以接受,但希望召回率能再提高。我们通过调整分类阈值,将模型预测概率的阈值从默认的0.5降低到0.3,使得召回率提升至92%,精确率牺牲到18%。最终根据这个阈值部署模型,并建立监控看板,持续跟踪精确率、召回率以及线上业务指标。
7. 常见陷阱、问题排查与高级技巧
即使掌握了上述方法,实践中依然会踩坑。下面是一些常见问题及我的排查经验。
7.1 过采样后模型性能反而下降?
- 可能原因1:过拟合。检查是否在整个数据集上做了重采样后才划分训练测试集?这会导致严重的数据泄露。务必确保重采样只在训练集上进行。
- 可能原因2:生成了低质量或噪声样本。特别是当少数类样本数量极少或特征空间重叠严重时,SMOTE生成的样本可能毫无意义。尝试使用SMOTE的变体,如
SMOTENC(处理混合类型特征)或SVMSMOTE(使用SVM支持向量来指导样本生成)。或者,先使用欠采样清理多数类噪声,再使用过采样。 - 可能原因3:评估指标不当。过采样后整体准确率下降是正常的,因为平衡了数据。此时应重点关注少数类的召回率/精确率或宏平均F1。
7.2 代价敏感学习中的权重应该设多少?
class_weight='balanced'是一个安全的起点。但最佳权重是业务相关的。一个实用的调优方法是:
- 将少数类的权重设为1。
- 将多数类的权重作为一个超参数进行搜索,例如在
[0.01, 0.1, 0.5, 1, 2, 5, 10]等范围内。 - 使用交叉验证的宏平均F1或少数类召回率作为优化目标来寻找最佳权重。 记住,权重之比反映的是误分类代价之比,而非简单的样本数量反比。
7.3 如何处理多类别且每个少数类都不平衡?
这是更复杂的场景。imbalanced-learn库中的RandomOverSampler和SMOTE等支持sampling_strategy参数,可以指定一个字典,为每个类别指定期望的样本数或比例。例如,你可以设定让所有类别的样本数等于最大类的样本数,或者指定一个特定的目标分布。 另一种策略是使用**“一对多”**方法,为每个少数类训练一个二分类器(将该类作为正类,其余所有类作为负类),然后综合这些分类器的结果。这种方法下,每个二分类器都面临一个不平衡问题,可以单独应用上述技术。
7.4 模型校准问题
重采样或代价敏感学习会改变训练数据的分布,导致模型输出的概率不再校准(即预测概率为0.8并不代表真实概率是80%)。如果你需要精确的概率估计(如风险评分),在模型训练后,需要在原始的、未重采样的验证集上使用CalibratedClassifierCV或Platt Scaling等方法对模型进行校准。
7.5 高级技巧:阈值移动
这是最简单却常被忽视的技巧。在不平衡学习中,默认的0.5分类阈值通常不是最优的。你可以根据业务需求,直接在验证集上通过PR曲线或ROC曲线寻找最佳阈值。
- 如果你想平衡精确率和召回率,就找PR曲线上F1最大的点对应的阈值。
- 如果你对误报有严格的成本约束,就设定一个可接受的最低精确率,然后找该精确率下召回率最高的阈值。 训练完成后,在预测时使用
model.predict_proba()获取概率,然后与你找到的最佳阈值进行比较,而非使用model.predict()。
处理类别不平衡没有银弹,它需要你深入理解数据、业务目标和算法原理。从建立一个可靠的评估基准开始,系统地尝试数据重采样、算法调优和评估指标调整,并将这些方法有机结合。记住,最终的目标不是得到一个在平衡测试集上分数最高的模型,而是得到一个在现实世界的不平衡数据流中,能为你的业务创造最大价值的可靠系统。每一次调整,都要问自己:这个改动,是让模型更贴近数据,还是更贴近我们真正要解决的业务问题?