☰
反洗钱交易行为监控模型:神经网络特征工程与实战避坑指南
2026/10/9 6:20:35 网站建设 项目流程

简介:这份PDF文档围绕反洗钱交易行为监控展开,聚焦RBF神经网络在可疑交易识别中的应用,面向金融风控从业者、数据建模学习者及机器学习方向的研究人员。内容从洗钱行为的起源与定义切入,梳理国内外反洗钱监控系统的发展脉络,重点阐述基于APC-III与RIS构建的RBF神经网络模型,并分析其在降低误检率、提升检测率方面的表现,适合作为神经网络落地金融场景的参考案例。资源包内仅含1个PDF文件,大小约233KB,篇幅紧凑,便于快速通读与重点查阅。目前已有268人学习,说明该主题在数据建模与智能风控领域具有一定关注度。读者可从中获取反洗钱监控的建模思路、RBF网络结构设计要点以及可疑交易标识与追踪的实现逻辑,为后续开展相关课题研究或工程实践提供可借鉴的方法框架。

1. 反洗钱交易行为监控模型:为什么传统规则引擎开始漏报

一笔 4800 元的转账,收款方是当天刚注册的商户,付款账户在过去 72 小时内有 11 笔来自不同省份的小额入账——这套组合放在规则引擎里可能一条都不触发,因为单笔金额没超阈值、收款方不在黑名单、账户也没被举报过。但把它放进一个训练好的神经网络里,风险分直接飙到 0.93。这就是反洗钱交易行为监控模型要解决的核心问题:规则能抓住已知的坏模式,但抓不住「看起来都合规、组合起来很可疑」的行为。

基于神经网络的反洗钱交易行为监控,本质是把每笔交易及其上下文编码成特征向量,用前馈神经网络、RBF 网络或 LSTM 这类结构去学习「正常交易长什么样」,再把偏离正常分布的行为标出来。它适合两类人:一是手里有交易流水、想做可疑行为识别的风控或数据工程师;二是想从规则引擎迁移到模型打分、但不确定特征怎么搭、阈值怎么定的从业者。这篇笔记按「特征怎么造 → 模型怎么选 → 怎么训练不翻车 → 怎么上线验证」的顺序拆开讲,参数和坑都给到能直接抄的程度。

2. 交易行为特征工程:把流水变成神经网络能吃的向量

2.1 单笔交易的基础字段与衍生维度

原始交易流水通常只有时间、付款方、收款方、金额、渠道、摘要这几列。直接把这些丢给神经网络,模型学不到东西,因为「账户 ID」是类别值、「金额」量纲差异大、「摘要」是文本。常见做法是先做三层衍生:

第一层是金额维度:原始金额、对数金额、该账户过去 30 天金额的 z-score、金额是否落在该账户历史金额的 95 分位以上。第二层是时间维度:交易小时、是否深夜(0-6 点)、距上一笔交易的时间间隔、当日累计交易笔数。第三层是对手方维度:收款方是否首次出现、该对手方近 7 天被多少不同账户转账、付款方与收款方的地区是否一致。

import pandas as pd import numpy as np def build_txn_features(df): # df 列: txn_id, ts, from_acct, to_acct, amount, channel, region df = df.sort_values(['from_acct', 'ts']).copy() df['hour'] = df['ts'].dt.hour df['is_night'] = df['hour'].between(0, 6).astype(int) df['log_amount'] = np.log1p(df['amount']) # 账户级滚动统计,窗口 30 天 grp = df.groupby('from_acct') df['amt_mean_30d'] = grp['amount'].transform( lambda s: s.rolling('30D', on=df.loc[s.index, 'ts']).mean()) df['amt_std_30d'] = grp['amount'].transform( lambda s: s.rolling('30D', on=df.loc[s.index, 'ts']).std()) df['amt_zscore'] = (df['amount'] - df['amt_mean_30d']) / (df['amt_std_30d'] + 1e-6) # 距上一笔交易间隔(秒) df['gap_sec'] = grp['ts'].diff().dt.total_seconds().fillna(-1) # 当日累计笔数 df['day'] = df['ts'].dt.date df['txn_cnt_today'] = df.groupby(['from_acct', 'day']).cumcount() + 1 return df

这段代码的关键在rolling('30D', on=...)这个写法:它按时间窗口而不是行数滚动,避免把半年前的交易算进「近期均值」。amt_zscore用1e-6兜底防止除零,这是血泪经验——新账户前几笔交易标准差为 0,不处理会直接产生 inf,后面归一化全崩。gap_sec首笔填 -1 而不是 0,是为了让模型区分「没有上一笔」和「上一笔就在同一秒」。

2.2 图结构特征:对手方网络的度与聚集系数

反洗钱里最有效的信号往往不在单笔交易上,而在资金网络结构里。一个账户如果同时和 50 个刚注册的账户有往来,它的对手方网络度就异常高。用图神经网络(GNN)当然可以端到端学,但落地时更稳的做法是先手工算几个图指标喂给前馈网络:

特征名含义计算方式异常方向
out_degree_7d近 7 天转出对手方数按 from_acct 去重计数过高
in_degree_7d近 7 天转入对手方数按 to_acct 去重计数过高
repeat_ratio与同一对手方重复交易占比最大对手方笔数 / 总笔数过高或过低
region_entropy交易地区分布熵各地区占比的香农熵过高
new_counterparty_ratio首次出现对手方占比首次对手方数 / 总对手方数过高

region_entropy这个特征特别值得说:正常用户交易地区集中,熵值低;洗钱账户为了分散,地区熵会明显偏高。但注意熵值对样本量敏感,交易笔数少于 5 的账户这个特征不可靠,我一般会加一个txn_cnt做交互项,或者对少于 5 笔的账户直接置为缺失值让模型自己学。

2.3 特征归一化与类别编码的取舍

神经网络对量纲敏感,金额类特征必须归一化。但反洗钱数据是极度不平衡的,用全局 min-max 会被极端值拉偏,常见做法是用分位数裁剪:

from sklearn.preprocessing import QuantileTransformer num_cols = ['log_amount', 'amt_zscore', 'gap_sec', 'out_degree_7d', 'in_degree_7d', 'region_entropy', 'new_counterparty_ratio'] qt = QuantileTransformer(n_quantiles=1000, output_distribution='normal') df[num_cols] = qt.fit_transform(df[num_cols].fillna(-999))

QuantileTransformer把特征映射到正态分布,比 StandardScaler 更抗极端值。fillna(-999)是给缺失值一个独立编码,因为「这个账户没有历史」本身就是信号。类别特征如渠道,用 embedding 层比 one-hot 更省参数,渠道数超过 20 时尤其明显。注意fit_transform只能在训练集上 fit,验证集和线上要用同一个 transformer 做 transform,这个顺序搞反是新手最常见的翻车点。

3. 神经网络选型:前馈、RBF 还是 LSTM

3.1 前馈神经网络作为基线:结构、参数量与训练成本

绝大多数反洗钱场景,前馈神经网络(MLP)就是性价比最高的选择。原因很直接:交易特征经过上面那套工程后已经是固定维度的向量,样本之间没有强时序依赖(单笔判断),MLP 足够。一个可用的基线结构是 3 层:输入层 → 64 → 32 → 1,激活用 ReLU,输出过 sigmoid 做二分类。

import torch import torch.nn as nn class AMLNet(nn.Module): def __init__(self, n_num, n_cat, emb_dim=8): super().__init__() self.emb = nn.Embedding(n_cat, emb_dim) self.fc = nn.Sequential( nn.Linear(n_num + emb_dim, 64), nn.ReLU(), nn.Dropout(0.3), nn.Linear(64, 32), nn.ReLU(), nn.Linear(32, 1) ) def forward(self, x_num, x_cat): e = self.emb(x_cat) x = torch.cat([x_num, e], dim=1) return self.fc(x).squeeze(-1)

参数量算一下:数值特征假设 20 维,加 embedding 8 维共 28 维输入,第一层 28×64+64=1856,第二层 64×32+32=2080,输出层 33,总共约 4000 参数。这个量级在几万到几十万条样本上不会过拟合,训练几分钟就能收敛。Dropout(0.3)是必须的,反洗钱正样本通常只占 0.1%-2%,不加 dropout 模型会把所有样本判成负类。

3.2 RBF 网络在异常检测中的适用边界

RBF(径向基函数)网络在反洗钱里有个特殊用法:做无监督异常检测。它的隐层每个神经元是一个高斯核,中心用 K-means 在正常样本上聚类得到,输出是样本到各中心的距离加权和。正常交易离中心近、输出高,异常交易离中心远、输出低。这个思路适合没有标注数据的场景——很多机构一开始根本没有确认过的可疑案例。

但 RBF 的边界也很清楚:中心数量和宽度参数 σ 极难调。中心太少,正常行为的多样性覆盖不住,误报爆炸;中心太多,每个中心只覆盖几个样本,等于记住训练集,新样本全判异常。我一般用 K-means 聚 20-50 个中心,σ 取各中心到最近邻中心距离的中位数。RBF 更适合做第一层粗筛,把明显异常的交易挑出来,再交给有监督模型精判,而不是直接当最终模型。

3.3 LSTM 处理时序行为序列的输入组织方式

当你要判断的不是单笔交易,而是一个账户一段时间的行为序列时,LSTM 才有优势。比如「这个账户最近 20 笔交易的金额、间隔、对手方是否构成可疑模式」。输入组织方式是关键:

def make_sequence(df, seq_len=20): seqs, labels = [], [] for acct, g in df.groupby('from_acct'): g = g.sort_values('ts') feats = g[['log_amount', 'gap_sec', 'is_night', 'new_counterparty_ratio']].values lab = g['label'].values for i in range(len(g) - seq_len): seqs.append(feats[i:i+seq_len]) labels.append(lab[i+seq_len]) # 用前 20 笔预测第 21 笔 return np.array(seqs), np.array(labels)

这里有个容易忽略的点:gap_sec在序列里要做截断,比如超过 7 天统一置为 7 天对应的秒数,否则一个休眠半年的账户突然交易,间隔值会大到把 LSTM 的输入分布带偏。另外序列样本之间高度重叠(滑窗),训练集和验证集必须按账户划分而不是按样本随机划分,否则同一个账户的序列同时出现在两边,验证指标虚高,上线就翻车。

4. 训练与阈值设定:类别不平衡下的实操参数

4.1 损失函数选择:Focal Loss 与加权 BCE 的对比

反洗钱正负样本比例动辄 1:500 甚至 1:5000,普通 BCE 会让模型倾向于全判负。两种常见处理:加权 BCE 和 Focal Loss。加权 BCE 给正样本一个权重pos_weight = neg_cnt / pos_cnt,实现简单;Focal Loss 在此基础上再加(1-p)^γ调制因子,让模型聚焦难分样本。

# 加权 BCE pos_weight = torch.tensor([neg_cnt / pos_cnt]) criterion = nn.BCEWithLogitsLoss(pos_weight=pos_weight) # Focal Loss class FocalLoss(nn.Module): def __init__(self, alpha=0.25, gamma=2.0): super().__init__() self.alpha, self.gamma = alpha, gamma def forward(self, logits, targets): bce = nn.functional.binary_cross_entropy_with_logits( logits, targets, reduction='none') p = torch.sigmoid(logits) pt = torch.where(targets == 1, p, 1 - p) w = self.alpha * (1 - pt) ** self.gamma return (w * bce).mean()

我的经验是:正样本少于 500 条时用 Focal Loss,gamma=2.0、alpha=0.25是论文默认值,先照抄再微调;正样本上千条时加权 BCE 更稳,因为 Focal Loss 的alpha和gamma两个超参一起调很容易过拟合验证集。注意pos_weight不要设得过大,我见过有人直接设 5000,结果模型对任何风吹草动都报警,精确率掉到 3%,业务方直接弃用。

4.2 验证指标:为什么 AUC 会骗你,要看 PR-AUC 和召回@K

类别极不平衡时,AUC 是个会骗人的指标。因为负样本太多,即使模型把正样本排得很靠后,AUC 也可能有 0.9。真正该看的是 PR-AUC(精确率-召回率曲线下面积)和召回@K——即按风险分排序后,取前 K 个(比如前 1%)能命中多少真实可疑案例。

指标适用场景阈值参考
PR-AUC整体排序能力比随机基线高 5 倍以上
召回@1%人工审核资源有限前 1% 命中 30%+ 正样本
精确率@阈值确定报警线业务可接受的误报率
KS区分度0.4 以上可用

召回@1%这个指标最贴近业务:审核团队每天只能看 100 条,那就看模型排序前 100 条里有多少是真的。我一般会画一条「审核量 vs 召回」曲线,让业务方自己选工作点,而不是我拍一个 0.5 的阈值。

4.3 阈值不是 0.5:按业务成本反推报警线

模型输出的是概率,但报警阈值应该由误报成本和漏报成本反推。假设漏报一个可疑案例的代价是 100(监管罚款、声誉损失),误报一个的代价是 1(人工审核工时),那么最优阈值满足:

# 按成本最小化找阈值 costs = [] for t in np.arange(0.01, 1.0, 0.01): pred = (probs >= t).astype(int) fp = ((pred == 1) & (y == 0)).sum() fn = ((pred == 0) & (y == 1)).sum() costs.append((t, fp * 1 + fn * 100)) best_t = min(costs, key=lambda x: x[1])[0]

这段逻辑说明:阈值不是模型的一部分,是业务决策的一部分。fn * 100里的 100 是漏报/误报成本比,这个数要业务方给,不能工程师自己拍。实际落地时我还会加一个「灰区」——比如 0.3 到 0.7 之间的交易不直接报警,而是进入人工抽检池,这样既不漏掉边缘案例,也不让审核团队被低分报警淹没。

5. 避坑与排查:上线后模型失效的 5 个真实原因

5.1 特征穿越:用未来信息预测过去

现象:离线验证 PR-AUC 0.85,上线一周后召回率掉到 0.2。原因:计算amt_mean_30d时用了整张表的时间窗口,包含了当前交易之后的数据。模型在离线时「偷看」了未来。解决:所有滚动特征必须严格用ts < 当前交易 ts的数据。用rolling时加closed='left',或者手动 shift 一位。上线前做一次「时间切分验证」:用 1-6 月训练,7 月测试,如果指标比随机切分低很多,基本就是穿越。

5.2 账户 ID 泄漏:模型记住了账户而不是行为

现象:训练集指标完美,新账户上完全失效。原因:把from_acct的哈希值当特征喂进去了,模型直接记住了「这个账户是坏的」,而不是学行为模式。解决:账户 ID 绝对不能作为特征。如果要用账户历史,只能用聚合统计量(历史均值、笔数等),且这些统计量必须用当前交易之前的数据算。验证时专门留一批「训练集里没出现过的新账户」做测试。

5.3 阈值漂移:业务量变化导致报警量失控

现象:上线第一个月每天报警 200 条,第三个月变成 2000 条。原因:交易量本身涨了,固定阈值下报警数线性增长;或者模型分数分布随季节漂移。解决:用分位数阈值代替固定阈值,比如每天取分数前 0.5% 报警,保证审核量稳定。同时监控分数分布的 PSI(群体稳定性指标),PSI 超过 0.2 就触发重新训练。

5.4 样本标注偏差:只学了过去被抓到的模式

现象:模型对新型洗钱手法毫无反应。原因:训练标签来自历史确认案例,而这些案例只覆盖了已知手法。模型学的是「过去被抓到的坏人长什么样」,不是「坏人长什么样」。解决:引入无监督异常检测(比如前面说的 RBF 或自编码器重构误差)作为补充信号,把「行为异常但未被标注」的样本挑出来给人工复核,形成主动学习闭环。别指望有监督模型能发现全新模式。

5.5 线上特征计算与离线不一致

现象:同一个样本,离线打分 0.8,线上打分 0.3。原因:离线用 pandas 算的region_entropy,线上用 Flink 算,两边对「地区」的定义不同(一个用注册地,一个用 IP 归属地),或者缺失值填充策略不同。解决:特征计算逻辑必须离线线上同一套代码,或者至少用同一份特征定义文档加单元测试对齐。上线前做「一致性校验」:抽 1000 个样本,离线线上各打一次分,差异超过 0.01 的样本逐个排查。

6. 模型上线后的持续验证:用回溯测试和影子模式守住效果

模型上线不是终点。我一般会做两件事来持续验证:回溯测试和影子模式。

回溯测试是每月固定做一次:取上个月的全部交易,用当前模型重新打分,看排序前 1% 的样本里有多少被人工确认可疑。这个数字如果连续两个月下降超过 20%,说明模型在退化,需要排查是特征漂移还是新型手法出现。回溯测试的好处是它用的是真实业务结果做验证,比任何离线指标都可信。

影子模式更适合新模型上线初期:新模型和旧规则引擎并行跑,新模型只打分不报警,积累两周后对比两者在相同审核量下的召回差异。如果新模型召回明显更高且误报可接受,再切换。这个过程中我会特别关注两者分歧的样本——规则报警但模型不报警的、模型报警但规则不报警的,逐批人工看,这些分歧样本往往藏着最有价值的改进线索。

# 回溯测试:按月重打分并计算召回@K def backtest(model, df_month, k_ratio=0.01): probs = model.predict(df_month) df_month = df_month.assign(score=probs) k = int(len(df_month) * k_ratio) topk = df_month.nlargest(k, 'score') recall_at_k = topk['label'].sum() / df_month['label'].sum() return recall_at_k # 影子模式:对比新旧模型分歧 def shadow_compare(df, old_score, new_score, threshold=0.5): old_pred = (old_score >= threshold).astype(int) new_pred = (new_score >= threshold).astype(int) disagree = df[old_pred != new_pred] return disagree[['txn_id', 'label', 'old_score', 'new_score']]

backtest里k_ratio=0.01对应「审核前 1%」,这个比例要按实际审核人力调整。shadow_compare返回的分歧样本是金矿,我习惯每周抽 50 条分歧样本人工标注,标注结果直接进下一轮训练集。这个闭环跑起来后,模型效果通常能稳定住,而不是上线三个月就废掉。

最后说个我自己的习惯:每次模型迭代,我都会在验证集上单独看一遍假阴性样本(真实可疑但模型没报警的),按金额排序,看漏掉的是不是集中在小额高频场景。如果是,说明特征里金额权重过高,需要调整损失函数或加交互特征。这个动作花不了半小时,但能避免很多「指标好看、业务骂娘」的尴尬。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询