AUC/K-S/混淆矩阵:模型评估实战与避坑
发布日期: 2026/08/05 阅读总量: 0

一个把「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

注意:sklearnroc_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看区分度,混淆矩阵选阈值。少踩一个坑,就少一次线上事故。