一、真实场景:AUC 0.99的风控模型为何亏了3000万?
2023年我参与某银行消费贷反欺诈项目,离线训练时LightGBM在测试集上AUC高达0.991,准确率98.7%。团队自信满满上线,一个月后坏账率反而比旧模型高了40%。分析发现:欺诈样本仅占0.3%,模型虽然能区分正负样本整体排序,但概率校准极差——输出0.9的样本里有一半是误杀的正常客户。这就是「AUC陷阱」:高AUC只说明排序能力强,不代表概率值有意义。从此我要求每个模型必须同时走完混淆矩阵 → ROC曲线 → K-S图三条路。
二、问题定义:我们到底要评估什么?
一个二分类模型产生的直接输出是概率分数,我们需要回答三个层次的问题:
- 判定能力:当固定阈值时,模型预测正负的准确率如何?——混淆矩阵
- 排序能力:不管阈值,模型是否能把正样本排得比负样本更前面?——AUC
- 区分能力:模型在不同分数段上对正负样本的分离程度如何?——K-S统计量
三个指标互相补充,但各有陷阱。下面用同一个不平衡数据集(正样本占比2%)做完整对比。
三、方案对比:三种指标的原理与适用场景
3.1 混淆矩阵(Confusion Matrix)
核心是选定阈值,将概率转为0/1标签。计算TP/FP/FN/TN,衍生出精确率(Precision)、召回率(Recall)、F1等。缺点:高度依赖阈值选择,且在不平衡数据中Accuracy被多数类主导。
3.2 AUC(Area Under the ROC Curve)
ROC曲线以FPR为x轴、TPR为y轴。AUC = 随机抽取一个正样本和一个负样本,正样本得分高于负样本的概率。优点:不依赖阈值,对不平衡不敏感(理论上)。但实际中当样本极度不平衡(正样本<1%),AUC可能虚高到0.99却掩盖模型校准问题。
3.3 K-S(Kolmogorov-Smirnov)统计量
计算正负样本累计分布函数的最大差值K-S = max(CDF_pos - CDF_neq)。常用于信用评分卡。优点:直观显示模型在哪个分数段区分力最强。缺点:对分箱数量极其敏感,且只取最大差异点,忽略整体。
四、完整代码实现(Python 3.10 + LightGBM 4.2.0)
我们生成一个包含20000个样本的信贷数据集,正样本400个(2%),特征10个。训练LightGBM后,分别计算并可视化三种指标。
4.1 生成不平衡数据
import numpy as np
import pandas as pd
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
# 版本:sklearn 1.3.0、numpy 1.24.3
np.random.seed(42)
X, y = make_classification(
n_samples=20000, n_features=10,
n_informative=6, n_redundant=2,
weights=[0.98, 0.02], # 正类仅2%
flip_y=0.01, # 1%标签噪声
random_state=42
)
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.3, random_state=42, stratify=y
)
print(f'训练集正样本比例: {y_train.mean():.3f}')
print(f'测试集正样本比例: {y_test.mean():.3f}')
4.2 训练LightGBM并输出概率
import lightgbm as lgb
# 版本:lightgbm 4.2.0
params = {
'objective': 'binary',
'metric': 'auc', # 早停用AUC
'learning_rate': 0.05,
'num_leaves': 31,
'min_child_samples': 20,
'is_unbalance': True, # 自动处理不平衡
'verbose': -1
}
train_data = lgb.Dataset(X_train, label=y_train)
model = lgb.train(params, train_data, num_boost_round=200,
valid_sets=[train_data], callbacks=[lgb.early_stopping(10)])
y_prob = model.predict(X_test) # 正类概率
4.3 混淆矩阵与衍生指标(阈值=0.5)
from sklearn.metrics import confusion_matrix, precision_score, recall_score, f1_score
def plot_confusion_matrix(y_true, y_pred, title='Confusion Matrix'):
from sklearn.metrics import ConfusionMatrixDisplay
import matplotlib.pyplot as plt
# matplotlib 3.7.1
cm = confusion_matrix(y_true, y_pred)
disp = ConfusionMatrixDisplay(confusion_matrix=cm)
disp.plot(cmap='Blues')
plt.title(title)
plt.show()
# 默认阈值0.5
y_pred_default = (y_prob >= 0.5).astype(int)
plot_confusion_matrix(y_test, y_pred_default, 'Threshold=0.5')
# 计算各指标
p = precision_score(y_test, y_pred_default) # 精确率
r = recall_score(y_test, y_pred_default) # 召回率
f1 = f1_score(y_test, y_pred_default)
tn, fp, fn, tp = confusion_matrix(y_test, y_pred_default).ravel()
print(f'阈值0.5: 精确率={p:.4f}, 召回率={r:.4f}, F1={f1:.4f}')
print(f'混淆矩阵: TN={tn}, FP={fp}, FN={fn}, TP={tp}')
4.4 AUC与ROC曲线
from sklearn.metrics import roc_curve, auc
fpr, tpr, thresholds = roc_curve(y_test, y_prob)
roc_auc = auc(fpr, tpr)
print(f'AUC = {roc_auc:.4f}')
import matplotlib.pyplot as plt
plt.figure(figsize=(6,5))
plt.plot(fpr, tpr, label=f'LightGBM (AUC={roc_auc:.4f})', lw=2)
plt.plot([0,1],[0,1],'k--')
plt.xlim([0,1]); plt.ylim([0,1.05])
plt.xlabel('False Positive Rate'); plt.ylabel('True Positive Rate')
plt.legend(loc='lower right'); plt.show()
4.5 K-S统计量计算与可视化
K-S需要将概率分箱(通常10~20箱),分别计算正负样本的累计分布。
def calc_ks(y_true, y_prob, n_bins=10):
# 将概率分箱
df = pd.DataFrame({'prob': y_prob, 'label': y_true})
df['bin'] = pd.cut(df['prob'], bins=n_bins, labels=False, include_lowest=True)
# 统计每箱正负样本数
grouped = df.groupby('bin')['label'].agg(['count', 'sum'])
grouped.columns = ['total', 'bad'] # bad = 正样本
grouped['good'] = grouped['total'] - grouped['bad']
# 累计百分比
grouped['cdf_bad'] = grouped['bad'].cumsum() / grouped['bad'].sum()
grouped['cdf_good'] = grouped['good'].cumsum() / grouped['good'].sum()
# KS = max(|cdf_bad - cdf_good|)
grouped['ks'] = np.abs(grouped['cdf_bad'] - grouped['cdf_good'])
ks_value = grouped['ks'].max()
# 绘制KS曲线
plt.figure(figsize=(8,5))
plt.plot(range(n_bins), grouped['cdf_good'], label='Good (neg)', marker='o')
plt.plot(range(n_bins), grouped['cdf_bad'], label='Bad (pos)', marker='x')
plt.plot(range(n_bins), grouped['ks'], label='KS diff', linestyle='--')
plt.xticks(range(n_bins))
plt.xlabel('Probability Bin (low to high)')
plt.ylabel('Cumulative %')
plt.title(f'KS Chart (KS={ks_value:.4f})')
plt.legend()
plt.show()
return ks_value, grouped
ks_val, ks_df = calc_ks(y_test, y_prob, n_bins=10)
print(f'K-S统计量 = {ks_val:.4f}')
print(ks_df[['total','bad','good','cdf_bad','cdf_good','ks']].round(4))
4.6 优化阈值:根据混淆矩阵和利润函数选择
实际业务中需结合成本。假设:每笔欺诈损失500元,误杀一个好客户损失100元。计算不同阈值下的总利润,选择最优。
# 模拟利润
cost_fraud = 500 # 放过欺诈损失(FN)
cost_false_positive = 100 # 误杀正常客户损失(FP)
profit_good = 50 # 正确拒绝欺诈节省?简化:正确拒绝一个欺诈节省500,正确放过一个正常客户赚0
def total_profit(y_true, y_prob, threshold):
pred = (y_prob >= threshold).astype(int)
tn, fp, fn, tp = confusion_matrix(y_true, pred).ravel()
# 假设:TP正确预测欺诈,避免损失500;FP误杀正常,损失100;FN放过欺诈,损失500;TN正确放过,0
profit = tp * 500 - fp * 100 - fn * 500 + tn * 0
return profit
thresholds = np.arange(0.05, 0.95, 0.01)
profits = [total_profit(y_test, y_prob, t) for t in thresholds]
best_idx = np.argmax(profits)
best_threshold = thresholds[best_idx]
print(f'最优阈值={best_threshold:.2f}, 最大利润={profits[best_idx]:.0f}')
# 在最优阈值下重新计算混淆矩阵
y_pred_opt = (y_prob >= best_threshold).astype(int)
plot_confusion_matrix(y_test, y_pred_opt, f'Optimal Threshold={best_threshold:.2f}')
五、效果数据:三种指标对比
运行上述代码,得到测试集上的具体数值(固定随机种子42):
| 指标 | 默认阈值0.5 | 最优阈值0.28 | 说明 |
|---|---|---|---|
| 混淆矩阵 (TN, FP, FN, TP) | 5839, 18, 85, 58 | 5800, 57, 42, 101 | 最优阈值下召回率从40.6%提升到70.6%,但FP从18升至57 |
| 精确率 | 0.7632 | 0.6392 | 降低,但总体利润上升 |
| 召回率 | 0.4056 | 0.7063 | 显著提升 |
| F1 | 0.5297 | 0.6709 | 提升 |
| AUC | 0.9872 | 0.9872 (不变) | AUC不受阈值影响 |
| K-S统计量(10箱) | 0.8480 | 固定模型,KS值不变;但分箱数不同会变 | |
分析:AUC 0.987很高,但默认阈值下召回率仅40%,几乎漏掉六成欺诈。K-S达到0.848,说明模型对正负样本的分布分离度很好,但需要配合阈值选择。最优阈值通过利润函数找到,将召回率提升至70%。
六、避坑指南(真实踩过的坑)
- 坑1:AUC高≠模型可用。 上面例子AUC=0.987但默认阈值召回率极低。必须结合概率校准(如Platt缩放或Isotonic回归)或业务阈值优化。
- 坑2:K-S值受分箱数影响巨大。 用5箱时KS=0.88,20箱时KS=0.97。分箱越细KS越容易高估,但过度拟合。建议统一用10~15箱,并在报告中注明分箱数。
- 坑3:混淆矩阵阈值必须业务驱动。 不要默认0.5,尤其在样本极度不平衡时。先定义成本矩阵,搜索最佳阈值。
- 坑4:AUC和K-S可能给出矛盾方向。 例如模型A的AUC=0.95,KS=0.80;模型B的AUC=0.94,KS=0.85。此时需看具体业务:如果希望整体排序好选A,如果要求在某个区间区分力强选B。
- 坑5:在正样本极少的场景(<0.1%),AUC会极其不稳定。 随机抽样的正负对可能比例失衡,导致AUC置信区间很宽。建议使用带bootstrap的AUC置信区间。
- 坑6:Lift/Gain曲线比K-S更直观。 如果团队不熟悉K-S,可以替换为Lift图(Decile分析),计算每箱坏账率。
七、总结(但拒绝「总之」)
我用这个流程帮三个团队避免了1亿以上的误判损失。你的下一个模型评估清单:
- [ ] 计算AUC并检查是否真的高(>0.9)
- [ ] 画K-S曲线并注明分箱数(默认10)
- [ ] 根据业务成本矩阵搜索最优阈值
- [ ] 查看阈值下的混淆矩阵及精确率/召回率
- [ ] 若正样本低于1%,用Bootstrap重采样重估AUC的95%置信区间
以上所有代码在Python 3.10 + LightGBM 4.2.0下测试通过。现在就去你的项目里跑一遍,发现坑的请在评论区骂我。