ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

PyTorch实战:LSTM+Transformer混合模型在实时交易欺诈检测中的应用

2026/10/8 2:42:08 拓冰建站 浏览量
PyTorch实战:LSTM+Transformer混合模型在实时交易欺诈检测中的应用 简介《金融风控新范式PyTorchLSTMTransformer构建实时交易欺诈检测模型》是一份52页的PDF技术文档面向有PyTorch基础或希望转入金融风控方向的算法工程师与学习者。文档从金融风控背景、技术原理讲到模型设计、训练评估与部署上线覆盖数据预处理与特征工程、整体架构设计、评估指标、实时监测和案例分析并支持目录跳转与左侧大纲定位内容完整、条理清晰。资源包仅含1个PDF文件约2.35MB便于下载后离线系统阅读目前已有145人学习/浏览。阅读者可掌握长短期记忆网络与Transformer的组合建模思路获得数据清洗、特征编码、训练循环、损失函数与优化器选择、评估指标计算、模型部署等关键环节的具体实现参考还能借助目录快速定位重点章节适合作为课题研究、毕业设计或工程入门的系统资料。1. 实时交易欺诈检测为什么需要 LSTMTransformer三个不能妥协的约束做实时交易欺诈检测的都知道规则引擎在召回率和误报率上越来越力不从心而 PyTorch 生态让 LSTM 和 Transformer 这套组合拳真正落地到了生产环境。这份 PDF 用 PyTorch 搭建了一个 LSTMTransformer 混合模型面向的是「每笔交易要在几百毫秒内给出欺诈概率」的真实场景——不是 Kaggle 比赛里的离线预测而是线上实时推理。它对三类人最有价值被规则引擎折磨的风控工程师、想从时间序列预测切到交易风控的算法同学、以及需要给技术决策者解释为什么深度学习比规则更优的人。全文不谈模型竞赛只谈数据怎么构造、模型怎么拼、实时推理怎么压延迟。2. 交易数据序列化与特征构造从原始流水到滑窗样本2.1 按用户分组、按时间排序滑动窗口才是模型能吃的形状原始交易流水在数据库里长什么样做过风控的人都清楚一张大表每行是一笔交易字段包括 user_id、ts、amount、device_id、ip、merchant_category、is_fraud 之类的标签。问题在于单笔交易的特征再丰富也表达不了「连续行为异常」——欺诈往往不是一个点上的事而是一段时间内行为模式的突变。所以第一步必须把流水变成序列样本。常见做法是按 user_id 分组组内按时间戳升序排列然后滑窗取最近 N 笔交易作为一条样本。这里有一个容易被忽略的细节滑窗的终点应该落在「每一笔交易」上而不是每隔 N 笔才取样一次。原因很简单线上推理时每一笔新交易进来都要出分训练数据如果只对部分时刻取样模型对中间时刻的行为模式就学不全。import pandas as pd import numpy as np # 原始交易流水按 user_id 和 ts 排序 df pd.read_csv(transactions.csv, parse_dates[ts]) df df.sort_values([user_id, ts]).reset_index(dropTrue) SEQ_LEN 32 # 每个样本保留最近 32 笔交易 FEATURE_DIM 12 # 每笔交易的特征维度 def build_sequences(df, seq_lenSEQ_LEN): X, y, masks [], [], [] # 按用户分组逐窗滑动每个时刻都作为一次样本终点 for uid, group in df.groupby(user_id): feats group[[amount, hour, device_change, ip_distance]].values labels group[is_fraud].values for i in range(len(group)): end i 1 start max(0, end - seq_len) window feats[start:end] # 左侧补零到固定长度同时记录 mask pad_len seq_len - window.shape[0] window np.pad(window, ((pad_len, 0), (0, 0)), modeconstant) mask np.array([0] * pad_len [1] * window.shape[0]) X.append(window) y.append(labels[i]) masks.append(mask) return np.array(X), np.array(y), np.array(masks)这段代码有三个设计点值得说透。第一padding 放在左边而不是右边因为序列的最右端是「当前这笔交易」LSTM 和注意力机制天然对序列尾部更敏感真实交易必须留在末尾。第二mask 数组和窗口同步生成后面模型计算注意力时要用它屏蔽 padding 位置否则模型会把补的零也当成真实交易去学。第三start max(0, end - seq_len)保证了用户交易笔数不足 SEQ_LEN 时也能产出样本这也是为什么必须配 mask 的原因。2.2 特征工程做的是「信号的密度」而不是「字段的堆砌」原始字段不能直接进模型这是时间序列建模的老规矩。金额、时间戳、设备 ID 这些裸值要么量纲差异太大要么是离散 ID 需要额外处理。欺诈检测的特征工程重点在于把行为转译成「密度」和「强度」给定一个用户最近 N 笔里设备更换了几次、平均间隔多少秒、当前金额是他历史均值的几倍。这些特征直接对应欺诈行为的典型信号——盗号后设备突变、大额快速试探、深夜异常登录。特征名计算方式捕获的欺诈信号amount_zscore(当前金额 - 用户历史均值) / 历史标准差单笔大额突变device_change_rate近 10 笔中设备变更次数 / 10设备频繁更换ip_distance_km当前 IP 与最近 5 笔 IP 的平均地理距离异地盗刷txn_interval_sec与上一笔交易的时间间隔秒自动化脚本高频试探night_txn_ratio近 10 笔中 0-5 点交易占比非活跃时段行为amount_cumsum近 5 笔金额累计值快速转移资金这里有一条血泪经验归一化一定要用「时间切分后的训练集」去拟合 scaler绝对不能在整个数据集上先算 mean 和 std。欺诈检测的时间依赖极强如果你用全局均值归一化训练集里已经混入了未来交易的信息验证和线上效果都会虚高。我一般用 sklearn 的 StandardScaler先 fit 到训练集上再 transform 验证集和测试集。3. 混合模型搭建LSTM 抓近因突变、Transformer 抓长程依赖3.1 为什么是 LSTM Transformer而不是二选一先说结论单一模型在这个任务上都存在短板。LSTM 的优势在于对序列顺序敏感最近的几笔交易状态会直接影响当前决策这正好匹配欺诈行为「近因驱动」的特点——盗号之后几笔交易内就要出分。但它的问题也很明显长距离信息经过多步传递后会衰减用户过去十天前的行为模式对当前决策的贡献LSTM 很难显式建模。Transformer 的优势在于多头注意力可以跳过时间步直接建立「远距离关联」比如用户每周五固定有一笔大额消费这种周期性模式注意力一抓一个准。但 Transformer 本身是位置不敏感的它需要位置编码而且对序列中的局部突变它的建模精细度不如 LSTM 的隐藏状态传递。混合模型把两个分支的特征拼接起来本质上是让 LSTM 负责「最近发生了什么」让 Transformer 负责「长期看这个用户是什么画像」两者互补。3.2 PyTorch 模型定义双分支特征拼接模型结构并不复杂核心是 embedding 层共享、LSTM 和 Transformer 并行、最后拼接特征过全连接层。import torch import torch.nn as nn class FraudLSTMTransformer(nn.Module): def __init__(self, input_dim, embed_dim64, lstm_hidden128, num_heads4, num_layers2, dropout0.3): super().__init__() self.embed nn.Linear(input_dim, embed_dim) self.pos_embed nn.Parameter(torch.randn(1, 32, embed_dim)) # LSTM 分支单层单向保留最后一步状态 self.lstm nn.LSTM(input_sizeembed_dim, hidden_sizelstm_hidden, num_layers1, batch_firstTrue, bidirectionalFalse) # Transformer 分支两层的 TransformerEncoder encoder_layer nn.TransformerEncoderLayer( d_modelembed_dim, nheadnum_heads, dim_feedforward256, dropoutdropout, batch_firstTrue) self.transformer nn.TransformerEncoder(encoder_layer, num_layersnum_layers) # 分类头拼接 LSTM 和 Transformer 的输出 self.classifier nn.Sequential( nn.Linear(lstm_hidden embed_dim, 64), nn.ReLU(), nn.Dropout(dropout), nn.Linear(64, 1) ) def forward(self, x, mask): # x: (batch, seq_len, feat_dim)mask: (batch, seq_len)1 表示真实交易 emb self.embed(x) self.pos_embed # LSTM 分支取序列最后一步的输出padding 在左侧最后一步即最新交易 lstm_out, _ self.lstm(emb) lstm_feat lstm_out[:, -1, :] # Transformer 分支需要传入 key_padding_mask 屏蔽 padding 位置 key_padding_mask (mask 0) trans_out self.transformer(emb, src_key_padding_maskkey_padding_mask) # 用 mask 做平均池化得到全局交易序列表征 trans_mask mask.unsqueeze(-1).float() trans_feat (trans_out * trans_mask).sum(dim1) / trans_mask.sum(dim1).clamp(min1e-6) # 拼接两个分支的特征过分类头 feat torch.cat([lstm_feat, trans_feat], dim-1) logit self.classifier(feat) return logit两个关键参数值得解释。第一LSTM 分支取lstm_out[:, -1, :]而不是h_n[-1]在大多数情况下两者等价但前者语义更直观——它明确表示「序列最后一步的隐藏状态」也就是当前交易进来后模型看到的最新上下文。第二Transformer 分支没有直接取最后一步输出而是做了 mask 平均池化原因在于 Transformer 每一层的输出是整条序列的加权融合最后一步并不天然包含全局信息平均池化对序列长度变化更鲁棒。参数取值选择理由embed_dim6412 维特征映射到 64 维足够表达交叉信息再大容易过拟合lstm_hidden128单层 LSTM 128 维能覆盖近因模式且推理延迟可控num_heads4序列长度只有 324 头足够建模多跳依赖8 头收益有限num_layers2Transformer 两层是短序列的经验选择更深层在数据量不足时反而退化dropout0.3欺诈样本少dropout 低于 0.3 很容易过拟合dim_feedforward256FFN 维度取 embed_dim 的 4 倍PyTorch 默认经验值3.3 训练配置与初始化短序列场景下不要盲目堆参数交易序列长度一般就是 20 到 50 笔这是和 NLP 场景最大的区别。序列短意味着模型容量不需要太大embed_dim64、num_heads4这些参数是安全的起点。如果你的数据量超过百万笔交易可以尝试把embed_dim提到 128、Transformer 层加到 3 层但每加一层都要用验证集 AUC 验证收益不要只看训练集 loss 下降就认为模型变强了。优化器我一般用 AdamW学习率初始 1e-3权重衰减 1e-5。这里有一个训练上的细节因为正负样本极不平衡模型初始阶段会疯狂偏向预测「正常」需要在前几个 epoch 把学习率调大一些用 warmup 策略在 5 个 epoch 内从 1e-4 升到 1e-3之后再用余弦衰减。PyTorch 的get_cosine_schedule_with_warmup可以直接实现这个流程省得自己写调度器。4. 训练与实时推理Focal Loss、时间切分和延迟预算4.1 类别不平衡Focal Loss 比加权 BCE 更稳欺诈交易占比通常不到 1%直接拿 BCE loss 训练模型学会永远输出「正常」就能拿到 99% 的准确率。加权 BCE 给正样本加权重是最简单的解法但权重对学习率极其敏感权重设大了训练震荡设小了正样本还是学不动。Focal Loss 在这种场景下更稳它通过(1 - p_t)^gamma这个调制系数让模型自动把注意力集中在难分样本上。class FocalLoss(nn.Module): def __init__(self, alpha0.25, gamma2.0): super().__init__() self.alpha alpha self.gamma gamma def forward(self, logit, target): p torch.sigmoid(logit) eps 1e-8 # p_t样本真实类别对应的预测概率 p_t p * target (1 - p) * (1 - target) # alpha_t正负样本的非对称权重 alpha_t self.alpha * target (1 - self.alpha) * (1 - target) # Focal Loss 核心对易分样本降权 loss -alpha_t * (1 - p_t) ** self.gamma * torch.log(p_t eps) return loss.mean()参数说明alpha控制正负样本的基础权重0.25 表示负样本贡献 0.75 的权重、正样本贡献 0.25但因为正样本数量极少这个比例实际是在放大正样本的梯度。gamma控制难易样本的降权程度2.0 是检测任务里的常见起点如果你发现模型召回率很低但精确率很高可以把gamma降到 1.5 试试让易分负样本的梯度更大一些防止模型过于保守。4.2 评估指标准确率是陷阱P99 延迟才是生产指标训练过程中我只看三个数验证集 AUC、欺诈样本的 RecallPrecision0.9、以及推理延迟的 P99。AUC 衡量排序能力近似于「模型把欺诈排在正常前面的概率」不受阈值影响是调参时期最稳定的指标。Recall固定精确率则更贴近业务——风控团队通常会设一个误报预算在这个预算下能召回多少欺诈比 AUC 的绝对值更有说服力。实时场景下还要额外测 P99 延迟线上要求是单笔交易从特征构造到模型出分控制在 100 毫秒内P99 是水位线平均延迟没有参考意义。指标计算方式在欺诈检测中的意义AUCROC 曲线下面积排序能力调参时用RecallPrecision0.9精确率为 90% 时的召回率误报预算约束下的业务效果P99 延迟99% 请求的推理耗时线上性能预算是否达标欺诈召回率被模型判为欺诈的真实欺诈占比直接业务价值这里有一个容易翻车的习惯很多人训练完只看测试集 AUC0.95 就觉得模型好了上线后发现一天要处理几十万笔交易模型推理延迟 300 毫秒根本扛不住。模型结构一样离线 AUC 和线上表现是两码事后面避坑章节我会专门展开。4.3 实时推理eval 模式、静态图和 ONNX 导出模型训练是一回事部署是另一回事。PyTorch 动态图的灵活性和实时推理的低延迟需求天然冲突所以部署时至少要过一遍这几步切换到 eval 模式、关闭梯度计算、固定序列长度、用小批量甚至单条推理。更激进的方案是导出 ONNX 或 TorchScript把模型从 Python 运行时解放出来。import torch # 加载训练好的模型权重 model FraudLSTMTransformer(input_dimFEATURE_DIM) model.load_state_dict(torch.load(fraud_model.pt, map_locationcpu)) model.eval() # 关闭 dropout 和 batch norm 的 training 行为 # 在线推理单笔交易进来序列右侧追加左侧丢弃 with torch.no_grad(): # 不再计算梯度显著降低显存和延迟 x_tensor torch.from_numpy(window).unsqueeze(0).float() # (1, seq_len, feat_dim) mask_tensor torch.from_numpy(mask).unsqueeze(0).bool() logit model(x_tensor, mask_tensor) prob torch.sigmoid(logit).item()这段代码里最容易被忽略的是torch.no_grad()。很多同学在离线测试时环境允许但线上服务如果忘了包这一个上下文管理器每一笔交易都会额外走一遍自动求导的图构建延迟直接翻倍。我在自己项目里会把model.eval()和no_grad()写成强制检查项上线前用torch.jit.trace生成静态图版本对比动态图版本的输出差是否在 1e-6 以内确认没问题再切流量。要提醒的是如果用了 TransformerEncodertrace 后通常需要重新走一遍正常的推理路径做校验mask 传入方式不同会导致行为不一致。5. 避坑指南交易序列建模里最常见的四个翻车现场5.1 时间泄露归一化用了全局统计量模型偷看了未来现象训练集 AUC 0.97验证集 AUC 0.96看起来异常好但上线后线上 AUC 暴跌到 0.8。原因在构造特征阶段用整个数据集的均值、标准差去归一化金额和交易间隔。训练集和验证集里每一笔交易的特征值实际上都隐含了「后来发生的交易」的统计信息。模型学到了这个不该有的信号把「与全局平均值的关系」当作预测依据可线上推理时新的交易根本不存在全局统计量信号瞬间消失效果自然崩。这是交易序列建模里最隐蔽的翻车方式损失函数、模型结构、调参全都没问题问题出在最开始的特征工程。解决特征归一化之前先把数据按时间戳排序用前 70% 时间的交易拟合 scaler后 30% 只做 transform。更严格一点特征工程的每一步都要问自己「线上跑的时候这笔交易的这一列特征能通过历史数据实时算出来吗」如果答案是不能这个特征就不该进模型。5.2 Padding 位置与 Mask 不一致模型学会了「关注空位」现象模型在验证集上 Recall 不错但可视化注意力权重时发现模型对序列最左侧的 padding 位置分配了很高的注意力权重尤其是短序列样本上更明显。原因PyTorch 的TransformerEncoderLayer里src_key_padding_mask和attention_mask是两个不同的参数前者屏蔽的是序列外部的 padding后者屏蔽的是序列内部的未来位置。很多人在实现时把 mask 传错了位置或者干脆没传 padding maskTransformer 的注意力就会在 padding 位置上做无效的 softmax 分配。交易序列通常左侧补零这些零值经过 embed 层后并不是一个「无意义向量」模型在学习时会把它们当成某种特殊的交易行为。解决训练和推理时严格统一 mask 的生成逻辑。LSTM 分支其实可以无视 padding但 Transformer 分支务必传入src_key_padding_mask(mask 0)同时检查 padding 方向是左侧。我在代码里会把build_sequences生成的 mask 和模型输入的 mask 做一次断言长度不一致就抛异常这个习惯帮我拦下了至少三次线上事故。5.3 类别极端不平衡下准确率 99% 但欺诈召回率却不到 5%现象训练过程中 loss 一直在降准确率冲到 99.2%看起来模型已经收敛。打开混淆矩阵发现欺诈样本的召回率只有 4.8%模型几乎把所有欺诈都判成了正常。原因这是二分类任务最常见的大脑欺骗术。负样本占 99%模型只要无脑预测「正常」就能拿 99% 准确率loss 也会因为负样本梯度的主导地位而缓慢下降。如果只用准确率看结果你根本意识不到模型废了。更隐蔽的是当你用加权 BCE 把正样本损失放大 100 倍后准确率会掉到 98%有些人一看指标降了就开始回滚权重其实那个 98% 的模型在欺诈召回率上可能比 99.2% 的好十倍。解决训练伊始就把准确率从监控指标里移除只用 AUC、RecallPrecision、欺诈召回率这三个。每个 epoch 结束后打印混淆矩阵直接看正样本那一行。如果训练了 10 个 epoch 召回率仍然没超过 20%优先检查损失函数是否真的在放大正样本梯度其次检查正样本的特征是否和负样本存在严重重叠。5.4 滑窗重叠导致训练集和验证集样本「穿帮」现象训练集与验证集按 80/20 随机切分验证集 AUC 0.95看起来模型泛化完美。上线后第一个月效果尚可第二个月开始模型分数明显偏移第三个月效果骤降。原因交易序列模型是典型的样本重叠场景。同一用户的相邻交易前一条样本的后半段和后一条样本的前半段高度重合随机切分训练集和验证集时验证集里的样本其时间窗口很可能覆盖了训练集里的某个时刻信息泄露在不知不觉中发生。这种情况比特征归一化的全局统计量泄露更隐蔽因为你不看时间戳根本发现不了问题。解决验证集必须按时间切分而且要保证切分点在用户维度上也是统一的。我的做法是取最后 30% 的自然日作为验证区间验证区间内的所有用户的所有交易全部进验证集训练区间不变。更严格的项目还会要求验证集内用户不能出现在训练集里避免模型记住「某个用户的固定行为模式」这种身份信息。如果你发现时间切分之后的 AUC 比随机切分低了 0.1 以上不要怀疑切分方法有问题这个差值才是模型真实的泛化能力。6. 验证模型是否真的可用注意力可视化和时间切分回测模型训练完第一件事不是看 AUC而是把注意力权重拉出来看一眼。PyTorch 里可以注册 forward hook 抓住 TransformerEncoder 某一层的 attention 矩阵把最近 32 笔交易的注意力分配画成热力图。有价值的结论通常有两种模型在欺诈交易附近几笔上集中了注意力说明它学到了「异常行为是连续的」这个信号如果注意力散落在整条序列上毫无规律那模型大概率只是记住了训练集里的噪声模式这种模型上线后稳定性会很差。时间切分回测是最后一道关。我习惯把最后 30% 时间的数据当作「伪线上集」在这份数据上模拟真实推理流程逐笔追加到窗口右侧、左侧丢弃旧交易、实时计算特征。这段代码逻辑上其实只有十几行核心是按时间戳切分而不是随机切分每笔交易只用「当时能拿到的历史数据」构造特征。从那以后我每次做欺诈检测实验都强制走一遍「时间切分回测 注意力检查」这两步再快的模型调参进度也得停下来等这个验证跑完。模型可以快速迭代但验证流程偷懒一次后面补回来的成本是十倍这句话希望帮到你。本文还有配套的精品资源点击获取