
简介PDF文档聚焦金融反欺诈场景下的图神经网络应用面向有一定深度学习基础、希望用PyTorch处理复杂交易网络异常检测的读者系统讲解从图建模、PyG/DGL选型到模型训练与部署的完整链路。文档共40页单文件PDF约2.16MB支持目录章节跳转与阅读器大纲定位文字图表显示完整。内容涵盖金融欺诈定义与类型、复杂交易网络节点关系特征、GCN/GAT/GraphSAGE等主流模型原理以及基于PyTorch的异常检测模型构建、数据预处理与特征工程、模型评估与调优并配有实际应用案例分析。既讲清“为什么”也讲清“怎么做”适合机器学习工程师、风险控制从业者及金融科技研究者用于技术选型与方案设计。目前已有142人学习可作为从理论到落地实践的参考手册。1. 金融反欺诈攻防战异常检测为什么要用图神经网络交易流水单看每一笔都合规连成网络却是洗钱团伙的骨架——这正是PyTorch图神经网络做异常检测的典型战场。账户A每天给B转三笔C又给A转回同等金额单账户特征全在正常范围内规则引擎和GBDT都难报出异常因为它们把交易当独立样本团伙欺诈的强信号恰恰藏在交易关系里。图神经网络把账户做节点、交易做边消息传递把邻居特征和拓扑信息聚合进节点表示异常检测从“看一笔交易”变成“看一个子图”。借助PyTorch Geometric大规模交易图可以像普通PyTorch模型一样训练下面的内容覆盖建图、PyG实现、不均衡评估和落地部署代码可直接改到自己的流水数据上。2. 交易网络图建模与PyTorch下GNN选型从异构图到PyG2.1 从流水到异构图节点、边与特征怎么定建图是整个流程里最影响效果的一步模型选型反而不是瓶颈。常见做法是先把账户作为节点、交易作为有向边形成一张同构图跑通基线再考虑把设备、IP、商户作为额外节点升级成异构图。异构图的优势很直接一个设备ID下挂着两百个账户这种设备聚集信号在纯账户图里完全体现不出来。第一次做我一般先把账户特征做足等基线稳定再引入其它实体否则特征量和调参面同时膨胀问题反而不容易定位。边的定义同样要谨慎。原始流水按交易发生同一对账户之间一天可能有几十笔。直接把这些边全部放进去图规模随交易量线性膨胀单笔金额噪声也大。更稳的做法是滑动窗口聚合窗口内同一from、同一to的所有交易合并成一条边边特征带上交易笔数、金额合计、最大单笔和时间跨度。窗口长短跟欺诈团伙的资金周转周期挂钩多数场景在6小时到7天之间我一般同时算多套窗口特征再拼接让模型自己决定时间尺度。账户节点特征至少要覆盖四个维度交易量、金额分布、对手方结构、时间行为。下面的代码把常用聚合特征一次算出来。import pandas as pd def build_node_features(edges: pd.DataFrame, cutoff_ts: int, window_h: int 24): 按滑动窗口聚合账户级特征。 edges 需包含 from、to、amount、ts 四列ts 为秒级时间戳。 w edges[edges[ts] cutoff_ts - window_h * 3600] agg_from w.groupby(from).agg( out_cnt(amount, count), # 转出笔数 out_sum(amount, sum), # 转出总额 out_avg(amount, mean), # 平均单笔转出 out_max(amount, max), # 最大单笔 out_peer(to, nunique), # 不同对手方个数 out_std(amount, std), # 金额标准差团伙转账往往过于均匀 ) agg_to w.groupby(to).agg( in_cnt(amount, count), in_sum(amount, sum), in_peer(from, nunique), ) feat agg_from.join(agg_to, howouter).fillna(0.0) return feat参数说明window_h 是聚合窗口24表示只看最近24小时的交易账户在窗口内没有交易时特征全为0这个0代表着“窗口内无活动”本身就是有意义的输入不要顺手删节点。out_std单独列出来是因为不少团伙会把转账金额控制在固定区间金额方差异常小这个特征对确认型欺诈很有效。out_peer、in_peer分别刻画资金发散和汇聚典型的洗钱子图是“多账户向一个账户集中再分批转出”两个特征能直接命中这类结构。注意调用前要先把from、to映射成连续整数索引否则groupby会落到账户ID字符串上内存和时间都会浪费。这组特征只是起点。按经验对手方熵按金额占比算Shannon熵和夜间交易占比对跑分类、赌博类欺诈区分度很高可以放进第二版迭代。建完图先检查三个数字节点数、边数、平均度。平均度低于2说明图太稀疏模型学不到结构信号平均度超过几百则要检查是否存在连接上万个对手方的超集节点这类节点会让邻居聚合的方差变大一般直接拆掉或单独建边类型。2.2 GCN、GAT、GraphSAGE在交易异常检测里的取舍同构图定下来后模型在三类GNN里选GCN、GAT、GraphSAGE。模型邻居聚合方式交易图上的优势主要限制GCN邻接矩阵归一化后的固定权重平均实现简单、训练快边权重固定无法区分重要边GAT注意力机制动态加权聚合能区分可疑大额边与日常小额边全图训练内存开销大GraphSAGE邻居采样 可训练聚合函数支持超大图和增量推理采样带偏差小图上稳定性弱于GAT为什么交易网络优先试GAT一张图里账户和商户之间的小额日常消费边可能几十条而一笔指向从未交易过账户的百万元转账只有一条。GCN的归一化权重把两类边同等对待注意力机制则让模型学着把更高权重放到信息量大的边上。需要考虑攻防对抗欺诈团伙一旦摸清规则会刻意制造大量小额边稀释信号固定的归一化权重对这种规避几乎没有抵抗力注意力权重可以随图结构变化重新分配。GraphSAGE的价值主要在工程侧。交易图动辄上亿条边GAT全图训练要把整张图放进显存生产环境不现实。NeighborLoader按批次采样邻居每个batch只载入采样子图内存复杂度从O(VE)降到O(batch_size * fanout^层数)。如果每天有大量新账户上线GraphSAGE的归纳式推理还有一个好处新节点不需要重新训练就能通过邻居采样拿到表示。实践中的混合方案是离线用GAT全图训练保证精度在线推理换成NeighborLoader采样关键是两端输入特征完全一致这个口径问题处理不好线上分数直接失真。2.3 PyTorch与PyTorch Geometric环境准备和Data对象代码基于PyTorch PyTorch Geometric环境用conda按下面顺序装能避开大部分protobuf和CUDA版本冲突。conda create -n fraud python3.10 -y conda activate fraud conda install pytorch torchvision pytorch-cuda12.1 -c pytorch -c nvidia pip install torch_geometric python -c import torch_geometric; print(torch_geometric.__version__)参数说明pytorch-cuda12.1是把CUDA运行时和PyTorch绑在一起装的写法换GPU驱动版本时要保持CUDA主版本一致CPU机器去掉这一行即可PyG在CPU上能跑只是训练慢一个数量级。安装最后一行验证PyG版本能打印出版本号说明环境就绪。torch-scatter、torch-sparse是PyG的可选加速依赖按PyG官方文档对应PyTorch版本安装即可装不上时PyG自动回退到基础实现功能不缺失只是GATConv这类算子会慢一些。这个环境同时就解决了anaconda配置pytorch环境的问题后续所有实验都在fraud环境里跑。PyG里图数据统一封装成Data对象下面把已经算好的边表和特征表装进去。import torch from torch_geometric.data import Data edge_index torch.tensor( [rows[from_idx].values, rows[to_idx].values], dtypetorch.long, ) # shape: [2, E]第0行src第1行dst edge_attr torch.tensor(edges[[amount, ts]].values, dtypetorch.float) x torch.tensor(node_features.values, dtypetorch.float) data Data( xx, # 节点特征 [N, F] edge_indexedge_index, # 边索引 [2, E] edge_attredge_attr, # 边特征 [E, D] ytorch.tensor(node_labels, dtypetorch.float), # 节点标签 [N] )Data对象的字段约定是PyG的核心x是节点特征矩阵edge_index第二维是边数每条边由(src, dst)组成有向边的方向会被保留GATConv默认给每个节点加自环不需要手动补。edge_attr可选GATConv通过edge_dim参数把边特征拼进注意力计算。y是账户维度的标签窗口内命中一次确认欺诈就标1这是标准的节点级异常检测设定如果要做边级检测标签就得换到边的维度评估口径也完全不同。一个关键的划分原则图模型的训练和验证划分不能随机抽样。交易图里节点通过边互相连接随机划分会让验证集的标签沿边泄漏进训练集评估结果虚高得离谱。正确做法是按时序切分具体操作在下一章展开。3. PyTorch实现GAT异常检测模型建图、训练与类别不均衡3.1 特征标准化与时间切分所有特征在进模型前要标准化。聚合特征里金额量级从几块钱到几百万不标准化的话GAT的输入层会花大量轮次去调整量纲差异。用StandardScaler逐列做z-score注意只用训练集的比例参数防止验证集信息参与拟合。from sklearn.preprocessing import StandardScaler scaler StandardScaler() x_np scaler.fit_transform(node_features[train_idx]) x_np scaler.transform(node_features) # 用训练集参数转换全量 x_np np.nan_to_num(x_np, nan0.0, posinf0.0, neginf0.0)说明fit_transform只跑在训练集上transform再作用到全量这是防止数据泄漏的标准动作。nan_to_num把缺失、正负无穷统一补0——账户在特征窗口内无交易时标准化后的值就是均值偏移没问题真正的nan出现在out_std这类列上账户只有一笔交易时标准差为nan补0表示“无法计算方差”语义上等价于无波动信息。划分数据集按账户首次出现时间而不是随机抽样。import torch first_ts node_first_seen.values # 每个账户首次出现在样本中的时间戳 t1, t2 cutoff_ts - 14 * 86400, cutoff_ts - 7 * 86400 train_mask torch.tensor(first_ts t1, dtypetorch.bool) val_mask torch.tensor((first_ts t1) (first_ts t2), dtypetorch.bool) test_mask torch.tensor(first_ts t2, dtypetorch.bool) data.train_mask train_mask data.val_mask val_mask data.test_mask test_mask这里的思想很简单模型要预测的是“未来”的欺诈训练集就必须全部来自更早的观测。训练、验证、测试各占一个时间窗口t1和t2按业务节奏调整常见切法是14天训练、7天验证、7天测试。时间切分不能完全消除标签传播——横跨切分点的边仍然存在训练节点的标签会经边影响验证节点。保守做法是把切分点前后一天内的边整体从训练图删掉或者干脆用归纳式采样器让每个batch只看到目标节点的K跳邻域这个放在第五章展开。3.2 定义FraudGAT模型两层GATConv加Dropout模型采用两层GATConv第一层多头注意力把输入特征映射到隐空间第二层单头输出标量logit。两层对应两跳感受野正好覆盖“资金汇聚后立即转出”这类两跳欺诈模式再加深会引入过平滑节点表示趋向一致异常反而被抹平。import torch import torch.nn.functional as F from torch_geometric.nn import GATConv class FraudGAT(torch.nn.Module): def __init__(self, in_dim: int, hidden_dim: int 64, heads: int 4, dropout: float 0.3): super().__init__() self.dropout dropout self.conv1 GATConv(in_dim, hidden_dim, headsheads) self.conv2 GATConv(hidden_dim * heads, 1, heads1, concatFalse) def forward(self, x, edge_index, return_attnFalse): x self.conv1(x, edge_index) x F.elu(x) x F.dropout(x, pself.dropout, trainingself.training) if return_attn: x, attn self.conv2(x, edge_index, return_attention_weightsTrue) return x.squeeze(-1), attn return self.conv2(x, edge_index).squeeze(-1)模型的层配置和输出维度对应关系如下层配置输出维度作用conv1GATConv(in_dim, 64, heads4)64×4多头映射捕捉不同类型的邻域模式dropoutp0.3不变抑制对局部结构的过拟合conv2GATConv(256, 1, heads1)1聚合后输出异常logit参数说明conv1的heads4表示4个注意力头并行计算每头输出hidden_dim维concatTrue默认时输出维度变成hidden_dim * heads所以conv2的输入维度要写成hidden_dim * headsconv2是输出层heads1、concatFalse把多头结果平均成1维logit。elu激活在GNN里的效果通常比ReLU稳定因为它对负值有平滑的饱和区能避免欺诈节点那种特征极端但梯度爆炸的情况。dropout放在两个卷积之间交易图噪声大0.3的丢弃率能防止模型记住局部结构。forward里的return_attn分支后面用来做告警解释训练阶段用不到。3.3 类别不均衡与训练循环欺诈账户在真实数据里的比例通常低于1%直接最小化交叉熵模型会收敛到“全判正常”。标准做法是用pos_weight加重正样本的损失等价于代价敏感学习比过采样更稳因为过采样会复制节点等于人为改变图的拓扑。optimizer torch.optim.AdamW(model.parameters(), lr1e-3, weight_decay1e-4) neg int((data.y[data.train_mask] 0).sum()) pos int((data.y[data.train_mask] 1).sum()) # 权重太高容易把正常账户大量误报工业实践里建议封顶 pos_weight torch.tensor(min(neg / pos, 50.0)) def train_step(): model.train() optimizer.zero_grad() logit model(data.x, data.edge_index) loss F.binary_cross_entropy_with_logits( logit[data.train_mask], data.y[data.train_mask], pos_weightpos_weight, ) loss.backward() optimizer.step() return loss.item()参数说明pos_weight越大正样本错判付出的代价越高recall上升、precision下降。neg/pos可能到几百直接照抄会造成正常账户告警刷屏所以一般封顶在50剩下靠决策阈值去压。AdamW配合weight_decay1e-4对GNN足够不需要额外的学习率调度训练前100轮loss下降明显后续进入平台期。full-batch训练要求x和edge_index完整放进显存节点数在百万以下没问题以上就要换NeighborLoader。训练按验证集AP早停保留最佳权重避免过拟合把最后几个epoch的噪声也学进去。best_ap, patience 0.0, 0 for epoch in range(300): loss train_step() ap evaluate() # 在验证集上算PR-AUC代码见第四章 if ap best_ap: best_ap ap torch.save(model.state_dict(), fraudgat.pt) patience 0 else: patience 1 if patience 20: break早停的20轮patience是经验值交易图的验证集只覆盖7天数据噪声大patience太短会把还在爬坡的模型提前停掉如果验证窗口拉长到30天可以相应放宽。evaluate函数指代的是下一章要写的PR-AUC评估这里先占位完整逻辑马上给出。4. 复杂网络异常检测的评估指标与PyTorch调参4.1 准确率在1%样本上如何骗人PR-AUC与Top命中率欺诈率0.5%时一个“全部判正常”的模型准确率是99.5%看起来漂亮实际一点用没有。反欺诈评估的主指标必须是对正样本敏感的PR-AUC、召回率、以及工业界最常看的Top命中率。PR曲线直接刻画precision和recall的权衡不受负样本基数影响比ROC更符合欺诈场景——ROC的横轴FPR在负样本占绝对多数时被压得极低曲线虚高。import numpy as np from sklearn.metrics import average_precision_score, precision_recall_curve with torch.no_grad(): model.eval() prob torch.sigmoid(model(data.x, data.edge_index)).numpy() y_val data.y[data.val_mask].numpy() prob_val prob[data.val_mask] ap average_precision_score(y_val, prob_val) precision, recall, thresholds precision_recall_curve(y_val, prob_val) # Top 1%命中率取分数最高的1%样本看里面的欺诈占比 top_k max(int(len(prob_val) * 0.01), 1) idx_top np.argsort(prob_val)[-top_k:] top_hit y_val[idx_top].mean()说明PR-AUC把所有可能的阈值都遍历一遍对排序质量敏感是模型选型的依据。Top 1%命中率对应真实风控的操作口径全量账户打一次分按分数倒序取前1%进人工复核队列命中率就是队列里真正欺诈的占比假设模型输出顶端集中了30%的欺诈人工复核的效率就是随机抽样的30倍。两个指标一起看AP衡量整体排序Top命中率衡量运营成本后者不达标前者再高也上不了线。4.2 决策阈值怎么定先卡召回再提精度模型输出的是logit转概率不是最终“异常/正常”的判定。阈值是上线前最后一个必调参数而且必须要结合复核人力来定。常见的定法是先设最低召回在满足召回的前提下取精度最高点也可以反过来先定复核队列容量再算阈值。# 策略一要求召回 0.9取精度最高的阈值 target_recall 0.9 valid recall[:-1] target_recall # thresholds 比 precision 少一位 if valid.any(): idx np.where(valid)[0][np.argmax(precision[valid])] threshold thresholds[idx] else: threshold thresholds[len(thresholds) // 2] # 策略二固定复核队列占比比如只允许前2%进人工 budget 0.02 threshold np.quantile(prob_val, 1 - budget)参数说明precision_recall_curve返回的thresholds长度比precision少一所以索引要对齐到[:-1]。策略一的逻辑是先确保欺诈的检出率不低于90%再尽量压低误报适合欺诈损失远大于复核成本的场景策略二适合复核人力紧张的平台阈值按分位点倒推运营成本上限可控。两种策略的结果通常不一致上线前要和业务确认哪一个约束更硬别让算法单方面拍板。4.3 GAT关键超参数表与调优顺序参数建议范围影响hidden_dim32~128越大表达越强越容易过拟合特征维度高时可取128heads4~8多头注意力提升稳定性计算量随头数线性增长8头以上收益递减层数2~33层以上出现过平滑节点分数向全图均值收敛dropout0.2~0.5交易图噪声大0.3左右最稳太小会记住局部结构pos_weight10~50封顶控制漏报误报平衡按负正比折算后封顶特征窗口6h~7d决定捕获的欺诈周期短窗口抓爆发长窗口抓慢速洗钱weight_decay1e-5~1e-3正则化防止邻居聚合把噪声带进表示调参顺序比单点数值更重要。我一般先固定两层GAT、hidden 64、heads 4、dropout 0.3不做任何调优先把基线的AP跑出来然后调pos_weight这个参数对结果影响最大一次只调它基线稳定后再回去动特征窗口和特征列加法式迭代层数和heads放在最后因为改它们牵扯感受野和计算量验证周期最长。每一步只动一个变量AP和Top命中率同时记录否则多个参数一起变根本说不清是哪一步带来的提升。验证集还要注意跨交易日覆盖。只用一天做验证碰上当日行情波动比如大促、工资日评估会失真验证窗口至少跨三个交易日线上效果和验证结果才对得上。5. 反欺诈系统落地增量推理、注意力解释与漂移监控5.1 冷启动与增量推理NeighborLoader处理新账户训练是full-batch推理要按天跑。每天新增几千到几百万新账户不可能天天重训全图常见做法是T1离线全图重算一次表示日内新账户先用规则引擎兜底次日进图模型打分。图模型推理用NeighborLoader做采样每个目标节点只带自己的K跳邻居进子图避免每次推理都遍历全图边表。from torch_geometric.loader import NeighborLoader infer_loader NeighborLoader( data, num_neighbors[15, 10], # 第一层采样15个邻居第二层每节点再采样10个 batch_size2048, shuffleFalse, ) model.eval() scores torch.zeros(data.num_nodes) with torch.no_grad(): for batch in infer_loader: out model(batch.x, batch.edge_index) scores[batch.n_id[:batch.batch_size]] out.squeeze(-1)参数说明num_neighbors决定采样子图规模[15, 10]表示两跳采样每个batch的子图节点数上限约等于 batch_size × (1 15 15×10)也就是2048乘以166内存可控新账户的特征向量是零向量标准化后是均值偏移值模型对它的判断主要依赖邻居聚合传入的结构信息所以邻居采样质量直接决定冷启动打分质量。如果冷启动账户的邻居也大多是新账户两天内不要急着给高分规则兜底会更稳。5.2 用注意力权重定位告警链路上的可疑边GAT的注意力权重可以当可解释性材料用。把第二层每个头的注意力按边取平均权重最高的边就是模型认为“最能解释这个节点异常”的交易关系。把这个信息拼到告警详情页人工复核人员可以沿着高注意力边快速审查不用再从几十条交易里猜。model.load_state_dict(torch.load(fraudgat.pt)) model.eval() with torch.no_grad(): _, (ei, attn) model(data.x, data.edge_index, return_attnTrue) # attn 形状 [E, heads]按头平均后作为每条边的可疑度 edge_susp attn.mean(dim-1) idx edge_susp.argsort(descendingTrue)[:10] for e, a in zip(ei[:, idx].T, edge_susp[idx]): if a 0.5: print(可疑边:, e.tolist(), 注意力权重:, round(a.item(), 3))说明GATConv返回的attn对应实际参与第二层聚合的边PyG会自动加自环自环边的注意力通常偏高按头平均是为了消除单头注意力的随机性。需要提醒的是注意力权重说明“模型重点看了哪些边”不代表因果关系只能当定位线索业务上真正要回查的是这些高权重边对应的交易流水。如果一段时间内高注意力边大量指向冷启动账户往往是欺诈团伙正在繁殖新账户的信号。5.3 三个监控指标判断模型何时退化模型上线后会漂移欺诈团伙改手法、新业务上线、账户分布变化都会让旧模型失效。靠投诉被动发现太晚我习惯监控三个指标分数分布的PSI、交易图的边统计特征、人工复核的告警命中率。def psi(expected, actual, bins10): eps 1e-6 c_exp, _ np.histogram(expected, binsbins, range(0, 1)) c_act, _ np.histogram(actual, binsbins, range(0, 1)) p_exp c_exp / c_exp.sum() eps p_act c_act / c_act.sum() eps return float(((p_act - p_exp) * np.log(p_act / p_exp)).sum())PSI超过0.2说明分数分布整体漂移模型阈值不再适用平均出度、平均单笔金额这类边特征按天统计突增往往对应团伙扩网或改金额结构告警命中率是人工复核结果回流后算的命中率连续一周下滑而PSI没变多半是欺诈在向模型注意力盲区迁移。三个信号交叉看PSI先动、命中率后动中间平均有几天的窗口期。把分数PSI、平均出度和告警命中率放进同一块监控面板模型退化一般比业务投诉早三到五个工作日出现那时触发重训练成本最低。本文还有配套的精品资源点击获取