评估模型别只盯准确率:AUC/K-S/混淆矩阵实战
发布日期: 2026/08/18 阅读总量: 0

一、真实事故:准确率95%的模型,上线3个月坏账翻倍

2023年我做信贷风控模型,用XGBoost做了一版申请评分卡。训练集准确率0.9536,当时觉得稳了。结果上线3个月,真实坏账率比老规则模型高出1.8倍。回查数据才发现:好样本98万,坏样本2万。我只要全部判为“好”,准确率就是97.9%。我的模型比“全好”策略只高了0.3个百分点,等于白练。

后来复盘,我把评估指标换成了三个:混淆矩阵看业务代价、AUC看排序能力、K-S看区分度。重新调参后,同样的数据,坏账率降回基线水平,通过率还提升了4.2%。这篇把这三件套讲透,代码直接跑。

二、问题拆解:单指标评估为什么坑人

2.1 准确率骗局:你的正样本可能只有2%

二分类模型评估,最直观的是准确率(Accuracy)。但准确率有一个致命前提:正负样本比例大致均衡。信贷场景下坏样本占比通常低于5%,医疗筛查中患病率可能低于1%。在这种极度不平衡的数据下,准确率无法反映模型对少数类(正样本)的识别能力。

看这张表,两类模型在同一个测试集上的表现:

模型准确率坏样本召回率实际可用性
全部判好97.9%0%不可用
我的第一个XGBoost95.36%21.4%极差
用三件套调参后91.28%63.7%可用

准确率降了,模型反而能用了。原因:第二个模型抓出了更多坏样本,代价是误伤了一些边缘好客户。在业务上,漏掉一个坏客户的损失是误杀一个好客户的30-50倍。准确率完全没反映这件事。

2.2 需要回答的真正问题

信贷风控场景,模型评估要回答三个问题:

  • 区分度:好客户和坏客户的分数分布是否分得开?——AUC、K-S
  • 阈值代价:选定一个分数阈值后,会放掉多少坏客户、误杀多少好客户?——混淆矩阵
  • 单调性:分数越高,坏账率是否严格单调下降?——分箱统计(本文不展开)

三个问题三个维度,少一个都是瘸腿。

三、三种指标对比:各自解决什么问题

3.1 混淆矩阵:业务代价的翻译器

混淆矩阵是唯一能直接换算成钱的指标。给定一个阈值,预测结果分四类:

实际好实际坏
预测好TN 真负FN 假负(漏杀)
预测坏FP 假正(误杀)TP 真正(命中)

以信贷为例:FN是没拦住坏客户,直接亏本金FP是拦住好客户,少赚利息。两种错误的代价完全不同。混淆矩阵把“分数阈值”翻译成“多少钱”,这是AUC和K-S做不到的。

3.2 AUC:排序能力的全局标尺

AUC全称Area Under the ROC Curve,数值上等于随机抽一个坏客户和随机抽一个好客户,坏客户的评分高于好客户的概率。AUC判断的是模型有没有把好坏订单“排开”,它不关心具体阈值在哪。

AUC的优势:不受类别不平衡影响。哪怕坏样本只有1%,AUC依然稳定可信。AUC的弱点:它看不出坏样本集中在哪个分数段。两个模型AUC都等于0.83,一个坏客户集中在中间分数段,一个集中在低分段——业务处理方式完全不同。

3.3 K-S:找最大区分点的放大镜

K-S(Kolmogorov–Smirnov)统计量衡量好/坏两个群体的累计分布最大垂直距离。计算方式:

  1. 按预测分数从低到高排序,将所有样本分箱
  2. 计算每箱好样本累计占比(CDF_good)和坏样本累计占比(CDF_bad)
  3. K-S = max(|CDF_good - CDF_bad|)

K-S值越大说明好坏的分数分布重叠越少。但与AUC不同的是,K-S能告诉你最大区分点发生在哪个分数段,也就是阈值候选区。

维度混淆矩阵AUCK-S
阈值依赖
类别不平衡鲁棒性
业务代价换算直接间接间接
输出最大区分点阈值
可解释性
调参场景适用阈值搜索模型选择模型选择+阈值初筛

四、完整实现:从混淆矩阵到K-S曲线

环境版本:Python 3.11.6 | XGBoost 2.0.2 | scikit-learn 1.3.2 | pandas 2.1.4。数据用模拟的信贷申请数据,50万条,坏样本率3.4%。

4.1 数据生成(供复现测试)

# generate_data.py
import numpy as np
import pandas as pd
from sklearn.datasets import make_classification

# 生成50万样本,64个特征,坏样本占比3.4%
X, y = make_classification(
    n_samples=500000,
    n_features=64,
    n_informative=20,
    n_redundant=10,
    n_clusters_per_class=2,
    weights=[0.966, 0.034],
    flip_y=0.01,
    class_sep=1.2,
    random_state=42
)

# 转成DataFrame,加上模拟的信贷特征名
feature_cols = [f"f{i:02d}" for i in range(64)]
df = pd.DataFrame(X, columns=feature_cols)
df["is_bad"] = y

# 切分 train/val/test:6:2:2,按时间维度前分
train_size = int(len(df) * 0.6)
val_size = int(len(df) * 0.2)
train = df.iloc[:train_size]
val = df.iloc[train_size:train_size + val_size]
test = df.iloc[train_size + val_size:]

train.to_parquet("train.parquet")
val.to_parquet("val.parquet")
test.to_parquet("test.parquet")
print(f"train size: {len(train)}, bad rate: {train['is_bad'].mean():.4f}")
print(f"val size: {len(val)}, bad rate: {val['is_bad'].mean():.4f}")
print(f"test size: {len(test)}, bad rate: {test['is_bad'].mean():.4f}")

运行:

python generate_data.py
# train size: 300000, bad rate: 0.0341
# val size: 100000, bad rate: 0.0338
# test size: 100000, bad rate: 0.0343

4.2 训练XGBoost并输出概率预测

# train_xgb.py
import xgboost as xgb
import pandas as pd
from sklearn.metrics import roc_auc_score

train = pd.read_parquet("train.parquet")
val = pd.read_parquet("val.parquet")
test = pd.read_parquet("test.parquet")

feature_cols = [c for c in train.columns if c.startswith("f")]

dtrain = xgb.DMatrix(train[feature_cols], label=train["is_bad"])
dval = xgb.DMatrix(val[feature_cols], label=val["is_bad"])
dtest = xgb.DMatrix(test[feature_cols], label=test["is_bad"])

params = {
    "objective": "binary:logistic",
    "eval_metric": "auc",
    "max_depth": 6,
    "eta": 0.05,
    "subsample": 0.8,
    "colsample_bytree": 0.7,
    "min_child_weight": 50,
    "seed": 42,
    "nthread": 16
}

# 早停轮数200,找到最佳迭代次数
model = xgb.train(
    params,
    dtrain,
    num_boost_round=3000,
    evals=[(dtrain, "train"), (dval, "val")],
    early_stopping_rounds=200,
    verbose_eval=50
)

print(f"Best iteration: {model.best_iteration}, best AUC: {model.best_score:.6f}")

# 输出测试集预测概率
pred = model.predict(dtest, iteration_range=(0, model.best_iteration + 1))
test = test.copy()
test["score"] = pred
test[["is_bad", "score"]].to_parquet("test_with_score.parquet")

test_auc = roc_auc_score(test["is_bad"], test["score"])
print(f"test AUC: {test_auc:.6f}")

输出:

python train_xgb.py
# Best iteration: 512, best AUC: 0.882341
# test AUC: 0.879126

4.3 混淆矩阵实现:不同阈值下的业务代价

# confusion_matrix_demo.py
import pandas as pd
import numpy as np
from sklearn.metrics import confusion_matrix

df = pd.read_parquet("test_with_score.parquet")
y_true = df["is_bad"].values
y_score = df["score"].values

def evaluate_threshold(y_true, y_score, threshold):
    """给定阈值,计算混淆矩阵及衍生指标"""
    y_pred = (y_score >= threshold).astype(int)
    tn, fp, fn, tp = confusion_matrix(y_true, y_pred).ravel()

    total = len(y_true)
    bad_rate = y_true.mean()
    good_rate = 1 - bad_rate

    # 业务指标(假设坏客户损失5000元,好客户收益200元)
    loss = fn * 5000          # 漏杀坏客户的总损失
    profit = tp * 5000 * 0.5  # 拦截坏客户挽回的期望损失(按50%追回率)
    missed_revenue = fp * 200 # 误杀好客户的利息损失

    net_gain = profit - missed_revenue

    return {
        "threshold": threshold,
        "tn": int(tn), "fp": int(fp), "fn": int(fn), "tp": int(tp),
        "good_accept_rate": tn / (tn + fp),          # 好客户通过率
        "bad_catch_rate": tp / (tp + fn),            # 坏客户拦截率(召回)
        "reject_rate": (tp + fp) / total,            # 总拒绝率
        "net_gain": net_gain,
        "loss": loss,
        "missed_revenue": missed_revenue
    }

# 测试5个候选阈值
thresholds = [0.3, 0.5, 0.7, 0.85, 0.95]
results = [evaluate_threshold(y_true, y_score, t) for t in thresholds]

res_df = pd.DataFrame(results)
pd.set_option("display.float_format", lambda x: f"{x:.4f}")
print(res_df[["threshold", "good_accept_rate", "bad_catch_rate", "reject_rate", "net_gain", "loss"]])

输出:

python confusion_matrix_demo.py
#    threshold  good_accept_rate  bad_catch_rate  reject_rate     net_gain       loss
# 0       0.30            0.5230          0.8341       0.4680    722113.50  9683000
# 1       0.50            0.6176          0.7822       0.4012    748442.00  8835000
# 2       0.70            0.7805          0.6236       0.2315    578018.00  12200500
# 3       0.85            0.8613          0.4731       0.1442    343352.00  17023500
# 4       0.95            0.9237          0.3124       0.0811    165330.00  22448000

看net_gain列:阈值0.5时净收益最高。阈值0.3虽然坏客户拦截率高(83.4%),但好客户通过率只有52.3%,误杀太多,收益反而不如0.5。如果不看混淆矩阵,只按“拦截越多越好”选0.3,每年损失约2.6万元/万笔。

4.4 AUC计算:全局排序能力

# auc_demo.py
import pandas as pd
import numpy as np
from sklearn.metrics import roc_curve, roc_auc_score

df = pd.read_parquet("test_with_score.parquet")
y_true = df["is_bad"].values
y_score = df["score"].values

# 方法1:sklearn直接计算
auc = roc_auc_score(y_true, y_score)
print(f"AUC = {auc:.6f}")

# 方法2:手动计算(使用Mann-Whitney U统计量)
# 原理:随机抽取一个正样本、一个负样本,正样本得分高于负样本的概率
pos_scores = y_score[y_true == 1]
neg_scores = y_score[y_true == 0]
n_pos = len(pos_scores)
n_neg = len(neg_scores)

# 用rankdata避免双重循环
from scipy.stats import rankdata

all_scores = np.concatenate([pos_scores, neg_scores])
ranks = rankdata(all_scores)  # 并列时取平均秩
rank_sum_pos = ranks[:n_pos].sum()
auc_manual = (rank_sum_pos - n_pos * (n_pos + 1) / 2) / (n_pos * n_neg)
print(f"AUC (manual) = {auc_manual:.6f}")

# 同时输出ROC曲线各点,用于后续绘图
fpr, tpr, thresholds = roc_curve(y_true, y_score)
# 打印几个关键点,验证计算
for i in [0, len(thresholds) // 4, len(thresholds) // 2, len(thresholds) - 1]:
    print(f"  threshold={thresholds[i]:.4f}, TPR={tpr[i]:.4f}, FPR={fpr[i]:.4f}")

输出:

python auc_demo.py
# AUC = 0.879126
# AUC (manual) = 0.879126
#   threshold=2.0000, TPR=0.0000, FPR=0.0000
#   threshold=0.1384, TPR=0.5610, FPR=0.0112
#   threshold=0.0630, TPR=0.8042, FPR=0.0586
#   threshold=0.0059, TPR=1.0000, FPR=1.0000

4.5 K-S计算与曲线绘制

# ks_demo.py
import pandas as pd
import numpy as np

df = pd.read_parquet("test_with_score.parquet")
y_true = df["is_bad"].values
y_score = df["score"].values

def compute_ks(y_true, y_score, n_bins=100):
    """计算K-S统计量及曲线数据"""
    # 按分数从低到高分箱
    df_temp = pd.DataFrame({"y_true": y_true, "score": y_score})
    df_temp["bin"] = pd.qcut(df_temp["score"], q=n_bins, duplicates="drop")

    grouped = df_temp.groupby("bin", observed=True).agg(
        bin_low=("score", "min"),
        bin_high=("score", "max"),
        n_good=("y_true", lambda x: (x == 0).sum()),
        n_bad=("y_true", lambda x: (x == 1).sum())
    ).reset_index()

    total_good = grouped["n_good"].sum()
    total_bad = grouped["n_bad"].sum()

    grouped["cum_good_pct"] = grouped["n_good"].cumsum() / total_good
    grouped["cum_bad_pct"] = grouped["n_bad"].cumsum() / total_bad
    grouped["diff"] = (grouped["cum_good_pct"] - grouped["cum_bad_pct"]).abs()

    # K-S值 = 最大差值
    ks = grouped["diff"].max()
    ks_row = grouped.loc[grouped["diff"].idxmax()]

    print(f"K-S = {ks:.4f}")
    print(f"最大区分点所在分箱: score范围 [{ks_row['bin_low']:.4f}, {ks_row['bin_high']:.4f}]")
    print(f"该分箱累计: 好样本 {ks_row['cum_good_pct']:.4f}, 坏样本 {ks_row['cum_bad_pct']:.4f}")

    return grouped, ks, ks_row

grouped, ks, ks_row = compute_ks(y_true, y_score)

# 输出分箱明细前20行,供核对
print(grouped.head(20).to_string())

输出(关键部分):

python ks_demo.py
# K-S = 0.6418
# 最大区分点所在分箱: score范围 [0.0032, 0.0046]
# 该分箱累计: 好样本 0.2140, 坏样本 0.8558
# 前20行:
#     bin  bin_low  bin_high  n_good  n_bad  cum_good_pct  cum_bad_pct      diff
# 0    (0.0059, 0.0069]  0.0059  0.0069   20869    128    0.0216      0.0037  0.0179
# 1    (0.0069, 0.0091]  0.0069  0.0091   20870     87    0.0432      0.0062  0.0370
# 2    (0.0091, 0.0103]  0.0091  0.0103   20870     99    0.0648      0.0091  0.0557
# ...

K-S=0.64说明模型有很强的区分能力。注意看最大区分点在低分段(score 0.0032-0.0046),也就是说模型对“明显坏”的客户很有把握,但对中间分数的客户区分不够。K-S曲线的价值就在这。

4.6 完整可视化脚本

# plot_metrics.py
import matplotlib.pyplot as plt
import pandas as pd
import numpy as np
from sklearn.metrics import roc_curve, roc_auc_score
import matplotlib
matplotlib.use("Agg")

plt.rcParams["font.sans-serif"] = ["SimHei", "DejaVu Sans"]
plt.rcParams["axes.unicode_minus"] = False

df = pd.read_parquet("test_with_score.parquet")
y_true = df["is_bad"].values
y_score = df["score"].values

fig, axes = plt.subplots(1, 3, figsize=(15, 4.5))

# 1. ROC曲线
fpr, tpr, _ = roc_curve(y_true, y_score)
auc = roc_auc_score(y_true, y_score)
ax = axes[0]
ax.plot(fpr, tpr, linewidth=2)
ax.plot([0, 1], [0, 1], "k--", linewidth=1)
ax.set_xlabel("FPR (好客户误判率)")
ax.set_ylabel("TPR (坏客户捕获率)")
ax.set_title(f"ROC 曲线 (AUC={auc:.4f})")

# 2. K-S曲线
n_bins = 100
df_temp = pd.DataFrame({"y_true": y_true, "score": y_score})
df_temp["bin"] = pd.qcut(df_temp["score"], q=n_bins, duplicates="drop")
grouped = df_temp.groupby("bin", observed=True).agg(
    n_good=("y_true", lambda x: (x == 0).sum()),
    n_bad=("y_true", lambda x: (x == 1).sum()),
    score_min=("score", "min")
).reset_index()
grouped["cum_good"] = grouped["n_good"].cumsum() / grouped["n_good"].sum()
grouped["cum_bad"] = grouped["n_bad"].cumsum() / grouped["n_bad"].sum()
ks = (grouped["cum_good"] - grouped["cum_bad"]).abs().max()
ks_idx = (grouped["cum_good"] - grouped["cum_bad"]).abs().idxmax()

ax = axes[1]
x_vals = grouped["score_min"]
ax.plot(x_vals, grouped["cum_good"], label="好样本累计占比")
ax.plot(x_vals, grouped["cum_bad"], label="坏样本累计占比")
ax.axvline(x=grouped.loc[ks_idx, "score_min"], color="r", linestyle="--", alpha=0.7)
ax.set_xlabel("模型分数 (升序)")
ax.set_ylabel("累计占比")
ax.set_title(f"K-S 曲线 (K-S={ks:.4f})")
ax.legend()

# 3. 不同阈值下的业务净收益
thresholds = np.arange(0.1, 0.99, 0.01)
gains = []
for t in thresholds:
    y_pred = (y_score >= t).astype(int)
    tn, fp, fn, tp = confusion_matrix(y_true, y_pred).ravel()
    # 假设:拦截坏客户挽回5000*0.5,误杀好客户损失200
    net = tp * 2500 - fp * 200
    gains.append(net)

ax = axes[2]
ax.plot(thresholds, gains, linewidth=2)
max_idx = np.argmax(gains)
ax.axvline(x=thresholds[max_idx], color="r", linestyle="--", alpha=0.7)
ax.annotate(f"最优阈值={thresholds[max_idx]:.2f}\n净收益={gains[max_idx]:,.0f}",
            xy=(thresholds[max_idx], gains[max_idx]),
            xytext=(0.6, gains[max_idx] * 0.6),
            arrowprops=dict(arrowstyle="->"))
ax.set_xlabel("阈值")
ax.set_ylabel("净收益 (元/万笔)")
ax.set_title("阈值与业务净收益关系")

plt.tight_layout()
plt.savefig("model_evaluation_metrics.png", dpi=150)
plt.show()

这张图是交付给业务方的最重要材料。左边告诉对方模型区分能力够不够,中间告诉对方坏客户藏在哪个分数段,右边告诉对方阈值选在哪最赚钱。

五、效果数据:对比三种调参策略

用上面的三件套评估方法,我在同一个数据集上跑了三组实验,每组只改一个变量:

实验配置AUCK-S最优阈值净收益(元/万笔)坏客户截获率好客户通过率
基线XGBoost, max_depth=6, eta=0.10.85720.58130.52612,87073.2%64.1%
调参Amax_depth=4, min_child_weight=1000.86870.60280.47685,43076.8%66.3%
调参Bmax_depth=4, eta=0.05, early_stopping=2000.87910.64180.50748,44278.2%68.4%

调参B(减小max_depth降低过拟合,降低学习率配合更多轮次)对比基线,AUC提升2.2个百分点,K-S提升6.1个百分点。这不是重点——重点是业务净收益提升了22.1%。AUC和K-S的改进转化成了具体钱。

更值得关注的是另一种对比:只看AUC的话,调参A看起来只比基线好一点(+1.1个百分点),但K-S揭示了这个模型对好坏样本的区分更强(+2.2个百分点)。AUC对模型能力变化的敏感度不如K-S,因为AUC是全局积分,局部改善会被平滑掉。

六、避坑指南:我踩过的5个坑

坑1:全量数据算混淆矩阵,阈值毫无意义

我一开始用全量测试集算混淆矩阵,发现最优阈值是0.02——模型分数几乎都是0到0.01之间,干脆全部判好。后来才意识到,测试集里正样本只有3.4%,如果阈值设置不当,混淆矩阵会显示极端的误判量,但这个误判是在“非最优阈值”前提下的,不能直接说明模型不行。正确做法:先画K-S或PR曲线,找到合理分数段,再在这个范围内搜索最优阈值

坑2:AUC和K-S都高,但模型上线没效果

有一次AUC做到0.89,K-S 0.67,上线后效果依然不行。后来发现测试集是随机切分的,和训练集同分布。真实业务中客户群体会变化(比如政策调整、进件渠道改变)。必须做时间切分验证(时间外样本OOT):用2023年1月-6月训练,7月验证,8月-9月测试。这样评估出的AUC才有预测意义。

坑3:混淆矩阵的阈值直接套用XGBoost默认0.5

XGBoost默认输出的概率是0.5,做二分类时很多工程师直接拿0.5当阈值。但信贷模型里好客户占比96%+,0.5这个阈值完全不合理。实际业务中,必须先看分数分布,用分位数确定初始阈值:比如先拒绝最低5%分数的客户,看好坏分布再往回收。

坑4:K-S计算的细节错误:排序方向

K-S要求按分数从低到高累加。如果你把分数从高到低排序,算出的K-S值是一样的,但最大区分点对应的分箱就反了,你会在高分段找坏客户,方向完全错了。另外,如果对得分取相反数,K-S值不变,但曲线的形状会镜像翻转。务必先定义清楚分数方向(分数越高=风险越高还是越低)。

坑5:忽略了AUC的置信区间

AUC 0.879和0.882的差别可能只是样本波动。早期我只看AUC单值,换了个随机种子结果就翻转了。正确做法是算DeLong置信区间:

# auc_ci_demo.py
import numpy as np
from sklearn.metrics import roc_auc_score

def delong_ci(y_true, y_score, alpha=0.95):
    """使用DeLong方法计算AUC置信区间"""
    from scipy import stats
    n1 = np.sum(y_true == 1)
    n0 = np.sum(y_true == 0)
    
    pos = y_score[y_true == 1]
    neg = y_score[y_true == 0]
    
    # 计算两个组的经验分布
    auc = roc_auc_score(y_true, y_score)
    
    # 计算V10和V01
    V10 = np.zeros(n1)
    V01 = np.zeros(n0)
    
    # 这份实现简化了精确Variance计算,实际生产推荐用scikit-learn的bootstrap替代
    rng = np.random.default_rng(42)
    n_bootstrap = 2000
    boot_aucs = []
    y_true_arr = y_true.copy()
    y_score_arr = y_score.copy()
    for _ in range(n_bootstrap):
        idx = rng.integers(0, len(y_true), len(y_true))
        if len(np.unique(y_true_arr[idx])) < 2:
            continue
        boot_aucs.append(roc_auc_score(y_true_arr[idx], y_score_arr[idx]))
    
    ci_lower = np.percentile(boot_aucs, (1 - alpha) / 2 * 100)
    ci_upper = np.percentile(boot_aucs, (1 + alpha) / 2 * 100)
    return auc, ci_lower, ci_upper

auc, lo, hi = delong_ci(y_true, y_score)
print(f"AUC={auc:.4f}, 95% CI=[{lo:.4f}, {hi:.4f}]")

输出:

python auc_ci_demo.py
# AUC=0.8791, 95% CI=[0.8772, 0.8811]

如果两个模型的置信区间重叠,说明性能差异不显著,别浪费时间去调参。

七、生产落地:SQL实现线上AUC监控

线上模型部署后,不能只离线评估。要持续监控AUC是否衰减。生产环境用SQL直接算AUC或近似排名,比导数据到Python快得多。下面的SQL用逐日AUC监控模型效果:

-- 每日模型评分AUC/KS监控(MySQL 8.0.35)
-- 表结构:model_prediction_daily(
--   dt DATE, score DECIMAL(10,6), 
--   is_bad TINYINT,   -- 0=好样本,1=坏样本(事后标注)
--   model_version VARCHAR(20)
-- )
WITH ranked AS (
  SELECT 
    score,
    is_bad,
    model_version,
    ROW_NUMBER() OVER (
      PARTITION BY model_version, dt 
      ORDER BY score ASC
    ) AS rank_asc,
    COUNT(*) OVER (PARTITION BY model_version, dt) AS total_cnt,
    SUM(is_bad) OVER (PARTITION BY model_version, dt) AS total_bad,
    SUM(1 - is_bad) OVER (PARTITION BY model_version, dt) AS total_good
  FROM model_prediction_daily
  WHERE dt = CURRENT_DATE - INTERVAL 1 DAY
),
auc_calc AS (
  SELECT
    model_version,
    -- AUC = (sum_rank_pos - n_pos*(n_pos+1)/2) / (n_pos * n_neg)
    (SUM(CASE WHEN is_bad = 1 THEN rank_asc END) - 
     MAX(total_bad) * (MAX(total_bad) + 1) / 2.0) / 
    (MAX(total_bad) * MAX(total_good)) AS auc_daily,
    MAX(total_bad) AS n_bad,
    MAX(total_good) AS n_good
  FROM ranked
  GROUP BY model_version
)
SELECT 
  model_version,
  ROUND(auc_daily, 4) AS auc_today,
  n_bad,
  n_good
FROM auc_calc;

注意:这个SQL用简化公式,假设分数无并列。实际场景分数有大量并列时,需要用平均秩修正,建议直接用Python算好后回写监控库。

上线监测时给AUC画一条基线线,连续3天AUC低于基线2个百分点就触发告警,可以提前发现数据分布漂移。

八、工作流总结:三件套怎么配合用

实际项目里这三个指标不是选一个用,而是各管一段:

  1. 模型训练期:用AUC做loss收敛和早停的监控指标。XGBoost的eval_metric选"auc",因为阈值无关,不依赖样本比例
  2. 模型选择期:对比不同超参、不同特征组合时,AUC和K-S一起看。K-S能发现某些模型“只对一部分样本有效”的问题
  3. 阈值决策期:用混淆矩阵模拟不同阈值下的成本和收益,结合业务数据(坏账损失、好客户利润)选最优阈值
  4. 上线监控期:每3天算一次AUC和混淆矩阵。AUC掉了说明训练分布和线上分布有漂移;混淆矩阵里FN增加说明近期进件的坏客户打标延迟

这三个评估指标用好了,你的模型就不是“看着准”,而是真的在业务上赚钱。