引子:三道让你怀疑人生的面试题
在开始之前,请先思考下面几个问题。如果你能一口气全部答出来,说明你对数据不平衡已经有了比较透彻的理解;如果答不上来,那么这一章就是为你准备的。
问题 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)不再是简单复制,而是在少数类样本之间插值生成全新样本,这是它和随机过采样的本质区别。
生成步骤:
xnew=xi+λ⋅(xnn−xi),λ∈[0,1] x_{new} = x_i + \\lambda \\cdot (x_{nn} – x_i), \\quad \\lambda \\in [0, 1] xnew=xi+λ⋅(xnn−xi),λ∈[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(1−p)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(1−pt)γlog(pt)
两个关键设计:
不同 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} ∂p∂CE=−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] ∂p∂FL=−αt(1−p)γ−1[γplog(p)+(1−p)]
当 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 的做法非常直观:
这样,每个基分类器都在平衡的数据上训练,而集成后的模型利用了全部多数类信息。
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)和在线学习场景下的不平衡问题。
网硕互联帮助中心



![[人工智能]前馈神经网络:结构与训练实践-网硕互联帮助中心](https://www.wsisp.com/helps/wp-content/uploads/2026/08/20260803224837-6a711ac55129a-220x150.png)

评论前必须登录!
注册