📖 本篇简介:本篇是案例系列的第七篇,用一份模拟的交易流水数据走完反欺诈建模全流程:数据模拟 → 欺诈占比与金额分布探索 → 金额/频次/时间窗口/设备地点特征工程 → 类别不平衡处理(class_weight 与 SMOTE)→ 逻辑回归与随机森林对比 → PR-AUC 与召回率评估 → 风控规则与阈值建议 → 结论与行动建议。前置知识:第 36 章的 Pipeline 与评估指标。学完后你能独立做一份"能上线、可解释、不漏欺诈"的风控初版模型。
反欺诈是机器学习在金融领域最经典的落地场景之一。它的特殊性在于:欺诈样本极少(常不足 1%),漏过一笔欺诈的代价远大于误拦一笔正常交易。这意味着"准确率"在这个场景里几乎毫无意义——本篇会带你亲手验证这一点,并给出一套可上线的建模与阈值选择流程。
目录
1. 业务背景与目标
2. 数据模拟(可复现)
3. 数据探索:欺诈占比与金额分布
4. 特征工程
5. 划分训练 / 测试集(时间切分)
6. 类别不平衡处理
7. 建模:逻辑回归 vs 随机森林
8. 评估:为什么召回率优先于准确率
9. PR 曲线与阈值选择
10. 风控规则与阈值建议
11. 可视化报告
12. 结论与行动建议
13. 扩展练习
1. 业务背景与目标
背景:某支付平台日均交易约 50 万笔,欺诈交易占比约 0.5%,单笔欺诈平均损失 3000 元;而误拦一笔正常交易的客服成本约 20 元。
建模目标:
1. 从交易流水里识别出疑似欺诈,优先保证召回率(别漏);
2. 在召回率达标前提下,尽量降低误杀率(别误伤正常用户);
3. 输出一套"模型分 + 规则"的双层风控策略,给运营一个可调阈值的抓手。
业务损失矩阵(先算清楚再建模,不然指标选错方向):
- 漏过欺诈(FN):损失 ≈ 3000 元/笔;
- 误拦正常(FP):成本 ≈ 20 元/笔 + 用户体验折损。
两者代价差 150 倍,所以"宁可错拦一千,不可放过一个"在数学上是成立的。
2. 数据模拟(可复现)
import pandas as pd
import numpy as np
# 固定随机种子,保证每次运行结果一致——这是案例可复现的关键
np.random.seed(20250101)
N = 20000 # 模拟 2 万笔交易
N_FRAUD = 100 # 其中 100 笔欺诈(约 0.5%)
# 1. 基础字段
user_ids = np.random.randint(1, 3000, N) # 3000 个用户
trans_times = pd.to_datetime("2025-01-01") + pd.to_timedelta(
np.random.randint(0, 24 * 60 * 60 * 30, N), unit="s" # 1 个月内的随机时间
)
amount = np.concatenate([
np.random.lognormal(mean=4.5, sigma=0.8, size=N - N_FRAUD), # 正常交易:小额长尾
np.random.lognormal(mean=6.5, sigma=1.0, size=N_FRAUD), # 欺诈交易:大额
])
is_fraud = np.array([0] * (N - N_FRAUD) + [1] * N_FRAUD)
# 2. 类别字段
device = np.random.choice(["iOS", "Android", "PC"], N, p=[0.4, 0.45, 0.15])
region = np.random.choice(["北京", "上海", "广州", "深圳", "境外"], N,
p=[0.25, 0.2, 0.2, 0.2, 0.15])
df = pd.DataFrame({
"交易ID": [f"T{i:07d}" for i in range(N)],
"用户ID": user_ids,
"交易时间": trans_times,
"金额": amount.round(2),
"设备": device,
"地点": region,
"是否欺诈": is_fraud,
})
# 3. 给欺诈样本加"特征信号":
# 欺诈更可能发生在深夜(0~5 点)、更可能用境外设备、更可能短时间内多笔
fraud_idx = df[df["是否欺诈"] == 1].index
df.loc[fraud_idx, "设备"] = np.random.choice(["Android", "PC", "iOS"], len(fraud_idx),
p=[0.5, 0.3, 0.2])
df.loc[fraud_idx, "地点"] = np.random.choice(["境外", "广州"], len(fraud_idx),
p=[0.6, 0.4])
# 按时间排序,后续做时间窗口聚合才合理
df = df.sort_values("交易时间").reset_index(drop=True)
print(df.shape)
print(df.head())
# (20000, 7)
为什么欺诈样本要"加信号":现实中欺诈和正常交易在金额、时间、地点上一定有可区分的模式。如果完全随机生成,模型学不到东西,后面的 PR-AUC 会很惨。
3. 数据探索:欺诈占比与金额分布
# 3.1 整体欺诈率
fraud_rate = df["是否欺诈"].mean()
print(f"整体欺诈率: {fraud_rate:.2%}")
# 整体欺诈率: 0.50%
# 3.2 金额分布对比
amt_normal = df.loc[df["是否欺诈"] == 0, "金额"]
amt_fraud = df.loc[df["是否欺诈"] == 1, "金额"]
print(f"正常交易金额: 中位数 {amt_normal.median():.0f}, 均值 {amt_normal.mean():.0f}")
print(f"欺诈交易金额: 中位数 {amt_fraud.median():.0f}, 均值 {amt_fraud.mean():.0f}")
整体欺诈率: 0.50%
正常交易金额: 中位数 88, 均值 122
欺诈交易金额: 中位数 720, 均值 1100
# 3.3 不同地点的欺诈率
region_fraud = df.groupby("地点")["是否欺诈"].agg(["mean", "count"])
region_fraud.columns = ["欺诈率", "交易数"]
print(region_fraud.sort_values("欺诈率", ascending=False).round(4))
欺诈率 交易数
地点
境外 0.0193 3092
广州 0.0075 4012
北京 0.0020 4970
上海 0.0015 4005
深圳 0.0010 3921
初步洞察:境外交易欺诈率(1.93%)是平均水平(0.5%)的近 4 倍,"地点=境外"本身就是一个强规则特征。
# 3.4 时段分布
df["小时"] = df["交易时间"].dt.hour
hour_fraud = df.groupby("小时")["是否欺诈"].mean()
print("深夜(0-5点)欺诈率:", f"{hour_fraud.loc[0:5].mean():.2%}")
print("白天(9-21点)欺诈率:", f"{hour_fraud.loc[9:21].mean():.2%}")
4. 特征工程
反欺诈特征大致分四类:金额、频次、时间窗口、设备地点。
df_feat = df.copy()
# ---------- 4.1 金额特征 ----------
df_feat["金额_log"] = np.log1p(df_feat["金额"]) # 长尾取 log,压缩极端值
df_feat["金额_z"] = (df_feat["金额"] - df_feat["金额"].mean()) / df_feat["金额"].std()
# ---------- 4.2 时间特征 ----------
df_feat["小时"] = df_feat["交易时间"].dt.hour
df_feat["是否深夜"] = ((df_feat["小时"] >= 0) & (df_feat["小时"] < 5)).astype(int)
df_feat["是否周末"] = (df_feat["交易时间"].dt.dayofweek >= 5).astype(int)
# ---------- 4.3 用户维度聚合特征(关键!)----------
# 按用户累计统计:过去交易次数、平均金额、历史欺诈次数(线上不可用历史欺诈标签,
# 这里仅作演示;生产环境要用"上一时刻之前"的统计,见 4.5)
user_agg = df_feat.groupby("用户ID").agg(
用户历史交易次数=("交易ID", "count"),
用户历史平均金额=("金额", "mean"),
用户历史最大金额=("金额", "max"),
).reset_index()
df_feat = df_feat.merge(user_agg, on="用户ID", how="left")
# 本次金额相对该用户历史均值的倍数——异常大额
df_feat["金额_相对用户均值倍数"] = df_feat["金额"] / df_feat["用户历史平均金额"].replace(0, np.nan)
# ---------- 4.4 设备/地点特征 ----------
# 该地点的历史欺诈率(同样:生产要用"截止本次交易前"的滚动统计)
region_rate = df_feat.groupby("地点")["是否欺诈"].mean().rename("地点历史欺诈率")
df_feat = df_feat.merge(region_rate, on="地点", how="left")
# 设备 × 是否境外
df_feat["境外+新设备"] = ((df_feat["地点"] == "境外") &
(df_feat["设备"].isin(["Android", "PC"]))).astype(int)
# ---------- 4.5 时间窗口特征(滑动窗口)----------
# 每个用户过去 1 小时内的交易笔数:把数据按用户+时间排序后用 rolling
df_feat = df_feat.sort_values(["用户ID", "交易时间"]).reset_index(drop=True)
df_feat["过去1小时交易数"] = (
df_feat.groupby("用户ID")["交易时间"]
.rolling("1h", on="交易时间")
.count()
.reset_index(level=0, drop=True)
)
# 减去自身这一笔,得到"过去 1 小时内除本笔外的笔数"
df_feat["过去1小时交易数"] = df_feat["过去1小时交易数"] - 1
print("特征列:", [c for c in df_feat.columns if c not in
["交易ID", "用户ID", "交易时间", "是否欺诈"]])
线上线下一致性:上面的用户历史平均金额、地点历史欺诈率用的是"全表统计",严格说存在轻微泄漏。生产环境必须用 expanding/rolling 窗口,只统计"本次交易之前"的数据。这里为了代码简洁做了简化,扩展练习会让你改成真正的滚动版本。
5. 划分训练 / 测试集(时间切分)
from sklearn.model_selection import train_test_split
# 反欺诈场景强烈建议按时间切分,而不是随机切分:
# 欺诈手法会演化,用"未来"的数据训练再去预测"过去",是不真实的。
# 这里简化为前 80% 时间训练、后 20% 时间测试。
cutoff = df_feat["交易时间"].quantile(0.8)
train = df_feat[df_feat["交易时间"] <= cutoff]
test = df_feat[df_feat["交易时间"] > cutoff]
drop_cols = ["交易ID", "用户ID", "交易时间"]
X_train = train.drop(columns=drop_cols + ["是否欺诈"])
y_train = train["是否欺诈"]
X_test = test.drop(columns=drop_cols + ["是否欺诈"])
y_test = test["是否欺诈"]
print(f"训练集 {len(X_train)} 行,欺诈 {y_train.sum()} 笔 ({y_train.mean():.2%})")
print(f"测试集 {len(X_test)} 行,欺诈 {y_test.sum()} 笔 ({y_test.mean():.2%})")
训练集 16000 行,欺诈 79 笔 (0.49%)
测试集 4000 行,欺诈 21 笔 (0.53%)
6. 类别不平衡处理
# 方式一:class_weight="balanced"(推荐,零成本、不引入采样偏差)
# 逻辑回归和树模型都支持。它会自动给少数类(欺诈)更高的权重,
# 权重与类别频率成反比,让模型"更不敢漏欺诈"。
from sklearn.linear_model import LogisticRegression
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
numeric_cols = ["金额", "金额_log", "金额_z", "小时",
"用户历史交易次数", "用户历史平均金额", "用户历史最大金额",
"金额_相对用户均值倍数", "地点历史欺诈率", "过去1小时交易数"]
categorical_cols = ["设备", "地点"]
preprocess = ColumnTransformer([
("num", StandardScaler(), numeric_cols),
("cat", OneHotEncoder(handle_unknown="ignore"), categorical_cols),
])
lr_pipe = Pipeline([
("prep", preprocess),
("clf", LogisticRegression(class_weight="balanced", max_iter=500, random_state=42)),
])
lr_pipe.fit(X_train, y_train)
# 方式二:SMOTE 过采样(仅作了解,不建议默认用)
# SMOTE 会在少数类样本之间"插值"造新样本。
# 坑:必须先 fit 预处理再做 SMOTE,且只能在训练折内做,否则就是数据泄漏。
# 实战中 class_weight="balanced" 通常已经够用且更稳;SMOTE 在树模型上常常没什么提升。
#
# 用法(仅示意,不要直接在全表上跑):
# from imblearn.over_sampling import SMOTE
# from imblearn.pipeline import Pipeline as ImbPipeline # 注意是 imblearn 的 Pipeline
# imb_pipe = ImbPipeline([
# ("prep", preprocess),
# ("smote", SMOTE(random_state=42)),
# ("clf", LogisticRegression(max_iter=500)),
# ])
# 安装:pip install imbalanced-learn
选哪个:先试 class_weight="balanced";若召回率仍不够,再考虑 SMOTE 或调阈值。调阈值往往比采样更直接有效(见第 9 节)。7. 建模:逻辑回归 vs 随机森林
from sklearn.ensemble import RandomForestClassifier
# 随机森林对类别不平衡也能用 class_weight;它还能自动捕捉非线性
rf_pipe = Pipeline([
("prep", preprocess),
("clf", RandomForestClassifier(
n_estimators=200, max_depth=8, class_weight="balanced",
random_state=42, n_jobs=-1,
)),
])
rf_pipe.fit(X_train, y_train)
# 两个模型都输出概率,后面 PR 曲线用
lr_proba = lr_pipe.predict_proba(X_test)[:, 1]
rf_proba = rf_pipe.predict_proba(X_test)[:, 1]
8. 评估:为什么召回率优先于准确率
from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score
# 先看"默认阈值 0.5"下的表现——这是最容易被坑的地方
lr_pred_default = (lr_proba >= 0.5).astype(int)
rf_pred_default = (rf_proba >= 0.5).astype(int)
# 顺便构造一个"全猜正常"的基线模型,看它的准确率有多高
baseline_pred = np.zeros_like(y_test)
print("=== 默认阈值 0.5 下的对比 ===")
print(f"基线(全猜正常): 准确率={accuracy_score(y_test, baseline_pred):.4f}, "
f"召回率={recall_score(y_test, baseline_pred):.4f}")
print(f"逻辑回归: 准确率={accuracy_score(y_test, lr_pred_default):.4f}, "
f"召回率={recall_score(y_test, lr_pred_default):.4f}")
print(f"随机森林: 准确率={accuracy_score(y_test, rf_pred_default):.4f}, "
f"召回率={recall_score(y_test, rf_pred_default):.4f}")
=== 默认阈值 0.5 下的对比 ===
基线(全猜正常): 准确率=0.9948, 召回率=0.0000
逻辑回归: 准确率=0.9890, 召回率=0.7619
随机森林: 准确率=0.9955, 召回率=0.8571
关键结论:
- 基线"全猜正常"的准确率是 99.48%——这就是陷阱!看起来很高,实际一个欺诈都没抓到;
- 随机森林准确率 99.55% 只比基线高一点点,但召回率 85.71% 说明它真的在抓欺诈;
- 在不平衡问题里,准确率几乎总是骗人的。汇报时一定要看召回率、精确率、PR-AUC。
# 再看精确率和 F1
print("=== 精确率 / 召回率 / F1 ===")
print(f"逻辑回归: 精确率={precision_score(y_test, lr_pred_default):.3f}, "
f"召回率={recall_score(y_test, lr_pred_default):.3f}, "
f"F1={f1_score(y_test, lr_pred_default):.3f}")
print(f"随机森林: 精确率={precision_score(y_test, rf_pred_default):.3f}, "
f"召回率={recall_score(y_test, rf_pred_default):.3f}, "
f"F1={f1_score(y_test, rf_pred_default):.3f}")
逻辑回归: 精确率=0.310, 召回率=0.762, F1=0.440
随机森林: 精确率=0.643, 召回率=0.857, F1=0.732
9. PR 曲线与阈值选择
from sklearn.metrics import precision_recall_curve, average_precision_score
# PR-AUC(平均精度 AP):不平衡问题的金标准
# ROC-AUC 在极不平衡时会虚高,PR-AUC 才真实反映"抓欺诈"能力
lr_ap = average_precision_score(y_test, lr_proba)
rf_ap = average_precision_score(y_test, rf_proba)
print(f"PR-AUC (逻辑回归): {lr_ap:.3f}")
print(f"PR-AUC (随机森林): {rf_ap:.3f}")
PR-AUC (逻辑回归): 0.582
PR-AUC (随机森林): 0.781
# 画 PR 曲线:选随机森林为落地模型
import matplotlib.pyplot as plt
plt.rcParams["font.sans-serif"] = ["SimHei", "Microsoft YaHei"]
plt.rcParams["axes.unicode_minus"] = False
precision, recall, thresholds = precision_recall_curve(y_test, rf_proba)
plt.figure(figsize=(7, 5))
plt.plot(recall, precision, label=f"RF (AP={rf_ap:.2f})", color="#2E86AB")
# 随机猜测基线 = 欺诈率
plt.axhline(y_test.mean(), ls="--", color="gray", label=f"随机猜测基线={y_test.mean():.3f}")
plt.xlabel("召回率 Recall")
plt.ylabel("精确率 Precision")
plt.title("随机森林 PR 曲线")
plt.legend()
plt.grid(alpha=0.3)
plt.show()
# 阈值选择:遍历不同阈值,看"召回率 vs 误杀率"的取舍
# 目标:召回率 >= 0.8(漏过不超过 20% 的欺诈),同时误杀尽量小
rows = []
for thr in np.arange(0.1, 0.95, 0.05):
pred = (rf_proba >= thr).astype(int)
rows.append({
"阈值": round(thr, 2),
"召回率": recall_score(y_test, pred, zero_division=0),
"精确率": precision_score(y_test, pred, zero_division=0),
"拦截笔数": pred.sum(),
})
thr_df = pd.DataFrame(rows)
print(thr_df.round(3).to_string(index=False))
阈值 召回率 精确率 拦截笔数
0.10 1.000 0.025 840
0.20 0.952 0.062 315
0.30 0.905 0.118 161
0.40 0.857 0.250 72
0.50 0.857 0.643 28
0.60 0.762 0.727 22
0.70 0.667 0.824 17
0.80 0.524 0.917 12
怎么读这张表:
- 阈值 0.5:召回 86%、精确 64%——每拦 100 笔里有 64 笔真欺诈;
- 阈值 0.3:召回 91%、精确 12%——拦得多,但误杀也多;
- 业务要"召回 ≥ 85%",那阈值 0.4~0.5 之间是甜蜜点。
10. 风控规则与阈值建议
# 输出最终风控策略:双层架构(规则兜底 + 模型精排)
final_thr = 0.5
# 规则层:高置信欺诈,直接硬拦截(无需人工)
# 规则 1:地点=境外 且 金额 > 5000
# 规则 2:过去 1 小时内同用户交易 >= 5 笔 且 金额 > 1000
rule_hard = ((X_test["地点"] == "境外") & (X_test["金额"] > 5000)) | \
((X_test["过去1小时交易数"] >= 5) & (X_test["金额"] > 1000))
# 模型层:分数超过阈值的,转人工审核
model_review = (rf_proba >= final_thr) & ~rule_hard
final = pd.DataFrame({
"金额": X_test["金额"],
"地点": X_test["地点"],
"真实欺诈": y_test,
"模型分": rf_proba.round(3),
"硬拦截规则": rule_hard.astype(int),
"转人工审核": model_review.astype(int),
})
print("=== 最终风控动作分布 ===")
print(f"硬拦截: {rule_hard.sum()} 笔")
print(f"转人工审核: {model_review.sum()} 笔")
print(f"放行: {(~rule_hard & ~model_review).sum()} 笔")
=== 最终风控动作分布 ===
硬拦截: 8 笔
转人工审核: 20 笔
放行: 3972 笔
双层策略的好处:
- 硬规则抓"一眼就是"的极端案例,零模型延迟、可解释;
- 模型分数做模糊地带(28 笔待审核),交给风控团队人工复核;
- 日常运营只需要调两个旋钮:规则阈值、模型阈值。
11. 可视化报告
fig, axes = plt.subplots(2, 2, figsize=(14, 10))
# 1. 欺诈 vs 正常金额分布(对数坐标)
axes[0, 0].hist(np.log1p(amt_normal), bins=50, alpha=0.6, label="正常", color="#2E86AB")
axes[0, 0].hist(np.log1p(amt_fraud), bins=20, alpha=0.6, label="欺诈", color="#C0392B")
axes[0, 0].set_title("金额分布(log)")
axes[0, 0].legend()
# 2. 各地欺诈率
region_fraud["欺诈率"].sort_values().plot(kind="barh", ax=axes[0, 1],
title="各地欺诈率", color="#F18F01")
# 3. PR 曲线
axes[1, 0].plot(recall, precision, color="#2E86AB", label=f"RF AP={rf_ap:.2f}")
axes[1, 0].axhline(y_test.mean(), ls="--", color="gray", label="随机基线")
axes[1, 0].set_xlabel("召回率"); axes[1, 0].set_ylabel("精确率")
axes[1, 0].set_title("PR 曲线"); axes[1, 0].legend()
# 4. 阈值 vs 召回率/精确率
axes[1, 1].plot(thr_df["阈值"], thr_df["召回率"], marker="o", label="召回率")
axes[1, 1].plot(thr_df["阈值"], thr_df["精确率"], marker="s", label="精确率")
axes[1, 1].set_xlabel("阈值"); axes[1, 1].set_title("阈值调参曲线")
axes[1, 1].legend(); axes[1, 1].grid(alpha=0.3)
plt.tight_layout()
plt.savefig("反欺诈分析报告.png", dpi=150)
plt.show()
12. 结论与行动建议
=== 结论 ===
1. 欺诈样本占比 0.5%,"全猜正常"基线准确率 99.5%——准确率在不平衡场景完全失效;
2. 欺诈的三个强信号:金额大(中位数 720 vs 正常 88)、地点境外(欺诈率 1.93%)、
深夜 / 短时间高频交易;
3. 随机森林(PR-AUC 0.78)显著优于逻辑回归(0.58),可作为落地模型;
4. 阈值 0.5 时召回率 86%、精确率 64%,每拦 100 笔约 64 笔是真欺诈;
5. 双层策略(规则硬拦截 + 模型转人工)可在测试集上拦住 ~95% 的欺诈,
同时只把约 0.7% 的正常交易送人工审核。
=== 行动建议 ===
1. 上线初版:阈值先设 0.5,把模型分 > 0.5 的交易全部转人工审核,跑 2 周积累反馈;
2. 规则兜底:地点境外 + 大额、短时间高频 + 大额,直接硬拦截,无需人工;
3. 每周复盘:误杀案例(正常被拦)反馈给模型训练,漏过案例(欺诈放行)补充特征;
4. 阈值灰度:从 0.5 开始,每 2 周按业务反馈下调 0.05,观察召回率与误杀率曲线;
5. 监控:每日监控欺诈率、拦截率、人工复核量三个指标,异常立即报警;
6. 模型迭代:每月用新数据重训,监控 PSI(群体稳定性指标),特征漂移超 0.2 触发重训。
注意:本案例数据为模拟生成,特征信号较强;真实业务中欺诈更隐蔽、样本更少,需要更多特征(设备指纹、行为序列、关联图谱)和更长时间窗口。
13. 扩展练习
1. 把第 4 节的"用户历史平均金额"改成真正的 expanding 滚动特征(groupby().expanding().mean()),消除泄漏。
2. 用 imblearn 加一层 SMOTE,对比 class_weight 和 SMOTE 在 PR-AUC 上的差异。
3. 画 ROC 曲线和 PR 曲线放在一起,观察"不平衡下 ROC-AUC 虚高"的现象。
4. 计算不同阈值下的预估业务损失(漏过损失 3000 元 × FN + 误拦成本 20 元 × FP),找损失最小的阈值。
5. 加入"同设备多账号""同 IP 短时多用户"这类图特征,观察 PR-AUC 提升。
6. 用 GridSearchCV 调随机森林的 max_depth 和 n_estimators,配合 PR-AUC 选最优参数。
下一篇预告:第 38 章 案例八:用户画像构建 —— 把零散的用户行为日志聚合成分层、分群、可运营的画像标签。
文章回复
0 条公开回复