深度学习样本不平衡的实战调优策略与代码实现
1. 样本不平衡:不只是个数学问题,更是工程难题
做深度学习项目,尤其是分类任务,, 你肯定遇到过这种情况:模型在训练集上准确率高达99%,一上线就“翻车”,死活识别不出你想找的那个关键类别。比如,在金融风控里,欺诈交易可能只占万分之一;在工厂质检中,不良品率可能只有千分之几;在医疗影像诊断里,某种罕见病的阳性样本更是凤毛麟角。这种“多数类”和“少数类”样本数量悬殊的情况,就是典型的样本不平衡问题。
很多人一开始会把它当成一个简单的数学比例问题,想着“我调一下采样比例不就行了?”。但真上手了才发现,事情没那么简单。我踩过的坑告诉我,粗暴地处理样本比例,可能会引入新问题:比如,过度上采样少数类,模型可能会记住那些重复的、甚至被噪声放大的样本特征,导致过拟合;而过度下采样多数类,又会丢掉大量有价值的信息,让模型学不到足够丰富的特征。更头疼的是,不同的模型架构、不同的任务目标(是追求高召回率还是高精确率?),对不平衡的敏感度也完全不同。
所以,处理样本不平衡,远不止是调整几个数字。它是一个系统工程,需要我们从数据、模型、损失函数、评估指标四个维度协同作战。这篇文章,我就结合自己这些年调参、填坑的实战经验,和你聊聊那些真正有效、能落地的策略,并配上可直接运行的代码。我们的目标不是追求理论上的完美,而是在工程实践中,用最小的代价,拿到最稳的效果。
2. 数据层面的手术刀:巧用采样与增强
当数据本身“先天不足”时,我们的第一反应往往是从数据本身动手。这是最直观、也最常用的一层。核心思路就两个:要么让少的变多(过采样),要么让多的变少(欠采样)。但具体怎么做,很有讲究。
2.1 基础采样:imbalanced-learn 的快速上手与陷阱
imbalanced-learn 是处理这类问题的瑞士军刀。原始文章里提到了最简单的随机过采样和欠采样,我们直接看代码:
from imblearn.over_sampling import RandomOverSampler, SMOTE
from imblearn.under_sampling import RandomUnderSampler, NearMiss
import numpy as np
from collections import Counter
# 假设我们有一个严重不平衡的数据集
X = np.random.randn(1000, 10) # 1000个样本,10个特征
y = np.array([0] * 950 + [1] * 50) # 950个负样本,50个正样本,比例19:1
print(f"原始分布: {Counter(y)}")
# 1. 随机过采样 (Random Over-Sampling)
ros = RandomOverSampler(random_state=42)
X_ros, y_ros = ros.fit_resample(X, y)
print(f"随机过采样后: {Counter(y_ros)}") # 正负样本都变成950个
# 2. 随机欠采样 (Random Under-Sampling)
rus = RandomUnderSampler(random_state=42)
X_rus, y_rus = rus.fit_resample(X, y)
print(f"随机欠采样后: {Counter(y_rus)}") # 正负样本都变成50个
但是,这里有大坑! 随机过采样就是简单复制少数类样本,极易导致模型过拟合,记住这些重复样本的“噪声”而非通用特征。随机欠采样则粗暴地丢弃了大量多数类样本,可能损失了关键的数据分布信息。
所以,我们很少直接用这两种“暴力”方法。更高级的策略是:
SMOTE(合成少数类过采样技术):它不是在现有少数类样本里复制,而是创造新样本。原理是在特征空间中,找到少数类样本的k个近邻,然后在样本和近邻的连线上随机选取点作为新样本。这比单纯复制好多了。
# 3. SMOTE 过采样
smote = SMOTE(random_state=42)
X_smote, y_smote = smote.fit_resample(X, y)
print(f"SMOTE后: {Counter(y_smote)}")
NearMiss(欠采样):它不是随机丢弃多数类,而是有选择地保留那些“最有价值”的多数类样本。比如 NearMiss-1 会选择那些与少数类样本平均距离最小的多数类样本,目的是保留边界附近的信息。
# 4. NearMiss 欠采样
nearmiss = NearMiss(version=1)
X_nm, y_nm = nearmiss.fit_resample(X, y)
print(f"NearMiss欠采样后: {Counter(y_nm)}")
我的实战建议是:对于中小型数据集,可以优先尝试 SMOTE。对于非常大的数据集(比如百万级),计算所有样本的邻居可能很慢,可以考虑 随机欠采样 或 NearMiss。一个更稳妥的“组合拳”是 SMOTEENN,它先做SMOTE过采样,再用ENN(编辑最近邻)方法清理可能产生的重叠样本或噪声样本。
from imblearn.combine import SMOTEENN
smote_enn = SMOTEENN(random_state=42)
X_combined, y_combined = smote_enn.fit_resample(X, y)
print(f"SMOTEENN后: {Counter(y_combined)}")
2.2 高级玩法:面向文本与图像的智能数据增强
对于图像和文本数据,我们还有更“高级”的武器——数据增强。这不仅仅是增加样本数量,更是增加数据的多样性。
对于图像任务,除了经典的旋转、裁剪、翻转、颜色抖动,我强烈推荐试试 CutMix 和 MixUp。它们不是简单地修改一张图,而是将两张图混合起来,同时标签也按比例混合。这能迫使模型学习更鲁棒的特征,而不是记住某些像素位置。
# 以TensorFlow/Keras为例,使用 albumentations 库进行增强
import albumentations as A
transform = A.Compose([
A.RandomRotate90(),
A.Flip(),
A.Transpose(),
A.OneOf([
A.MotionBlur(p=0.2),
A.MedianBlur(blur_limit=3, p=0.1),
A.Blur(blur_limit=3, p=0.1),
], p=0.2),
A.OneOf([
A.OpticalDistortion(p=0.3),
A.GridDistortion(p=0.1),
A.PiecewiseAffine(p=0.3),
], p=0.2),
A.OneOf([
A.CLAHE(clip_limit=2),
A.Sharpen(),
A.Emboss(),
A.RandomBrightnessContrast(),
], p=0.3),
A.HueSaturationValue(p=0.3),
])
# 然后在数据加载器中应用这个 transform
对于文本任务,传统过采样复制句子意义不大。我们可以用 回译(比如将中文句子翻译成英文再译回中文)、同义词替换(利用WordNet或同义词词林)、随机删除或交换词语 等方式。更前沿的方法是使用 预训练语言模型进行生成。比如,对于少数类文本,我们可以用少量样本去微调一个GPT-2或T5模型,让它生成语义相似但表述多样的新句子。这比简单的替换要强大得多。
# 示例:使用 nlpaug 库进行文本同义词替换
import nlpaug.augmenter.word as naw
aug = naw.SynonymAug(aug_src='wordnet') # 使用WordNet
text = "The quick brown fox jumps over the lazy dog."
augmented_text = aug.augment(text)
print(augmented_text)
# 可能输出:The quick brown fox leap over the lazy dog.
数据层面的操作是第一步,它直接改变了输入给模型的数据分布。但记住,没有免费的午餐,任何采样或增强都会引入偏差。我们的原则是:先尝试简单的、可解释的方法(如SMOTE),观察验证集效果,再考虑更复杂的增强策略。
3. 算法与损失函数:让模型“看见”少数类
如果动数据代价太大,或者效果不理想,我们就把目光转向模型本身。核心思想是:在模型学习的过程中,提高少数类样本的“话语权”。
3.1 类别权重:给模型一个明确的指示
这是最简单有效的方法之一。在训练时,我们告诉模型:“嘿,少数类样本犯错的代价更高,请多关注它们。” 在 scikit-learn 和 Keras/TensorFlow 中,这很容易实现。
# 方法1: scikit-learn 中直接设置 class_weight='balanced'
from sklearn.linear_model import LogisticRegression
clf = LogisticRegression(class_weight='balanced') # 自动按样本数反比设置权重
clf.fit(X_train, y_train)
# 方法2: 手动计算并指定权重
from sklearn.utils.class_weight import compute_class_weight
import numpy as np
classes = np.unique(y_train)
weights = compute_class_weight(class_weight='balanced', classes=classes, y=y_train)
class_weight_dict = dict(zip(classes, weights))
# 假设输出为 {0: 0.526, 1: 9.5},表示正样本的权重是负样本的约18倍
# 方法3: 在 Keras/TensorFlow 中
import tensorflow as tf
from tensorflow import keras
# 方式A: 在 model.fit 中传入 class_weight 字典
model = keras.Sequential([...])
model.compile(...)
history = model.fit(X_train, y_train,
class_weight=class_weight_dict,
epochs=10)
# 方式B: 在损失函数中直接加权 (更灵活)
def weighted_binary_crossentropy(y_true, y_pred, weight_positive=9.5, weight_negative=0.526):
# 标准二分类交叉熵
bce = keras.losses.binary_crossentropy(y_true, y_pred)
# 根据真实标签应用权重
weight_vector = y_true * weight_positive + (1. - y_true) * weight_negative
weighted_bce = weight_vector * bce
return keras.backend.mean(weight_bce)
model.compile(optimizer='adam',
loss=weighted_binary_crossentropy,
metrics=['accuracy'])
设置 class_weight='balanced' 是最省事的做法,它会自动将权重设置为 n_samples / (n_classes * np.bincount(y))。但有时候自动计算的比例可能不是最优的,你可以把它当作一个超参数来调。比如,在风控场景,我们可能愿意用更高的误报成本(给正样本更高权重)来换取更高的欺诈召回率。
3.2 Focal Loss:聚焦难分样本的“神器”
类别权重解决了类别数量不平衡的问题,但没解决 样本难度不平衡 的问题。想象一下,数据里有一大堆“一眼就能看出”的简单负样本(比如清晰的正常图片),和少量“模棱两可”的难分样本(可能是噪声,也可能是关键边界样本)。模型会倾向于通过快速降低简单样本的损失来刷低整体Loss,而忽略了那些更需要被关注的难分样本。
Focal Loss 就是为解决这个问题而生的。它通过一个可调节的聚焦参数 gamma,动态降低简单样本对总损失的贡献,让模型更专注于学习难分的样本。公式看起来复杂,但思想很直观:对于模型已经预测得很准(概率接近0或1)的样本,给它一个很小的权重;对于预测不准(概率在0.5附近)的样本,给予高权重。
import tensorflow as tf
def focal_loss(gamma=2., alpha=0.25):
"""
Focal Loss 实现。
gamma: 聚焦参数,越大,对易分类样本的抑制越强。
alpha: 类别平衡权重,可以是一个浮点数,也可以是长度为2的列表 [负类权重, 正类权重]。
"""
def focal_loss_fixed(y_true, y_pred):
epsilon = keras.backend.epsilon()
y_pred = keras.backend.clip(y_pred, epsilon, 1. - epsilon)
# 计算交叉熵部分
cross_entropy = -y_true * keras.backend.log(y_pred) - (1 - y_true) * keras.backend.log(1 - y_pred)
# 计算调制因子 (1 - p_t)^gamma
p_t = y_true * y_pred + (1 - y_true) * (1 - y_pred) # 模型对真实类别的预测概率
modulating_factor = keras.backend.pow(1. - p_t, gamma)
# 应用类别权重 alpha
if isinstance(alpha, (list, tuple, np.ndarray)):
alpha_factor = y_true * alpha[1] + (1 - y_true) * alpha[0]
else:
# 如果alpha是标量,通常用于平衡正负类,这里简化处理为正类权重
alpha_factor = y_true * alpha + (1 - y_true) * (1 - alpha)
# 组合得到最终的 Focal Loss
loss = alpha_factor * modulating_factor * cross_entropy
return keras.backend.mean(loss)
return focal_loss_fixed
# 在模型中使用
model.compile(optimizer='adam',
loss=focal_loss(gamma=2.0, alpha=0.25), # gamma和alpha是需要调的超参
metrics=['accuracy'])
参数经验:gamma 通常在 [0.5, 5] 之间调节,常用 2.0。alpha 可以用来进一步平衡类别,如果你已经用了其他采样方法,可以设为 None 或 [0.5, 0.5]。我的经验是,在目标检测、图像分类中,Focal Loss 效果拔群,能显著提升难例和少数类的召回率。
3.3 集成学习与异常检测:换个思路解决问题
当样本不平衡极端严重(比如1:1000以上),或者少数类样本本身就很“异常”时,我们可以换一种思路。
集成方法:如 EasyEnsemble 和 BalanceCascade。它们的核心是把多数类样本分成多个子集,每个子集与少数类样本组合成平衡的子训练集,分别训练多个基分类器,最后集成结果。这既缓解了欠采样的信息丢失,又通过集成降低了方差。
# 使用 imbalanced-learn 中的 EasyEnsemble
from imblearn.ensemble import EasyEnsembleClassifier
from sklearn.tree import DecisionTreeClassifier
base_estimator = DecisionTreeClassifier(max_depth=10)
eec = EasyEnsembleClassifier(n_estimators=10,
base_estimator=base_estimator,
random_state=42)
eec.fit(X_train, y_train)
异常检测思路:如果少数类样本极少,且与多数类差异明显,不妨把问题转化为 单分类 或 异常检测。我们不再区分“A类”和“B类”,而是学习“正常”样本的模式,把偏离这个模式的都判为“异常”。这特别适用于欺诈检测、工业缺陷检测。常用的模型有 One-Class SVM、孤立森林(Isolation Forest) 和基于自编码器(AutoEncoder)的重构误差检测。
# 使用 PyOD 库进行异常检测 (以孤立森林为例)
from pyod.models.iforest import IForest
# 假设我们只使用“正常”样本(多数类)来训练
X_train_normal = X_train[y_train == 0]
clf = IForest(contamination=0.01) # 假设异常比例约为1%
clf.fit(X_train_normal)
# 预测时,所有样本输入,得到是否为异常的标签 (1为异常,0为正常)
y_train_pred = clf.predict(X_train)
4. 评估与调优:避开准确率的陷阱
用了这么多方法,怎么知道哪个真的有效?这里最大的坑就是评估指标。在样本不平衡的场景下,准确率(Accuracy)是毫无意义的! 一个把所有样本都预测为多数的“傻瓜模型”,就能获得极高的准确率。
我们必须使用更能反映模型真实性能的指标:
- 混淆矩阵(Confusion Matrix):这是所有分析的起点。一眼就能看出模型在每一类上犯了什么错误(假阳性FP、假阴性FN)。
- 精确率(Precision) 与 召回率(Recall):这是一对需要权衡的指标。精确率 关注“预测为正的样本里,有多少是真的正”(宁缺毋滥);召回率 关注“真正的正样本里,你找回了多少”(宁可错杀)。在风控中,我们通常追求高召回率(尽量抓住所有欺诈);在推荐系统中,可能更看重高精确率(推荐的内容要尽量精准)。
- F1-Score:精确率和召回率的调和平均数,在两者间取得平衡。
- PR曲线(Precision-Recall Curve)和 AUC-PR:这比ROC曲线更适合不平衡数据! ROC曲线下的面积(AUC-ROC)在正负样本比例悬殊时可能会过于乐观,因为横坐标的假正率(FPR)会被庞大的负样本数稀释。而PR曲线直接描绘了精确率和召回率的关系,其面积(AUC-PR)能更敏感地反映模型对少数类的识别能力。
- Kappa系数:考虑了随机猜测的准确率,比单纯的准确率更可靠。
from sklearn.metrics import classification_report, confusion_matrix, precision_recall_curve, auc, average_precision_score
import matplotlib.pyplot as plt
# 训练模型后...
y_pred = model.predict(X_test)
y_pred_class = (y_pred > 0.5).astype(int) # 以0.5为阈值
# 1. 打印详细的分类报告
print(classification_report(y_test, y_pred_class, target_names=['Class 0', 'Class 1']))
# 2. 绘制混淆矩阵
cm = confusion_matrix(y_test, y_pred_class)
disp = ConfusionMatrixDisplay(confusion_matrix=cm, display_labels=['Class 0', 'Class 1'])
disp.plot(cmap='Blues')
plt.show()
# 3. 计算并绘制PR曲线
precision, recall, _ = precision_recall_curve(y_test, y_pred)
average_precision = average_precision_score(y_test, y_pred)
print(f"Average Precision (AP): {average_precision:.3f}")
plt.figure()
plt.step(recall, precision, where='post')
plt.xlabel('Recall')
plt.ylabel('Precision')
plt.ylim([0.0, 1.05])
plt.xlim([0.0, 1.0])
plt.title(f'Precision-Recall curve: AP={average_precision:0.3f}')
plt.show()
决策阈值调优:默认的0.5阈值不一定是最优的。我们可以根据业务需求,在PR曲线上选择一个合适的点。比如,如果需要高召回率,就选择召回率高的点,尽管精确率可能会下降。这可以通过直接调整预测时的阈值来实现:
# 寻找使 F1-Score 最大的阈值
from sklearn.metrics import f1_score
thresholds = np.arange(0.1, 0.9, 0.05)
f1_scores = [f1_score(y_test, (y_pred > t).astype(int)) for t in thresholds]
best_threshold = thresholds[np.argmax(f1_scores)]
print(f"最佳阈值: {best_threshold}")
y_pred_optimized = (y_pred > best_threshold).astype(int)
5. 实战组合拳:一个完整的Pipeline示例
纸上得来终觉浅,我们把这些策略串起来,看一个完整的代码流程。假设我们面对一个信用卡欺诈检测的二分类任务,正样本(欺诈)极少。
import pandas as pd
import numpy as np
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from imblearn.pipeline import Pipeline # 注意使用 imblearn 的 Pipeline 以兼容采样器
from imblearn.over_sampling import SMOTE
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import classification_report, average_precision_score
import warnings
warnings.filterwarnings('ignore')
# 1. 加载数据 (这里用模拟数据)
# 假设 df 是包含特征和 'Class' 标签的DataFrame,'Class'=1为欺诈
# df = pd.read_csv('creditcard.csv')
print("1. 数据加载与探索...")
print(f" 样本分布: \n{df['Class'].value_counts()}")
print(f" 欺诈比例: {df['Class'].mean():.4%}")
# 2. 划分训练集和测试集 (务必先划分,再在训练集上做采样!)
X = df.drop('Class', axis=1)
y = df['Class']
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, stratify=y, random_state=42) # stratify保持分布
# 3. 构建处理管道
print("\n2. 构建处理管道 (标准化 + SMOTE + 分类器)...")
pipeline = Pipeline([
('scaler', StandardScaler()), # 标准化
('smote', SMOTE(random_state=42)), # 只在训练集上过采样
('classifier', RandomForestClassifier(
n_estimators=100,
class_weight='balanced', # 模型内部再加一层权重保险
random_state=42,
n_jobs=-1))
])
# 4. 训练模型
print("3. 训练模型...")
pipeline.fit(X_train, y_train)
# 5. 在未经过采样的测试集上评估!
print("\n4. 在原始测试集上评估性能...")
y_pred_proba = pipeline.predict_proba(X_test)[:, 1]
y_pred = (y_pred_proba > 0.5).astype(int)
print("分类报告:")
print(classification_report(y_test, y_pred, target_names=['正常', '欺诈']))
ap = average_precision_score(y_test, y_pred_proba)
print(f"\n平均精确率 (Average Precision): {ap:.4f}")
# 6. 阈值调优 (根据业务需求)
print("\n5. 阈值调优示例...")
# 假设业务要求召回率至少达到80%
from sklearn.metrics import precision_recall_curve
precision, recall, thresholds = precision_recall_curve(y_test, y_pred_proba)
# 找到第一个召回率 >= 0.8 的阈值索引
idx = np.where(recall >= 0.8)[0][0]
custom_threshold = thresholds[idx] if idx < len(thresholds) else thresholds[-1]
print(f" 达到召回率80%所需的阈值约为: {custom_threshold:.3f}")
y_pred_custom = (y_pred_proba > custom_threshold).astype(int)
print("\n使用自定义阈值后的分类报告:")
print(classification_report(y_test, y_pred_custom, target_names=['正常', '欺诈']))
这个Pipeline的关键点:
- 先划分,再采样:绝对不能在划分训练测试集之前对整个数据集进行过采样,否则会导致数据泄露,评估结果会虚高。
- 使用
imblearn的 Pipeline:确保采样步骤只在训练集的每个fold中进行,不会污染验证集或测试集。 - 组合策略:这里我们同时使用了数据层面的SMOTE和算法层面的
class_weight='balanced',双管齐下。 - 用对评估集:最终评估一定要在原始的、未经任何采样处理的测试集上进行,这才能反映模型在真实世界(不平衡)数据上的表现。
- 业务导向的阈值调整:模型输出的是概率,最终决策阈值需要结合业务成本(误判一个正常交易为欺诈 vs 漏掉一个欺诈交易)来定。
处理样本不平衡没有银弹。我的经验是,先从简单的类别权重和SMOTE开始,建立一个基线模型。然后观察PR曲线和混淆矩阵,看模型到底“死”在哪里。如果是难分样本问题,尝试 Focal Loss;如果数据量足够,可以试试 集成方法;如果少数类极其稀少且独特,考虑 异常检测 思路。最重要的是,始终用对业务有意义的指标(如捕获率、误报率)来指导你的优化方向,而不是盲目追求某个数学指标的最高分。记住,好模型是调出来的,更是根据业务场景“设计”出来的。
更多推荐
所有评论(0)