模型评估三剑客:混淆矩阵、AUC与K-S深坑实录
发布日期: 2026/07/26 阅读总量: 0

一、真实场景: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, 585800, 57, 42, 101最优阈值下召回率从40.6%提升到70.6%,但FP从18升至57
精确率0.76320.6392降低,但总体利润上升
召回率0.40560.7063显著提升
F10.52970.6709提升
AUC0.98720.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下测试通过。现在就去你的项目里跑一遍,发现坑的请在评论区骂我。