ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从DeepLOB到Transformer:限价订单簿预测工程实践

从DeepLOB到Transformer:限价订单簿预测工程实践

限价订单簿建模这两年在量化研究和深度学习圈子里热度一直往上走,但真正把 DeepLOB 和 Transformer 两条路线跑通、比清楚的人并不多。原因不在模型本身,而在数据:LOB 是典型的超高维、强噪声、非平稳时序,同一份网络结构换个归一化方式、换个标签阈值,结果能差出好几个点。DeepLOB 用 CNN 提取盘口的局部形态、用 LSTM 捕捉时序演化,是这条线上最经典的基线;Transformer 方案则把注意力直接铺到时间维或价格档位维上,代表工作是 TransLOB、Axial-LOB 和 ViT-LOB。

本文把这两条路线拆开讲:先给一张能力速览表,让你快速判断这套方案适不适合自己手上的数据;再从特征矩阵构造、标签设计、防泄漏划分讲起;然后给 DeepLOB 的完整 PyTorch 实现和 Transformer 版本的可运行骨架;最后落到训练评估、ONNX 推理、延迟预算和排错清单。

需要提前说清楚的是,这个方向的门槛不在显卡。一个 100 步窗口的 DeepLOB,参数量在百万级,一张入门级 GPU 甚至 CPU 都能训;真正的门槛是 tick 级数据的获取与清洗、特征口径的一致性、以及回测环节对泄漏的控制。如果手上的数据只有日线或分钟 K 线,这篇文章的方法论可以看,但模型本身用不上。

1. 核心能力速览

先把关键规格摆出来。下表中的参数量和显存属于量级估算,具体数值跟窗口长度、价格档位数、批量大小强相关,实际以本机nvidia-smi或torch.cuda.max_memory_allocated()为准。

能力项说明
任务类型限价订单簿短周期方向预测,典型是三分类(上涨/平稳/下跌),也可做回归预测中间价变动
输入形态二维矩阵(时间步 × 特征维),DeepLOB 典型为100 × 40
特征维构成前 10 档买卖价、10 档买卖量,加派生量后凑成 40 维
代表模型DeepLOB(CNN + Inception + LSTM)、TransLOB(因果 Transformer)、Axial-LOB(轴向注意力)、ViT-LOB(图像化 ViT)
参数量级简化版 DeepLOB 约 0.5M~2M;4 层 Transformerd_model=128约 1M~3M
训练显存批量 64、窗口 100、40 维特征下通常在 1GB~4GB 区间,取决于是否启用混合精度
CPU 推理支持,单样本前向在毫秒级到十毫秒级,需实测
训练硬件入门级独立显卡即可,CPU 训练可行但耗时会明显拉长
公开数据集FI-2010(Aalto 大学,5 只股票、10 个交易日、归一化后的 LOB 特征)
评估指标准确率、Macro-F1、Weighted-F1、MCC,FI-2010 惯例用 F1
部署形态PyTorch 直接推理、ONNX Runtime、TensorRT,按延迟预算选择
适合场景市场微观结构研究、特征有效性验证、教学复现、执行算法辅助建模
数据门槛高,需要逐笔委托与成交数据,且需要交易所授权

把这张表看完,基本能判断自己该从哪条路进。如果只是想验证「盘口形态到底有没有预测力」,FI-2010 加 DeepLOB 是最短路径;如果目标是做长窗口建模或者想把注意力权重拿出来做可解释性分析,Transformer 系列更合适;如果样本量偏少、想在有限数据上拿到更稳的结果,ViT-LOB 那种把 LOB 矩阵当图像处理、靠归纳偏置省样本的思路值得先试。

这里要强调一个容易忽略的点:模型选型的重要性远低于数据口径。同样一份数据,用逐样本 Z-score 还是全量 Z-score、标签用固定阈值还是分位数、训练集和验证集之间有没有重叠窗口,这些细节对最终指标的影响,往往比换一个网络结构大得多。所以在进入代码之前,先把数据处理这条链路想清楚。

2. 适用场景与使用边界

2.1 这套方案适合谁

第一类是做市场微观结构研究的同学。LOB 数据里蕴含的信息密度极高,排队位置、撤单节奏、大单拆分痕迹都能从盘口形态里读出来,DeepLOB 这类端到端模型的价值在于省掉手工构造特征这一步,让网络自己从矩阵里学形态。第二类是做执行算法和交易成本分析的工程团队,短周期方向预测可以作为下单时机的辅助信号,配合 TWAP、VWAP 这类基准算法使用。第三类是教学和复现,FI-2010 是公开数据,几十分钟就能跑起一个基线,非常适合作为时序深度学习课程的实战案例。

反过来说,如果你的数据只有分钟级 OHLCV,这套方案基本用不上。LOB 模型吃的就是十档盘口和逐笔委托的细粒度信息,撮合到分钟 K 线之后,绝大部分微观结构信号已经被平均掉了,此时用 LSTM 或 LightGBM 处理量价因子更实际。

2.2 不适合的场景与合规边界

有几条边界必须写在前面。模型输出的是统计意义上的方向概率,不是交易建议,任何基于此的实盘决策都需要独立的风控与合规审查;历史回测表现不代表未来收益,尤其在市场状态切换时,模型衰减会非常快。数据侧,逐笔委托和成交数据通常受交易所授权约束,商业使用需要确认许可范围,不能拿非授权数据做对外发布的结果。如果涉及真实账户或客户资金,还要考虑算法交易的报备要求。

技术上也有明确的失效场景。流动性极差的标的,盘口挂单稀疏,十档数据里大量空档,特征矩阵会出现大量零值或异常跳变;停牌、集合竞价、开收盘前后几分钟的数据分布与连续竞价阶段差异极大,混在一起训练会污染模型;跨市场、跨品种直接套用同一个模型权重,基本都会失效,因为 tick size、最小变动单位和撮合规则不同。

还有一个常见误区是把 LOB 方向预测当成价格预测。三分类标签本质是在问「未来 k 步内中间价往哪边动的概率更大」,对幅度几乎没有约束。用它做仓位管理是很危险的,需要额外接一层波动率模型或者直接预测收益率的分布。

3. 数据准备与特征工程

3.1 数据来源与格式

公开可用的选择里,FI-2010 是最省事的,直接给到归一化好的特征矩阵和标签,拿来就能训。真实场景则通常来自交易所的 Level-2 行情,包含逐笔委托(order-by-order)和逐笔成交,需要自己重建订单簿快照。重建环节的坑很多:委托号回绕、撤单与成交的时序错位、同一时间戳多笔消息的排序、快照与增量消息的对齐,每一项处理不好都会引入前视偏差。

落地上建议统一成两张表。一张是快照表,每行是一个时间切片的十档买卖价量;另一张是事件表,记录委托新增、撤单、成交。快照表用于喂模型,事件表用于构造派生特征和事后核查。时间戳统一到同一时区同一精度,最好保留原始交易所时间戳和本地接收时间戳两份,后者对延迟分析有用。

3.2 FI-2010 数据集的结构

FI-2010 的原始形式是文本文件,每个文件对应一只股票的一段区间,列是 40 维特征,行是时间步。前 20 维是十档买单的价格与数量交错排列,后 20 维是十档卖单。数据集本身已经做过归一化,官方给的是 Z-score 版本,同时提供最小-最大归一化版本,方便做对比实验。标签有三个类别,按未来 k 步中间价变动的阈值划分,k 和阈值在文件里是固定的。

使用时要注意,这个数据集的时间顺序是打散的,文件是按天分块的,官方建议的实验设置是前 7 天训练、后 3 天测试。如果自己重新划分,务必保持时间上的先后关系,绝不能随机打乱后按比例切分,那等于把未来信息塞进训练集。

3.3 特征矩阵与归一化

输入张量的形状是(batch, 1, T, F),T是窗口长度,F是特征维。DeepLOB 原论文用的是T=100、F=40。归一化的方式直接决定模型能不能收敛,也决定线上推理能不能复现。

推荐的做法是逐窗口归一化,即每个样本独立做 Z-score,而不是用全量数据的均值方差。原因很实际:盘口的价格水平会随行情整体漂移,用全量统计量做归一化,训练集和测试集的分布会对不齐;逐窗口归一化只保留窗口内的相对形态,泛化性更好,也更容易在线上复现,因为线上只需要缓存一个窗口的数据就能算。

import numpy as np def zscore_per_window(x, eps=1e-8): """x: (T, F) 单个窗口,按列做 Z-score""" mu = x.mean(axis=0, keepdims=True) sd = x.std(axis=0, keepdims=True) return (x - mu) / (sd + eps) def build_windows(features, seq_len=100, stride=1): """features: (N, F) -> (M, 1, seq_len, F)""" n = len(features) idx = range(0, n - seq_len + 1, stride) out = np.stack([features[i:i + seq_len] for i in idx], axis=0) out = np.stack([zscore_per_window(w) for w in out], axis=0) return out[:, None, :, :].astype(np.float32)

stride这个参数要谨慎。训练时用stride=1能最大化样本量,但相邻窗口高度重叠,验证集如果也这么切,指标会虚高。稳妥的做法是训练集允许重叠,验证集和测试集按时间块切分,块与块之间留出至少一个窗口长度的间隔。

3.4 标签构造

标签决定了模型在学什么,比网络结构重要得多。三分类的做法是:取未来第 k 步的中间价,算相对当前中间价的收益率,超过正阈值记为上 涨,低于负阈值记为下跌,中间记为平稳。

import numpy as np def make_labels(mid_price, k=10, alpha=1e-3): """ mid_price: (N,) 中间价序列 k: 向前看的步数 alpha: 分类阈值(相对收益率) 返回: 0=下跌, 1=平稳, 2=上涨, -1=无效 """ n = len(mid_price) y = np.full(n, -1, dtype=np.int64) if n <= k: return y cur = mid_price[:-k] fut = mid_price[k:] ret = (fut - cur) / np.maximum(cur, 1e-12) lab = np.ones_like(ret, dtype=np.int64) lab[ret > alpha] = 2 lab[ret < -alpha] = 0 y[:-k] = lab return y

k和alpha的取值要和实际持仓周期对齐。做几分钟级别的信号,k就取对应的步数;alpha建议用收益率的绝对分位数来定,比如取 60%~70% 分位,保证三类样本大致均衡。如果直接固定一个很小的阈值,平稳类会占绝大多数,模型只要全预测平稳就能拿到很高的准确率,但完全没有用。评估时一定要看 Macro-F1 而不是准确率,就是这个原因。

3.5 数据划分与泄漏检查

泄漏是这个方向最容易翻车的地方,列几条自查项:

  • 训练集、验证集、测试集严格按时间先后切分,不随机打乱。
  • 相邻集合之间留出至少T + k步的间隔,避免窗口跨界重叠。
  • 归一化统计量只用训练集计算,如果采用逐窗口归一化,天然规避了这个问题。
  • 特征里不能包含未来信息,比如用整段数据的均值做中心化、用后向填充处理缺失值。
  • 标签构造的k步不能跨过集合边界。

如果复现出的指标明显高于公开论文,先别急着高兴,回头查这五条。LOB 领域的「高分复现」里,相当一部分是窗口重叠导致的。

4. DeepLOB 模型结构与 PyTorch 实现

4.1 设计思路

DeepLOB 的核心想法是分两级抽象。第一级用卷积在「价格档位」这个维度上做压缩,把 40 维的盘口压成更有语义的特征图,这一层的卷积核在第一维(时间)上很高、在第二维(档位)上很宽,目的是同时看几个时间步和几个档位的组合形态。第二级用 LSTM 在时间维上建模,把卷积输出的序列串起来,最后取末状态做分类。

结构上分三块:前端的三个卷积层,中间的 Inception 模块(三个并行分支,分别用 1×1、3×1、5×1 的核,加一个池化分支,最后通道拼接),末端的 LSTM 加全连接。Inception 的作用是让网络自己选择时间感受野,短到 3 步、长到 5 步的形态都能覆盖。

需要提醒的是,不同开源实现里的通道数和形状处理有细微差异,有的是在卷积后直接拉平喂 LSTM,有的是先做维度变换。下面这份实现是形状自洽的版本,每一步都标了张量形状,方便对照调试。

4.2 完整实现

import torch import torch.nn as nn class DeepLOB(nn.Module): def __init__(self, seq_len=100, feat_dim=40, num_classes=3, lstm_hidden=64): super().__init__() # 输入: (B, 1, 100, 40) self.conv_block = nn.Sequential( nn.Conv2d(1, 16, kernel_size=(1, 2), stride=(1, 2)), # -> (B,16,100,20) nn.LeakyReLU(0.01), nn.BatchNorm2d(16), nn.Conv2d(16, 16, kernel_size=(4, 1)), # -> (B,16,97,20) nn.LeakyReLU(0.01), nn.BatchNorm2d(16), nn.Conv2d(16, 16, kernel_size=(4, 1)), # -> (B,16,94,20) nn.LeakyReLU(0.01), nn.BatchNorm2d(16), ) # Inception: 三个分支保持 (B,16,94,20) self.branch_a = nn.Sequential( nn.Conv2d(16, 16, kernel_size=(1, 1)), nn.LeakyReLU(0.01), nn.BatchNorm2d(16), nn.Conv2d(16, 16, kernel_size=(3, 1), padding=(1, 0)), nn.LeakyReLU(0.01), nn.BatchNorm2d(16), ) self.branch_b = nn.Sequential( nn.Conv2d(16, 16, kernel_size=(1, 1)), nn.LeakyReLU(0.01), nn.BatchNorm2d(16), nn.Conv2d(16, 16, kernel_size=(5, 1), padding=(2, 0)), nn.LeakyReLU(0.01), nn.BatchNorm2d(16), ) self.branch_c = nn.Sequential( nn.MaxPool2d(kernel_size=(3, 1), stride=(1, 1), padding=(1, 0)), nn.Conv2d(16, 16, kernel_size=(1, 1)), nn.LeakyReLU(0.01), nn.BatchNorm2d(16), ) # 拼接后 (B,48,94,20) -> 时间维 94,每步特征 48*20=960 self.lstm = nn.LSTM(input_size=48 * 20, hidden_size=lstm_hidden, num_layers=1, batch_first=True) self.head = nn.Sequential( nn.Linear(lstm_hidden, 32), nn.LeakyReLU(0.01), nn.Linear(32, num_classes), ) def forward(self, x): h = self.conv_block(x) h = torch.cat([self.branch_a(h), self.branch_b(h), self.branch_c(h)], dim=1) b, c, t, f = h.shape h = h.permute(0, 2, 1, 3).reshape(b, t, c * f) # (B, 94, 960) out, _ = self.lstm(h) return self.head(out[:, -1, :]) # (B, num_classes)

构造之后建议先做一次形状自检,这一步能省掉大量调试时间:

model = DeepLOB() dummy = torch.randn(2, 1, 100, 40) logits = model(dummy) print(logits.shape) # torch.Size([2, 3]) print(sum(p.numel() for p in model.parameters()))

4.3 训练要点

几个直接影响收敛的设置。损失函数用交叉熵,如果类别不均衡严重,加class_weight或者改用 Focal Loss;优化器 Adam,学习率从1e-3起试,配ReduceLROnPlateau按验证集 Macro-F1 调;批量 64 或 128,批太小 BN 的统计量不稳。训练轮数不用多,FI-2010 上通常 30 到 50 轮就收敛了,再多容易过拟合。

早停的判据建议用验证集 Macro-F1,而不是验证损失。损失下降但 F1 停滞的情况很常见,尤其在类别不均衡时,模型会倾向于把样本往多数类上推。

正则化方面,Dropout 加在 LSTM 之后和全连接层之间,比例 0.2 到 0.3;权重衰减1e-5到1e-4;数据侧可以做窗口的时间抖动(随机偏移几个时间步)来增强鲁棒性,但抖动幅度不能大到让标签失效。

5. Transformer 方案:TransLOB、Axial-LOB 与 ViT-LOB

5.1 TransLOB 的因果注意力思路

TransLOB 是 Wallbridge 在 2020 年提出的方案,核心改动是把 DeepLOB 的循环结构换成因果 Transformer。它把 LOB 矩阵按列切成若干 token(每一列是一个时间步的 40 维特征),加上位置编码后送进编码器,注意力掩码设为下三角,保证第 t 步只能看到 t 之前的信息。输出端只取最后一个 token 的表征做分类。

这样改的好处是并行度高,训练时不用像 LSTM 那样逐步递推,长窗口下的速度优势明显。另外一个副产品是注意力权重可以直接拿出来看,哪几个时间步对最终判断贡献大,一目了然,这对做因子解释很有用。代价是 Transformer 对数据量更敏感,样本少的时候容易过拟合,需要更强的正则。

5.2 Axial-LOB 的二维分解

Axial-LOB 针对的是 LOB 矩阵天然的二维结构。标准的自注意力如果直接把100 × 40展平成 4000 个 token,复杂度是平方级的,显存直接爆。轴向注意力的做法是把二维注意力拆成两次一维:先在时间维上做注意力(每个价格档位内部独立),再在档位维上做注意力(每个时间步内部独立),两次串起来,复杂度从O((T·F)²)降到O(T²·F + T·F²)。

这个设计在 LOB 场景下很自然,因为时间维和档位维的语义本来就不同:时间维刻画演化,档位维刻画深度结构。实测中 Axial-LOB 在样本效率上通常优于纯时间维的 Transformer,代价是实现复杂度高一些。

5.3 ViT-LOB 与图像化处理

ViT-LOB 的思路更直接:把100 × 40的 LOB 矩阵当成一张灰度图,切成 patch 后送进标准 ViT。这么做的动机是借用视觉领域成熟的归纳偏置和预训练经验,同时在数据量有限时表现更稳。原论文的对比实验显示,在相同数据规模下 ViT 类结构比纯 Transformer 更容易训起来。

工程上的好处是实现简单,直接调torchvision里的 ViT 或者自己写一个 patch embedding 就行。需要注意的是 patch 的切法,如果按10 × 10切,时间维切 10 块、档位维切 4 块,总共 40 个 token,序列长度适中,显存友好。

5.4 一个可运行的 Transformer 基线

下面这份实现是时间维 Transformer 的简化版,结构清晰、能直接跑,作为对比基线足够用。

import torch import torch.nn as nn class LOBTransformer(nn.Module): def __init__(self, feat_dim=40, seq_len=100, d_model=128, nhead=4, num_layers=4, num_classes=3, dropout=0.1): super().__init__() self.proj = nn.Linear(feat_dim, d_model) self.pos_emb = nn.Parameter(torch.zeros(1, seq_len, d_model)) nn.init.trunc_normal_(self.pos_emb, std=0.02) layer = nn.TransformerEncoderLayer( d_model=d_model, nhead=nhead, dim_feedforward=d_model * 4, dropout=dropout, batch_first=True, norm_first=True, ) self.encoder = nn.TransformerEncoder(layer, num_layers=num_layers) self.norm = nn.LayerNorm(d_model) self.head = nn.Linear(d_model, num_classes) def forward(self, x): # x: (B, T, F) h = self.proj(x) + self.pos_emb[:, :x.size(1), :] h = self.encoder(h) h = self.norm(h[:, -1, :]) return self.head(h)

如果要做因果版本,把mask传进去即可:

def causal_mask(t, device): return torch.triu(torch.full((t, t), float("-inf"), device=device), diagonal=1)

上面这个基线在 LOB 任务上通常比 DeepLOB 收敛慢一些,需要的轮数更多。调参优先级依次是:d_model从 64 或 128 起、层数 2 到 4、dropout从 0.1 往上加、学习率配 warmup。位置编码用可学习的比正弦编码在固定窗口长度下略好,但如果要处理变长窗口,正弦编码更省事。

5.5 三类结构对比

维度DeepLOBTransLOBAxial-LOB / ViT-LOB
归纳偏置局部卷积 + 时序递归全局注意力二维分解 / 图像 patch
训练速度快,LSTM 有串行瓶颈并行度高,短窗口优势不明显并行度高,长窗口优势明显
样本需求较低较高中等,ViT 类更省样本
长窗口扩展受 LSTM 限制好好
可解释性弱注意力可视化注意力可视化
实现复杂度低中高(轴向)/ 低(ViT)
推荐起步场景首个基线长窗口 + 可解释性需求样本受限或追求精度

选型建议很朴素:先跑 DeepLOB 做基线,把数据处理链路和评估流程都对齐;确认基线正常之后,再换 Transformer 做对比。直接上 Transformer 而跳过基线的做法,出问题时很难判断是数据链路错了还是模型本身不行。

6. 训练、评估与复现实验

6.1 评估指标怎么选

FI-2010 的惯例是用 F1,具体分 Macro-F1 和 Weighted-F1。类别均衡时两者接近,不均衡时 Macro-F1 更能反映每个类别的表现。准确率可以看,但不能作为唯一指标,尤其在平稳类占多数的情况下。

再加一个 MCC(Matthews Correlation Coefficient),它对类别不均衡不敏感,取值在 -1 到 1 之间,0 表示随机。LOB 任务上 MCC 能到 0.2 以上就算有信号了,0.4 以上属于比较强的结果。

from sklearn.metrics import (accuracy_score, f1_score, matthews_corrcoef, confusion_matrix) def evaluate_all(y_true, y_pred): return { "acc": accuracy_score(y_true, y_pred), "macro_f1": f1_score(y_true, y_pred, average="macro"), "weighted_f1": f1_score(y_true, y_pred, average="weighted"), "mcc": matthews_corrcoef(y_true, y_pred), } def print_report(y_true, y_pred): metrics = evaluate_all(y_true, y_pred) for k, v in metrics.items(): print(f"{k:>12s}: {v:.4f}") print("confusion matrix:\n", confusion_matrix(y_true, y_pred))

混淆矩阵一定要看。三类任务里,模型通常能比较准地区分「上涨」和「下跌」,但「平稳」和另外两类的混淆比较严重。如果平稳类的召回率极低,说明阈值alpha设小了,或者标签本身就没区分度,调整标签比调模型更有效。

6.2 训练脚本骨架

import torch from torch.utils.data import TensorDataset, DataLoader from copy import deepcopy def train_model(model, X_tr, y_tr, X_va, y_va, epochs=50, batch_size=64, lr=1e-3, patience=8, device="cuda"): model = model.to(device) tr_loader = DataLoader(TensorDataset(torch.from_numpy(X_tr), torch.from_numpy(y_tr)), batch_size=batch_size, shuffle=True, drop_last=True) va_x = torch.from_numpy(X_va).to(device) va_y = torch.from_numpy(y_va).to(device) opt = torch.optim.Adam(model.parameters(), lr=lr, weight_decay=1e-5) sched = torch.optim.lr_scheduler.ReduceLROnPlateau(opt, mode="max", factor=0.5, patience=3) loss_fn = torch.nn.CrossEntropyLoss() best_f1, best_state, wait = -1.0, None, 0 for ep in range(epochs): model.train() for xb, yb in tr_loader: xb, yb = xb.to(device), yb.to(device) opt.zero_grad() loss = loss_fn(model(xb), yb) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) opt.step() model.eval() with torch.no_grad(): pred = model(va_x).argmax(dim=1).cpu().numpy() f1 = f1_score(va_y.cpu().numpy(), pred, average="macro") sched.step(f1) print(f"epoch {ep:02d} val_macro_f1={f1:.4f}") if f1 > best_f1: best_f1, best_state, wait = f1, deepcopy(model.state_dict()), 0 else: wait += 1 if wait >= patience: print("early stop") break model.load_state_dict(best_state) return model, best_f1

几个细节值得说。梯度裁剪对 LSTM 和 Transformer 都有必要,尤其 Transformer 在训练初期容易爆;drop_last=True是让 BN 的统计量稳定;验证集评估用torch.no_grad(),别忘了切eval()模式,否则 Dropout 和 BN 的行为会不一致。

6.3 消融与基线

做对比实验时,除了 DeepLOB 和 Transformer 的横向对比,建议加几个纵向消融:

  • 只用卷积、去掉 LSTM,看时序建模的实际贡献。
  • 只用一个时间步的输入(退化成交叉截面特征),看窗口长度的贡献。
  • 打乱标签重训,确认指标不会碰巧很高,这是检验泄漏的经典手段。
  • 换归一化方式,对比逐窗口和全量的差异。

第一项能告诉你模型是不是真的在利用时序信息;第三项是必须做的,如果打乱标签之后 Macro-F1 还能到 0.5 以上,数据链路一定有问题。

7. 推理部署与延迟预算

7.1 延迟该怎么拆

LOB 模型的延迟包含四段:行情接收、特征构造、模型前向、决策输出。很多讨论只关注模型前向那几十微秒,实际上特征构造往往是最耗时的部分,尤其当派生特征需要扫窗口内全部数据时。线上部署要把这四段分开计时,找到真正的瓶颈。

延迟预算取决于策略周期。做秒级以上周期的,模型前向几毫秒完全够用;做亚毫秒的高频策略,纯 Python 前向基本不可能达标,需要导出到 ONNX 或 TensorRT,把特征计算也放到 C++ 或 Rust 侧。这篇文章不展开高频工程细节,但要把这个界限说清楚,避免误判。

7.2 ONNX 导出与压测

先把模型导出成 ONNX,再做独立压测,这样测的是纯推理耗时,不受训练框架开销干扰。

import torch import numpy as np import onnxruntime as ort import time model = DeepLOB().eval() dummy = torch.randn(1, 1, 100, 40) torch.onnx.export( model, dummy, "deeplob.onnx", input_names=["lob"], output_names=["logits"], opset_version=17, dynamic_axes={"lob": {0: "batch"}, "logits": {0: "batch"}}, ) sess = ort.InferenceSession("deeplob.onnx", providers=["CPUExecutionProvider"]) x = np.random.randn(1, 1, 100, 40).astype(np.float32) # 预热 for _ in range(10): sess.run(None, {"lob": x}) n = 200 t0 = time.perf_counter() for _ in range(n): sess.run(None, {"lob": x}) cost = (time.perf_counter() - t0) / n * 1000 print(f"avg latency: {cost:.3f} ms")

批量推理的吞吐提升在小模型上不一定明显,因为瓶颈可能在内存拷贝和数据加载。做批量回测时,把特征一次性构造好成(N, 1, 100, 40)的大数组,再分块送进模型,比逐样本循环快很多。

7.3 线上特征一致性

这是上线后最容易出问题的地方。训练时的特征是在离线环境下、用整段数据算出来的,线上是流式数据逐条更新的。两者如果在归一化、缺失值填充、档位对齐、时间戳取整这些环节有任何差异,模型表现会明显下滑,而且很难排查。

建议做一套特征对齐测试:取一段历史数据,分别用离线管线和线上管线算特征,逐列比对,误差应该小于 1e-6。这个测试要纳入 CI,每次改特征代码都跑一遍。另外,模型输入的时间戳要和行情时间戳严格对齐,不能用接收时间代替交易所时间,否则在行情延迟抖动的时候会出现特征错位。

8. 常见问题与排查方法

问题现象可能原因排查方式解决方案
训练损失不下降学习率过大、归一化异常打印首批输入统计量检查是否含 NaN/Inf,降低学习率到 1e-4
验证指标远高于论文窗口重叠、数据泄漏打乱标签重训对比按时间块划分并留出间隔
验证指标震荡剧烈批量太小、BN 统计不稳观察 loss 曲线增大批量,或改用 LayerNorm
平稳类召回率极低标签阈值过小、类别不均衡看混淆矩阵调整alpha到分位数,或加类权重
显存溢出窗口过长、批量过大torch.cuda.max_memory_allocated()降批量、开混合精度、缩短窗口
LSTM 报维度错误卷积后形状与input_size不匹配逐层打印 shape按注释核对c * f的实际值
ONNX 导出失败存在不支持的自定义算子看报错定位层拆出该层单独处理,或升级 opset
线上表现突然劣化特征口径不一致、市场状态切换跑特征对齐测试修正管线,加入漂移监控
推理延迟超标Python 解释开销、单样本循环分段计时导出 ONNX/TensorRT,批量推理
多进程训练卡住DataLoader 死锁设num_workers=0试调整 worker 数和persistent_workers

表格之外补两个高频坑。第一个是 BatchNorm 在推理时的行为,如果训练时用了很小的批量,running_mean 和 running_var 估计不准,推理时表现会掉。解决办法是训练末期用较大批量再跑几百步「校准」,或者直接把 BN 换成 LayerNorm / GroupNorm。第二个是时间戳精度,很多行情数据的时间戳只到毫秒,同一毫秒内有多条消息,排序错了就会造成委托和成交的顺序倒置,特征完全失真。处理时要保留交易所的序列号字段,按序列号排序而不是按时间戳。

9. 最佳实践与落地建议

先把最小可运行链路搭起来,再谈优化。具体顺序是:FI-2010 上的 DeepLOB 基线跑通并复现出合理指标,然后把数据管线换成自己的数据,确认特征和标签口径正确,再尝试 Transformer 变体。跳过第一步直接上自有数据,出问题时无法区分是模型问题还是数据问题,排查成本会翻好几倍。

实验管理要规范。每次训练记录配置文件、随机种子、数据版本、指标曲线,模型权重和对应的特征处理代码绑定存档。这个方向的可复现性本来就差,靠记忆和临时脚本管理,几周之后就完全对不上了。

特征和模型解耦。把特征构造封装成独立模块,输入原始快照、输出模型输入张量,训练和推理共用同一份代码。这能从根上避免线上线下的口径差异,也方便做特征对齐测试。

监控要覆盖两头。模型侧监控预测分布的漂移,比如三类概率的均值、熵、置信度分布,和训练期基线对比;数据侧监控盘口特征分布,比如十档挂单量的均值、买卖不平衡度。任一侧出现明显偏移,就该触发重训或者降级。

合规和信息安全方面有几条底线。交易数据的使用要符合交易所授权范围,不能把受限数据用于对外发布的结果或模型;涉及真实账户的策略上线前要走内部风控和合规流程;公开发表的研究结论要注明数据来源和回测假设,避免误导。模型本身不构成投资建议,任何实盘使用都需要独立验证。

最后是心态。LOB 方向的可预测性是有上限的,公开研究里那些看起来很高的指标,很多是在特定数据划分和特定市场状态下取得的。把一个在测试集上 Macro-F1 0.5 的模型当成稳定盈利工具,是这个领域最常见的误判。合理的目标是:验证盘口特征中确实存在微弱但稳定的信号,然后在这个基础上叠加风控和执行优化。

如果需要继续往前走,几个方向值得试:把订单流不平衡(OFI)、微观价格这类有明确经济学含义的派生特征加进输入;尝试多任务学习,同时预测方向和波动幅度;用对比学习或自监督掩码重建做预训练,缓解标注数据稀缺;以及把模型输出接进执行算法的报价偏移里,看实际成交成本有没有改善。每一个方向都需要独立的数据和评估设计,不能混在一起一次做完。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进