云计算百科
云计算领域专业知识百科平台

【机器学习专栏】5.1 训练工程:数据不平衡处理

引子:三道让你怀疑人生的面试题

在开始之前,请先思考下面几个问题。如果你能一口气全部答出来,说明你对数据不平衡已经有了比较透彻的理解;如果答不上来,那么这一章就是为你准备的。

问题 1:你在一个二分类任务上训练模型,测试集准确率达到了 99.3%。你非常高兴,但你的上级看了一眼数据分布说:“正样本只占 0.7%,你这模型什么都没学到。” 为什么?该怎么避免这种情况?

问题 2:面试官让你手写 Focal Loss 的 PyTorch 实现。你写出来了,但他追问:“为什么 Focal Loss 能缓解不平衡?调节因子 gamma 起到什么作用?” 你能从梯度角度解释清楚吗?

问题 3:你的数据中正负样本比例是 1:10,000。你试了 SMOTE,模型反而变差了。面试官说:“SMOTE 在高不平衡率下可能会引入噪声。你试试其他方法。” 你能说出至少三种改进方案并说明它们各自的适用场景吗?


2.1 准确率陷阱 —— 99% 准确率的"虚假繁荣"

2.1.1 为什么准确率会骗人?

准确率(Accuracy)的定义是:

Accuracy=TP+TNTP+TN+FP+FN \\text{Accuracy} = \\frac{TP + TN}{TP + TN + FP + FN} Accuracy=TP+TN+FP+FNTP+TN

当类别极度不平衡时,这个指标会完全"失灵"。考虑一个欺诈检测场景:100,000 笔交易中只有 100 笔是欺诈(正样本率 = 0.1%)。如果一个模型把所有交易都判为"正常":

Accuracy=0+99, ⁣900100, ⁣000=99.9% \\text{Accuracy} = \\frac{0 + 99,\\!900}{100,\\!000} = 99.9\\% Accuracy=100,0000+99,900=99.9%

这个 99.9% 的准确率看似完美,但模型实际上什么都没做——它连一个欺诈样本都没识别出来!

2.1.2 更可靠的评估指标

指标定义对不平衡的鲁棒性
精确率 (Precision) TP / (TP + FP) 中等
召回率 (Recall) TP / (TP + FN) 中等
F1-Score 2 x P x R / (P + R) 较好
AUC-ROC ROC 曲线下面积 较好
AUC-PR Precision-Recall 曲线下面积 最好
G-Mean sqrt(Recall x Specificity) 较好
Matthews 相关系数 综合考虑四类预测结果 最好

关键认知:在极度不平衡场景下,AUC-PR 比 AUC-ROC 更敏感。因为 ROC 曲线受负样本(多数类)影响较大,而 PR 曲线聚焦于正样本(少数类)的表现。

import numpy as np
from sklearn.metrics import (
accuracy_score, precision_score, recall_score, f1_score,
roc_auc_score, average_precision_score, matthews_corrcoef
)

def evaluate_imbalanced(y_true, y_pred, y_prob=None):
"""全面评估不平衡数据下的模型表现"""
metrics = {
'accuracy': accuracy_score(y_true, y_pred),
'precision': precision_score(y_true, y_pred, zero_division=0),
'recall': recall_score(y_true, y_pred, zero_division=0),
'f1': f1_score(y_true, y_pred, zero_division=0),
'mcc': matthews_corrcoef(y_true, y_pred),
}
if y_prob is not None:
metrics['auc_roc'] = roc_auc_score(y_true, y_prob)
metrics['auc_pr'] = average_precision_score(y_true, y_prob)
return metrics

# 示例:全部判负的"假模型"
y_true = np.array([0] * 99900 + [1] * 100)
y_pred = np.array([0] * 100000) # 全部判负

print(evaluate_imbalanced(y_true, y_pred))
# 输出: accuracy=0.999, precision=0.0, recall=0.0, f1=0.0, mcc=0.0


面试官追问链

Q:除了换指标,还有什么办法避免准确率陷阱?

A: 第一,使用分层采样(Stratified Sampling)划分训练/测试集,确保验证集中的正样本比例与整体一致。第二,在训练时就用加权损失或采样策略,而不是等模型训练完了才发现指标有问题。第三,建立业务层面的收益矩阵——把混淆矩阵的四个象限映射到实际的业务成本或收益。

Q:为什么在高度不平衡时 AUC-PR 比 AUC-ROC 更可靠?

A: ROC 曲线的横轴是 FPR = FP / (FP + TN),分母包含大量 TN(多数类),所以 FPR 对 FP 的变化不敏感。而 PR 曲线的横轴是 Recall,纵轴是 Precision,直接聚焦于正样本的识别质量。当正样本极度稀少时,PR 曲线的下降幅度远比 ROC 曲线明显,更能区分模型好坏。


2.2 数据层面方法:过采样与欠采样

2.2.1 随机欠采样(Random Undersampling)

从多数类中随机移除样本,使多数类和少数类数量接近。

from imblearn.under_sampling import RandomUnderSampler

rus = RandomUnderSampler(random_state=42, sampling_strategy='auto')
X_res, y_res = rus.fit_resample(X, y)
print(f'采样前: {Counter(y)}')
print(f'采样后: {Counter(y_res)}')

优点:简单直接、减少训练数据量、加快训练速度。

缺点:可能丢弃有价值的多数类样本,导致信息损失。当不平衡率极高(如 1:10,000)时,欠采样后的数据量可能太少,模型欠拟合。

2.2.2 随机过采样(Random Oversampling)

复制少数类样本,使少数类和多数类数量接近。

from imblearn.over_sampling import RandomOverSampler

ros = RandomOverSampler(random_state=42)
X_res, y_res = ros.fit_resample(X, y)
print(f'采样前: {Counter(y)}')
print(f'采样后: {Counter(y_res)}')

优点:不丢失信息、实现简单。

缺点:只是简单复制样本,模型容易过拟合——同一个样本见过多次,决策边界会过于紧贴这些复制样本。

2.2.3 过采样 vs 欠采样对比

维度过采样欠采样
数据量变化 增多 减少
信息保留 保留全部多数类信息 可能丢弃多数类信息
过拟合风险 高(复制样本导致)
欠拟合风险 高(数据量太少)
训练时间 增加 减少
适用场景 数据总量小、多数类可靠 数据总量大、多数类有冗余

2.3 SMOTE 及其变体家族

2.3.1 SMOTE 核心原理

SMOTE(Synthetic Minority Over-sampling Technique)不再是简单复制,而是在少数类样本之间插值生成全新样本,这是它和随机过采样的本质区别。

生成步骤:

  • 对每个少数类样本 x_i,在特征空间中找出 k 个最近邻(同为少数类)
  • 从 k 个近邻中随机选择一个 x_nn
  • 在 x_i 与 x_nn 的连线上随机生成一个新样本:
    xnew=xi+λ⋅(xnn−xi),λ∈[0,1] x_{new} = x_i + \\lambda \\cdot (x_{nn} – x_i), \\quad \\lambda \\in [0, 1] xnew=xi+λ(xnnxi),λ[0,1]
  • import numpy as np
    from sklearn.neighbors import NearestNeighbors

    def smote_generate(X_minority, N, k=5):
    """手动实现 SMOTE 核心逻辑(简化版)"""
    n_samples, n_features = X_minority.shape
    nbrs = NearestNeighbors(n_neighbors=k).fit(X_minority)
    synthetic = []

    for _ in range(N):
    idx = np.random.randint(0, n_samples)
    x_i = X_minority[idx]
    distances, indices = nbrs.kneighbors([x_i])
    nn_idx = np.random.choice(indices[0])
    x_nn = X_minority[nn_idx]
    lam = np.random.random()
    x_new = x_i + lam * (x_nn x_i)
    synthetic.append(x_new)

    return np.array(synthetic)

    # 实际使用推荐直接调 imblearn
    from imblearn.over_sampling import SMOTE

    smote = SMOTE(random_state=42, k_neighbors=5)
    X_resampled, y_resampled = smote.fit_resample(X, y)

    SMOTE 的根本缺陷:

    • 只在少数类内部插值,没有考虑多数类的分布,可能生成与多数类重叠的样本
    • 在高维空间中,欧氏距离不再可靠,近邻选择可能没有意义
    • 当不平衡率极高时,少数类样本过于稀疏,近邻可能并不是真正的"同类"

    2.3.2 SMOTE 变体全家福

    变体核心思想解决的问题适用场景
    Borderline-SMOTE 只在决策边界附近的少数类样本上生成 减少远离边界的噪声样本 边界清晰的分类问题
    ADASYN 根据密度分布自适应生成数量——密度越低生成越多 让生成集中在"困难区域" 非均匀分布的少数类
    SVMSMOTE 用 SVM 找出支持向量,在支持向量附近生成 聚焦于真正影响决策面的样本 高维稀疏数据
    KMeansSMOTE 先用 KMeans 聚类,在每个簇内独立应用 SMOTE 解决多模态分布的少数类 少数类有多个子簇
    SMOTE-ENN SMOTE 后用 Edited Nearest Neighbors 清洗噪声 去除生成后仍然分类错误的样本 生成样本质量差时

    from imblearn.over_sampling import BorderlineSMOTE, ADASYN, SVMSMOTE, KMeansSMOTE
    from imblearn.combine import SMOTEENN

    # Borderline-SMOTE: 关注边界样本
    borderline_smote = BorderlineSMOTE(kind='borderline-1', random_state=42)

    # ADASYN: 自适应合成采样
    adasyn = ADASYN(random_state=42, n_neighbors=5)

    # SMOTE-ENN: 结合欠采样清洗
    smote_enn = SMOTEENN(random_state=42)

    # 实际使用时注意: SMOTE 假设特征是连续数值型
    from imblearn.over_sampling import SMOTENC
    smote_nc = SMOTENC(categorical_features=[1, 3], random_state=42)

    面试官追问链

    Q:SMOTE 生成的样本可能不真实,怎么处理?

    A: 对于图像(原始像素空间),相邻样本插值会产生"鬼影";但对于数值型表格数据,插值通常合理。应对方法有:(1)使用 SMOTE-ENN 或 SMOTE-Tomek 在生成后清洗;(2)在生成后做数据验证,剔除与多数类高度重叠的样本;(3)改用 GAN 或 VAE 生成更真实的样本。

    Q:SMOTE 对高维数据效果如何?为什么?

    A: 高维空间下效果往往不好。主要原因有两个:(1)维度诅咒——高维空间中样本极度稀疏,欧氏距离趋于一致,近邻选择失效;(2)噪声放大——在高维空间中插值会引入大量虚假特征组合。解决办法:先降维(PCA、Autoencoder)再做 SMOTE,或使用基于 GAN 的方法。

    Q:SMOTE 能用于 NLP 或时间序列数据吗?

    A: 不能直接使用。NLP 数据的特征空间是非连续、离散的,插值在语义上没有意义。时间序列的数据存在时序依赖,独立插值会破坏时间结构。NLP 可以尝试在 Embedding 空间做插值,时间序列可以尝试子序列级别的 DTW 插值或生成式方法。


    2.4 Focal Loss —— 从损失函数层面解决不平衡

    2.4.1 标准交叉熵的局限性

    二分类交叉熵损失函数:

    CE(p,y)={−log⁡(p)if y=1−log⁡(1−p)if y=0 \\text{CE}(p, y) = \\begin{cases} -\\log(p) & \\text{if } y = 1 \\\\ -\\log(1 – p) & \\text{if } y = 0 \\end{cases} CE(p,y)={log(p)log(1p)if y=1if y=0

    更简洁的表达(令 p_t = p * y + (1-p) * (1-y)):

    CE(p,y)=−log⁡(pt) \\text{CE}(p, y) = -\\log(p_t) CE(p,y)=log(pt)

    对于多数类(负样本),模型很容易将其预测为一个高置信度的负样本(即 p_t 接近 1),但即使这样,每个样本的损失仍然为 -log(0.99) 约等于 0.01。当多数类样本数量巨大时,这些微小损失的总和会压倒少数类样本的贡献。

    2.4.2 Focal Loss 的核心思想

    Focal Loss 由 Lin et al. (2017) 在目标检测任务中提出,其核心思想可以概括为一句话:让模型"聚焦"于难分类的样本,降低易分类样本的权重。

    FL(pt)=−αt(1−pt)γlog⁡(pt) \\text{FL}(p_t) = -\\alpha_t (1 – p_t)^\\gamma \\log(p_t) FL(pt)=αt(1pt)γlog(pt)

    两个关键设计:

  • 调节因子 (1 – p_t)^gamma:当样本被正确分类且置信度高时(p_t -> 1),因子趋近于 0,损失被大幅降低;当样本被误分类时(p_t -> 0),因子趋近于 1,损失几乎不受影响。
  • 权重因子 alpha_t:用于调整正负样本的权重比例,控制类别平衡。
  • 不同 gamma 值的效果:

    gamma易分样本的损失衰减典型场景
    0 不衰减(等价于 CE + alpha) 基线
    0.5 温和衰减 轻度不平衡
    1.0 中等衰减 中度不平衡
    2.0 强烈衰减 严重不平衡(默认值)
    5.0 极端衰减 极稀疏的正样本

    2.4.3 Focal Loss 的梯度分析(面试重点)

    从梯度角度可以更直观地理解 Focal Loss 为什么有效。以 y=1 为例:

    标准交叉熵的梯度:
    ∂CE∂p=−1p \\frac{\\partial \\text{CE}}{\\partial p} = -\\frac{1}{p} pCE=p1

    Focal Loss 的梯度:
    ∂FL∂p=−αt(1−p)γ−1[γplog⁡(p)+(1−p)] \\frac{\\partial \\text{FL}}{\\partial p} = -\\alpha_t (1 – p)^{\\gamma-1} \\left[ \\gamma p \\log(p) + (1-p) \\right] pFL=αt(1p)γ1[γplog(p)+(1p)]

    当 p -> 1(易分类样本)时,(1-p)^{gamma-1} 趋近于 0,梯度被拉低——模型不再花精力在已经学会的样本上。当 p -> 0(难分类样本)时,(1-p)^{gamma-1} -> 1,梯度保持较大——模型持续关注这些困难样本。

    这就是 Focal Loss 的精髓:它不是简单地给少数类加权重,而是动态地根据每个样本的学习难度来分配权重。

    2.4.4 PyTorch 实现

    import torch
    import torch.nn as nn
    import torch.nn.functional as F

    class FocalLoss(nn.Module):
    """
    Focal Loss 的 PyTorch 实现
    Args:
    alpha: 类别权重因子 (float),用于控制正负样本比例
    gamma: 聚焦参数,gamma >= 0,默认 2.0
    reduction: 'none' | 'mean' | 'sum'
    """

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

    def forward(self, inputs, targets):
    # inputs: raw logits (未经过 sigmoid)
    # targets: 二分类标签 {0, 1}
    probs = torch.sigmoid(inputs)
    p_t = probs * targets + (1 probs) * (1 targets)

    # 计算交叉熵损失: -log(p_t)
    ce_loss = F.binary_cross_entropy_with_logits(
    inputs, targets, reduction='none'
    )

    # Focal Loss 权重: (1 – p_t) ** gamma
    focal_weight = (1 p_t) ** self.gamma

    # alpha 平衡权重
    if self.alpha is not None:
    alpha_weight = targets * self.alpha + (1 targets) * (1 self.alpha)
    focal_weight = focal_weight * alpha_weight

    loss = focal_weight * ce_loss

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

    # 多分类版本
    class FocalLossMultiClass(nn.Module):
    def __init__(self, alpha=None, gamma=2.0, reduction='mean'):
    super().__init__()
    self.alpha = alpha
    self.gamma = gamma
    self.reduction = reduction

    def forward(self, inputs, targets):
    log_probs = F.log_softmax(inputs, dim=1)
    probs = torch.exp(log_probs)
    p_t = probs.gather(1, targets.unsqueeze(1)).squeeze(1)
    focal_weight = (1 p_t) ** self.gamma

    if self.alpha is not None:
    alpha_weight = self.alpha.gather(0, targets)
    focal_weight = focal_weight * alpha_weight

    loss = focal_weight * (log_probs.gather(1, targets.unsqueeze(1)).squeeze(1))

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

    面试官追问链

    Q:Focal Loss 和加权的 Cross Entropy 有什么区别?

    A: 加权 CE 是静态的——每个正样本的权重系数是固定的(例如正样本权重=100,负样本权重=1),不关心样本本身是否容易被分类。Focal Loss 是动态的——权重 (1-p_t)^gamma 随训练动态变化:已经是高置信度的正样本权重降低,低置信度的困难样本权重保持。换句话说,加权 CE 只在样本类别维度上做区别对待,Focal Loss 在样本个体难度维度上做区别对待。

    Q:Focal Loss 中的 alpha 和样本权重有什么关系?为什么不只用一个 gamma 就够了?

    A: gamma 控制的是"聚焦程度",解决的是难易样本不平衡问题;alpha 控制的是正负样本数量不平衡问题。二者解决的是不同层面的不平衡。在实际使用中,两者配合效果最好:alpha 平衡数量,gamma 让模型关注困难样本。如果不设 alpha,多数类中仍然有大量中等难度的样本贡献显著的损失总和。

    Q:Focal Loss 是否可以推广到回归问题?

    A: 可以。基本思路是:对残差小的样本(易拟合的样本)降低权重,对残差大的样本(异常值或极少数样本)保持或增加权重。例如:FL-L1 = (1 – e^{-|y – y_hat|})^gamma * |y – y_hat|。


    2.5 代价敏感学习 —— 让模型"知道"犯错代价不同

    2.5.1 核心思想

    代价敏感学习(Cost-Sensitive Learning)不改变数据的分布,而是通过修改学习目标,让模型意识到"错判少数类的代价远高于错判多数类"。

    代价矩阵:定义一个 C x C 的矩阵(C 为类别数),其中 c_ij 表示将真实类别 i 预测为类别 j 的代价。

    对于二分类问题:

    真实\\预测预测为正预测为负
    正类 c_11 = 0 c_10 = FN_cost
    负类 c_01 = FP_cost c_00 = 0

    目标是最小化期望代价:R(model) = E[ c_y_y_hat ]

    2.5.2 实现方式

    方式一:在损失函数中加权

    import torch.nn as nn

    # 正样本权重 = 负样本数 / 正样本数
    pos_weight = torch.tensor([n_negative / n_positive])
    criterion = nn.BCEWithLogitsLoss(pos_weight=pos_weight)

    方式二:在模型输出后调整阈值

    传统分类器使用 0.5 作为决策阈值。对于不平衡数据,降低阈值可以让更多样本被分到少数类:

    from sklearn.ensemble import RandomForestClassifier

    model = RandomForestClassifier()
    model.fit(X_train, y_train)

    y_prob = model.predict_proba(X_test)[:, 1]

    # 在验证集上搜索最优阈值
    from sklearn.metrics import f1_score

    thresholds = np.linspace(0.05, 0.95, 100)
    best_thresh = 0.5
    best_f1 = 0.0

    for thresh in thresholds:
    y_pred = (y_prob >= thresh).astype(int)
    f1 = f1_score(y_val, y_pred)
    if f1 > best_f1:
    best_f1 = f1
    best_thresh = thresh

    print(f"最优阈值: {best_thresh:.3f}, 最优 F1: {best_f1:.4f}")

    方式三:在模型中嵌入代价信息

    # XGBoost 中设置 scale_pos_weight
    import xgboost as xgb

    scale_pos_weight = n_negative / n_positive
    model = xgb.XGBClassifier(
    scale_pos_weight=scale_pos_weight,
    eval_metric='auc'
    )

    # LightGBM 中设置 class_weight
    import lightgbm as lgb

    model = lgb.LGBMClassifier(
    class_weight='balanced',
    scale_pos_weight=scale_pos_weight
    )

    2.5.3 数据采样 vs 代价敏感 vs 损失函数方法的对比

    方法原理层面是否改数据训练开销可解释性调参难度
    过采样 数据层面 略增
    欠采样 数据层面 减少
    SMOTE 数据层面 增加
    代价敏感 算法层面 几乎不变
    Focal Loss 损失函数 几乎不变
    阈值调整 后处理

    面试官追问链

    Q:代价敏感学习和 Focal Loss 有什么本质区别?

    A: 代价敏感学习是"类别维度"的加权——同一类别的所有样本共享一个权重,权重由业务给出的代价矩阵决定。Focal Loss 是"样本维度"的加权——每个样本的权重取决于它当前被分类的难易程度,随训练动态变化。从数学上说,代价敏感学习相当于固定了 alpha 权重(gamma=0 的 Focal Loss),而 Focal Loss 是代价敏感的推广。

    Q:阈值移动(Threshold Moving)在什么情况下会失效?

    A: 阈值移动假设模型输出的概率是良好校准的(well-calibrated)。如果模型输出概率校准不良(如 SVM 的 decision function、未经温度缩放的深度学习模型),调整阈值的意义不大。这种情况下需要先做概率校准(Platt Scaling 或 Isotonic Regression),或者直接使用代价敏感学习。


    2.6 集成方法 —— EasyEnsemble 与 BalanceCascade

    2.6.1 为什么需要集成方法?

    当不平衡率极高(如 1:10,000)时,单次欠采样会丢弃大量多数类信息,而单次过采样会引入大量噪声或导致过拟合。集成方法通过多次采样 + 多个基分类器来综合解决这个问题。

    2.6.2 EasyEnsemble

    EasyEnsemble 的做法非常直观:

  • 从多数类中有放回地采样出 T 个子集,每个子集的样本数量与少数类相同
  • 用少数类 + 每个多数类子集训练一个基分类器(共 T 个)
  • 对 T 个分类器的预测结果取平均或投票
  • 这样,每个基分类器都在平衡的数据上训练,而集成后的模型利用了全部多数类信息。

    import numpy as np
    from sklearn.tree import DecisionTreeClassifier
    from sklearn.base import BaseEstimator, ClassifierMixin
    from sklearn.utils import resample

    class EasyEnsemble(BaseEstimator, ClassifierMixin):
    """EasyEnsemble 手动实现"""
    def __init__(self, base_estimator=None, n_estimators=10):
    self.n_estimators = n_estimators
    self.base_estimator = base_estimator or DecisionTreeClassifier(max_depth=3)
    self.estimators_ = []

    def fit(self, X, y):
    X_pos = X[y == 1]
    X_neg = X[y == 0]
    n_pos = len(X_pos)

    for _ in range(self.n_estimators):
    X_neg_sample = resample(X_neg, n_samples=n_pos, random_state=None)
    X_bal = np.vstack([X_pos, X_neg_sample])
    y_bal = np.hstack([np.ones(n_pos), np.zeros(n_pos)])

    clf = self.base_estimator.__class__(**self.base_estimator.get_params())
    clf.fit(X_bal, y_bal)
    self.estimators_.append(clf)
    return self

    def predict_proba(self, X):
    probs = np.mean([clf.predict_proba(X)[:, 1] for clf in self.estimators_], axis=0)
    return np.vstack([1 probs, probs]).T

    def predict(self, X):
    return (self.predict_proba(X)[:, 1] > 0.5).astype(int)

    # 使用 imbalanced-learn 中的 EasyEnsemble
    from imblearn.ensemble import EasyEnsembleClassifier

    eec = EasyEnsembleClassifier(
    n_estimators=10,
    base_estimator=DecisionTreeClassifier(max_depth=3),
    sampling_strategy='auto',
    random_state=42
    )
    eec.fit(X_train, y_train)

    2.6.3 BalanceCascade

    BalanceCascade 在 EasyEnsemble 的基础上做了一个重要的改进:级联淘汰。每训练完一个基分类器后,从多数类中移除那些被当前分类器正确分类的样本,让下一个分类器专注于"更难"的多数类样本。

    步骤 1: 从多数类中采样一个大小为 n_pos 的子集
    步骤 2: 用少数类 + 该子集训练基分类器
    步骤 3: 从多数类中移除该基分类器能正确分类的样本(级联)
    步骤 4: 重复步骤 1-3,直到达到 n_estimators 或多数类耗尽

    这样做的直觉是:已经被正确分类的多数类样本不需要再被后面的分类器关注,而"难的"多数类样本(和少数类混淆的)需要更多分类器来辨析。

    from imblearn.ensemble import BalancedBaggingClassifier, BalancedRandomForestClassifier

    # Balanced Bagging: 每个基学习器使用随机过采样
    bbc = BalancedBaggingClassifier(
    base_estimator=DecisionTreeClassifier(),
    n_estimators=50,
    sampling_strategy='auto',
    replacement=True,
    random_state=42
    )

    # Balanced Random Forest: 每个树在平衡子集上训练
    brf = BalancedRandomForestClassifier(
    n_estimators=100,
    max_depth=10,
    sampling_strategy='auto',
    random_state=42
    )

    面试官追问链

    Q:EasyEnsemble 和普通的 Bagging 有什么区别?

    A: 普通 Bagging 是在全量数据上 Bootstrap 采样(每份数据分布与原始数据保持一致,即还是不平衡的),每个基学习器面对的还是不平衡数据。EasyEnsemble 的每个子集都是平衡的(少数类 + 等量多数类),所以每个基学习器面对的是平衡数据——它"被迫"学习少数类的模式。此外,EasyEnsemble 中多数类是有放回采样,而少数类是不变地复制到每个子集中。

    Q:为什么不直接用 AdaBoost 解决不平衡?

    A: 传统 AdaBoost 会给分错的样本增加权重。在不平衡数据上,多数类的分错样本数量远多于少数类,所以权重会不成比例地集中在多数类上,反而让模型更偏向多数类。改进方法有 RUSBoost(先欠采样再做 Boosting)和 SMOTEBoost(先 SMOTE 再做 Boosting),它们把采样和 Boosting 组合起来避免这个问题。


    2.7 异常检测方法 —— 把不平衡问题转化为异常发现

    2.7.1 当不平衡率极高时

    当正负样本比例达到 1:100,000 甚至更低时,传统的采样 + 分类方法基本失效:

    • 欠采样后数据量太少,模型无法学到有效模式
    • 过采样生成的样本数量远超真实正样本,引入大量噪声
    • SMOTE 在极稀疏的正样本空间中插值,生成的样本质量极低

    此时,可以将问题重新定义为异常检测(Anomaly Detection)——把占极少数的正样本看作"异常",把多数类看作"正常",用无监督或半监督方法建模"正常"数据的分布。

    2.7.2 常用异常检测方法

    方法原理适用场景优缺点
    Isolation Forest 随机切割特征空间,异常点更容易被隔离 高维连续特征 快,但不适用于局部异常
    LOF 基于局部密度,密度低于邻居则为异常 密度差异明显 计算复杂度 O(n^2)
    One-Class SVM 在核空间中用一个超球面包裹正常数据 中等维度 对核参数敏感
    Autoencoder 正常样本重建误差小,异常样本重建误差大 图像、序列等非结构化数据 需要足够多的正常样本
    GANomaly 用 GAN 学习正常样本的生成分布 高维复杂数据 训练不稳定
    Deep SVDD 用神经网络将正常样本映射到紧凑的超球面内 大规模数据 需要精细调参

    from sklearn.ensemble import IsolationForest

    # Isolation Forest
    iso_forest = IsolationForest(
    contamination=0.01,
    random_state=42,
    n_estimators=100,
    max_samples='auto'
    )
    y_pred = iso_forest.fit_predict(X) # 正常=1, 异常=-1

    # One-Class SVM
    from sklearn.svm import OneClassSVM

    oc_svm = OneClassSVM(
    nu=0.01,
    kernel='rbf',
    gamma='auto'
    )
    y_pred = oc_svm.fit_predict(X)

    # Autoencoder 方法
    import torch.nn as nn

    class AnomalyAE(nn.Module):
    """用重建误差检测异常的自编码器"""
    def __init__(self, input_dim, encoding_dim=16):
    super().__init__()
    self.encoder = nn.Sequential(
    nn.Linear(input_dim, 64),
    nn.ReLU(),
    nn.Linear(64, encoding_dim),
    nn.ReLU()
    )
    self.decoder = nn.Sequential(
    nn.Linear(encoding_dim, 64),
    nn.ReLU(),
    nn.Linear(64, input_dim),
    nn.Sigmoid()
    )

    def forward(self, x):
    return self.decoder(self.encoder(x))

    def anomaly_score(self, x):
    reconstruction = self.forward(x)
    return ((x reconstruction) ** 2).mean(dim=1)

    # 训练:只用"正常"样本
    model = AnomalyAE(input_dim=X_train.shape[1])
    criterion = nn.MSELoss()
    optimizer = torch.optim.Adam(model.parameters())

    for epoch in range(100):
    X_normal = X_train[y_train == 0]
    recon = model(X_normal)
    loss = criterion(recon, X_normal)
    optimizer.zero_grad()
    loss.backward()
    optimizer.step()

    # 推理:重建误差大的判为异常
    with torch.no_grad():
    scores = model.anomaly_score(torch.FloatTensor(X_test))
    threshold = np.percentile(scores.numpy(), 99)
    y_pred = (scores > threshold).int().numpy()

    2.7.3 异常检测与分类方法的根本区别

    维度传统分类方法异常检测方法
    训练数据 需要正负样本 只需正常样本(无监督)
    问题定义 学习决策边界 学习正常数据的分布
    对不平衡的适应 需要采样/加权 天然适应
    输出 类别标签 异常分数 + 阈值
    可解释性 特征重要性 重建误差 / 密度值
    适用不平衡率 一般 < 1:100 可 > 1:10,000

    面试官追问链

    Q:异常检测方法能替代采样方法吗?

    A: 不能完全替代。异常检测适用于极不平衡(1:1000+)且没有足够的正样本的情况。经验法则:正样本数 < 100 时优先考虑异常检测,正样本数 > 1000 时优先考虑采样 + 分类。

    Q:为什么 Isolation Forest 在不平衡数据上表现好?

    A: Isolation Forest 的核心假设是"异常点更容易被隔离"——少量切割就能将异常点分离出来。多数类样本(正常)密集分布在特征空间中,需要更多切割才能隔离;少数类样本(异常)稀疏,更容易被隔离,因此其路径长度(从根节点到叶子节点的切割次数)更短。所以用路径长度作为异常分数天然适用于不平衡数据。

    Q:Autoencoder 检测异常的核心假设是什么?它有什么限制?

    A: 核心假设是:Autoencoder 只在正常样本上训练,学会了"正常模式"的低维流形。正常样本重建误差小,而异常样本不在这个流形上,重建误差大。限制:(1)如果异常样本恰好落在正常流形上(即异常看起来像正常),不会被检测出来;(2)Autoencoder 可能学到"恒等映射"(什么都不压缩),导致所有样本重建误差都很小——需要用正则化或降噪自编码器来避免。


    2.8 数据不平衡处理的完整决策流程

    面对一个实际的不平衡问题时,可以按以下决策树进行选择:

    数据是否不平衡?
    ├── 不平衡率 < 1:10 → 轻度不平衡
    │ └── 调整评估指标 + 轻微权重调整即可

    ├── 不平衡率 1:10 ~ 1:100 → 中度不平衡
    │ ├── 首选:SMOTE + 决策树 / XGBoost
    │ ├── 备选:Focal Loss(深度模型)或代价敏感学习
    │ └── 注意:尝试不同的 SMOTE 变体

    ├── 不平衡率 1:100 ~ 1:10,000 → 重度不平衡
    │ ├── EasyEnsemble / Balanced Random Forest
    │ ├── Focal Loss + 数据增强
    │ ├── 代价敏感 + 阈值移动
    │ └── 可以先尝试降维 + SMOTE

    └── 不平衡率 > 1:10,000 → 极度不平衡
    ├── 异常检测方法 (Isolation Forest / Autoencoder)
    ├── 转为异常检测问题,放弃分类思路
    └── 如果仍有标签:用 EasyEnsemble 但不抱太大期望

    # 一个实用的不平衡数据处理流水线示例
    from sklearn.pipeline import Pipeline
    from sklearn.preprocessing import StandardScaler
    from sklearn.model_selection import StratifiedKFold
    from sklearn.metrics import classification_report

    from imblearn.over_sampling import SMOTE, RandomOverSampler
    from imblearn.under_sampling import RandomUnderSampler
    from imblearn.pipeline import Pipeline as ImbPipeline
    from xgboost import XGBClassifier

    def build_imbalanced_pipeline(ratio):
    """根据不平衡率自动选择处理策略"""
    if ratio < 10:
    sampler = RandomOverSampler(sampling_strategy='auto')
    elif ratio < 100:
    sampler = SMOTE(sampling_strategy=0.5, k_neighbors=5)
    elif ratio < 10000:
    from imblearn.ensemble import EasyEnsembleClassifier
    return Pipeline([
    ('scaler', StandardScaler()),
    ('classifier', EasyEnsembleClassifier(
    n_estimators=10,
    base_estimator=XGBClassifier(eval_metric='logloss'),
    random_state=42
    ))
    ])
    else:
    from sklearn.ensemble import IsolationForest
    contamination = min(1.0 / (1 + ratio), 0.5)
    return Pipeline([
    ('scaler', StandardScaler()),
    ('classifier', IsolationForest(contamination=contamination, random_state=42))
    ])

    return ImbPipeline([
    ('scaler', StandardScaler()),
    ('sampler', sampler),
    ('classifier', XGBClassifier(
    scale_pos_weight=ratio,
    eval_metric='auc',
    use_label_encoder=False
    ))
    ])


    本章总结

    考点核心要点面试频度
    准确率陷阱 不平衡数据下 Accuracy 失效;用 Precision/Recall/F1/AUC-PR 替代 ⭐⭐⭐⭐⭐
    过采样 vs 欠采样 过采样保留信息但易过拟合;欠采样信息损失但训练快 ⭐⭐⭐⭐
    SMOTE 原理 在少数类近邻间插值生成新样本;关键是理解插值假设和局限 ⭐⭐⭐⭐⭐
    SMOTE 变体 Borderline/ADASYN/KMeansSMOTE/SMOTE-ENN 各有适用场景 ⭐⭐⭐
    Focal Loss 推导 标准 CE -> 加入 (1-p_t)^gamma 调节因子;gamma 和 alpha 的分工 ⭐⭐⭐⭐⭐
    梯度解释 Focal Loss 易分样本梯度被压制,难分样本梯度保持——动态调节 ⭐⭐⭐⭐
    代价敏感学习 权重矩阵、阈值移动、scale_pos_weight 三种实现方式 ⭐⭐⭐
    EasyEnsemble 多次欠采样 + 基分类器集成,保留全部多数类信息 ⭐⭐⭐
    BalanceCascade EasyEnsemble + 级联淘汰已分类正确的多数类 ⭐⭐
    异常检测方法 Isolation Forest / Autoencoder 适用于极不平衡场景 ⭐⭐⭐
    决策流程 按不平衡率分四级:轻度->加权、中度->SMOTE、重度->集成、极度->异常检测 ⭐⭐⭐⭐

    延伸阅读:

    • He, H., & Garcia, E. A. (2009). “Learning from Imbalanced Data.” IEEE TKDE.
    • Lin, T. Y., et al. (2017). “Focal Loss for Dense Object Detection.” ICCV.
    • Liu, F. T., et al. (2008). “Isolation Forest.” ICDM.
    • Chawla, N. V., et al. (2002). “SMOTE: Synthetic Minority Over-sampling Technique.” JAIR.
    • 面试加分项:了解如何处理多分类不平衡(如 Macro-F1)和在线学习场景下的不平衡问题。
    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 【机器学习专栏】5.1 训练工程:数据不平衡处理
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!