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