欢迎光临

机器学习类别不平衡问题完全指南:从重采样到代价敏感学习的实战攻略

在实际的机器学习项目中,类别不平衡(Class Imbalance)是最常见也最容易被忽视的问题之一。无论是欺诈检测、疾病诊断还是故障预警,正例往往只占样本的极小比例。如果直接用常规算法训练,模型往往会倾向于将所有样本都预测为多数类,虽然在准确率上看起来很高,但对少数类的识别能力几乎为零。本文将系统性地介绍处理类别不平衡的主流方法,从数据层面的重采样技术到算法层面的代价敏感学习,再到评估指标的选择,配合完整的 Python 代码示例,帮助你在实际项目中有效应对这一挑战。

一、类别不平衡问题的本质与影响

类别不平衡指的是训练数据中各类别的样本数量存在显著差异。通常,我们将占比极小的类别称为少数类(Minority Class)或正类,占比极大的类别称为多数类(Majority Class)或负类。不平衡比例(Imbalance Ratio, IR)定义为多数类样本数与少数类样本数之比。

在信用卡欺诈检测中,欺诈交易可能仅占全部交易的 0.1%;在医疗诊断中,罕见病的发病率可能仅为万分之一;在工业质检中,缺陷产品的比例可能低于 1%。这些场景下,如果直接使用准确率(Accuracy)作为评估指标,一个将所有样本都预测为负类的朴素模型就能达到 99.9% 的准确率,但这显然毫无意义。

类别不平衡对模型的影响主要体现在以下几个方面:

  • 决策边界偏移:大多数分类算法的目标是最小化总体分类错误率,这会导致决策边界向少数类方向偏移,使得少数类更难被正确分类。
  • 概率校准失真:模型对少数类样本的预测概率普遍偏低,即使使用阈值 0.5 也难以捕获足够的正例。
  • 梯度主导:在神经网络训练中,多数类产生的梯度信号远强于少数类,导致模型更新方向主要由多数类决定。
  • 过拟合风险:少数类样本稀少,模型容易对有限的正例产生过拟合,泛化能力差。

二、数据层面的解决方案:重采样技术

重采样是最直观的处理方式,通过调整训练集中各类别的样本比例来缓解不平衡问题。重采样主要分为过采样(Oversampling)和欠采样(Undersampling)两大类。

2.1 随机过采样与随机欠采样

随机过采样通过复制少数类样本来增加其数量,直到与多数类达到平衡。随机欠采样则通过随机丢弃多数类样本来减少其数量。两者各有优劣:

方法 优点 缺点
随机过采样 不丢失信息,简单易实现 容易过拟合(重复样本)
随机欠采样 减少训练时间,无过拟合风险 可能丢失多数类的重要信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from imblearn.over_sampling import RandomOverSampler
from imblearn.under_sampling import RandomUnderSampler

# 生成不平衡数据集
X, y = make_classification(
    n_samples=10000,
    n_features=20,
    n_informative=10,
    n_redundant=5,
    weights=[0.95, 0.05],  # 5% 正例
    random_state=42
)

X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.3, random_state=42, stratify=y
)

print(f"原始训练集: 负类={sum(y_train==0)}, 正类={sum(y_train==1)}")
print(f"不平衡比例: {sum(y_train==0)/sum(y_train==1):.1f}:1")

# 随机过采样
ros = RandomOverSampler(random_state=42)
X_ros, y_ros = ros.fit_resample(X_train, y_train)
print(f"过采样后: 负类={sum(y_ros==0)}, 正类={sum(y_ros==1)}")

# 随机欠采样
rus = RandomUnderSampler(random_state=42)
X_rus, y_rus = rus.fit_resample(X_train, y_train)
print(f"欠采样后: 负类={sum(y_rus==0)}, 正类={sum(y_rus==1)}")

2.2 SMOTE 及其变体

SMOTE(Synthetic Minority Over-sampling Technique)是解决随机过采样过拟合问题的经典方法。它不是简单地复制少数类样本,而是在少数类样本的特征空间中插值生成新的合成样本。

SMOTE 的核心思想:对于每个少数类样本 x,找到其 k 个最近邻的少数类样本,从中随机选择一个邻居 x’,然后在 x 和 x’ 之间的连线上随机选取一个点作为新的合成样本。


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from imblearn.over_sampling import SMOTE, BorderlineSMOTE, SVMSMOTE, ADASYN

# 标准 SMOTE
smote = SMOTE(random_state=42, k_neighbors=5)
X_sm, y_sm = smote.fit_resample(X_train, y_train)
print(f"SMOTE 后: 负类={sum(y_sm==0)}, 正类={sum(y_sm==1)}")

# BorderlineSMOTE — 只在边界区域生成样本
borderline_smote = BorderlineSMOTE(random_state=42, kind='borderline-1')
X_bl, y_bl = borderline_smote.fit_resample(X_train, y_train)
print(f"BorderlineSMOTE 后: 负类={sum(y_bl==0)}, 正类={sum(y_bl==1)}")

# SVMSMOTE — 使用 SVM 确定边界
svm_smote = SVMSMOTE(random_state=42)
X_svm, y_svm = svm_smote.fit_resample(X_train, y_train)
print(f"SVMSMOTE 后: 负类={sum(y_svm==0)}, 正类={sum(y_svm==1)}")

# ADASYN — 自适应合成采样
adasyn = ADASYN(random_state=42)
X_ada, y_ada = adasyn.fit_resample(X_train, y_train)
print(f"ADASYN 后: 负类={sum(y_ada==0)}, 正类={sum(y_ada==1)}")

SMOTE 各变体的选择建议:

  • 标准 SMOTE:适用于大多数场景,作为基线方法。
  • BorderlineSMOTE:当少数类与多数类有较多重叠区域时效果更好,因为它专注于难以分类的边界样本。
  • SVMSMOTE:当类别边界较为复杂时,利用 SVM 找到更精确的决策边界。
  • ADASYN:根据样本的难易程度自适应地分配合成样本数量,更关注那些难以分类的少数类样本。

2.3 组合采样:SMOTE + Tomek / SMOTE + ENN

单独使用过采样或欠采样都有局限性,组合方法先过采样增加少数类样本,再用欠采样清理可能产生的噪声样本。


1
2
3
4
5
6
7
8
9
10
11
12
13
from imblearn.combine import SMOTETomek, SMOTEENN

# SMOTE + Tomek 连接
# 先用 SMOTE 过采样,再用 Tomek 链接移除最近的异类样本对
smote_tomek = SMOTETomek(random_state=42)
X_st, y_st = smote_tomek.fit_resample(X_train, y_train)
print(f"SMOTE+Tomek 后: 负类={sum(y_st==0)}, 正类={sum(y_st==1)}")

# SMOTE + ENN
# 先过采样,再用 Edited Nearest Neighbors 移除被多数邻居"投票"掉的样本
smote_enn = SMOTEENN(random_state=42)
X_se, y_se = smote_enn.fit_resample(X_train, y_train)
print(f"SMOTE+ENN 后: 负类={sum(y_se==0)}, 正类={sum(y_se==1)}")

SMOTE+ENN 通常比 SMOTE+Tomek 清理得更彻底,因为 ENN 会移除所有与多数邻居不一致的样本,而 Tomek 只移除最近的异类对。如果你的数据噪声较大,推荐使用 SMOTE+ENN;如果数据相对干净,SMOTE+Tomek 足矣。

三、算法层面的解决方案:代价敏感学习

与数据层面通过调整样本分布不同,代价敏感学习(Cost-sensitive Learning)直接在算法内部引入不同类别的误分类代价。其核心思想是:将少数类错分为多数类的代价应远高于反之的代价。

3.1 类别权重调整

最直接的方式是为不同类别设置不同的权重。Scikit-learn 中大多数分类器都支持

1
class_weight

参数:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier
from sklearn.svm import SVC

# 方式一:balanced 自动按比例设置权重
lr_balanced = LogisticRegression(
    class_weight='balanced',
    max_iter=1000,
    random_state=42
)
lr_balanced.fit(X_train, y_train)

# 方式二:手动指定权重
custom_weights = {0: 1, 1: 20}  # 正类的误分类代价是负类的 20 倍
lr_custom = LogisticRegression(
    class_weight=custom_weights,
    max_iter=1000,
    random_state=42
)
lr_custom.fit(X_train, y_train)

# 随机森林也支持
rf_balanced = RandomForestClassifier(
    n_estimators=200,
    class_weight='balanced_subsample',
    random_state=42
)
rf_balanced.fit(X_train, y_train)

# SVM 的类别权重
svm_balanced = SVC(
    class_weight='balanced',
    kernel='rbf',
    probability=True,
    random_state=42
)
svm_balanced.fit(X_train, y_train)
1
class_weight='balanced'

的计算公式为:w_j = n_samples / (n_classes * n_samples_j),即总样本数除以(类别数乘以该类别的样本数)。这使得少数类获得更大的权重,多数类获得较小的权重,从而在不改变数据分布的情况下达到平衡效果。

3.2 Focal Loss:深度学习中的利器

Focal Loss 最初由 Lin 等人在 RetinaNet 论文中提出,用于解决目标检测中的类别不平衡问题。其核心思想是降低易分类样本的损失权重,让模型更关注难以分类的样本。

标准交叉熵损失:CE(p, y) = -α * y * log(p) – (1-α) * (1-y) * log(1-p)

Focal Loss:FL(p, y) = -α * y * (1-p)^γ * log(p) – (1-α) * (1-y) * p^γ * log(1-p)

其中 γ(gamma)是聚焦参数,当 γ > 0 时,易分类样本(p 接近 1 的正例或 p 接近 0 的负例)的损失被大幅降低,模型训练将更关注那些难以分类的样本。γ 通常取 2.0。


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import torch
import torch.nn as nn
import torch.nn.functional as F

class FocalLoss(nn.Module):
    def __init__(self, alpha=0.25, gamma=2.0, reduction='mean'):
        super(FocalLoss, self).__init__()
        self.alpha = alpha
        self.gamma = gamma
        self.reduction = reduction

    def forward(self, inputs, targets):
        BCE_loss = F.binary_cross_entropy_with_logits(inputs, targets, reduction='none')
        pt = torch.exp(-BCE_loss)  # pt = p if y=1, pt = 1-p if y=0
        # alpha 权重
        alpha_t = self.alpha * targets + (1 - self.alpha) * (1 - targets)
        # focal 调制项
        focal_weight = alpha_t * (1 - pt) ** self.gamma
        loss = focal_weight * BCE_loss

        if self.reduction == 'mean':
            return loss.mean()
        elif self.reduction == 'sum':
            return loss.sum()
        return loss

# 使用示例
model = nn.Sequential(
    nn.Linear(20, 64),
    nn.ReLU(),
    nn.Dropout(0.3),
    nn.Linear(64, 32),
    nn.ReLU(),
    nn.Linear(32, 1)
)

criterion = FocalLoss(alpha=0.75, gamma=2.0)  # alpha 偏向少数类
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)

# 训练循环
X_tensor = torch.FloatTensor(X_train)
y_tensor = torch.FloatTensor(y_train).unsqueeze(1)

for epoch in range(100):
    optimizer.zero_grad()
    outputs = model(X_tensor)
    loss = criterion(outputs, y_tensor)
    loss.backward()
    optimizer.step()
    if (epoch + 1) % 20 == 0:
        print(f"Epoch {epoch+1}, Loss: {loss.item():.4f}")

四、阈值调整与概率校准

即使使用了重采样或代价敏感方法,默认的 0.5 分类阈值往往不是最优的。对于不平衡数据,适当降低阈值可以提高少数类的召回率。

4.1 寻找最优阈值


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import numpy as np
from sklearn.metrics import precision_recall_curve, f1_score, roc_curve

# 使用训练好的模型获取预测概率
y_proba = lr_balanced.predict_proba(X_test)[:, 1]

# 方法一:基于 F1 Score 寻找最优阈值
precisions, recalls, thresholds = precision_recall_curve(y_test, y_proba)
f1_scores = 2 * precisions * recalls / (precisions + recalls + 1e-8)
optimal_idx = np.argmax(f1_scores)
optimal_threshold_f1 = thresholds[optimal_idx]
print(f"最优阈值(F1最大化): {optimal_threshold_f1:.4f}")
print(f"对应 F1: {f1_scores[optimal_idx]:.4f}")

# 方法二:基于 Youden's J 统计量
fpr, tpr, roc_thresholds = roc_curve(y_test, y_proba)
j_scores = tpr - fpr
optimal_idx_j = np.argmax(j_scores)
optimal_threshold_j = roc_thresholds[optimal_idx_j]
print(f"最优阈值(Youden's J): {optimal_threshold_j:.4f}")

# 使用最优阈值进行预测
y_pred_optimal = (y_proba >= optimal_threshold_f1).astype(int)
print(f"默认阈值 F1: {f1_score(y_test, (y_proba >= 0.5).astype(int)):.4f}")
print(f"最优阈值 F1: {f1_score(y_test, y_pred_optimal):.4f}")

4.2 概率校准

不平衡数据下模型的预测概率往往不够准确,概率校准可以修正这一问题。常用的方法有 Platt Scaling(逻辑回归校准)和 Isotonic Regression(保序回归校准)。


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from sklearn.calibration import CalibratedClassifierCV

# Platt Scaling 校准
calibrated_lr = CalibratedClassifierCV(
    estimator=LogisticRegression(class_weight='balanced', max_iter=1000),
    method='sigmoid',
    cv=5
)
calibrated_lr.fit(X_train, y_train)

# Isotonic Regression 校准(需要更多数据)
calibrated_iso = CalibratedClassifierCV(
    estimator=RandomForestClassifier(n_estimators=200, class_weight='balanced'),
    method='isotonic',
    cv=5
)
calibrated_iso.fit(X_train, y_train)

# 比较校准前后的概率分布
y_proba_raw = lr_balanced.predict_proba(X_test)[:, 1]
y_proba_cal = calibrated_lr.predict_proba(X_test)[:, 1]

print(f"原始概率 - 正例均值: {y_proba_raw[y_test==1].mean():.4f}")
print(f"校准概率 - 正例均值: {y_proba_cal[y_test==1].mean():.4f}")

五、评估指标选择:超越准确率

对于不平衡数据,准确率是一个极具误导性的指标。以下指标更为合适:

  • Precision(精确率):预测为正的样本中,真正为正的比例。关注减少误报。
  • Recall(召回率):实际为正的样本中,被正确预测为正的比例。关注减少漏报。
  • F1 Score:精确率和召回率的调和平均数,兼顾两者。
  • PR-AUC:Precision-Recall 曲线下面积,对不平衡数据比 ROC-AUC 更敏感。
  • ROC-AUC:真阳性率 vs 假阳性率曲线下面积,对类别比例不敏感,可能过于乐观。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
from sklearn.metrics import (
    classification_report, confusion_matrix,
    roc_auc_score, average_precision_score,
    PrecisionRecallDisplay, RocCurveDisplay
)

# 综合评估多个模型
models = {
    'Logistic (balanced)': lr_balanced,
    'Random Forest (balanced)': rf_balanced,
    'SVM (balanced)': svm_balanced,
}

for name, model in models.items():
    y_pred = model.predict(X_test)
    y_proba = model.predict_proba(X_test)[:, 1]
   
    print(f"\n{'='*50}")
    print(f"模型: {name}")
    print(f"{'='*50}")
    print(classification_report(y_test, y_pred, digits=4))
    print(f"ROC-AUC: {roc_auc_score(y_test, y_proba):.4f}")
    print(f"PR-AUC:  {average_precision_score(y_test, y_proba):.4f}")
   
    cm = confusion_matrix(y_test, y_pred)
    print(f"混淆矩阵:\n{cm}")

在极度不平衡的场景下,PR-AUC 是比 ROC-AUC 更可靠的指标。原因在于 ROC-AUC 的假阳性率(FPR)分母是多数类样本数,当多数类数量庞大时,即使 FPR 很低,误判的绝对数量也可能很大。而 PR-AUC 的精确率直接反映了预测为正的样本中有多少是真正的正例,更适合评估模型在少数类上的表现。

六、完整实战案例:信用卡欺诈检测

下面通过一个完整的端到端流程,综合运用上述技术来解决真实的类别不平衡问题。


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import StratifiedKFold, cross_val_score
from imblearn.pipeline import Pipeline as ImbPipeline
from imblearn.over_sampling import SMOTE

# 构建含重采样的 Pipeline(避免数据泄露)
pipeline = ImbPipeline([
    ('scaler', StandardScaler()),
    ('smote', SMOTE(random_state=42)),
    ('classifier', LogisticRegression(max_iter=1000, random_state=42))
])

# 使用分层交叉验证
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)

# 交叉验证评估
scoring = ['f1', 'roc_auc', 'average_precision']
for metric in scoring:
    scores = cross_val_score(pipeline, X_train, y_train,
                             cv=cv, scoring=metric, n_jobs=-1)
    print(f"{metric}: {scores.mean():.4f} ± {scores.std():.4f}")

# 最终训练和测试
pipeline.fit(X_train, y_train)
y_pred_final = pipeline.predict(X_test)
y_proba_final = pipeline.predict_proba(X_test)[:, 1]

print(f"\n测试集 F1: {f1_score(y_test, y_pred_final):.4f}")
print(f"测试集 PR-AUC: {average_precision_score(y_test, y_proba_final):.4f}")
print(f"\n分类报告:\n{classification_report(y_test, y_pred_final, digits=4)}")

关键注意事项:在交叉验证中,重采样必须仅作用于训练折,验证折必须保持原始的不平衡分布。如果先对整个数据集进行重采样再交叉验证,会导致数据泄露(data leakage),使得评估结果过于乐观。使用 imblearn 的 Pipeline 可以确保每折的重采样独立进行。

七、方法选择指南与最佳实践

面对类别不平衡问题,不同场景下应选择不同的策略组合:

场景 推荐方法 原因
轻度不平衡(IR < 10) class_weight=’balanced’ + 阈值调整 不平衡程度低,简单调整即可
中度不平衡(IR 10-100) SMOTE + class_weight + F1优化阈值 需要数据层面和算法层面双管齐下
重度不平衡(IR > 100) BorderlineSMOTE/ADASYN + Focal Loss + 集成方法 需要更精细的采样策略和更强的算法
极端不平衡(IR > 1000) 异常检测思路 + 单类 SVM + 深度度量学习 传统分类思路失效,需转换为异常检测

最后总结几条经过实践验证的最佳实践:

  • 始终使用分层抽样:在训练/测试划分和交叉验证中,确保各折中正例比例一致。
  • 重采样必须在 Pipeline 内部:避免数据泄露,每折独立进行重采样。
  • 优先尝试 class_weight:在调整数据分布之前,先用算法内置的权重参数,简单且不易引入噪声。
  • 不要忽视阈值调整:即使使用了重采样和代价敏感学习,0.5 往往不是最优阈值。
  • 用 PR-AUC 而非 ROC-AUC 评估:在极度不平衡场景下,PR-AUC 更能反映模型真实性能。
  • 极端不平衡考虑转换思路:当正例只有几十个时,单类分类或异常检测方法可能比二分类更有效。
  • 集成方法提升稳定性:EasyEnsemble 和 BalanceCascade 等专为不平衡设计的集成方法通常优于单一模型。

类别不平衡是机器学习中一个既普遍又深刻的问题。本文介绍的重采样、代价敏感学习、阈值调整和评估指标选择,构成了一套完整的工具箱。在实际项目中,没有放之四海而皆准的解决方案,需要根据不平衡程度、数据规模和业务约束灵活组合运用。核心原则是:始终从业务目标出发,选择合适的评估指标,然后围绕指标优化模型。

【本站文章皆为原创,未经允许不得转载】:汤不热吧 » 机器学习类别不平衡问题完全指南:从重采样到代价敏感学习的实战攻略
分享到: 更多 (0)