深度学习样本不平衡的实战调优策略与代码实现
1. 样本不平衡:不只是数据问题,更是模型偏见的根源
做深度学习项目,尤其是金融风控、医疗诊断这类二分类任务,最头疼的问题之一就是样本不平衡。我遇到过不少项目,正样本(比如欺诈交易、患病病例)只有几百条,而负样本(正常交易、健康人群)动辄几十万条。一开始我天真地以为,只要模型准确率高就行,结果训练出来的模型,准确率轻松飙到99.5%,但一上线就发现,它只会把所有样本都预测为“正常”或“健康”,那些我们真正关心的少数样本,一个都抓不出来。这种模型,准确率再高也是废的。
样本不平衡的本质,是模型在学习过程中,被“多数派”的声音淹没了。想象一下,一个班级里99个学生都说答案是A,只有1个学生说是B。老师如果只听“大多数”的意见,自然会认为答案是A,那个说B的学生,他的声音再正确也会被忽略。模型也一样,当它看到成千上万的负样本和寥寥无几的正样本时,它的“损失函数”会本能地倾向于讨好多数派——因为只要把所有样本都预测为多数类,总体的“错误”看起来就会最小。这直接导致了三个致命问题:模型决策边界会严重偏向多数类,牺牲掉少数类的识别能力;训练时梯度更新被多数类主导,模型难以学到区分少数类的关键特征;并且极易在多数类上过拟合,泛化能力极差。
所以,处理样本不平衡,绝不是简单地让数据量“看起来”均衡,而是要从根源上让模型“听见”少数类的声音,重新平衡它在学习过程中的影响力。接下来,我会结合我踩过的坑和实战经验,从数据、算法、损失函数到评估,给你一套完整的、可落地的调优策略和代码。
2. 数据层面的手术刀:重采样与数据增强
当数据本身不平衡时,最直接的思路就是动手调整数据。这里主要有两大流派:重采样和数据增强。我的经验是,没有绝对的好坏,关键看你的数据特性和计算资源。
2.1 重采样:欠采样与过采样的权衡
重采样就是人为改变训练集的样本分布。imbalanced-learn 这个库是我们的瑞士军刀。
欠采样:减少多数类的数量。优点是训练速度快,能避免模型过度关注多数类的噪声。但风险是可能丢失重要信息。千万别用简单的随机丢弃,那太粗暴了。
from imblearn.under_sampling import NearMiss, TomekLinks
# 使用NearMiss-1:选择那些与少数类样本平均距离最近的多数类样本
nm1 = NearMiss(version=1)
X_resampled, y_resampled = nm1.fit_resample(X_train, y_train)
print(f"欠采样后类别分布: {Counter(y_resampled)}")
# 使用TomekLinks:移除类别边界附近“纠缠不清”的样本对,使边界更清晰
tl = TomekLinks()
X_cleaned, y_cleaned = tl.fit_resample(X_train, y_train)
过采样:增加少数类的数量。最简单的就是随机复制,但这极易导致过拟合,因为模型会反复看到一模一样的样本。所以我们需要更聪明的方法。
from imblearn.over_sampling import SMOTE, ADASYN
# SMOTE:在少数类样本的特征空间内,为每个样本找K个近邻,然后在这些样本连线上随机插值,生成新样本
smote = SMOTE(sampling_strategy=0.5, random_state=42) # 使少数类达到多数类的一半
X_resampled, y_resampled = smote.fit_resample(X_train, y_train)
# ADASYN:基于数据分布自适应地生成样本,对更难学习的少数类区域生成更多样本
adasyn = ADASYN(sampling_strategy=0.5, random_state=42)
X_resampled, y_resampled = adasyn.fit_resample(X_train, y_train)
我的实战建议:对于计算资源有限、多数类噪声较多的场景,可以尝试NearMiss或TomekLinks进行欠采样。如果少数类样本极其珍贵(少于100条),SMOTE或ADASYN过采样是更好的选择,它们能生成“相似但不同”的新样本。更高级的玩法是SMOTEENN,先过采样再用Edited Nearest Neighbours清洗噪声,效果往往更稳健。
2.2 数据增强:创造“新”样本
对于图像和文本数据,重采样可能不够,我们需要“无中生有”地创造高质量的少数类样本。
图像数据:旋转、裁剪、加噪声、色彩抖动这些基础操作就不说了。在目标检测中,对于少数类目标,可以有针对性地进行复制-粘贴增强,将少数类物体随机粘贴到背景图像的不同位置,能显著提升召回率。
文本数据:这是我处理金融风控文本时常用的。除了同义词替换、随机删除插入,更有效的是回译和上下文增强。
# 示例:使用回译进行文本增强 (简化流程)
import googletrans # 需要安装 googletrans==4.0.0-rc1
translator = googletrans.Translator()
def back_translate(text, src_lang='zh-cn', mid_lang='en'):
# 翻译到中间语言再译回
translated = translator.translate(text, src=src_lang, dest=mid_lang).text
back_translated = translator.translate(translated, src=mid_lang, dest=src_lang).text
return back_translated
original_text = "该用户交易行为异常,涉嫌欺诈风险。"
augmented_text = back_translate(original_text)
print(f"增强后: {augmented_text}")
核心要点:数据层面的操作是第一步,目的是为模型提供一个更均衡的“学习环境”。但切记,不要过度改变数据分布,导致训练集和真实线上分布差异过大。通常我会保留一个未经采样的验证集,来监控这种分布偏移。
3. 算法与损失函数:让模型“主动”关注少数类
调整了数据,我们还需要从模型学习机制内部入手,强制它平等看待所有类别。这主要靠损失函数和模型结构的改进。
3.1 类别加权:最简单的代价敏感学习
这是最直接、最常用的方法。在训练时,给少数类的样本损失赋予更高的权重。在Keras和PyTorch里都很容易实现。
# Keras 实现
import tensorflow as tf
from tensorflow import keras
def create_model():
model = keras.Sequential([
keras.layers.Dense(128, activation='relu', input_shape=(input_dim,)),
keras.layers.Dropout(0.3),
keras.layers.Dense(64, activation='relu'),
keras.layers.Dense(1, activation='sigmoid')
])
return model
model = create_model()
# 关键:计算类别权重。常用公式:weight = total_samples / (num_classes * count_per_class)
from sklearn.utils.class_weight import compute_class_weight
import numpy as np
classes = np.unique(y_train)
class_weights = compute_class_weight('balanced', classes=classes, y=y_train)
class_weight_dict = dict(zip(classes, class_weights))
print(f"计算的类别权重: {class_weight_dict}")
model.compile(optimizer='adam',
loss='binary_crossentropy',
metrics=['accuracy', keras.metrics.Recall(name='recall')])
history = model.fit(X_train, y_train,
validation_data=(X_val, y_val),
epochs=50,
batch_size=32,
class_weight=class_weight_dict, # 在这里传入
verbose=1)
# PyTorch 实现
import torch
import torch.nn as nn
from torch.utils.data import DataLoader, TensorDataset
# 计算权重并应用于损失函数
pos_weight = torch.tensor([10.0]) # 假设我们希望正样本权重是负样本的10倍
criterion = nn.BCEWithLogitsLoss(pos_weight=pos_weight) # 这个损失函数内置了正样本权重参数
# 或者在计算损失时手动加权
def weighted_bce_loss(outputs, targets, pos_weight):
loss = - (pos_weight * targets * torch.log(outputs) + (1 - targets) * torch.log(1 - outputs))
return loss.mean()
注意:权重的设置是个超参数。sklearn的balanced模式是一个不错的起点,但最佳权重往往需要通过验证集上的表现来微调。权重过大可能导致模型对少数类过拟合,对噪声敏感。
3.2 Focal Loss:聚焦难分样本的利器
类别加权平等地对待了所有少数类样本,但事实上,样本的“难度”也不同。那些已经很容易区分的少数类样本,不需要模型过分关注;而那些处在边界、很难区分的“硬样本”,才应该是我们关注的重点。Focal Loss就是为此而生。
# TensorFlow/Keras 实现 Focal Loss
def focal_loss(gamma=2.0, alpha=0.25):
def focal_loss_fixed(y_true, y_pred):
epsilon = tf.keras.backend.epsilon()
y_pred = tf.clip_by_value(y_pred, epsilon, 1. - epsilon)
# 计算交叉熵的基础部分
cross_entropy = -y_true * tf.math.log(y_pred)
# 计算调制因子 (1 - pt)^gamma
p_t = y_true * y_pred + (1 - y_true) * (1 - y_pred)
modulating_factor = tf.pow(1.0 - p_t, gamma)
# 应用类别权重 alpha
alpha_weight = y_true * alpha + (1 - y_true) * (1 - alpha)
# 组合得到 Focal Loss
loss = modulating_factor * alpha_weight * cross_entropy
return tf.reduce_mean(loss)
return focal_loss_fixed
model.compile(optimizer='adam',
loss=focal_loss(gamma=2.0, alpha=0.25), # gamma调节难易样本权重,alpha平衡类别
metrics=['accuracy'])
参数解读:gamma是调节因子,越大,模型越关注难样本(通常2.0效果不错)。alpha是类别平衡因子,可以替代class_weight。Focal Loss在目标检测(如RetinaNet)和极端不平衡的分类任务中效果显著。我曾在一个人脸活体检测项目(真脸样本远多于攻击样本)中使用,将少数类的召回率提升了近15%。
3.3 集成学习与异常检测思路
当样本不平衡非常极端时,单一模型可能力不从心,可以考虑集成方法。
EasyEnsemble 或 BalanceCascade:这类方法的核心是,通过多次对多数类下采样,生成多个平衡的子训练集,分别训练基分类器,然后集成结果。这既减轻了欠采样丢失信息的问题,又通过集成降低了方差。
# 使用 imbalanced-learn 的 EasyEnsemble
from imblearn.ensemble import EasyEnsembleClassifier
from sklearn.tree import DecisionTreeClassifier
eec = EasyEnsembleClassifier(base_estimator=DecisionTreeClassifier(),
n_estimators=10,
random_state=42)
eec.fit(X_train, y_train)
转为异常检测:如果正样本(少数类)比例低于1%,甚至可以考虑将其视为“异常”。使用One-Class SVM或孤立森林专门学习负样本(多数类)的分布,将偏离该分布的样本判为正例。这种方法在欺诈检测中非常有效,因为欺诈行为本身就是正常模式下的异常点。
4. 评估与调优:抛弃准确率,拥抱更科学的指标
在样本不平衡的场景下,准确率是最大的“骗子”。我们必须使用更能反映模型真实能力的评估体系。
4.1 构建全面的评估矩阵
混淆矩阵是你的作战地图,一定要可视化出来。
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.metrics import confusion_matrix, classification_report, roc_auc_score, average_precision_score
def evaluate_model(model, X_test, y_test):
y_pred = model.predict(X_test)
y_pred_class = (y_pred > 0.5).astype(int) # 默认阈值0.5
# 1. 混淆矩阵
cm = confusion_matrix(y_test, y_pred_class)
plt.figure(figsize=(8,6))
sns.heatmap(cm, annot=True, fmt='d', cmap='Blues')
plt.ylabel('真实标签')
plt.xlabel('预测标签')
plt.title('混淆矩阵')
plt.show()
# 2. 关键分类报告
print(classification_report(y_test, y_pred_class, target_names=['负类', '正类']))
# 3. AUC 和 AUPRC (更关键!)
roc_auc = roc_auc_score(y_test, y_pred)
auprc = average_precision_score(y_test, y_pred)
print(f"ROC-AUC: {roc_auc:.4f}")
print(f"PR-AUC (AUPRC): {auprc:.4f}")
return roc_auc, auprc
为什么AUPRC比ROC-AUC更重要? 在极端不平衡时,由于负样本太多,ROC曲线下的面积(AUC)可能会虚高。而精确率-召回率曲线下的面积更关注正样本(少数类)的表现,对不平衡更敏感,是更可靠的指标。
4.2 阈值调优:寻找最佳决策点
默认的0.5阈值通常不是最优的。我们可以通过PR曲线或最大化F1分数来寻找最佳阈值。
from sklearn.metrics import precision_recall_curve, f1_score
def find_optimal_threshold(model, X_val, y_val):
y_val_pred_prob = model.predict(X_val).ravel()
precisions, recalls, thresholds = precision_recall_curve(y_val, y_val_pred_prob)
# 方法1:寻找使F1分数最大的阈值
f1_scores = 2 * (precisions[:-1] * recalls[:-1]) / (precisions[:-1] + recalls[:-1] + 1e-8)
optimal_idx = np.argmax(f1_scores)
optimal_threshold = thresholds[optimal_idx]
print(f"基于F1的最优阈值: {optimal_threshold:.4f}, F1分数: {f1_scores[optimal_idx]:.4f}")
# 方法2:根据业务需求设定最低召回率,寻找满足条件的最小阈值
min_recall = 0.9 # 例如,我们要求必须召回90%的正样本
idx = np.where(recalls[:-1] >= min_recall)[0]
if len(idx) > 0:
# 在满足召回率的阈值中,选精确率最高的
selected_idx = idx[np.argmax(precisions[idx])]
business_threshold = thresholds[selected_idx]
print(f"满足召回率{min_recall}的业务阈值: {business_threshold:.4f}, 对应精确率: {precisions[selected_idx]:.4f}")
optimal_threshold = business_threshold # 以业务需求为准
return optimal_threshold
# 使用最优阈值重新预测
best_threshold = find_optimal_threshold(model, X_val, y_val)
y_test_pred_optimized = (model.predict(X_test).ravel() > best_threshold).astype(int)
5. 实战流程与避坑指南
结合以上所有策略,我总结了一个可复用的四步实战流程,并附上几个我亲身踩过的大坑。
5.1 端到端调优流程
- 基准建立:首先,用原始不平衡数据训练一个基准模型。使用AUPRC和召回率作为核心指标。这个模型通常很烂,但它是你的起点。
- 数据层实验:
- 尝试不同的重采样方法(SMOTE, ADASYN, NearMiss),在验证集上比较AUPRC。
- 对于文本/图像,尝试数据增强。记录下每种方法带来的验证集性能变化。
- 算法层实验:
- 在最佳数据采样方案上,加入类别权重(
class_weight='balanced')。 - 尝试换用Focal Loss,调节
gamma和alpha参数。可以做个简单的网格搜索。 - 如果效果仍不理想,考虑EasyEnsemble等集成方法。
- 在最佳数据采样方案上,加入类别权重(
- 评估与部署:
- 在独立的测试集上,用最优模型和最优阈值进行最终评估。
- 部署时,务必记录模型在新数据上的正负样本比例。如果比例发生显著漂移,需要重新评估模型甚至重新训练。
5.2 常见大坑与解决方案
坑1:过采样导致严重的过拟合。现象:训练集指标奇高,验证集/测试集一塌糊涂。解法:过采样(尤其是随机过采样)后,一定要配合更强的正则化(如Dropout, L2正则),或者使用SMOTE+清洗(如SMOTEENN)的方法。更根本的,考虑用交叉验证来评估过采样的效果,确保其泛化能力。
坑2:调整阈值后线上效果不符预期。这是因为验证集/测试集的分布可能和线上实时数据仍有差异。解法:除了在静态测试集上调阈值,如果条件允许,最好能做一个小流量的A/B测试,用线上反馈来校准阈值。同时,建立数据监控,持续跟踪预测结果的分布变化。
坑3:盲目追求高召回率,导致误报太多。在风控或医疗场景,过高的误报会带来巨大成本。解法:这本质是一个业务权衡。你需要和业务方确定一个可接受的误报率上限,然后在精确率-召回率曲线上,选择一个在满足最低召回率的同时,尽可能控制误报的阈值。没有完美的模型,只有最适合当前业务成本的折中点。
处理样本不平衡,是一个系统工程,需要数据、算法、评估、业务四方面的紧密结合。我习惯在项目开始就分析样本比例,并将其作为核心风险点来制定应对策略。记住,我们的目标不是让数字好看,而是让模型在现实世界中,尤其是在识别那些关键且稀少的“信号”时,真正可靠、可用。多实验,多分析,根据你的具体数据和任务特点,灵活组合这些策略,你一定能训练出更公平、更强大的模型。
更多推荐


所有评论(0)