模型评估三件套:AUC/K-S/混淆矩阵实战
发布日期: 2026/08/12 阅读总量: 1

一个让我加了两周班的教训

2023年,我们上线了一个信贷风控模型,离线测试报告写着「准确率98.2%」。老板很开心,我也有点飘。上线一个月,坏账率不降反升30%。

排查到最后,问题出在评估方式上:正负样本比1:99的数据集,把全部用户判成「正常」准确率就是99%。我们那个98.2%,本质上是个废指标。

而评估分类模型,真正该看的不是准确率,是混淆矩阵、AUC、K-S这三个东西。它们解决的是三个不同的问题。这篇用完整可跑的代码讲清楚。

问题拆解:三个指标分别回答什么

分类模型评估最大的误区是拿一个指标打天下。准确率回答的是「整体判对多少」,但在样本不平衡时极度失真。真正要回答的问题是三个:

  • 给定一个阈值,模型判对/判错的人到底长什么样? → 混淆矩阵
  • 不依赖阈值,模型把坏人排到好人前面的概率是多少? → AUC
  • 模型在哪个分数点区分能力最强? → K-S

混淆矩阵:所有指标的老祖宗

把预测结果和真实标签做成2×2交叉表:

实际正例实际负例
预测正例TPFP(误报)
预测负例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%坏人漏网
AUC0.8918排序能力良好,但未到0.95优秀线
K-S0.66最大区分点在score≈0.44附近

再跑了不同样本量的耗时对比,环境:MacBook Pro M3 Pro,Python 3.11.7:

样本量混淆矩阵耗时AUC耗时K-S耗时
10,0000.9ms1.4ms1.1ms
100,0006.8ms11.7ms8.3ms
1,000,00052.3ms104.6ms62.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。下一个项目评估模型前,把这篇文章的脚本改一下路径,直接能用。