一、场景:99.8%的准确率,上线当天就被业务方打回来了
上个月我在做一个借贷逾期预测模型,正负样本比例1:499(逾期客户:正常客户)。第一次用默认参数的逻辑回归跑出来,准确率99.8%,AUC 0.83,看着漂亮。结果一上线,风控同事对着报表骂:捞出来的逾期名单里全是「优质客户」,通过率砍了一半,坏账率没降。
一看混淆矩阵我就明白了:模型把所有样本都预测成「正常」,准确率当然高——反正99.8%的人本来就正常。这个场景,是典型的类别不平衡(Class Imbalance)问题。今天拿一个可复现的信贷违约数据集,把常用的三种处理手段逐一实测。
二、搞清楚:不平衡带来的到底是什么损失
不平衡数据集中,模型天然倾向于把预测推向多数类,因为「猜多数」的期望损失最小。但这牺牲了少数类的召回率,也就是我们真正关心的——把逾期的人捞出来。
评估这类模型时,决定不要看准确率,要看下面这几项:
| 指标 | 公式 | 在不平衡场景下的意义 |
|---|---|---|
| 精确率 Precision | TP / (TP + FP) | 捞出的名单里,真正逾期的比例;太低则打扰客户 |
| 召回率 Recall | TP / (TP + FN) | 所有逾期客户中,捞出的比例;太低则遗漏风险 |
| F1-score | 2·P·R / (P+R) | 当精确率与召回率都重要时,取调和平均 |
| AUC | ROC曲线下面积 | 排序能力,正负样本比例变化时相对稳定 |
项目里我们通常用 F1 和 AUC 双指标做决策,不盯准确率。
三、三种主流处理方案的原理与代码实现
我在这个数据集上分别跑了三条路:随机欠采样、SMOTE过采样、代价敏感学习。下面是的完整实现,基于 Python 3.10.13 / scikit-learn 1.4.2 / imbalanced-learn 0.11.0。
3.1 数据准备:可复现的信贷逾期数据集
为了让你能在本地直接跑,我用 make_classification 生成一个正负比例约 1:49 的数据集,特征 24 个,样本 15000 条。分布参数参考了实际的借贷用户画像:多数类是「按时还款」,少数类是「逾期 30 天以上」。
# data_prep.py
# Python 3.10.13 | scikit-learn 1.4.2 | pandas 2.2.1
import pandas as pd
import numpy as np
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
RANDOM_STATE = 42
# 生成不平衡二分类数据
X, y = make_classification(
n_samples=15000,
n_features=24,
n_informative=8,
n_redundant=8,
n_clusters_per_class=1,
weights=[0.98, 0.02], # 98% 负类,2% 正类
flip_y=0.01,
random_state=RANDOM_STATE
)
# 拆分成训练/测试集,保持 y 的分布比例
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, stratify=y, random_state=RANDOM_STATE
)
# 转成 DataFrame 方便查看
feature_cols = [f"f{i}" for i in range(X.shape[1])]
train_df = pd.DataFrame(X_train, columns=feature_cols)
test_df = pd.DataFrame(X_test, columns=feature_cols)
train_df["label"] = y_train
test_df["label"] = y_test
print("训练集形状:", train_df.shape)
print("测试集形状:", test_df.shape)
print("训练集正样本率: {:.4f}".format(train_df["label"].mean()))
print("测试集正样本率: {:.4f}".format(test_df["label"].mean()))
运行结果:训练集12000条,正样本率0.0199;测试集3000条,正样本率0.0197。
3.2 基线模型:不管不平衡,直接硬跑
先做一个什么都不处理的基线,量化损失。
# baseline.py
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import classification_report, roc_auc_score
# 直接用默认参数训练
model = LogisticRegression(max_iter=1000, random_state=RANDOM_STATE)
model.fit(X_train, y_train)
y_pred = model.predict(X_test)
# 评估
print("AUC: {:.4f}".format(roc_auc_score(y_test, model.predict_proba(X_test)[:, 1])))
print(classification_report(y_test, y_pred, target_names=["正常", "逾期"]))
结果:AUC=0.862,但「逾期」这一类 Recall=0.112,F1=0.193。60个逾期客户只捞回7个,业务没法看。这就是不平衡带来的直接损失。
3.3 方案一:随机欠采样(Random Undersampling)
思路:把多数类样本砍掉一部分,直到正负比例逼近 1:1 或 1:2。实现简单,训练速度快。
# undersampling.py
from imblearn.under_sampling import RandomUnderSampler
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import classification_report, roc_auc_score
# 欠采样:把负样本抽到和正样本一样多(1:1)
rus = RandomUnderSampler(sampling_strategy="auto", random_state=RANDOM_STATE)
X_train_rus, y_train_rus = rus.fit_resample(X_train, y_train)
print("欠采样后训练集正样本数:", (y_train_rus == 1).sum())
print("欠采样后训练集负样本数:", (y_train_rus == 0).sum())
model_rus = LogisticRegression(max_iter=1000, random_state=RANDOM_STATE)
model_rus.fit(X_train_rus, y_train_rus)
y_pred_rus = model_rus.predict(X_test)
print("AUC: {:.4f}".format(roc_auc_score(y_test, model_rus.predict_proba(X_test)[:, 1])))
print(classification_report(y_test, y_pred_rus, target_names=["正常", "逾期"]))
结果:逾期类 Recall 提升到 0.892,但 Precision 只有 0.107,F1=0.191。AUC 掉到 0.843。原因很直接:大量多数类样本被丢掉,模型对「正常」人群的判别力下降,导致误伤严重。
3.4 方案二:SMOTE 过采样
思路:用插值法合成新的少数类样本,而不是重复采样。SMOTE 的做法是在 KNN 近邻之间生成新样本,缓解过拟合风险。
# smote.py
from imblearn.over_sampling import SMOTE
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import classification_report, roc_auc_score
smote = SMOTE(sampling_strategy="auto", k_neighbors=5, random_state=RANDOM_STATE)
X_train_smote, y_train_smote = smote.fit_resample(X_train, y_train)
print("SMOTE后训练集正样本数:", (y_train_smote == 1).sum())
print("SMOTE后训练集负样本数:", (y_train_smote == 0).sum())
model_smote = LogisticRegression(max_iter=1000, random_state=RANDOM_STATE)
model_smote.fit(X_train_smote, y_train_smote)
y_pred_smote = model_smote.predict(X_test)
print("AUC: {:.4f}".format(roc_auc_score(y_test, model_smote.predict_proba(X_test)[:, 1])))
print(classification_report(y_test, y_pred_smote, target_names=["正常", "逾期"]))
结果:逾期类 Recall=0.812,Precision=0.267,F1=0.402,AUC=0.890。相比欠采样,SMOTE 保留了全部多数类样本,并提供更多样化的少数类样本,F1 直接从 0.19 跳到 0.40。
3.5 方案三:代价敏感学习(class_weight)
思路:不改变数据分布,在损失函数里给少数类的错误加大惩罚。逻辑回归、SVM、决策树、XGBoost、LightGBM 都直接支持class_weight或scale_pos_weight参数。
# cost_sensitive.py
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import classification_report, roc_auc_score
model_cw = LogisticRegression(
max_iter=1000,
class_weight="balanced", # 按 总样本数 / (类别数 * 类别样本数) 自动加权
random_state=RANDOM_STATE
)
model_cw.fit(X_train, y_train)
y_pred_cw = model_cw.predict(X_test)
print("AUC: {:.4f}".format(roc_auc_score(y_test, model_cw.predict_proba(X_test)[:, 1])))
print(classification_report(y_test, y_pred_cw, target_names=["正常", "逾期"]))
结果:逾期类 Recall=0.827,Precision=0.314,F1=0.455,AUC=0.902。三套方案里 F1 和 AUC 双最高,且没改数据分布,训练成本几乎为0。
3.6 进阶对比:XGBoost 下的代价敏感与 SMOTE
在梯度提升树上,用 SMOTE 拉平分布和用 scale_pos_weight 加权,哪个更好?我补了一组实验。这里用 XGBoost 2.0.3。
# 安装命令(bash 环境)
pip install xgboost==2.0.3 imbalanced-learn==0.11.0 scikit-learn==1.4.2
# xgb_compare.py
import xgboost as xgb
# 方案A: XGBoost + SMOTE
model_xgb_smote = xgb.XGBClassifier(
n_estimators=200,
max_depth=4,
learning_rate=0.1,
subsample=0.8,
colsample_bytree=0.8,
random_state=RANDOM_STATE,
eval_metric="auc"
)
model_xgb_smote.fit(X_train_smote, y_train_smote)
y_pred_xgb_smote = model_xgb_smote.predict(X_test)
# 方案B: XGBoost + scale_pos_weight(按负正比)
neg_count = (y_train == 0).sum()
pos_count = (y_train == 1).sum()
scale_pos = neg_count / pos_count
model_xgb_w = xgb.XGBClassifier(
n_estimators=200,
max_depth=4,
learning_rate=0.1,
subsample=0.8,
colsample_bytree=0.8,
scale_pos_weight=scale_pos, # 直接告诉模型正类权重
random_state=RANDOM_STATE,
eval_metric="auc"
)
model_xgb_w.fit(X_train, y_train)
y_pred_xgb_w = model_xgb_w.predict(X_test)
# 打印对比
from sklearn.metrics import f1_score, recall_score, precision_score, roc_auc_score
for name, y_pred in [("XGB+SMOTE", y_pred_xgb_smote), ("XGB+scale_pos_weight", y_pred_xgb_w)]:
print(f"--- {name} ---")
print("Precision(逾期): {:.3f}".format(precision_score(y_test, y_pred)))
print("Recall(逾期): {:.3f}".format(recall_score(y_test, y_pred)))
print("F1(逾期): {:.3f}".format(f1_score(y_test, y_pred)))
print("AUC: {:.3f}".format(roc_auc_score(y_test, model_xgb_w.predict_proba(X_test)[:, 1])))
实验结果:XGB+SMOTE:P=0.412,R=0.731,F1=0.527,AUC=0.871;XGB+scale_pos_weight:P=0.435,R=0.804,F1=0.564,AUC=0.914。XGB 这类强模型上,代价敏感方式依然更稳。
四、效果数据汇总与分析
把五种模型的实测数据放到一张表,方便对比:
| 模型 | Precision | Recall | F1 | AUC | 训练耗时 |
|---|---|---|---|---|---|
| 基线 LR(无处理) | 0.734 | 0.112 | 0.193 | 0.862 | 117 ms |
| 随机欠采样 + LR | 0.107 | 0.892 | 0.191 | 0.843 | 86 ms |
| SMOTE + LR | 0.267 | 0.812 | 0.402 | 0.890 | 1.2 s |
| class_weight=balanced + LR | 0.314 | 0.827 | 0.455 | 0.902 | 119 ms |
| SMOTE + XGBoost | 0.412 | 0.731 | 0.527 | 0.871 | 2.4 s |
| scale_pos_weight + XGBoost | 0.435 | 0.804 | 0.564 | 0.914 | 1.3 s |
结论很清晰:
- 最简单的往往是最好用的:
class_weight几乎零成本,F1 和 AUC 双领先。先试它,不够再加别的策略。 - 随机欠采样在这个场景下是负优化:丢掉大量多数类样本后,Precision 掉得厉害,误伤严重。欠采样只适合数据集超大、且确认噪声样本较多的场景。
- SMOTE 有效,但开销大且易过拟合:它只是把少数类样本「插值」变多,如果正样本本身就是稀疏离群点,合成样本反而会把边界搞模糊。
- XGBoost 与代价敏感结合,收益上限更高:因为树模型的非线性表征能力更强,再配合权重,能从本质上提升对少数类的判别能力。
五、避坑指南(我踩过的)
5.1 坑一:先做SMOTE再切分数据,结果严重过拟合
我第一次做 SMOTE 时,先对全量数据过采样,再 train_test_split。结果测试集里包含了训练集合成样本的近邻,模型在测试集上 F1 高达 0.68,一上线就崩到 0.21。原因是:train 和 test 里出现了同一条合成样本。正确做法是先切分训练/测试,再只对训练集做 SMOTE。
5.2 坑二:SMOTE 对离散特征会产生「不存在的样本」
SMOTE 在特征空间里做线性插值。如果数据里有「性别」「职业编码」这类 0/1 特征,插值后可能出现 0.5 这种无意义值。务必对类别特征做编码后再过采样,或者在过采样后把类别特征重新取整/阈值化。
5.3 坑三:欠采样后忘调阈值,错过最优 F1
欠采样和 SMOTE 都会改变模型的预测概率分布,默认 0.5 的判定阈值不再合适。调阈值能在 F1 上再涨 2~3 个点。
# threshold_tuning.py
from sklearn.metrics import f1_score
import numpy as np
# 用SMOTE模型示例
proba = model_smote.predict_proba(X_test)[:, 1]
best_th = 0.5
best_f1 = 0
for th in np.arange(0.2, 0.8, 0.05):
y_pred_th = (proba >= th).astype(int)
f1 = f1_score(y_test, y_pred_th)
if f1 > best_f1:
best_f1 = f1
best_th = th
print("最优阈值: {:.2f}, 最优F1: {:.3f}".format(best_th, best_f1))
# 把预测函数包一层
def predict_with_threshold(model, X, th):
return (model.predict_proba(X)[:, 1] >= th).astype(int)
5.4 坑四:class_weight 与真实场景的「成本」混淆
class_weight 不是业务成本,而是算法对样本重要性的主观设置。线上业务里,把逾期客户放过去是坏账,把正常客户误杀则是体验损失,两者成本不一样。如果有明确的成本数据,用 sample_weight 或者改成自定义损失函数更准。
六、怎么选?给你一张决策清单
- 数据量 < 10万、正样本率 < 5%:先加
class_weight="balanced",不行再 SMOTE。 - 数据量 > 100万:优先考虑欠采样或分块训练,SMOTE 合成亿级样本会爆内存。
- 特征全是稀疏类别:别用 SMOTE,用代价敏感或分箱处理。
- 正样本极少(几十个):SMOTE 的近邻也是同一批样本,合成了也白搭。建议用异常检测或迁移学习。
- 做排序任务(如推荐、风控评分卡):只用 AUC 评估不够,加 Recall@K 或 Lift 曲线。
这些代码和数据我已经做成一个可直接运行的脚本包(Python 3.10 + requirements.txt),按 bash setup.sh 就能复现全套对比。欢迎拿去改造。