类别不平衡处理:过采样、欠采样、SMOTE
一句话概述
类别不平衡(Class Imbalance)是指分类任务中各类别样本数量差距悬殊的情况——如欺诈检测中 99.9% 是正常交易、0.1% 是欺诈。在不平衡数据上训练的标准分类器会倾向于预测多数类,导致少数类的召回率极低。处理方法分为数据层面和算法层面:数据层面包括欠采样(减少多数类)、过采样(复制少数类)和 SMOTE(合成少数类样本);算法层面包括代价敏感学习、阈值调整和集成方法。
💡 核心要点:① 类别不平衡的核心问题是模型被多数类「淹没」,忽略了少数类的模式 ② 欠采样简单但丢弃信息,过采样简单但易过拟合,SMOTE 是合成样本的折中方案 ③ 在不平衡场景中,准确率是误导性指标——应该关注精确率、召回率、F1 和 AUC ④ 工业级方案通常组合数据级和算法级方法,如 SMOTE + 代价敏感学习
教学与演示
一、类别不平衡的问题与评估
是什么:类别不平衡是指分类问题中各类别样本数量差异显著的情况。多数类(Majority Class)样本远多于少数类(Minority Class)。在极端情况下(如欺诈检测 1:10000),标准分类器可能简单地预测所有样本为多数类,获得 99.99% 的准确率,但对少数类毫无预测能力——而这种「高准确率」完全是误导性的。
大白话 就像一个班级有 99 个男生和 1 个女生,如果模型「偷懒」地把所有人都预测为男生,准确率就有 99%——但这显然不是我们想要的。类别不平衡就是让模型「偷懒」太容易了。
为什么:类别不平衡影响模型的根本原因是:大多数分类器的训练目标是最小化整体错误率(或交叉熵),这意味着模型对多数类的关注远大于少数类。从梯度角度看,多数类样本贡献的梯度总和远大于少数类,模型被「拖着走」。此外,少数类样本的决策边界可能非常模糊(样本太少,边界难以确定),进一步加剧了问题。
怎么做:
import numpy as np
# ========== 类别不平衡的影响演示 ==========
np.random.seed(42)
def class_imbalance_demo():
"""演示类别不平衡对分类器的影响"""
n_majority = 990 # 多数类数量
n_minority = 10 # 少数类数量
# 生成不平衡数据
# 多数类(类别0):中心在 (0, 0)
X_maj = np.random.randn(n_majority, 2) * 1.5
y_maj = np.zeros(n_majority)
# 少数类(类别1):中心在 (3, 3)
X_min = np.random.randn(n_minority, 2) * 0.5 + np.array([3, 3])
y_min = np.ones(n_minority)
X = np.vstack([X_maj, X_min])
y = np.hstack([y_maj, y_min])
# 打乱数据
idx = np.random.permutation(len(X))
X, y = X[idx], y[idx]
imbalance_ratio = n_majority / n_minority
print("=== 类别不平衡问题 ===")
print(f"多数类(类别0): {n_majority} 个样本")
print(f"少数类(类别1): {n_minority} 个样本")
print(f"不平衡比例: {imbalance_ratio:.0f}:1")
print()
# 模拟「偷懒分类器」——全部预测为多数类
lazy_pred = np.zeros(len(y))
print("--- 「偷懒分类器」的性能 ---")
print(f"预测全部为 0(多数类)")
accuracy = np.mean(lazy_pred == y)
# 各类别的准确率
maj_mask = y == 0
min_mask = y == 1
maj_acc = np.mean(lazy_pred[maj_mask] == y[maj_mask])
min_acc = np.mean(lazy_pred[min_mask] == y[min_mask])
print(f"整体准确率: {accuracy:.2%}")
print(f"多数类准确率: {maj_acc:.2%}")
print(f"少数类准确率: {min_acc:.2%} ← 完全失败!")
print()
print("重要教训:高准确率 ≠ 好模型(在不平衡数据中)")
print("应该使用:精确率(Precision)、召回率(Recall)、F1、AUC")
print()
# 计算正确的评估指标
# 模拟一个还不错的分类器预测
good_pred = np.zeros(len(y))
# 正确预测少数类(但有一些错误)
good_pred[min_mask] = np.random.choice([0, 1], n_minority, p=[0.3, 0.7])
# 正确预测多数类(但有少量错误)
good_pred[maj_mask] = np.random.choice([0, 1], n_majority, p=[0.95, 0.05])
tp = np.sum((good_pred == 1) & (y == 1)) # True Positive
fp = np.sum((good_pred == 1) & (y == 0)) # False Positive
fn = np.sum((good_pred == 0) & (y == 1)) # False Negative
precision = tp / (tp + fp) if (tp + fp) > 0 else 0
recall = tp / (tp + fn) if (tp + fn) > 0 else 0
f1 = 2 * precision * recall / (precision + recall) if (precision + recall) > 0 else 0
print(f"--- 使用正确的评估指标 ---")
print(f"精确率 (Precision): {precision:.3f} (预测为少数类的样本中真正是少数类的比例)")
print(f"召回率 (Recall): {recall:.3f} (真正的少数类中被正确识别的比例)")
print(f"F1 分数: {f1:.3f} (精确率和召回率的调和平均)")
class_imbalance_demo()
大白话 在不平衡数据中,准确率就像「只看总分不看单科成绩」——总分很高但挂了一科。精确率、召回率和 F1 才是真正能反映你「弱项」考得怎么样的指标。
二、欠采样(Undersampling)
是什么:欠采样通过减少多数类样本数量来平衡类别比例。最简单的方法是随机欠采样——从多数类中随机抽取与少数类相同数量的样本。更高级的方法包括 Tomek Links(删除边界附近的多类样本)、Edited Nearest Neighbors(删除被近邻误分类的样本)和 NearMiss(选择与少数类最近的多数类样本)。
大白话 欠采样就是「把多的砍掉一些」——如果男生太多、女生太少,就随机去掉一些男生,让男女比例平衡。问题是你去掉的男生可能包含重要信息。
为什么:欠采样简单有效,但最大问题是信息丢失——丢弃的多数类样本可能包含有价值的模式。当多数类样本之间存在子类结构时,随机欠采样可能破坏这些子类。Tomek Links 和 NearMiss 等方法试图「有选择地」删除样本——删除的是边界模糊的或有噪声的样本——而不是随机删除,从而减少信息损失。
怎么做:
import numpy as np
# ========== 欠采样方法演示 ==========
np.random.seed(42)
def undersampling_demo():
"""演示各种欠采样方法"""
# 生成不平衡数据
n_maj, n_min = 500, 50
# 多数类:两个子簇
X_maj1 = np.random.randn(n_maj // 2, 2) * 1.5 + np.array([-3, 0])
X_maj2 = np.random.randn(n_maj // 2, 2) * 1.5 + np.array([3, 0])
X_maj = np.vstack([X_maj1, X_maj2])
y_maj = np.zeros(n_maj)
# 少数类:中心在原点
X_min = np.random.randn(n_min, 2) * 0.8
y_min = np.ones(n_min)
print("=== 欠采样方法 ===")
print(f"原始数据: 多数类={n_maj}, 少数类={n_min}")
print(f"不平衡比例: {n_maj/n_min:.0f}:1")
print()
# 方法1:随机欠采样
print("--- 方法1:随机欠采样 ---")
undersample_size = n_min # 采样到与少数类相同
undersample_idx = np.random.choice(n_maj, undersample_size, replace=False)
X_maj_under = X_maj[undersample_idx]
print(f" 多数类从 {n_maj} → {len(X_maj_under)} 个样本")
print(f" 新的类别比例: {undersample_size}:{n_min} = 1:1")
print(f" 优点: 简单快速")
print(f" 缺点: 丢弃了 {n_maj - undersample_size} 个多数类样本的信息")
# 方法2:Tomek Links(概念演示)
print("\n--- 方法2:Tomek Links(概念)---")
print(" Tomek Link: 两个不同类别样本互为最近邻")
print(" 删除 Tomek Link 中的多数类样本 → 清理决策边界")
print(" 优点: 比随机欠采样更智能(只删除边界模糊的样本)")
print(" 缺点: 删除的样本数不可控,可能删得不够")
# 方法3:NearMiss(概念演示)
print("\n--- 方法3:NearMiss(概念)---")
print(" NearMiss-1: 选择与最近的少数类样本平均距离最小的多数类样本")
print(" NearMiss-2: 选择与最远的少数类样本平均距离最小的多数类样本")
print(" 优点: 保留对分类最有用的多数类样本(靠近决策边界的)")
print(" 缺点: 计算最近邻需要 O(n²) 时间")
print("\n欠采样的核心风险:信息丢失")
print(" - 如果多数类有两个子簇(如本例),随机欠采样可能仅保留一个")
print(" - 解决方案:使用聚类欠采样——先聚类,再从每个簇中等比例采样")
undersampling_demo()
大白话 欠采样的问题在于「扔掉的可能是宝贝」。如果多数类内部有很多「门派」(子类),随机削减可能导致某些门派全军覆没。Tomek Links 和 NearMiss 帮你「精挑细选」——只扔边界模糊的,保留有代表性的。
三、过采样与 SMOTE
是什么:过采样通过增加少数类样本来平衡类别比例。最简单的方法是随机过采样——直接复制少数类样本。SMOTE(Synthetic Minority Over-sampling Technique,Chawla et al., 2002)是更高级的方法——它不复制样本,而是在少数类样本之间进行线性插值,生成新的「合成」样本。SMOTE 有多种变体:Borderline-SMOTE(只在边界生成)、ADASYN(自适应生成)、SMOTE-ENN(生成后清洗)。
大白话 过采样就是「给少数派补充兵力」——最笨的方法是复制粘贴(随机过采样),聪明的方法是「人造新兵」(SMOTE):在两个少数类样本之间取中点或任意点,创造出新的合理的少数类样本。
为什么:随机过采样简单但会导致严重的过拟合——复制样本等于让模型在这些样本上重复学习,决策边界会过度收紧。SMOTE 通过合成新样本来扩张少数类的「领地」,帮助模型学习更广泛的少数类模式。SMOTE 的核心思想是:在特征空间中,少数类样本之间的连线上的点很可能也是少数类。
怎么做:
import numpy as np
# ========== SMOTE 算法实现演示 ==========
np.random.seed(42)
def smote_demo():
"""从零实现 SMOTE 算法"""
print("=== SMOTE(合成少数类过采样)===")
# 原始少数类样本(5个点)
X_min = np.array([
[1.0, 2.0],
[1.5, 2.5],
[2.0, 2.0],
[2.5, 3.0],
[1.5, 1.5],
])
print(f"原始少数类样本: {len(X_min)} 个")
print("原始样本:")
for i, x in enumerate(X_min):
print(f" 样本{i}: ({x[0]:.1f}, {x[1]:.1f})")
print()
# SMOTE 参数
k_neighbors = 3 # 最近邻数量
sampling_ratio = 2 # 每个样本生成 2 个新样本
def smote_single(X, k=3, ratio=2):
"""
SMOTE 算法:对每个少数类样本,找到其 k 个最近邻,
在样本与最近邻的连线上随机生成 ratio 个新样本
"""
n_samples, n_features = X.shape
synthetic_samples = []
for i in range(n_samples):
# 找到样本 i 的 k 个最近邻
distances = np.sqrt(np.sum((X - X[i]) ** 2, axis=1))
# 排除自身
distances[i] = np.inf
knn_indices = np.argsort(distances)[:k]
# 为样本 i 生成 ratio 个合成样本
for _ in range(ratio):
# 随机选择一个最近邻
nn_idx = np.random.choice(knn_indices)
# 在样本 i 和最近邻之间随机插值
alpha = np.random.random() # 0~1 之间的随机数
synthetic = X[i] + alpha * (X[nn_idx] - X[i])
synthetic_samples.append(synthetic)
return np.array(synthetic_samples)
X_synthetic = smote_single(X_min, k=k_neighbors, ratio=sampling_ratio)
print(f"SMOTE 生成的新样本: {len(X_synthetic)} 个 (每样本 ×{sampling_ratio})")
print("合成样本:")
for i, x in enumerate(X_synthetic):
print(f" 合成{i}: ({x[0]:.2f}, {x[1]:.2f})")
print(f"\nSMOTE 的核心公式: x_new = x_i + α · (x_nn - x_i)")
print(f" 其中 α ∈ [0, 1] 是随机插值系数")
print(f" x_i 是原始少数类样本,x_nn 是其随机选中的一个最近邻")
print(f"\nSMOTE 特点:")
print(f" 优点: 不简单复制,生成多样化的新样本")
print(f" 缺点: 如果少数类样本之间有多数类样本,SMOTE 会在多数类区域生成样本")
print(f" → 引入噪声 → Borderline-SMOTE 等变体解决此问题")
smote_demo()
大白话 SMOTE 就像一个「扩写作者」——它不是在抄原文(复制粘贴),而是根据已有内容创作新的合理内容。它在两个少数类样本之间画一条线,在线上随机取一个点作为新样本——这样新样本既有老样本的「基因」,又有一定的「变异」。
什么用:类别不平衡处理在 AI 工业中有海量应用场景:金融欺诈检测(正常交易 vs 欺诈交易,比例可高达 10000:1)、医疗诊断(健康 vs 患病)、工业缺陷检测(正常品 vs 缺陷品)、推荐系统中的负采样(点击 vs 曝光未点击)等。SMOTE 是最常用的数据级方法,而 XGBoost 的 scale_pos_weight 参数是最简单的算法级方法。
哪些坑:SMOTE 生成样本的质量取决于少数类样本的质量——如果少数类中有噪声样本,SMOTE 会扩散噪声。在高维空间中,SMOTE 的欧氏距离最近邻可能失去意义(维度灾难),建议先降维再做 SMOTE。SMOTE 默认用于数值特征,对于类别特征需要特殊处理(如 SMOTE-NC)。在极度不平衡(如 1:10000)时,单纯的数据级方法可能不足以解决问题。
四、综合策略与工业实践
是什么:在实际项目中,很少单独使用一种方法。常用的组合策略包括:① SMOTE + Tomek Links(SMOTETomek)——先生成少数类样本,再清理边界,② SMOTE + ENN(SMOTEENN)——生成后删除被误分类的样本,③ 数据级方法 + 算法级方法——SMOTE 过采样 + 代价敏感学习(如 class_weight),④ 集成方法——EasyEnsemble(多次欠采样 + 集成)和 BalanceCascade(级联欠采样)。
大白话 实战中不要只用一招——组合拳更强。生成新样本(SMOTE)扩大少数类的「地盘」,再清理战场(Tomek Links)扫除边界模糊的样本,最后用「更关注少数类」的模型(代价敏感学习)收尾。三重保险。
为什么:组合策略的优势在于各方法互补——SMOTE 解决了少数类样本不足的问题,Tomek Links 解决了 SMOTE 可能引入边界噪声的问题,代价敏感学习解决了模型优化目标偏向多数类的问题。EasyEnsemble 通过多次欠采样 + AdaBoost 集成,在几乎不损失多数类信息的前提下实现了类别平衡。
怎么做:
import numpy as np
# ========== 综合策略总结 ==========
def comprehensive_strategy():
"""总结类别不平衡处理的综合策略"""
print("=== 类别不平衡处理综合策略 ===")
print()
strategies = [
("数据层面", [
("随机欠采样", "简单,但丢失多数类信息", "多数类样本充足时"),
("随机过采样", "简单,但容易过拟合", "少数类样本极少时"),
("SMOTE", "合成新样本,信息更丰富", "数值特征为主"),
("SMOTE + Tomek", "生成+清理边界", "标准推荐"),
("EasyEnsemble", "多次欠采样+集成", "极度不平衡"),
]),
("算法层面", [
("class_weight='balanced'", "自动调整类别权重", "sklearn 模型"),
("代价敏感学习", "不同类别不同误分类代价", "业务有明确代价矩阵时"),
("阈值调整", "降低正类预测阈值", "需要提高召回率时"),
("Focal Loss", "降低易分类样本的权重", "深度学习场景"),
]),
("评估层面", [
("不使用 Accuracy", "改用 Precision/Recall/F1/AUC", "所有不平衡场景"),
("分层K折交叉验证", "保持每折类别比例", "标准评估流程"),
("PR 曲线 > ROC 曲线", "PR曲线对不平衡更敏感", "极度不平衡时"),
]),
]
for category, items in strategies:
print(f"【{category}】")
for name, desc, scenario in items:
print(f" • {name:<20} {desc:<30} 适用: {scenario}")
print()
print("工业界最佳实践流程:")
print(" 1. 先从不平衡比例判断严重程度")
print(" 2. 1:10 ~ 1:100 → class_weight='balanced' 通常就够")
print(" 3. 1:100 ~ 1:1000 → SMOTE + class_weight")
print(" 4. 1:1000+ → SMOTE + EasyEnsemble / 专门的异常检测算法")
print(" 5. 始终用 F1/AUC/PR-AUC 评估,放弃 Accuracy")
comprehensive_strategy()
大白话 处理类别不平衡就像「打仗」——不能只用一种武器。先用 SMOTE「招兵买马」,再用 Tomek Links「清理边界」,最后给少数类「加薪」(class_weight)让模型更重视它们。不管用什么方法,记得用 F1 和 AUC 而不是准确率来评估战果。
概念关系图谱
| 方法 | 类型 | 操作对象 | 主要风险 | 适用不平衡比例 |
|---|---|---|---|---|
| 随机欠采样 | 数据 | 多数类 | 信息丢失 | < 1:10 |
| 随机过采样 | 数据 | 少数类 | 过拟合 | < 1:50 |
| SMOTE | 数据 | 少数类 | 噪声扩散 | 1:10 ~ 1:100 |
| SMOTETomek | 数据(组合) | 两方 | 实现复杂 | 1:50 ~ 1:500 |
| Class Weight | 算法 | 损失函数 | 需调权重 | 1:2 ~ 1:100 |
| EasyEnsemble | 集成 | 多数类 | 训练慢 | 1:100+ |
重点答疑
Q1: 类别不平衡时,为什么准确率是一个坏指标?
因为准确率 = (TP+TN)/(TP+TN+FP+FN)。当多数类占 99% 时,模型只要预测全部为多数类就能获得 99% 的准确率,但对少数类的召回率为 0%。这种「高分低能」的现象在不平衡场景中非常普遍。应该使用精确率、召回率、F1、AUC-ROC 和 PR-AUC 等不受类别分布影响的指标。
Q2: SMOTE 和随机过采样有什么区别?SMOTE 更好吗?
随机过采样是直接复制少数类样本,SMOTE 是合成新的少数类样本。SMOTE 通常更好,因为:① 不会导致模型在重复样本上过度学习(降低过拟合),② 生成的样本具有多样性(插值系数 α 随机),③ 扩张了少数类的决策区域。但 SMOTE 也有缺点:如果少数类样本中有噪声,SMOTE 会扩散噪声;在高维空间中效果可能不好。
Q3: 极度不平衡(如 1:10000)应该怎么处理?
极度不平衡需要组合策略:① 先用专门的异常检测算法(如 Isolation Forest、One-Class SVM)而非传统分类器,② 如果必须用分类器,使用 EasyEnsemble(多次欠采样 + AdaBoost 集成),③ 在算法层面使用 Focal Loss 或代价敏感学习,④ 考虑将问题转化为排序问题(Learning to Rank),⑤ 评估时使用 PR-AUC 而非 ROC-AUC。
章节单词汇总
| 英文 | 音标 | 术语/释义 |
|---|---|---|
| Class Imbalance | /klæs ɪmˈbæləns/ | 类别不平衡;各类别样本数量差距悬殊 |
| Undersampling | /ˈʌndərˌsæmplɪŋ/ | 欠采样;减少多数类样本 |
| Oversampling | /ˈoʊvərˌsæmplɪŋ/ | 过采样;增加少数类样本 |
| SMOTE | /smoʊt/ | Synthetic Minority Over-sampling;合成少数类过采样 |
| Tomek Links | /ˈtoʊmek lɪŋks/ | Tomek 对;互为最近邻的不同类样本对 |
| Precision | /prɪˈsɪʒən/ | 精确率;预测为正的样本中真正的比例 |
| Recall | /rɪˈkɔːl/ | 召回率;真正的正样本中被找到的比例 |
| F1 Score | /ef wʌn skɔːr/ | F1 分数;精确率和召回率的调和平均 |
面试练习
Q1 [单选] 在类别不平衡数据中,以下哪个指标最能反映模型对少数类的预测能力?
- A. 准确率(Accuracy)
- B. 均方误差(MSE)
- C. F1 分数
- D. R² 分数
解答:F1 分数是精确率和召回率的调和平均,能综合反映模型对少数类的预测能力。准确率在不平衡数据中是误导性指标。
Q2 [单选] SMOTE 算法生成新样本的方式是什么?
- A. 复制少数类样本
- B. 在少数类样本之间的连线上进行线性插值
- C. 添加随机噪声到少数类样本
- D. 用生成对抗网络(GAN)生成
解答:SMOTE 的核心操作是:对每个少数类样本,找到其最近邻,在两者之间随机插值生成新样本。公式:x_new = x_i + α·(x_nn − x_i)。
Q3 [多选] 以下哪些是处理类别不平衡的有效方法?
- A. 欠采样(减少多数类)
- B. 过采样(复制少数类)
- C. SMOTE(合成少数类样本)
- D. 代价敏感学习(给少数类更高的误分类代价)
解答:四种都是有效的类别不平衡处理方法。前三种是数据层面方法,第四种是算法层面方法。
Q4 [单选] 随机欠采样的主要缺点是什么?
- A. 训练时间增加
- B. 丢弃的多数类样本可能包含重要信息
- C. 导致类别变得更加不平衡
- D. 只能用于二分类
解答:随机欠采样简单有效,但丢弃的多数类样本可能包含有价值的模式或子类信息。Tomek Links 和 NearMiss 等方法试图在删除时有选择地保留重要样本。
Q5 [单选] 在极度不平衡(如 1:10000)场景中,以下哪个评估指标最合适?
- A. 准确率(Accuracy)
- B. ROC-AUC
- C. PR-AUC(精确率-召回率曲线下面积)
- D. 均方误差(MSE)
解答:在极度不平衡场景中,ROC-AUC 可能过于乐观(因为 TN 多导致 FPR 低),PR-AUC 对少数类更敏感,是更好的评估指标。
Q6 [多选] SMOTE 的变体包括哪些?
- A. Borderline-SMOTE(只在边界生成样本)
- B. ADASYN(自适应合成采样)
- C. SMOTE-ENN(生成后用 ENN 清洗)
- D. SMOTE-CNN(用 CNN 生成样本)
解答:前三项都是 SMOTE 的经典变体。SMOTE-CNN 不是标准变体名称(虽然可以用 GAN 生成样本,但不叫 SMOTE-CNN)。
Q7 [单选] 算法层面的「class_weight='balanced'」是如何工作的?
- A. 删除多数类样本
- B. 给少数类分配更高的权重,使损失函数中对少数类错误的惩罚更重
- C. 复制少数类样本
- D. 只训练少数类样本
解答:class_weight='balanced' 自动计算各类别的权重(与类别样本数成反比),在损失函数中给少数类样本更高的权重,使模型在优化时更关注少数类。
Q8 [单选] Tomek Links 是什么?
- A. 一个集成学习算法
- B. 一对互为最近邻的不同类别样本
- C. 一个过采样方法
- D. 一个特征选择方法
解答:Tomek Link 是一对样本 (x_i, x_j),它们属于不同类别且互为最近邻。删除 Tomek Link 中的多数类样本可以清理决策边界,缓解类别不平衡。
Q9 [多选] 关于类别不平衡,以下哪些说法是正确的?
- A. 准确率高不代表模型好(在不平衡数据中)
- B. SMOTE 比随机过采样更能防止过拟合
- C. 组合数据级和算法级方法通常比单一方法效果好
- D. 类别不平衡对决策树模型没有影响
解答:前三项正确。类别不平衡对所有模型都有影响,决策树也不例外——在不平衡数据上,树的分类边界会偏向多数类。
Q10 [单选] EasyEnsemble 处理类别不平衡的核心思想是什么?
- A. 使用 SMOTE 生成样本
- B. 多次欠采样多数类,每次训练一个基分类器,最终集成
- C. 只使用少数类训练模型
- D. 给少数类添加噪声
解答:EasyEnsemble 对多数类进行多次独立欠采样,每次与少数类组合训练一个基分类器,最终用 AdaBoost 方式集成。这样既实现了类别平衡,又几乎保留了所有多数类信息。