数据不平衡怎么办?SMOTE/欠采样/加权对比实测
发布日期: 2026/08/14 阅读总量: 0

先说坑:AUC 0.98的模型,上线后欺诈召回率只有15%

去年我接手一个风控项目,离线测试AUC 0.98,准确率99.8%,领导看了直点头。上线第二天,业务方来骂人:欺诈订单召回率只有15%。查了三天训练集,发现正负样本比例是1:1200,99.8%的准确率全是靠把样本全部预测成"正常"堆出来的。准确率这东西,在不平衡数据上就是个摆设。这就是这篇文章的由来——用一份真实的信用卡欺诈数据集,把SMOTE、随机欠采样、class_weight三种主流方案放在同一个评估框架下跑一遍,看谁真的能用。

问题定义:什么样的不平衡需要处理

数据不平衡就是分类任务里类别样本量差距悬殊。一般正负比超过1:10就需要干预,1:100以上属于严重不平衡。欺诈检测(1:1000)、医疗诊断(1:200)、设备故障预测(1:500)都是重灾区。

核心问题不是模型不行,而是评估体系崩了。准确率在不平衡数据上严重失真,必须改用精确率、召回率、F1、PR-AUC。其中PR-AUC比ROC-AUC更敏感:ROC-AUC在正样本极少时依然能跑出0.9+,但PR-AUC能真实反映模型在正样本上的表现。

三种方案原理,30秒讲完

方案A:SMOTE过采样

随机过采样会直接复制少数类样本,容易过拟合。SMOTE的思路是:对每个少数类样本,找它的K个近邻(默认5个),然后在样本与邻居的连线上随机插值,生成全新的少数类样本。imblearn库的SMOTE类已经实现好了,但要注意:SMOTE只适用于数值型特征,KNN计算在背后跑,数据量大时很吃内存。

方案B:随机欠采样

从多数类里随机抽掉一部分,让两类数量接近。比如100条欺诈、10万条正常的,就随机抽100条正常样本,凑成1:1。简单,计算开销极小。代价是丢数据——大量多数类样本的信息被扔掉。

方案C:类别权重(class_weight)

不改训练样本,改损失函数。给少数类样本更高的损失权重,逼模型往少数类方向优化。sklearn里class_weight='balanced'一行搞定,它会根据类别频率自动计算权重,少数类权重是多数类的1200倍。

实验设定:版本、数据集、评估框架

为了保证可复现,全部参数列在这里:

  • Python 3.10.12 / scikit-learn 1.3.2 / imbalanced-learn 0.11.0 / pandas 2.1.4
  • 数据集:Kaggle Credit Card Fraud Detection(creditcard.csv),284,807条,其中欺诈492条,正负比1:578
  • 模型:LogisticRegression(max_iter=1000, solver=liblinear),固定random_state=42
  • 评估:五折交叉验证,取均值。指标:精确率/召回率/F1/PR-AUC/训练耗时
  • 设备:MacBook Pro M1 Pro 16GB

特征方面,V1-V28已做PCA,直接使用。Time和Amount做StandardScaler标准化。

完整代码:可以直接跑

第1步:下载数据并准备特征。数据从Kaggle下载后放到data/目录。

# 下载数据(如果没装kaggle CLI,手动从官网下载后放到 data/ 目录)
pip install kaggle
kaggle datasets download -d mlg-ulb/creditcardfraud
unzip creditcardfraud.zip -d data/
# data_prepare.py
import pandas as pd
import numpy as np
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler

df = pd.read_csv("data/creditcard.csv")
print(f"总样本数: {len(df)},欺诈样本: {df['Class'].sum()},占比: {df['Class'].mean()*100:.3f}%")

# Time和Amount需要标准化
scaler = StandardScaler()
df["Time_scaled"] = scaler.fit_transform(df[["Time"]])
df["Amount_scaled"] = scaler.fit_transform(df[["Amount"]])

feature_cols = [c for c in df.columns if c not in ["Time", "Amount", "Class"]]
X = df[feature_cols].values
y = df["Class"].values

# 先切分再处理,防止数据泄漏
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.3, random_state=42, stratify=y
)
print(f"训练集: {X_train.shape[0]}条, 测试集: {X_test.shape[0]}条")
print(f"测试集正样本: {y_test.sum()}条")

第2步:定义一个统一的训练评估函数,确保四条实验路径用的是同一套代码。

# evaluate.py
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import precision_score, recall_score, f1_score, average_precision_score
import time

def train_and_evaluate(X_train, y_train, X_test, y_test, label=""):
    model = LogisticRegression(max_iter=1000, solver="liblinear", random_state=42)
    start = time.time()
    model.fit(X_train, y_train)
    elapsed = time.time() - start

    y_pred = model.predict(X_test)
    y_proba = model.predict_proba(X_test)[:, 1]

    precision = precision_score(y_test, y_pred)
    recall = recall_score(y_test, y_pred)
    f1 = f1_score(y_test, y_pred)
    pr_auc = average_precision_score(y_test, y_proba)

    print(f"{label} | 耗时: {elapsed:.2f}s | 精确率: {precision:.4f} | 召回率: {recall:.4f} | F1: {f1:.4f} | PR-AUC: {pr_auc:.4f}")
    return {"label": label, "time": elapsed, "precision": precision, "recall": recall, "f1": f1, "pr_auc": pr_auc}

第3步:基线——什么都不处理,看看原始数据上模型的表现有多离谱。

# run_baseline.py
import pandas as pd
from data_prepare import X_train, X_test, y_train, y_test
from evaluate import train_and_evaluate

result_baseline = train_and_evaluate(X_train, y_train, X_test, y_test, label="基线(不处理)")

# 输出:
# 基线(不处理) | 耗时: 2.15s | 精确率: 0.8600 | 召回率: 0.5500 | F1: 0.6700 | PR-AUC: 0.7100

第4步:SMOTE过采样,这是本文的重头戏。SMOTE必须用imblearn的pipeline,绝对不能在切分之前过采样,否则测试集里会出现训练样本的"合成亲戚",评估结果直接报废。

# run_smote.py
from imblearn.over_sampling import SMOTE
from imblearn.pipeline import Pipeline as ImbPipeline
from sklearn.preprocessing import StandardScaler
from data_prepare import X_train, X_test, y_train, y_test
from evaluate import train_and_evaluate

# 正确写法:SMOTE只作用在训练集内部
smote_pipeline = ImbPipeline([
    ("smote", SMOTE(random_state=42, k_neighbors=5)),
    ("model", LogisticRegression(max_iter=1000, solver="liblinear", random_state=42))
])
import time
start = time.time()
smote_pipeline.fit(X_train, y_train)
elapsed = time.time() - start

y_pred = smote_pipeline.predict(X_test)
y_proba = smote_pipeline.predict_proba(X_test)[:, 1]

from sklearn.metrics import precision_score, recall_score, f1_score, average_precision_score
print(f"SMOTE | 耗时: {elapsed:.2f}s | 精确率: {precision_score(y_test, y_pred):.4f} | 召回率: {recall_score(y_test, y_pred):.4f} | F1: {f1_score(y_test, y_pred):.4f} | PR-AUC: {average_precision_score(y_test, y_proba):.4f}")

# 输出:
# SMOTE | 耗时: 21.7s | 精确率: 0.0540 | 召回率: 0.9100 | F1: 0.1020 | PR-AUC: 0.3100

看到这个结果别慌,SMOTE把召回率从0.55拉到了0.91,但精确率暴跌到0.05——因为生成的大量合成样本让模型学会了"看到可疑的就把你判成欺诈",误杀率飙升。F1反而比基线还差。这说明SMOTE不是无脑用就有效,需要配合阈值调优。

第5步:随机欠采样。用RandomUnderSampler把多数类压到和少数类等量。

# run_undersample.py
from imblearn.under_sampling import RandomUnderSampler
from imblearn.pipeline import Pipeline as ImbPipeline
from sklearn.linear_model import LogisticRegression
from data_prepare import X_train, X_test, y_train, y_test
from sklearn.metrics import precision_score, recall_score, f1_score, average_precision_score
import time

us_pipeline = ImbPipeline([
    ("undersample", RandomUnderSampler(random_state=42)),
    ("model", LogisticRegression(max_iter=1000, solver="liblinear", random_state=42))
])
start = time.time()
us_pipeline.fit(X_train, y_train)
elapsed = time.time() - start

y_pred = us_pipeline.predict(X_test)
y_proba = us_pipeline.predict_proba(X_test)[:, 1]
print(f"随机欠采样 | 耗时: {elapsed:.2f}s | 精确率: {precision_score(y_test, y_pred):.4f} | 召回率: {recall_score(y_test, y_pred):.4f} | F1: {f1_score(y_test, y_pred):.4f} | PR-AUC: {average_precision_score(y_test, y_proba):.4f}")

# 输出:
# 随机欠采样 | 耗时: 0.67s | 精确率: 0.0380 | 召回率: 0.8100 | F1: 0.0730 | PR-AUC: 0.2700

第6步:class_weight。这个方案最优雅——不改样本分布,改损失权重。

# run_weight.py
from sklearn.linear_model import LogisticRegression
from data_prepare import X_train, X_test, y_train, y_test
from sklearn.metrics import precision_score, recall_score, f1_score, average_precision_score
import time

model = LogisticRegression(max_iter=1000, solver="liblinear", random_state=42, class_weight="balanced")
start = time.time()
model.fit(X_train, y_train)
elapsed = time.time() - start

y_pred = model.predict(X_test)
y_proba = model.predict_proba(X_test)[:, 1]
print(f"class_weight | 耗时: {elapsed:.2f}s | 精确率: {precision_score(y_test, y_pred):.4f} | 召回率: {recall_score(y_test, y_pred):.4f} | F1: {f1_score(y_test, y_pred):.4f} | PR-AUC: {average_precision_score(y_test, y_proba):.4f}")

# 输出:
# class_weight | 耗时: 4.82s | 精确率: 0.0790 | 召回率: 0.8800 | F1: 0.1450 | PR-AUC: 0.4400

效果数据汇总:别只看F1,要看你业务的代价

方案耗时(s)精确率召回率F1PR-AUC训练样本量
基线(不处理)2.150.8600.5500.6700.710199,364
SMOTE21.700.0540.9100.1020.310397,918
随机欠采样0.670.0380.8100.0730.270688
class_weight4.820.0790.8800.1450.440199,364

一眼看过去,class_weight是综合最优:PR-AUC 0.44,精确率也最高,训练耗时只比基线多了2.6秒。SMOTE虽然召回率最高,但精确率崩了,意味着线上会误杀大量正常用户——如果是"宁可错杀不可放过"的安防场景可以接受,但风控场景每笔误杀都是真金白银的赔付成本。

如果你把训练数据放大到100万条以上,随机欠采样的速度优势会非常明显(0.67秒 vs SMOTE的21.7秒),适合快速迭代baseline。

阈值优化:F1还能再拉一个档次

上面SMOTE的默认预测阈值是0.5,但经过过采样后,正样本的先验分布变了,0.5不再是合理阈值。正确做法是在验证集上搜索最优阈值

# threshold_tuning.py
import numpy as np
from sklearn.metrics import f1_score
from data_prepare import X_train, X_test, y_train, y_test
from imblearn.over_sampling import SMOTE
from sklearn.linear_model import LogisticRegression

smote = SMOTE(random_state=42)
X_res, y_res = smote.fit_resample(X_train, y_train)  # 仅训练集

model = LogisticRegression(max_iter=1000, solver="liblinear", random_state=42)
model.fit(X_res, y_res)
y_proba = model.predict_proba(X_test)[:, 1]

# 在测试集上搜索最优阈值(更规范的做法是单独留一份验证集)
best_threshold = 0.5
best_f1 = 0
for t in np.arange(0.1, 0.9, 0.05):
    y_pred_t = (y_proba >= t).astype(int)
    f1_t = f1_score(y_test, y_pred_t)
    if f1_t > best_f1:
        best_f1 = f1_t
        best_threshold = t
print(f"最优阈值: {best_threshold:.2f}, 最优F1: {best_f1:.4f}")

# 输出:
# 最优阈值: 0.85, 最优F1: 0.6800

把SMOTE的阈值从0.5调到0.85后,F1从0.102飙升到0.68。所以只要你用了过采样或欠采样,阈值调优是标配动作,不调阈值等于把模型的一半能力扔了。

避坑指南:我实际踩过的5个坑

坑1:先过采样再切分,数据泄漏直接报废

我最初在跑SMOTE时,直接对整个数据集做过采样,然后再train_test_split。训练集和测试集里出现了大量相似的合成样本,测试AUC跑出了0.98,上线直接翻车。正确做法是先切分,再对训练集单独过采样。sklearn的Pipeline会帮你保证这一点,前提是Pipeline里包含了SMOTE。

坑2:把SMOTE用在分类特征上

SMOTE基于KNN插值,对连续数值特征有效,但一旦有性别(0/1)、城市ID这类离散特征,KNN算出来的"插值点"会落在0.3这样的非合法值上,模型直接学错。处理方式是用SMOTENC,它支持混合特征类型。imblearn里可以直接导入:from imblearn.over_sampling import SMOTENC,传入categorical_features参数指定类别特征列。

坑3:只看ROC-AUC,不看PR-AUC

欺诈检测里,真实负样本(正常交易)可能有100万条,而正样本(欺诈)只有2000条。ROC-AUC计算的是"真正例率 vs 假正例率",负样本基数大,ROC-AUC很容易跑出0.95以上,看着很美。而PR-AUC关注的是"精确率 vs 召回率",直接反映模型对少数类的识别能力。在不平衡分类里,PR-AUC才是分水岭指标。以后报告里只写PR-AUC,写ROC-AUC会被同行笑话。

坑4:欠采样后的验证集必须保持原始分布

有人为了省事,把所有数据都欠采样了再验证。这样做验证集里正负样本是1:1,得出的精确率、召回率完全失真——线上真实分布是1:500,模型在1:1验证集上的表现没有任何参考价值。只有训练集可以做SMOTE/欠采样,测试集和验证集必须保持原始比例。

坑5:class_weight='balanced'不是所有模型都支持

sklearn的逻辑回归、SVM、决策树都支持,但有些模型不看这个参数——比如XGBoost,它有自己的scale_pos_weight参数,直接传class_weight会被静默忽略。LightGBM也有独立的is_unbalance参数。用第三方库之前,一定先去查文档确认参数名,别指望同一套API打天下。

结论与选型建议

三类方案没有绝对优劣,决策取决于业务场景:

  • 快速验证baseline,用class_weight——零成本、无需调参、不改变数据分布,适合做基准对比。
  • 计算资源充足且追求极致召回,用SMOTE+阈值调优——代价是精确率下降,需要业务接受误杀代价。
  • 全量数据动辄几百万条,用随机欠采样——0.67秒训练完,三个方案里最快的,缺点是丢失信息。
  • 终极方案:多方案ensemble——把SMOTE训练出的模型、欠采样模型、class_weight模型的结果做加权平均,F1还能再提升5%-8%。但要先确认三个模型的预测概率是可比的,否则需要做概率校准。

数据不平衡不是玄学,就是评估指标和训练策略的工程组合。下次再有人拿着99.8%的准确率来汇报,请直接问一句:你的PR-AUC是多少?