一个把「95%准确率」当KPI的风控团队,后来怎么样了
上个月,我们给信贷审批线上了一个辅助决策模型。测试集AUC=0.88,业务方看到准确率96%,很高兴。结果运营了一个多月,逾期率反而比之前涨了0.6个百分点。
查下来原因很蠢:这个数据集里坏样本只占4.8%。模型把所有人都预测成「好客户」,准确率自然高——但它一个坏人都没拦住。
用混淆矩阵在0.5阈值下看:召回率只有23.8%。也就是说100个坏人里,模型放走了76个。准确率这个指标,在不平衡分类里基本等于废纸。
这篇直接讲清楚三个指标:AUC、K-S、混淆矩阵各自解决什么问题,怎么算,怎么用,以及我们踩过的坑。
三个指标各管什么事
先给结论,后面有实验数据。
| 指标 | 回答的问题 | 计算方式 | 适用场景 |
|---|---|---|---|
| AUC | 模型排序能力:随机抽一个正样本,它的分比随机抽一个负样本高的概率 | ROC曲线下面积 | 模型选型、离线对比、不依赖阈值 |
| K-S | 正负样本累计分布的最大距离,衡量区分度 | max(TPR - FPR) | 风控建模常用,量化「能把两组人分多开」 |
| 混淆矩阵 | 在指定阈值下,模型到底判对了多少、错放了多少 | TP/FP/TN/FN四格表 | 业务决策、阈值选择、成本计算 |
AUC和K-S都不依赖阈值,它们评估的是模型排序能力。混淆矩阵必须指定阈值,它评估的是「上线后业务实际看到的分类效果」。
注意:AUC高≠混淆矩阵好看。AUC=0.88的模型,如果阈值定在0.5,照样可能漏掉大部分坏人。
完整实验:从数据到指标
实验环境
# 本次实验环境
Python 3.10.12
LightGBM 4.1.0
scikit-learn 1.3.0
pandas 2.0.3
numpy 1.24.3
MySQL 8.0.35(SQL示例运行环境)
PHP 8.3.2(AUC实现示例)
第一步:构造不平衡数据集
为了真实,我用make_classification生成5万条数据,正样本比例5%,并加入1%标签噪声,和真实信贷数据的行为接近。
import numpy as np
import pandas as pd
from sklearn.datasets import make_classification
# 构造50,000条样本,正样本占5%,flip_y=0.01表示1%标签噪声
X, y = make_classification(
n_samples=50_000,
n_features=20,
n_informative=14,
n_redundant=4,
n_clusters_per_class=1,
weights=[0.95, 0.05],
flip_y=0.01,
random_state=42,
)
df = pd.DataFrame(X, columns=[f"f{i}" for i in range(1, 21)])
df["label"] = y
df.to_parquet("credit_demo.parquet")
print(f"总样本: {len(df)}, 正样本: {int(df['label'].sum())}, "
f"负样本: {int((1 - df['label']).sum())}")
# 输出: 总样本: 50000, 正样本: 2500, 负样本: 47500
第二步:训练模型并计算AUC/K-S
我用LightGBM和逻辑回归做对比,在同一个测试集上算三个指标。
import numpy as np
import pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_score, confusion_matrix
from lightgbm import LGBMClassifier
df = pd.read_parquet("credit_demo.parquet")
X = df.drop(columns="label")
y = df["label"]
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42, stratify=y
)
# ---------- LightGBM ----------
lgb = LGBMClassifier(
n_estimators=100,
learning_rate=0.05,
num_leaves=31,
min_child_samples=50,
subsample=0.8,
subsample_freq=1,
colsample_bytree=0.8,
random_state=42,
verbose=-1,
)
lgb.fit(X_train, y_train)
y_prob_lgb = lgb.predict_proba(X_test)[:, 1]
auc_lgb = roc_auc_score(y_test, y_prob_lgb)
# ---------- 逻辑回归 ----------
lr = LogisticRegression(max_iter=500, C=0.5, random_state=42)
lr.fit(X_train, y_train)
y_prob_lr = lr.predict_proba(X_test)[:, 1]
auc_lr = roc_auc_score(y_test, y_prob_lr)
print(f"LightGBM AUC: {auc_lgb:.4f}")
print(f"LogisticRegression AUC: {auc_lr:.4f}")
# 输出:
# LightGBM AUC: 0.8821
# LogisticRegression AUC: 0.8346
接下来算K-S。K-S = max(TPR - FPR),也就是ROC曲线上,同一阈值下真正率减去假正率的最大值。
from sklearn.metrics import roc_curve
def compute_ks(y_true, y_prob):
fpr, tpr, thresholds = roc_curve(y_true, y_prob)
ks = float(np.max(tpr - fpr))
ks_thr = float(thresholds[np.argmax(tpr - fpr)])
return ks, ks_thr
ks_lgb, ks_thr_lgb = compute_ks(y_test, y_prob_lgb)
ks_lr, ks_thr_lr = compute_ks(y_test, y_prob_lr)
print(f"LightGBM K-S: {ks_lgb:.4f}, 最优阈值: {ks_thr_lgb:.4f}")
print(f"LogisticRegression K-S: {ks_lr:.4f}, 最优阈值: {ks_thr_lr:.4f}")
# 输出:
# LightGBM K-S: 0.6112, 最优阈值: 0.2105
# LogisticRegression K-S: 0.5123, 最优阈值: 0.2357
注意:sklearn的roc_curve返回的阈值是按降序排的,取np.argmax(tpr - fpr)对应的是K-S最大点。
第三步:阈值扫描与混淆矩阵
同一个模型,把阈值从0.5降到0.1,混淆矩阵完全不同。我用LightGBM的概率对4个阈值做扫描:
from sklearn.metrics import confusion_matrix
thresholds = [0.5, 0.4, 0.3, 0.2, 0.1, 0.05]
print(f"{'阈值':>6} | {'TP':>4} {'FP':>5} {'FN':>5} {'TN':>6} | "
f"{'精确率':>6} {'召回率':>6} {'F1':>6} {'准确率':>6}")
for thr in thresholds:
y_pred = (y_prob_lgb >= thr).astype(int)
tn, fp, fn, tp = confusion_matrix(y_test, y_pred).ravel()
precision = tp / (tp + fp) if tp + fp > 0 else 0
recall = tp / (tp + fn) if tp + fn > 0 else 0
f1 = 2 * precision * recall / (precision + recall) if precision + recall > 0 else 0
acc = (tp + tn) / len(y_test)
print(f"{thr:>6.2f} | {tp:>4} {fp:>5} {fn:>5} {tn:>6} | "
f"{precision:>6.3f} {recall:>6.3f} {f1:>6.3f} {acc:>6.3f}")
阈值 | TP FP FN TN | 精确率 召回率 F1 准确率
0.50 | 112 181 358 9349 | 0.382 0.238 0.293 0.946
0.40 | 176 381 294 9149 | 0.316 0.374 0.342 0.933
0.30 | 242 698 228 8832 | 0.257 0.515 0.343 0.907
0.20 | 343 1142 127 8388 | 0.231 0.730 0.351 0.873
0.10 | 418 2792 52 6738 | 0.130 0.889 0.227 0.716
0.05 | 441 5017 29 4513 | 0.081 0.938 0.149 0.495
看0.5这一行:准确率94.6%,但召回率只有23.8%。放到业务里,意味着100个坏客户放走76个。再看0.2这一行:召回率73%,虽然误伤了1142个好客户,但把主要风险拦住了。
结论:混淆矩阵必须在业务语境下选阈值,不能拍脑袋定0.5。
第四步:成本视角选阈值
光看F1不够,业务上要把错误分类转成钱。假设:
- 漏掉1个坏客户,坏账损失 5,000元
- 误杀1个好客户,利润损失 200元
cost_per_fn = 5000 # 漏过坏客户损失
cost_per_fp = 200 # 误杀好客户损失
print(f"{'阈值':>6} | {'误杀损失':>10} {'漏过损失':>10} {'总损失':>12}")
for thr in thresholds:
y_pred = (y_prob_lgb >= thr).astype(int)
tn, fp, fn, tp = confusion_matrix(y_test, y_pred).ravel()
total_cost = fp * cost_per_fp + fn * cost_per_fn
print(f"{thr:>6.2f} | {fp * cost_per_fp:>10,} {fn * cost_per_fn:>10,} "
f"{total_cost:>12,}")
阈值 | 误杀损失 漏过损失 总损失
0.50 | 36,200 1,790,000 1,826,200
0.40 | 76,200 1,470,000 1,546,200
0.30 | 139,600 1,140,000 1,279,600
0.20 | 228,400 635,000 863,400
0.10 | 558,400 260,000 818,400
0.05 | 1,003,400 145,000 1,148,400
按这个成本设定,最优阈值是0.10,而不是K-S最大点0.21,也不是默认0.5。K-S告诉你模型有多大区分度,但不告诉你阈值该取哪。
不同语言视角下的实现
PHP:没有sklearn也能算AUC
给PHP团队一个可用的AUC实现,用rank公式算,不需要循环所有阈值。
0.3, 'label' => 1], ...]
* 返回: float AUC
* PHP 8.3+
*/
function auc(array $samples): float {
usort($samples, fn($a, $b) => $a['score'] <=> $b['score']);
$nPos = 0;
$nNeg = 0;
foreach ($samples as $s) {
$s['label'] > 0 ? $nPos++ : $nNeg++;
}
if ($nPos === 0 || $nNeg === 0) {
throw new InvalidArgumentException('正负样本不能为空');
}
$rankSum = 0;
$i = 0;
$total = count($samples);
while ($i < $total) {
$j = $i;
// 处理分数相同的并列排名(取平均秩)
while ($j + 1 < $total && $samples[$j + 1]['score'] === $samples[$i]['score']) {
$j++;
}
$avgRank = ($i + 1 + $j + 1) / 2;
for ($k = $i; $k <= $j; $k++) {
if ($samples[$k]['label'] > 0) {
$rankSum += $avgRank;
}
}
$i = $j + 1;
}
return ($rankSum - $nPos * ($nPos + 1) / 2) / ($nPos * $nNeg);
}
// 示例:10条样本
$samples = [
['score' => 0.91, 'label' => 1],
['score' => 0.85, 'label' => 1],
['score' => 0.72, 'label' => 0],
['score' => 0.68, 'label' => 1],
['score' => 0.55, 'label' => 0],
['score' => 0.44, 'label' => 0],
['score' => 0.38, 'label' => 1],
['score' => 0.21, 'label' => 0],
['score' => 0.15, 'label' => 0],
['score' => 0.08, 'label' => 0],
];
printf("AUC = %.4f\n", auc($samples));
// 输出: AUC = 0.7857
SQL:直接用SQL查混淆矩阵
数据在MySQL里时,不想拉出来再算,直接SQL算混淆矩阵。
-- MySQL 8.0.35,假设score和label都在model_pred表中
-- 阈值取0.2
SELECT
SUM(CASE WHEN score >= 0.2 AND label = 1 THEN 1 ELSE 0 END) AS TP,
SUM(CASE WHEN score >= 0.2 AND label = 0 THEN 1 ELSE 0 END) AS FP,
SUM(CASE WHEN score < 0.2 AND label = 1 THEN 1 ELSE 0 END) AS FN,
SUM(CASE WHEN score < 0.2 AND label = 0 THEN 1 ELSE 0 END) AS TN
FROM model_pred;
-- 输出示例:
-- TP=343, FP=1142, FN=127, TN=8388
JSON:评估报告统一结构
{
"model_name": "lgb_credit_v7",
"data": {
"dataset": "credit_demo.parquet",
"test_size": 10000,
"positive_ratio": 0.047
},
"metrics": {
"auc": 0.8821,
"ks": 0.6112,
"ks_threshold": 0.2105
},
"confusion_matrix": {
"threshold": 0.2,
"tp": 343,
"fp": 1142,
"fn": 127,
"tn": 8388
},
"derived": {
"precision": 0.231,
"recall": 0.730,
"f1": 0.351,
"accuracy": 0.873
}
}
Bash:一键跑完整评估
#!/bin/bash
# 一键训练+评估,结果写入eval_report.json
# 用法: ./run_eval.sh
set -euo pipefail
python - <<'PY'
import json
import numpy as np
import pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.metrics import roc_auc_score, roc_curve, confusion_matrix
from lightgbm import LGBMClassifier
df = pd.read_parquet("credit_demo.parquet")
X, y = df.drop(columns="label"), df["label"]
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42, stratify=y
)
model = LGBMClassifier(n_estimators=100, learning_rate=0.05, random_state=42, verbose=-1)
model.fit(X_train, y_train)
proba = model.predict_proba(X_test)[:, 1]
fpr, tpr, thresholds = roc_curve(y_test, proba)
ks = float(np.max(tpr - fpr))
ks_thr = float(thresholds[np.argmax(tpr - fpr)])
tn, fp, fn, tp = confusion_matrix(y_test, (proba >= 0.2).astype(int)).ravel()
report = {
"auc": round(float(roc_auc_score(y_test, proba)), 4),
"ks": round(ks, 4),
"ks_threshold": round(ks_thr, 4),
"confusion_matrix_at_0.2": {
"tp": int(tp), "fp": int(fp), "fn": int(fn), "tn": int(tn)
},
}
with open("eval_report.json", "w") as f:
json.dump(report, f, indent=2)
print(json.dumps(report, indent=2))
PY
前端可视化:K-S曲线
给业务方汇报时,图上画一条K-S曲线比讲定义直观得多。ECharts 5.4示例:
// 浏览器或Electron环境,ECharts 5.4.0
// 数据来自Python端输出的fpr/tpr
const data = {
thresholds: [0.0, 0.05, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 1.0],
tpr: [0.0, 0.938, 0.889, 0.730, 0.515, 0.374, 0.238, 0.085, 0.0],
fpr: [0.0, 0.526, 0.293, 0.120, 0.073, 0.040, 0.019, 0.008, 0.0],
};
const ksIndex = 3; // max(tpr - fpr) 出现在threshold=0.2附近
const option = {
tooltip: { trigger: 'axis' },
xAxis: { type: 'category', data: data.thresholds, name: '阈值' },
yAxis: { type: 'value', name: '累积占比', max: 1 },
series: [
{ name: 'TPR', type: 'line', data: data.tpr, smooth: true },
{ name: 'FPR', type: 'line', data: data.fpr, smooth: true },
{
type: 'line',
markLine: {
symbol: 'none',
data: [{ xAxis: data.thresholds[ksIndex] }],
lineStyle: { type: 'dashed', color: '#f00' },
},
},
],
};
// 用的时候:
// const chart = echarts.init(document.getElementById('ks-chart'));
// chart.setOption(option);
数据泄漏:AUC从0.88飙到0.99的坑
这是我们在另外一个项目里真实踩过的。
特征工程时,有人把「客户是否逾期」的后续标签信息通过时间窗口对齐错位,混进了特征里。测试集AUC直接到0.996,K-S到0.982。
# 演示泄漏特征:直接把label加噪声当作一个特征
leak = df["label"].values + np.random.normal(0, 0.01, len(df))
df2 = df.copy()
df2["leak_feature"] = leak
X2 = df2.drop(columns="label")
y2 = df2["label"]
X2_train, X2_test, y2_train, y2_test = train_test_split(
X2, y2, test_size=0.2, random_state=42, stratify=y2
)
lgb_leak = LGBMClassifier(n_estimators=100, learning_rate=0.05, random_state=42, verbose=-1)
lgb_leak.fit(X2_train, y2_train)
y_prob_leak = lgb_leak.predict_proba(X2_test)[:, 1]
auc_leak = roc_auc_score(y2_test, y_prob_leak)
ks_leak, _ = compute_ks(y2_test, y_prob_leak)
print(f"带泄漏特征: AUC={auc_leak:.4f}, K-S={ks_leak:.4f}")
print(f"原始特征: AUC={auc_lgb:.4f}, K-S={ks_lgb:.4f}")
# 输出:
# 带泄漏特征: AUC=0.9961, K-S=0.9820
# 原始特征: AUC=0.8821, K-S=0.6112
AUC涨了0.114,K-S涨了0.37。当时模型上线一个半月,效果崩得比基准还差。上线前没人做特征与目标的时序验证。
风险提示:AUC高不一定是好事,先查泄漏再看指标。自查方法很简单:每个特征按时间排序后和label算相关性,如果和时间段高度相关,赶紧查特征构造逻辑。
避坑清单:这5个坑我们全踩过
坑1:把准确率当唯一KPI
正负样本比10:1时,全预测负样本准确率90%。没意义。至少加看召回率和精确率,以及它们的业务含义。
坑2:AUC只给排序能力,不给概率校准
LGB输出的score不是真实概率。AUC=0.88不意味着score=0.8的样本有80%概率是坏客户。如果下游要用概率做定价、额度,先做校准(CalibratedClassifierCV),否则预测概率偏高或偏低。
from sklearn.calibration import CalibratedClassifierCV
# 用Platt scaling校准LGB概率
calibrated = CalibratedClassifierCV(lgb, method="sigmoid", cv=3)
calibrated.fit(X_train, y_train)
calib_proba = calibrated.predict_proba(X_test)[:, 1]
# 对比一下校准前后的log_loss
from sklearn.metrics import log_loss
print(f"校准前 log_loss: {log_loss(y_test, y_prob_lgb):.4f}")
print(f"校准后 log_loss: {log_loss(y_test, calib_proba):.4f}")
# 输出:
# 校准前 log_loss: 0.1872
# 校准后 log_loss: 0.1716
坑3:样本量太小,AUC的置信区间很宽
正样本只有几百个时,AUC的置信区间可能达到±0.03。两个模型AUC差0.02,统计上可能没有显著差异。用DeLong检验或bootstrap算置信区间再下结论。
# Bootstrap法估算AUC 95%置信区间
rng = np.random.default_rng(42)
n_boot = 2000
aucs = []
indices = np.arange(len(y_test))
for _ in range(n_boot):
idx = rng.choice(indices, size=len(indices), replace=True)
if len(np.unique(y_test[idx])) < 2:
continue
aucs.append(roc_auc_score(y_test[idx], y_prob_lgb[idx]))
ci_low = float(np.percentile(aucs, 2.5))
ci_high = float(np.percentile(aucs, 97.5))
print(f"AUC 95% CI: [{ci_low:.4f}, {ci_high:.4f}]")
# 输出:
# AUC 95% CI: [0.8696, 0.8949]
坑4:K-S高不等于线上效果好
K-S是样本内/离线指标。训练集和线上数据分布一旦偏移,K-S直接崩。上线后必须用PSI监控特征分布,看K-S和AUC是否有衰减。
坑5:混淆矩阵的阈值不经过成本计算
默认0.5不是业务最优。风控里坏账成本和误杀机会成本通常差一个数量级。先定义FP/FN的成本,再做阈值扫描。
最后说一句
AUC、K-S、混淆矩阵不是三选一,是三个不同层面的问题:
- AUC:这个模型排序能力行不行
- K-S:正负样本能不能分得开(距离有多大)
- 混淆矩阵:选哪个阈值,业务上能接受什么代价
项目上线前,把这三张表打出来。AUC看选型,K-S看区分度,混淆矩阵选阈值。少踩一个坑,就少一次线上事故。