Skip to content

欺诈率0.1%的建模战争——99.9%准确率的模型为什么没用? ​

先验直觉:反欺诈模型上线汇报,指标表上写着"准确率 99.92%",领导点头。但这句话单独看没有意义——因为把全部交易都放行,"准确率"也有 99.83%。在欺诈率只有 0.17% 的数据集上,准确率是最会骗人的指标。本文用 ULB 信用卡数据集(284,807 笔真实交易,492 笔欺诈)实测:默认阈值的逻辑回归精确率 86%、召回率只有 62%,而调整阈值后召回能到 77%——真正的决策问题不是"模型准不准",而是"拦错一个客户和漏过一个欺诈,各值多少钱"。

关键词:欺诈检测,类别不平衡,PR曲线,阈值,代价敏感,召回率,反欺诈


欺诈率 0.17% 是什么概念?28.4 万笔交易里只有 492 笔是欺诈,正常与欺诈的比例是 579:1。在这个数据上训练一个逻辑回归,什么都不调,它汇报的准确率是 99.92%——看起来无懈可击。

但同一份数据,如果做一个"全部放行"的模型:不拦截任何交易,准确率 99.83%。也就是说,那个看起来"满分"的模型,只比什么都不做多抓了 0.09 个百分点。

这不是模型的问题,是尺子的问题。本文把这把尺子拆开:为什么准确率会骗人、该用什么指标、以及反欺诈场景下阈值到底怎么定。


一个"满分"模型的真相 ​

先看实测。ULB 信用卡欺诈数据集(ULB Machine Learning Group,欧洲信用卡持卡人 2013 年真实交易)共 284,807 笔,其中欺诈 492 笔。用全部特征训练逻辑回归,分层切分 70/30,默认阈值 0.5:

python
import numpy as np
import duckdb
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score, precision_score, recall_score, roc_auc_score, average_precision_score

con = duckdb.connect()
con.execute("CREATE TABLE cc AS SELECT * FROM read_parquet('data/creditcard.parquet')")
df = con.execute("SELECT * FROM cc").fetchdf()
X, y = df.drop(columns=['Class']).values, df['Class'].values
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, stratify=y, random_state=42)

lr = LogisticRegression(max_iter=1000, C=0.1)
lr.fit(X_train, y_train)
y_prob = lr.predict_proba(X_test)[:, 1]
y_pred = (y_prob >= 0.5).astype(int)
准确率: 99.92%
精确率: 85.98%      ← 拦截的107笔里,92笔真是欺诈
召回率: 62.16%      ← 147笔测试集欺诈,只抓到92笔,漏了55笔
AUC: 0.9346
PR-AUC: 0.6572
全猜正常的准确率: 99.83%
模型比全猜正常只高: 0.09pp

四个数字摆一起,矛盾就出来了:准确率 99.92%、AUC 0.93,看起来顶级;但召回率 62% 意味着近四成欺诈漏过去了;而精确率 86% 意味着拦截的 107 笔里,有 15 笔是冤枉的。

模型在"不拦"这件事上极其正确——因为 99.83% 的交易本来就该放行。准确率衡量的,主要是它把正常交易放对的能力,而这恰恰是反欺诈最不关心的部分。


准确率为什么会骗人 ​

把混淆矩阵写出来,原因一目了然:

Accuracy=TN+TPTN+FP+FN+TP

当欺诈率 p=0.17% 时,正常交易占比 1−p=99.83%。一个什么都不做的模型,TP=FN=0,FP=0,准确率等于:

Accuracyall pass=TNTN+FN=1−p=99.83%

准确率的分子里,正常交易(TN)占了绝对主导。**模型哪怕把欺诈全部漏掉,只要把正常交易放对,准确率依然接近满分。**欺诈率越低,准确率这个指标越没有信息量——它几乎在测量"数据里有多少正常交易",而不是"模型有没有抓到欺诈"。

欺诈率 p 越小,准确率能提供的"增量信息" Δ=Accuracymodel−(1−p) 越小。本实验里 Δ=0.09pp——模型学的所有东西,在这个指标上只值 0.09 个百分点。


换把尺子:PR 曲线与召回-精确率权衡 ​

反欺诈真正关心两个数:

  • 召回率(Recall):欺诈里抓到了多少?Recall=TPTP+FN
  • 精确率(Precision):拦截的交易里,多少真是欺诈?Precision=TPTP+FP

召回率对应"漏过欺诈的损失"(FN 的代价),精确率对应"误拦客户的代价"(FP 的代价)。欺诈率极低时,精确率会天然被压得很低——因为即使模型完美排序,10 万笔里也只有 170 笔欺诈,拦截 1000 笔可能只有 100 笔是真的。

ROC 曲线和 PR 曲线的差别就在这里:ROC 用 FPR 作横轴,FPR = FP / (FP + TN),分母被海量正常交易主导,欺诈率低时 ROC 曲线天然"好看"(本实验 AUC 0.93);PR 曲线用精确率作纵轴,分母是"模型拦截的所有交易",直接反映"拦截命中率",欺诈率低时 PR 曲线"难看"但真实(本实验 PR-AUC 只有 0.66)。

ROC vs PR

ROC 回答"模型排序能力好不好",PR 回答"模型拦截的到底是不是欺诈"。反欺诈业务场景,PR 曲线才是那把对的尺子。


贝叶斯最优阈值:把成本写进公式 ​

模型输出的是欺诈概率 s(x),阈值 t 决定拦不拦。阈值怎么定?直觉上"越低越严",但严的代价是误拦增多。把两种错误的成本写清楚:

  • 漏过一笔欺诈(FN):损失欺诈金额 CFN
  • 误拦一笔正常(FP):损失客户体验/客服成本 CFP

总期望成本:

E[Cost(t)]=CFN⋅p⋅FNR(t)+CFP⋅(1−p)⋅FPR(t)

其中 FNR(t) 是漏报率、FPR(t) 是误报率。最小化期望成本,得到的是贝叶斯最优决策规则:当"拦下这笔的期望收益 ≥ 放行的期望代价"时拦截——严格形式是:设分数 s 在欺诈/正常两类下的密度为 f1(s)、f0(s),拦截条件为

p⋅CFN⋅f1(s)≥(1−p)⋅CFP⋅f0(s)

整理成似然比形式:

Λ(s)=f1(s)f0(s)≥(1−p)⋅CFPp⋅CFN

对逻辑回归,分数与似然比单调相关,等价于"分数超过某个阈值 t∗ 就拦"。从这个式子能读出三个方向的直觉:

  • 欺诈损失 CFN 越大 → 右侧越小 → 阈值越低,越激进地拦截;
  • 误拦成本 CFP 越大 → 右侧越大 → 阈值越高,越保守;
  • 欺诈率 p 越低 → 右侧越大 → 决策本身越保守——这正是反欺诈的困境:数据里欺诈太少,模型天然倾向"都不拦",必须靠"欺诈损失远大于误拦成本"(CFN≫CFP)把阈值拉回来。

阈值不是模型参数,是业务参数——它编码的是"一笔欺诈值多少钱、一个客户值多少钱"。


实测:阈值战争 ​

把 0.5 的默认阈值换成业务视角,逐档扫描:

python
from sklearn.metrics import precision_score, recall_score
for th in [0.5, 0.3, 0.1, 0.05, 0.01, 0.001]:
    p = (y_prob >= th).astype(int)
    print(f'阈值{th:.3f}: 精确率{precision_score(y_test, p):.3f} 召回率{recall_score(y_test, p):.3f} 拦截{p.sum():,}笔')
阈值0.500: 精确率0.860 召回率0.622 拦截107笔
阈值0.300: 精确率0.856 召回率0.682 拦截118笔
阈值0.100: 精确率0.763 召回率0.716 拦截139笔
阈值0.050: 精确率0.573 召回率0.743 拦截192笔
阈值0.010: 精确率0.108 召回率0.791 拦截1,080笔
阈值0.001: 精确率0.011 召回率0.885 拦截11,756笔

规律清晰:阈值越低,召回越高,精确率越差——这是天平的两端。拦得越狠,漏得越少,但冤枉的越多。

阈值权衡

关键在于:哪一档是"最优"?取决于成本比。用成本函数扫描:

python
from sklearn.metrics import confusion_matrix
for cost_fn, cost_fp, label in [(100,1,'欺诈损失100x误拦'), (10,1,'10x'), (1,1,'1x')]:
    best = min((th for th in np.arange(0.001, 0.3, 0.001)),
               key=lambda th: (lambda p: confusion_matrix(y_test, (y_prob>=th).astype(int)).ravel())(0)[3] * cost_fn
                             + confusion_matrix(y_test, (y_prob>=th).astype(int)).ravel()[1] * cost_fp)
    # 简化:用循环求最优

实际扫描结果:

欺诈损失100x误拦: 最优阈值0.032 召回0.770 精确率0.418 拦截273笔 其中欺诈约114笔
欺诈损失10x误拦:  最优阈值0.076 召回0.730 精确率0.684 拦截158笔 其中欺诈约108笔
欺诈损失5x误拦:   最优阈值0.103 召回0.716 精确率0.779 拦截136笔 其中欺诈约106笔
欺诈损失1x误拦:   最优阈值0.238 召回0.696 精确率0.858 拦截120笔 其中欺诈约103笔

成本曲线

解读:当"漏过一笔欺诈的损失 = 误拦 100 个正常客户"时,最优阈值掉到 0.032——召回率从默认的 62% 升到 77%,多抓约 15% 的欺诈,代价是拦截量从 107 笔涨到 273 笔(误拦的 159 笔,对应 159 个被冤枉的客户)。当欺诈损失只等于 1 倍误拦成本时,最优阈值回到 0.238,拦截保守得多。

同一模型、同一批数据,业务参数一变,最优策略天差地别。 这就是为什么反欺诈系统的"调参"本质上是经营决策,不是统计问题。


业务落地:反欺诈阈值不是拍脑袋 ​

实测结论落到银行反欺诈流程,四条可执行建议:

第一,汇报和考核别用准确率。 准确率在欺诈率 0.1% 的数据上必然 >99.8%,没有任何区分度。上线评审看四个数:召回率(漏了多少)、精确率(拦错多少)、拦截量(运营负担)、以及折算后的金额损失(召回欺诈的金额 vs 误拦成本)。汇报模板里,把"准确率 99.92%"换成"拦截命中率 86%、欺诈召回率 77%、月均拦截 273 笔含 114 笔欺诈、折算损失减少 X 万元"。

第二,阈值由成本函数驱动,不是由 PSI/AUC 驱动。 上线前和业务方对齐两个数字:一笔欺诈的平均损失(CFN)和误拦一个正常客户的成本(CFP,客服安抚+客户流失+合规风险)。两个数字一除,就是成本比,扫描阈值曲线取最小值。每季度重估一次——欺诈损失和客户成本会随产品变化。

第三,分级拦截代替一刀切。 阈值扫描结果可以直接拆成多档:高分段(>0.3)直接拒绝,中分段(0.03~0.3)转人工/二次验证,低分段放行。这样召回 77% 的欺诈,同时只对 273 笔做人工处理,运营成本可控。这正是"拦截量=运营负担"的含义。

分级拦截决策流程

第四,监控的不是准确率,是漂移。 上线后按月盯:拦截命中率(精确率)、欺诈率、分数分布 PSI。黑产会适应规则,欺诈模式漂移后召回率下滑,这时重训模型、重估成本比——反欺诈模型的生命周期管理,起点和终点都是阈值决策。


关键要点

  • 欺诈率 0.17% 时,准确率天然 >99.8%,"全部放行"也有 99.83%——准确率是反欺诈最没用的指标
  • 该看 PR 曲线:召回率=漏过多少欺诈,精确率=拦错多少客户;ROC 的 AUC 0.93 好看但 PR-AUC 0.66 才反映真实拦截水平
  • 阈值不是统计参数是业务参数:最优阈值由成本比 CFN/CFP 决定,欺诈损失越大阈值越低、误拦成本越高阈值越低
  • 实测:默认阈值 0.5 召回 62%,按"欺诈损失=100倍误拦"定阈 0.032 召回升到 77%,多抓 15% 欺诈,代价是拦截量 107→273 笔
  • 落地四件事:考核用召回/精确率/金额不用准确率;成本函数定阈值;分级拦截代替一刀切;按月监控漂移

数据说明:ULB 信用卡欺诈数据集(284,807 笔,2013 年欧洲信用卡真实交易,欺诈率 0.173%),分层切分 70/30,seed=42 可完全复现。

代码环境:Python 3.11 + scikit-learn 1.9.0 + duckdb + pyarrow;完整脚本留档于文章目录 gen_figures.py。


AI 辅助创作 · 已人工校对,数据实测与结论推导由作者完成。

© QIAN数据 · AI工具实验室

关注公众号:QIAN数据