在风控条线干过一段时间的人应该都有同感:异常交易检测这东西,听起来是个偏门方向,做起来却特别磨人。规则写得松,漏报一多,合规那边天天来催;规则写得紧,误杀一片,交易台的人又急得跳脚。这两年我带着团队把一个智能化异常交易检测模型从零做到了线上跑批,期间踩了不少坑,也沉淀了一些比较落地的做法。这篇文章就把整个项目的思路、特征设计、模型选型、阈值设定到工程落地的全过程拆开讲一遍,给正在做同类项目的朋友一个参考。
这个项目适合谁看?主要三类人:一是券商、期货、私募等机构里做风控模型或交易监控的算法工程师,二是刚转行做金融数据挖掘、想了解业务侧真实约束的数据分析师,三是负责风控系统架构、需要理解模型逻辑的技术负责人。我会尽量把每个决策背后的为什么讲透,也会把那些文档里不会写的坑点出来。
1. 问题定位与项目目标拆解
1.1 异常交易检测到底在防什么
先明确一个前提:异常交易检测不是一个纯粹的“找坏人”的算法题,背后有明确的监管要求和交易所自律规则约束。具体到监控维度,通常覆盖这么几类:
- 市场操纵类:连续交易拉抬股价、尾盘突击拉升、虚假申报误导他人、对倒制造活跃假象。
- 内幕交易与老鼠仓类:关联账户组在敏感时间点同步下单、持仓变动与公开信息发布时间高度吻合。
- 利益输送类:通过大宗交易或特定对手方以偏离市场价的价格成交,完成隐蔽的财富转移。
- 程序化异常类:撤单率异常高、频繁批量申报又撤单、日内回转交易量显著超出正常水平。
传统做法是给每类违规模式写一套规则阈值。比如“撤单率超过50%”就拉警报,“以涨幅偏离超过2%的价格买入”就拉警报。这类规则引擎简单直接,合规人员也容易理解,但问题也相当突出。
1.2 传统规则引擎的三大短板
规则引擎最核心的问题不是不准确,而是滞后。阈值是静态的,但市场环境是动态变化的。一只低价股的正常波动和一只高价股的正常波动完全不一样,牛市的成交量基线和熊市更是差别巨大。用一套固定阈值管全市场,结果就是要么在活跃期误报爆炸,要么在低迷期漏报严重。
第二个短板是维度稀疏。异常交易行为往往是多维度信号的叠加,比如“高撤单率+高申报频率+开盘后短时间内反复申报同一价位”这种组合行为,单看任何一个指标都不够异常,但组合起来就是非常典型的虚假申报特征。规则引擎很难优雅地表达这种多条件组合,写起来要么漏掉,要么变成一长串难以维护的 if-else。
第三个短板是自适应能力为零。市场参与者会学习,新的操纵模式会不断出现。规则引擎面对新型手法时毫无办法,只能等人举报或者事后复盘才发现,再去补一条新规则。这个循环本身就意味着风控永远慢半拍。
1.3 智能化模型的发力点与项目目标
智能化模型解决的就是上面三个问题。它不依赖人去设定“什么样的值算异常”,而是从大量正常交易行为中学习一个“正常的分布”,然后找出偏离这个分布的个体和群体。这种方式天然是动态的,市场活跃度高时,正常的阈值自动跟着抬高;市场清淡时,正常的判定标准自动收紧。
我们这个项目的目标在设计阶段就明确了两条硬指标:
- 对已知异常模式的覆盖率不低于传统规则引擎的 95%,否则没有替换的必要。
- 在相同覆盖率下,误报率比传统规则引擎降低 50% 以上,这是为了减轻人工复核的负担,让合规团队的人手能聚焦在真正可疑的交易上。
这两条指标不是拍脑袋定的。当时我们统计了传统规则引擎近一年的运行数据:月均报警量约 4000 笔,经过人工复核后确认真正需要上报的不到 40 笔,误报率超过 99%。这个数字非常难看,但也是行业常态。如果用模型能把那 40 笔全部保住,同时把总报警量压到 800 笔以内,合规团队的工作量就大幅下降,模型的价值就非常清晰。
1.4 模型边界与不做的事
再强调一下我们刻意不做的部分。这个模型不追求预测某一只股票会不会涨跌,也不做任何“荐股类”的智能化,和收益率预测、阿尔法策略完全是两回事。它只负责一个动作:判断某账户在某时间窗口内的交易行为是否明显偏离其自身历史模式和同类型账户的群体模式。所有与交易行为无关的数据,比如新闻情绪、舆情文本、分析师报告,在项目一期都明确排除在输入范围之外。
这背后的考虑是控制模型的可解释风险。风控模型输出的每一条告警都意味着对某个真实账户的调查动作,监管和合规对“为什么标记这个账户”必须有说得清的理由。如果混入新闻情绪这类软信号,会让解释链条变得非常模糊。我们坚持先用交易数据本身做建模,后续再考虑融合更多数据源。
2. 数据链路与特征工程实战
2.1 数据源盘点与接入方案
做异常交易检测,数据是地基。我们一期接入的数据源有这几类,按优先级排:
- 逐笔委托数据:每笔委托的账户标识、证券代码、买卖方向、价格、数量、申报时间、委托类型。
- 逐笔成交数据:每笔成交的时间、价格、数量、买卖订单编号,用于还原一次交易由哪些委托撮合而成。
- 账户持仓与资金变动数据:日终持仓、银证转账记录,用于识别空仓突然满仓这类极端行为。
- 上市公司公告时间表:用于和交易时间做交叉匹配,识别信息发布前的异常建仓行为。
这里有个实际项目里特别容易踩的坑:逐笔委托和逐笔成交数据的数据量非常大,尤其是股票市场活跃时段,一天的 Level-2 逐笔委托量可以达到数亿笔。传统关系型数据库根本撑不住这个体量的查询。我们当时的方案是用列式存储加分区表,按交易日做分区,委托表和成交表都按证券代码做二级分区。查询时必须带全证券代码和时间范围,否则全表扫描的代价不可接受。
2.2 特征设计三层框架
特征是整个模型的核心,比算法重要得多。我们最终落地了一套三层特征框架,每层解决一个维度的问题。
第一层是账户个体行为特征。核心思路是把每个账户当成一个独立的时间序列对象,刻画它自己的行为画像。具体包括:
- 申报频率特征:单位时间内的委托笔数、撤单笔数、撤单率。
- 价格行为特征:委托价格相对盘口最优买卖价差的分布、追价频率、撤单后改价幅度。
- 成交量特征:相对自身历史均值的偏离度、相对当日全市场均值的偏离度。
- 持仓周期特征:持仓时间分布、日内买卖方向切换次数。
这里有一个关键技巧:特征计算一定要做标准化,但不能用全市场的统一标准化。比如撤单率这个指标,一个高频做市商的撤单率如果只有 10%,对他来说都算很高的水平了,因为做市商的主要利润来源就是来回报价。而一个平时从不撤单的散户忽然撤了几笔,可能就是某种试探行为。所以我们在做标准化时,分母用的是该账户过去 60 个交易日的自身均值,而不是全市场均值。这个细节非常影响模型效果。
第二层是群体关联特征。很多异常交易不是单个账户完成的,而是一组关联账户协同操作。这类特征包括:
- 关联账户组的持仓重合度。
- 组内账户的委托时间同步性,比如多个账户在 10 秒内先后对同一只股票提交同方向委托。
- 对手方交易集中度,也就是一个账户的成交对手方是否高度集中在一两个账户上。
关联账户组的识别本身是个不小的工程,我们先用开户证件、设备指纹、MAC 地址、IP 归属做初步关联,再通过持仓重合度和交易行为相似度做扩展。这里需要注意合规边界,所有关联识别的依据都要来自合规允许的数据字段,不能碰明显越界的信息。
第三层是时序统计特征。异常交易往往发生在特定的时间窗口内,比如开盘后半小时、收盘前半小时,或者重大公告发布前后。我们针对这些敏感窗口单独计算了特征:
- 每个时间窗口内的委托笔数占比、成交量占比。
- 相对过去 20 个交易日同时段均值的偏离度。
- 日内成交量的单调性变化趋势,识别尾盘急拉这种特殊形态。
2.3 样本标注策略:从弱标注到强标注
这块我必须重点展开,因为样本标注是异常交易检测项目里最容易卡住的一步,也是很多团队项目夭折的直接原因。
问题在于:异常交易本质上是一种小概率事件,真实的“已确认异常”样本非常少。我们统计过团队历史上经合规确认并上报的案例,一年的量也就是三五百笔。这个样本量喂给监督学习模型,完全不够看,而且这些确认案例本身就存在选择性偏差,能被发现的大多已经是性质比较严重的。
所以我们的标注策略是分层级构建训练信号,从弱到强分三档:
- 弱信号:历史规则引擎命中的交易行为。这类样本包含大量误报,但其中确实有一定的异常信号含量,可以作为无监督学习的参考分布。我们用这类样本来刻画“疑似异常的行为空间”,而不是直接当标签用。
- 中信号:规则命中且人工复核后认为“值得关注”的样本。这类样本质量高一些,但依然不能完全确认是违规。
- 强信号:最终被认定为异常并触发进一步调查的案例。这类样本量最少,但最可靠。
在实际建模时,我们采用了两段式的方案。第一段用无监督模型在全量账户上做异常打分,得到一批“疑似异常账户”;第二段把历史确认案例和疑似异常账户合并,构建半监督特征空间,用梯度提升树训练一个排序模型,输出最终的异常概率得分。这样既用上了无监督的广度,也保留了监督模型对已知模式的高精度。
2.4 特征计算的工程化实现
特征计算用 PySpark 做的离线批处理和 Flink 做的实时流处理两套并行。离线部分每天收盘后跑一次全量特征,为第二天的模型提供基线数据;实时部分以 5 秒为一个聚合窗口,识别盘中突变行为。
这里给一段特征计算的示例代码,简化了实际业务逻辑,保留了核心思路:
import pandas as pd import numpy as np def calculate_aggregate_features(df, account_col='account_id', stock_col='stock_code', time_col='order_time', action_col='action'): """计算账户在指定时间窗口内的申报与撤单特征""" # 确保时间列是datetime类型 df[time_col] = pd.to_datetime(df[time_col]) # 按账户、股票、小时窗口聚合 df['window_hour'] = df[time_col].dt.hour grouped = df.groupby([account_col, stock_col, 'window_hour']) features = pd.DataFrame({ 'total_orders': grouped.size(), 'cancel_orders': grouped.apply( lambda x: (x[action_col] == 'cancel').sum() ), 'avg_order_size': grouped.apply( lambda x: x['order_volume'].mean() ), 'std_order_size': grouped.apply( lambda x: x['order_volume'].std() ), }).reset_index() # 撤单率 features['cancel_rate'] = ( features['cancel_orders'] / features['total_orders'] ) return features注意一个细节:这里的聚合基础是账户+股票+时间窗口,而不是简单按账户聚合。因为一个账户可能同时持有很多只股票,不同股票上的交易行为模式差异会非常大。如果不拆到股票维度,特征会被模糊掉,模型很难学到真实的个体行为模式。
3. 模型方案设计与训练调优
3.1 算法选型:为什么不用单一的深度学习模型
项目讨论阶段,有人提过用 Transformer 或者图神经网络来做。这类模型确实在某些场景下效果更好,比如考虑账户之间的交互关系时,图结构是天然的表达方式。但我们最终没有在一期采用 GNN,而是走了一条更保守也更实用的路线:孤立森林抓异常主体 + 自编码器做行为重构 + 梯度提升树做风险排序。
原因很简单:第一,样本量不支持训练一个可靠的 GNN。GNN 需要大量带标签的图结构数据,而我们连单个账户的可靠标签都缺,更别说图级标签。第二,GNN 的推理结果非常依赖图的构建质量,而账户关联图本身就有大量噪音,错误连接会把模型带偏。第三,也是最重要的,合规场景要求可解释性,GNN 输出的 embedding 很难向合规同事解释清楚。孤立森林的自变量贡献度、自编码器的重构误差逐特征拆分,这些是合规团队能听懂的东西。
3.2 孤立森林的实战参数与调优
孤立森林的原理一句话说就是:异常点容易被随机特征切分快速隔离,所以路径长度短。这个算法对高维数据的鲁棒性好,而且不要求数据服从特定分布,非常适合我们这种特征维度高、分布长尾明显的数据。
我们在实际训练中用了 3 个版本的特征集合分别训练,再从不同角度打分融合:
- 版本 A:只含账户个体特征,捕获单账户的行为突变。
- 版本 B:个体特征+群体关联特征,捕获协同操纵行为。
- 版本 C:全部特征加时序窗口统计,捕获特定时间段内的异常节奏。
每个版本训练时的关键参数如下:
from sklearn.ensemble import IsolationForest iso_forest = IsolationForest( n_estimators=500, max_samples=4096, contamination=0.02, max_features=0.7, bootstrap=True, random_state=42 ) iso_forest.fit(X_train) # 获取每个样本的异常得分 scores = iso_forest.decision_function(X_test) # 转换为0-1的异常概率 anomaly_proba = 1 - (scores + 0.5) / 1.0几个参数选择背后的考虑:
n_estimators=500:500 棵树的收敛已经足够稳定。但要注意,这里不是越大越好。每棵树的训练都要消耗内存,我们这里的特征维度有 80 多个,样本量是千万级,500 棵树跑一次全量大概需要 40 分钟。再往上加棵树,收益非常有限。max_samples=4096:每棵树的采样量。孤立森林一个特殊之处在于,它用小样本训练反而效果更好,因为小样本能增加每个样本被切分的概率,异常点更容易被隔离。contamination=0.02:这个参数代表你预估数据集中异常点的比例,它在算法中只影响阈值对齐,不影响每个样本的得分排序。实际业务中我们设为 0.02,因为差不多就是历史被关注账户的比例。max_features=0.7:每次切分只随机抽取 70% 的特征做候选,这个设置是为了应对特征间的相关性,减小强特征对切分顺序的干扰。
3.3 自编码器做行为重构评估
自编码器的思路更直观:用正常数据训练一个压缩-重建网络,让它学会重现正常行为模式。到了推理阶段,遇到异常行为时,重建误差就会变大。重建误差越大,说明这个行为越偏离模型见过的正常分布。
网络结构用的是简单的三层 MLP 自编码器,中间瓶颈层的维度设置为输入维度的 0.4 倍,激活函数用 ReLU,损失函数用均方误差。训练时唯一的技巧是:训练集里要刻意剔除掉所有曾经被规则引擎或人工标记过的账户,确保自编码器只见过“我们认为正常”的数据。
这里分享一个实战经验:自编码器的输入特征和孤立森林的输入特征必须做完全相同的标准化处理,连缺失值填充逻辑都不能有偏差。我们踩过一次坑:离线训练时对缺失值用了列均值填充,上线时实时特征管线对缺失值用了 0 填充,结果同一个正常账户在两个系统中的重建误差差出了 3 倍,导致实时引擎的上报量骤增。这个问题的排查花了一整天,最后定位到是填充逻辑不一致。后来我们把两套管线的预处理代码统一成一个共享模块,从根上解决了这个问题。
3.4 梯度提升树的排序模型融合
孤立森林和自编码器输出的是异常得分,但我们不能直接拿这个得分去决定是否告警。原因有两个:一是这两类模型都没有利用历史确认案例的信息,无法对得分做校准;二是我们需要的产出是一个可以解释的风险等级,而不是一个分布不明的连续值。
所以最后加了一个排序模型。我们以历史确认案例为正例,以正常账户为负例,把三路无监督得分加上原始特征作为输入,用 LightGBM 做二分类排序。这里的训练目标不是输出精确的概率值,而是输出一个相对排序:让确认案例的分数尽量排在所有样本的最前面。
这里有个重要的经验:排序模型的正负样本比不要刻意做平衡。在异常检测场景下,人为强制正负比 1:1 会导致模型严重高估异常概率,给后续的阈值校准带来极大麻烦。我们直接用了自然分布下的正负比,大概 1:30000,用 logloss 做评估,验证集表现出的 AUC 约为 0.92。这个数字听起来不错,但你要知道它是在极端不平衡样本上得到的,并不代表实际业务中的精确率就有多高。
3.5 阈值设定方法:从业务反推
阈值设定是整个项目中唯一不能完全交给算法做的事。我们用了收益曲线分析来选择最终阈值:
- 把验证集模型得分从高到低排序。
- 以每一步为一个潜在阈值,计算该阈值下的查全率(即捕获了多少真实异常案例)。
- 同时计算该阈值下的预计报警量(按历史正常账户在该阈值以上的比例换算)。
然后交给业务侧决策:你希望每天最多处理多少个告警?当时合规团队给出的答案是“每天 100 笔以内可以接受”。我们就用这个数字反推出对应的阈值,再去看该阈值下的查全率是否满足不低于规则引擎 95% 的硬指标。
最终确定的阈值是在验证集上捕获了 127 个确认异常中的 121 个,查全率 95.3%,但预计日告警量只有 87 笔。对比传统规则引擎近 99% 的误报率,这个方案相当于在保持同样查全率的前提下,把告警总量压到了原来的 2% 左右。
4. 工程落地与链路监控
4.1 实时计算架构选型
模型在离线评测里效果再好,如果线上跑不动,一切都是零。我们的实时链路要求是:从交易所逐笔数据到达系统,到模型输出告警通知合规人员,端到端延迟不超过 5 秒。这个要求意味着不能走传统的“攒一天数据、晚上批处理”的老路,必须用流式计算框架。
架构上我们分了四层:
- 接入层:行情网关订阅逐笔委托和逐笔成交数据,写入消息队列。
- 流计算层:Flink 消费消息队列,做特征聚合、滑窗统计、与离线基线数据关联。
- 模型推理层:读取特征后调用孤立森林和自编码器的在线模型文件做推理,再通过 LightGBM 模型输出最终风险分。
- 告警层:风险分超过动态阈值的账户信息写入业务库,同步推送到合规工作台。
4.2 性能优化实例:从 15 秒压到 4 秒
第一次上线压测时,从数据接入到告警生成全链路耗时 15 秒,比目标值 5 秒高了 3 倍。定位后发现瓶颈不在模型推理,而在特征计算的重复读取。我们的特征模块里有一个滑窗统计逻辑,每个账户来了新委托事件,都要重新统计过去 5 分钟内的委托笔数和撤单率。流量一大,Flink 的状态后端被频繁访问,IO 就成了瓶颈。
优化方案是两层缓存。第一层用 Flink 内置的 Keyed State 做分钟级聚合,把过去 5 分钟按每分钟一个状态存储,新事件只更新当前分钟的状态,再跨分钟加总,这样就避免了每次事件都去扫全量状态。第二层是把模型的中间结果做 Redis 缓存,同一个账户在 1 秒内触发多次计算时直接复用上一次的结果,因为交易行为在秒级尺度上不会发生质变。
优化后全链路耗时降到了 4 秒左右,其中 Flink 计算占 2.3 秒,模型推理占 0.8 秒,其余是网络传输和业务落库的时间。这里的一个额外收益是:状态变小之后,Flink 的内存占用从 32GB 降到了 12GB,集群成本也降了不少。
4.3 告警分级与复核闭环设计
模型输出的风险分是连续值,但业务侧的处置动作必须是分级分类的。我们做了一套三级告警机制:
- 一级告警(风险分 0.9 以上):当天触发,立即推送给合规专员,需要在 4 小时内完成初步复核。
- 二级告警(风险分 0.7-0.9):当日汇总后推送给业务复核组,24 小时内完成复核。
- 三级关注(风险分 0.5-0.7):不入库不推送,仅作为模型迭代的候选样本池。
这套分级的价值在于把合规团队的精力从“判断一条告警是否值得看”变成了“对一个已经高度可疑的账户做深入分析”,这个转变对团队的工作效率提升是非常明显的。
复核闭环还有一个关键设计:每一次复核的结论都要回写到模型迭代样本库。不管复核结果是确认异常还是排除误报,都会成为下一期模型训练的正负样本候选。这就形成了一个数据飞轮:模型上线时间越长,积累的复核反馈越多,下一版模型的精度就会更高。
4.4 模型漂移监控与定期重训
最后说说模型上线之后的事情。风控模型和推荐模型一个很大的不同在于:异常交易行为具有对抗性。市场参与者一旦发现某种行为经常被触发告警,就会想方设法改变行为模式来规避监控,这就导致模型有效性的持续衰减。
我们监控模型漂移用了两个核心指标:
- PSI(群体稳定性指数):按日比较模型打分分布在近 30 天和近 7 天之间的偏移程度。PSI 超过 0.25 时触发人工分析,排查市场微观结构是否发生了变化。
- 特征贡献度变化:每天记录 LightGBM 模型各特征的重要性排序和平均分裂增益。如果原有主导特征的重要性持续下降,同时某个未参与训练的新特征区分度上升,就提示可能有新的异常模式正在形成。
重训频率我们定的是双轨制:离线模型每月用增量数据重训一次,覆盖过去 3 个月的新样本;但如果 PSI 连续 5 个交易日超过 0.25,则立即触发紧急重训,等模型验证通过后灰度上线。从上线到现在,这个机制已经自动触发了 3 次紧急重训,每一次都是因为市场环境出现了明显的风格切换,如果不重新训练,告警量会明显偏离正常区间。
5. 常见问题与排查技巧实录
5.1 特征数据延迟导致的漏报问题
项目上线后遇到的第一个典型问题:实时特征计算的基线数据用的是 T-1 日离线跑批的结果,但某些账户在 T 日发生了大量新开户后的首次交易,没有历史基线数据。这类账户的特征值就会显示为空或异常,模型往往会把它们打上很高的风险分。
排查看下来发现,不是模型有问题,而是冷启动逻辑缺失。解决方案是给所有账户构建了一个“首次交易替代基线”,用该账户所在营业部、相同资产区间的账户群的均值作为替代基线。等账户积累了 5 个交易日的数据后,再平滑过渡到自身历史基线。这个改动看起来不起眼,但把新开户账户的误报率降了 60% 以上。
5.2 阈值的动态自适应调整
固定阈值在实际运行中最大问题是:市场极端行情下,所有账户的异常得分会同步抬升,导致一级告警量瞬间爆表。我们都经历过单日告警量飙升到平时 10 倍的情况,合规团队根本处理不过来,只能临时手工合并告警。
后面设计的方案是“相对阈值 + 上限熔断”:模型输出的原始风险分先做百分位排名,取当日所有活跃账户风险分的 99.5 分位作为一级告警的动态阈值;同时设定单日一级告警总数不超过 150 笔的熔断上限,超过部分自动降级为二级,次日人工复核。这个机制让告警量在不同市场环境下都保持了相对稳定,业务团队再也不用在极端行情下熬夜盯盘了。
5.3 复盘总结
这个项目从立项到全量上线,前后花了不到四个月的时间。真的要给同行一个建议的话,我会说:不要一上来就追求最先进的算法,先把特征工程做扎实,把数据管线打通,把业务反馈闭环建起来。异常交易检测这个场景,拼的从来不是算法有多酷,而是整个系统能不能持续稳定地产出高精度的告警,并且让业务侧的人看得懂、信得过、用得起来。模型再强,如果合规团队不信任它的判断,整个系统就等于白做。这一点,我是在和业务团队反复磨合了大半年之后才真正体会到的。