一、真实事故:准确率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% | 不可用 |
| 我的第一个XGBoost | 95.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)统计量衡量好/坏两个群体的累计分布最大垂直距离。计算方式:
- 按预测分数从低到高排序,将所有样本分箱
- 计算每箱好样本累计占比(CDF_good)和坏样本累计占比(CDF_bad)
- K-S = max(|CDF_good - CDF_bad|)
K-S值越大说明好坏的分数分布重叠越少。但与AUC不同的是,K-S能告诉你最大区分点发生在哪个分数段,也就是阈值候选区。
| 维度 | 混淆矩阵 | AUC | K-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()
这张图是交付给业务方的最重要材料。左边告诉对方模型区分能力够不够,中间告诉对方坏客户藏在哪个分数段,右边告诉对方阈值选在哪最赚钱。
五、效果数据:对比三种调参策略
用上面的三件套评估方法,我在同一个数据集上跑了三组实验,每组只改一个变量:
| 实验 | 配置 | AUC | K-S | 最优阈值 | 净收益(元/万笔) | 坏客户截获率 | 好客户通过率 |
|---|---|---|---|---|---|---|---|
| 基线 | XGBoost, max_depth=6, eta=0.1 | 0.8572 | 0.5813 | 0.52 | 612,870 | 73.2% | 64.1% |
| 调参A | max_depth=4, min_child_weight=100 | 0.8687 | 0.6028 | 0.47 | 685,430 | 76.8% | 66.3% |
| 调参B | max_depth=4, eta=0.05, early_stopping=200 | 0.8791 | 0.6418 | 0.50 | 748,442 | 78.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个百分点就触发告警,可以提前发现数据分布漂移。
八、工作流总结:三件套怎么配合用
实际项目里这三个指标不是选一个用,而是各管一段:
- 模型训练期:用AUC做loss收敛和早停的监控指标。XGBoost的eval_metric选"auc",因为阈值无关,不依赖样本比例
- 模型选择期:对比不同超参、不同特征组合时,AUC和K-S一起看。K-S能发现某些模型“只对一部分样本有效”的问题
- 阈值决策期:用混淆矩阵模拟不同阈值下的成本和收益,结合业务数据(坏账损失、好客户利润)选最优阈值
- 上线监控期:每3天算一次AUC和混淆矩阵。AUC掉了说明训练分布和线上分布有漂移;混淆矩阵里FN增加说明近期进件的坏客户打标延迟
这三个评估指标用好了,你的模型就不是“看着准”,而是真的在业务上赚钱。