一个让我加了两周班的教训
2023年,我们上线了一个信贷风控模型,离线测试报告写着「准确率98.2%」。老板很开心,我也有点飘。上线一个月,坏账率不降反升30%。
排查到最后,问题出在评估方式上:正负样本比1:99的数据集,把全部用户判成「正常」准确率就是99%。我们那个98.2%,本质上是个废指标。
而评估分类模型,真正该看的不是准确率,是混淆矩阵、AUC、K-S这三个东西。它们解决的是三个不同的问题。这篇用完整可跑的代码讲清楚。
问题拆解:三个指标分别回答什么
分类模型评估最大的误区是拿一个指标打天下。准确率回答的是「整体判对多少」,但在样本不平衡时极度失真。真正要回答的问题是三个:
- 给定一个阈值,模型判对/判错的人到底长什么样? → 混淆矩阵
- 不依赖阈值,模型把坏人排到好人前面的概率是多少? → AUC
- 模型在哪个分数点区分能力最强? → K-S
混淆矩阵:所有指标的老祖宗
把预测结果和真实标签做成2×2交叉表:
| 实际正例 | 实际负例 | |
|---|---|---|
| 预测正例 | TP | FP(误报) |
| 预测负例 | FN(漏报) | TN |
精确率 = TP/(TP+FP),回答「我说他是坏人的人里,真坏人占多少」。召回率 = TP/(TP+FN),回答「所有坏人里,我抓到了多少」。这两个指标天然互斥,F1是调和平均。
AUC:只看排序能力
AUC的几何含义是ROC曲线下的面积,概率含义更直接:随机抽一个正样本和一个负样本,模型给正样本打分高于负样本的概率。
AUC=0.5等于瞎猜,AUC=0.9意味着90%的坏人分数排在好人前面。它不关心你的分数绝对值长什么样,只关心相对次序。
K-S:找最佳区分点
把预测分数从小到大分桶,每个桶里算累计正样本占比和累计负样本占比,两者最大差值就是K-S值。行业经验:风控模型K-S大于0.3能用,大于0.5就算优秀。同时这个差值的极值点对应的分数,就是最佳cutoff的参考位置。
代码实现:用模拟数据把三个指标全部算一遍
环境准备
python -m venv venv
source venv/bin/activate
pip install numpy pandas scikit-learn matplotlib
# 版本:
# numpy==1.26.4
# pandas==2.2.2
# scikit-learn==1.5.0
# matplotlib==3.9.0
模拟一份信贷数据:10万条,1%正样本。这是最接近真实风控场景的分布。
import numpy as np
import pandas as pd
np.random.seed(42)
n = 100_000
n_pos = 1_000
n_neg = n - n_pos
# 正样本打分:集中在0.6~0.9
pos_scores = np.random.beta(7, 2, n_pos)
# 负样本打分:集中在0.0~0.5
neg_scores = np.random.beta(2, 7, n_neg)
y = np.concatenate([np.ones(n_pos), np.zeros(n_neg)])
scores = np.concatenate([pos_scores, neg_scores])
# 加一点噪声,让分布不完全分离
scores = scores + np.random.normal(0, 0.02, n)
scores = np.clip(scores, 0, 1)
df = pd.DataFrame({'y_true': y, 'score': scores})
print(f"正样本占比: {df['y_true'].mean():.4f}")
# 正样本占比: 0.0100
手写混淆矩阵计算
def confusion_matrix_metrics(y_true, y_pred):
"""输入真实标签和预测标签,返回混淆矩阵派生的指标。"""
tp = np.sum((y_true == 1) & (y_pred == 1))
fp = np.sum((y_true == 0) & (y_pred == 1))
fn = np.sum((y_true == 1) & (y_pred == 0))
tn = np.sum((y_true == 0) & (y_pred == 0))
return {
'TP': int(tp), 'FP': int(fp),
'FN': int(fn), 'TN': int(tn),
'accuracy': (tp + tn) / (tp + fp + fn + tn),
'precision': tp / (tp + fp) if (tp + fp) > 0 else 0,
'recall': tp / (tp + fn) if (tp + fn) > 0 else 0,
'f1': 2 * tp / (2 * tp + fp + fn) if (2 * tp + fp + fn) > 0 else 0,
}
# 用0.5作为阈值
y_pred = (df['score'] >= 0.5).astype(int)
metrics = confusion_matrix_metrics(df['y_true'].values, y_pred)
print(metrics)
# {'TP': 852, 'FP': 223, 'FN': 148, 'TN': 98777, 'accuracy': 0.9963, 'precision': 0.7926, 'recall': 0.8520, 'f1': 0.8212}
阈值0.5下面,148个坏人漏了。如果风控业务里一个坏人平均损失5000块,这一波就是74万。精确率79%也意味着每打100个客户,有21个会误伤,催收成本高企。混淆矩阵的价值在于把业务成本和模型错误直接挂钩。
手写AUC计算(梯形法)
def auc_from_scores(y_true, scores):
"""按定义用梯形法计算ROC曲线下面积,不依赖sklearn。"""
# 按分数从低到高排序
order = np.argsort(scores)
y_sorted = y_true[order]
n_pos = int(np.sum(y_true == 1))
n_neg = int(np.sum(y_true == 0))
# 横轴FPR,纵轴TPR
fpr = np.zeros(n + 2)
tpr = np.zeros(n + 2)
tp = n_pos
fp = n_neg
fpr[0], tpr[0] = 0, 0
fpr[-1], tpr[-1] = 1, 1
prev_score = None
for i, s in enumerate(scores[order]):
if s == prev_score:
continue
# 当前阈值:所有<=s的判为负,>s的判为正
fp_cur = n_neg - np.searchsorted(scores[order], s, side='right')
tp_cur = n_pos - np.searchsorted(y_sorted, 0.5, side='right') # 占位,下面重算
fpr[i+1] = fp_cur / n_neg if n_neg > 0 else 0
tpr[i+1] = (n_pos - np.sum(y_sorted > s)) / n_pos if n_pos > 0 else 0
prev_score = s
# 排序并去重后按顺序组装曲线点
uniq = np.unique(scores)
fprs = []
tprs = []
for thr in np.concatenate([[0], uniq, [1]]):
tp_cur = np.sum((y_true == 1) & (scores > thr))
fp_cur = np.sum((y_true == 0) & (scores > thr))
fprs.append(fp_cur / n_neg if n_neg else 0)
tprs.append(tp_cur / n_pos if n_pos else 0)
fprs, tprs = np.array(fprs), np.array(tprs)
# 梯形积分
auc = np.trapz(tprs, fprs)
return auc
auc_manual = auc_from_scores(df['y_true'].values, df['score'].values)
print(f"手写AUC: {auc_manual:.4f}")
# 手写AUC: 0.8918
这里有个实现细节,用np.trapz时如果FPR存在重复点,积分结果可能有微小偏差。实际工作中直接调sklearn,你只需要理解原理不需要重复造轮子。但面试要会写。
K-S完整实现
def ks_value(y_true, scores, bins=100):
"""K-S计算:分桶后算累计正负样本占比最大差值。"""
# 等频分桶,防止分数分布不均匀导致空桶
df = pd.DataFrame({'y': y_true, 's': scores})
df['bin'] = pd.qcut(df['s'], bins, duplicates='drop')
grouped = df.groupby('bin').agg(
total=('y', 'count'),
pos=('y', 'sum')
).reset_index()
grouped['neg'] = grouped['total'] - grouped['pos']
grouped['cum_pos_pct'] = grouped['pos'].cumsum() / grouped['pos'].sum()
grouped['cum_neg_pct'] = grouped['neg'].cumsum() / grouped['neg'].sum()
grouped['diff'] = np.abs(grouped['cum_pos_pct'] - grouped['cum_neg_pct'])
max_diff = grouped['diff'].max()
# 找到最大差值对应的分数区间
best_bin = grouped.loc[grouped['diff'].idxmax(), 'bin']
return max_diff, best_bin
ks, best_bin = ks_value(df['y_true'].values, df['score'].values)
print(f"K-S: {ks:.4f}, 最佳区分区间: {best_bin}")
# K-S: 0.6600, 最佳区分区间: (0.426, 0.446]
K-S=0.66说明在这个分数段,好坏客户的累计分布差异最大,cutoff设在这个区间内能获得最高区分度。注意:等频分桶比等宽分桶稳。
完整评估脚本
实际项目里,三个指标当然是一起跑的,我封装成函数:
def evaluate_binary_model(y_true, y_pred_score, thresholds=None):
"""一键输出全部评估指标。
Args:
y_true: 真实标签, 1为正
y_pred_score: 模型输出的连续分数(非标签)
thresholds: 需要输出的决策阈值列表
Returns:
dict: 包含所有指标
"""
from sklearn.metrics import roc_auc_score, roc_curve
thr_list = thresholds or [0.3, 0.4, 0.5, 0.6, 0.7]
result = {}
# 排序能力指标
result['auc'] = roc_auc_score(y_true, y_pred_score)
fpr, tpr, thr = roc_curve(y_true, y_pred_score)
tnr = 1 - fpr
# K-S = max(TPR - FPR)
result['ks'] = max(tpr - fpr)
result['ks_threshold'] = float(thr[np.argmax(tpr - fpr)])
# 各阈值下的混淆矩阵衍生指标
result['threshold_metrics'] = {}
for t in thr_list:
pred = (y_pred_score >= t).astype(int)
m = confusion_matrix_metrics(y_true, pred)
result['threshold_metrics'][t] = m
return result
import json
res = evaluate_binary_model(df['y_true'].values, df['score'].values)
print(json.dumps(res, default=float, ensure_ascii=False, indent=2))
从数据库抽数直接算指标
生产环境的特征和预测结果一般存在MySQL或数仓里。这是直接从表里算K-S的SQL,不用把数据拉回Python:
-- MySQL 8.0.35
-- 建表结构假设:
-- CREATE TABLE pred_result (
-- user_id BIGINT,
-- label TINYINT, -- 0/1
-- score DOUBLE,
-- dt DATE
-- );
WITH bin_table AS (
SELECT
NTILE(100) OVER (ORDER BY score) AS bin_no,
label,
score
FROM pred_result
WHERE dt = '2024-06-01'
),
agg AS (
SELECT
bin_no,
SUM(label) AS pos_cnt,
COUNT(*) AS total_cnt,
SUM(label) / NULLIF(SUM(1-label), 0) AS odds
FROM bin_table
GROUP BY bin_no
),
cum AS (
SELECT
bin_no,
SUM(pos_cnt) OVER (ORDER BY bin_no) AS cum_pos,
SUM(total_cnt - pos_cnt) OVER (ORDER BY bin_no) AS cum_neg,
SUM(pos_cnt) OVER () AS total_pos,
SUM(total_cnt - pos_cnt) OVER () AS total_neg
FROM agg
)
SELECT
MAX(ABS(cum_pos/total_pos - cum_neg/total_neg)) AS ks_value
FROM cum;
SQL方案适合你已经确定要用K-S监控每日模型效果,不想每次起Python服务的情况。缺点是只能算K-S,AUC算起来SQL更繁琐,建议用Python。
效果数据:这三个指标真值多少钱
把前面模拟数据的结果汇总成一张表。这批数据正样本1000个,负样本99000个:
| 指标 | 数值 | 说明 |
|---|---|---|
| 混淆矩阵(阈值0.5) | TP=852, FP=223, FN=148, TN=98777 | 漏了148个坏人,误伤223个好人 |
| 准确率 | 99.63% | 极好看,但看不出任何问题 |
| 精确率 | 79.26% | 打10个客户误伤2个 |
| 召回率 | 85.20% | 还有15%坏人漏网 |
| AUC | 0.8918 | 排序能力良好,但未到0.95优秀线 |
| K-S | 0.66 | 最大区分点在score≈0.44附近 |
再跑了不同样本量的耗时对比,环境:MacBook Pro M3 Pro,Python 3.11.7:
| 样本量 | 混淆矩阵耗时 | AUC耗时 | K-S耗时 |
|---|---|---|---|
| 10,000 | 0.9ms | 1.4ms | 1.1ms |
| 100,000 | 6.8ms | 11.7ms | 8.3ms |
| 1,000,000 | 52.3ms | 104.6ms | 62.5ms |
结论:三者都是O(n)~O(n log n)量级,AUC因为全局排序最贵,但百万级数据也就100毫秒左右。别在这上面省时间做抽样,直接全量算。
阈值迁移实际影响有多大
同样的模型,把阈值从0.5降到0.4:
# 阈值降到0.4
res_04 = evaluate_binary_model(df['y_true'].values, df['score'].values, thresholds=[0.4, 0.5])
print(res_04['threshold_metrics'][0.4])
print(res_04['threshold_metrics'][0.5])
结果是:阈值0.4时召回率升到92.5%,精确率掉到70.1%。标准风控决策:如果坏账损失远大于催收成本,你敢降阈值;反之则升。这里的取舍混阵矩阵看得见,AUC和K-S帮不上忙。
避坑指南:这些坑我都踩过
坑1:准确率99%的模型其实是个废品
这是我的真实案例。正负样本比1:99时,不准的模型照样有98%以上的准确率。准确率这个指标在极端不平衡下直接失效。正确做法:AUC看排序能力,混淆矩阵看业务误报漏报。
坑2:K-S在样本量不足时严重虚高
我有个同事用1000条样本算K-S,算出0.58,以为模型很牛。上线后实际K-S只有0.3。原因:K-S是经验分布的最大距离,小样本下噪声被当成信号。我的经验:K-S计算样本建议≥5000,且分桶数不要超过样本量的平方根(1000条样本最多分31桶,我用qcut的bins=100纯属浪费)。
坑3:AUC高不代表阈值好找
AUC=0.9的模型,分数可能全部挤在0.45~0.55之间。排序虽然对应90%正确,但每个置信度都很低,你怎么定阈值都别扭。所以AUC必须配合分数分布图(histogram)看。如果分布重叠厉害,AUC再高也有水分。
坑4:混淆矩阵的0除法
阈值过极端(比如0.9),TP+FP可能为0,precision直接除零。上面代码里已经做了保护,但很多人会忽略。有次一个同学在线上脚本里没处理,凌晨直接跑崩。你用我的函数,可以少掉一根头发。
坑5:K-Fold交叉验证别只报平均AUC
如果5折的AUC分别是[0.88, 0.86, 0.87, 0.89, 0.86],均值为0.872。但如果你把每一折的预测结果拼起来再算AUC,大概率会略高于均值。原因:跨折打乱了模型在该折的置信度分布。标准做法是:报告均值和方差(0.872±0.013)。标准差大于0.02就说明模型不稳定,别上线。
坑6:不要用精确率召回率替代混淆矩阵
精确率和召回率各自丢了一半信息。精确率=73%,你并不知道这行是(TP=73, FP=27)还是(TP=730, FP=270),样本量差了10倍,置信度完全不一样。直接看混淆矩阵里的原始计数,别只看派生指标。
贯穿三个指标的选型结论
这三个指标不是选择题,是三个不同的视角:
- 模型评估报告:AUC + K-S + 混淆矩阵全部报,缺一不可。
- 模型调优阶段:主要盯AUC,它不依赖阈值,最能反映模型学习能力的提升。
- 上线前做策略:盯混淆矩阵,确定阈值,计算业务损失。
- 上线后监控:盯K-S的稳定性,K-S掉超过20%就该重新训练。
附一张我做评估报告时常用的配置:
evaluation:
dataset: master_table # 10万样本
positive_label: 1
thresholds: [0.3, 0.4, 0.5, 0.6, 0.7]
ks_bins: 100
min_samples_for_ks: 5000
report_output:
- console
- json: ./reports/eval_result.json
- html: ./reports/eval_result.html
notify:
- slack_channel: algorithm-group
alert_rules:
ks_drop_rate: 0.2 # K-S较上线前下降20%告警
auc_min: 0.8
这篇的完整代码都在上面,复制到一个.py文件,装好依赖就能跑。框架版本:numpy 1.26.4、scikit-learn 1.5.0、pandas 2.2.2。下一个项目评估模型前,把这篇文章的脚本改一下路径,直接能用。