限价订单簿(LOB)建模这几年在量化研究里出现的频率越来越高,但很多人第一次接触 DeepLOB 或 Transformer 方案时,容易把注意力全放在网络结构上,结果代码跑通了、损失也降了,却说不清模型到底学到了什么。这篇内容面向两类读者:一类是想把 LOB 预测跑通的研究者和学生,另一类是准备把模型往实盘研究流程里接的工程同学。核心价值不在于复现某一篇论文,而在于把数据形态、标签定义、模型取舍、评估口径和排查顺序这五件事讲清楚。因为在这类任务上,模型结构往往不是瓶颈,标签怎么定、验证集怎么切、指标怎么看,才是决定结果有没有意义的地方。
1. 先把 LOB 预测的目标拆清楚,再决定堆什么模型
限价订单簿建模最容易走偏的一步,是一上来就去抄 DeepLOB 的网络结构。结构本身不复杂,真正决定成败的是输入张量的含义和你让模型预测的东西。这两件事没定清楚,换十个模型也不会有稳定结果。
1.1 订单簿快照里到底有哪些信息
限价订单簿按时间切片保存,每个快照包含买盘和卖盘各自若干档的委托价格与数量。公开研究中常见的处理方式是取前 10 档,每档 2 个字段(价格、数量),买盘 20 维加卖盘 20 维,拼成一个 40 维向量。多个时间步串起来,就得到了形状类似 (时间步, 40) 的矩阵。
以 FI-2010 这类公开数据集为例,它的组织方式就是每行一个时间片、40 列特征,最后一列是标签。这个格式对入门很友好,因为它把"数据对齐"这件麻烦事提前替你做了。但换成自己的数据,就要自己完成从逐笔委托、成交、撤单到定时快照的聚合。
这里有几个容易忽略的细节:
- 快照频率决定样本量。按秒聚合和按 100 毫秒聚合,样本数量差一个数量级,噪声水平也完全不同。
- 价格列通常是绝对价格,直接喂给网络会带来量纲问题。常见做法是转成相对中间价的差值,或者做逐特征标准化。
- 数量列分布极度长尾。少数大单会把均值和方差拉得很夸张,直接标准化之后其余样本几乎被压成一条线。对数变换或分位数裁剪往往更稳。
我一般建议先把前 10 档做成一个可视化检查:把买一卖一价格差、各档数量之和、中间价走势画出来,确认没有明显的缺失段、重复段和异常跳变。这一步花不了多少时间,但能省掉后面大量"模型不收敛"的无效调试。
1.2 标签定义比网络结构更重要
LOB 预测通常被建模成三分类问题:未来一段时间中间价上涨、下跌、基本不变。看起来简单,但里面有两个必须自己拍板的参数。
第一个是预测跨度 k。 用未来第 k 个时间片相对当前的变化,还是用未来 k 步的平均价格相对当前的变化,结果差别很大。平均价的做法更平滑,抗单点噪声,但会稀释短时信号;单点做法响应快,但对噪声极其敏感。k 取太小,标签几乎是随机噪声;k 取太大,模型学到的东西跟高频执行已经没多少关系。
第二个是不变类的阈值 α。 中间价变动绝对值小于 α 的样本被归为"不变"。α 设得太小,不变类几乎消失,三分类退化成二分类;α 设得太大,不变类占比可能超过七八成,模型学出一个"永远预测不变"的退化解,准确率还挺好看。
注意:先统计一下标签分布再训练。如果不变类占比超过 70%,先回头检查 α 和 k,而不是急着换损失函数。
一个比较稳妥的起步方式是:先按原始材料或常见设置跑一遍,打印三类样本的比例和中间价变动的分位数,再根据分位数反过来定 α。比如把 α 定在变动绝对值的 50% 到 60% 分位附近,让三类样本不至于严重失衡。这个做法不保证最优,但能让你快速判断问题出在数据还是模型。
1.3 准确率高不代表这个模型有用
这是必须提前说清楚的一点。LOB 三分类任务里,如果不变类占大头,一个"全预测不变"的基线准确率可能就有五六成。模型跑到 60% 出头,看着比随机好,实际可能只是学会了偏向多数类。
更有参考价值的是这几类指标:
| 指标 | 看什么 | 什么时候会骗人 |
|---|---|---|
| 整体准确率 | 总体对不对 | 类别不平衡时虚高 |
| 宏平均 F1 | 三类是否均衡 | 少数类样本极少时波动大 |
| 各类混淆矩阵 | 涨跌是否被混淆 | 单独看容易忽略量级 |
| MCC(马修斯相关系数) | 整体相关性 | 接近 0 说明基本没学到 |
| 按时间分段的准确率 | 稳定性 | 全样本平均会掩盖崩塌段 |
我通常会把宏平均 F1 和 MCC 放在一起看。如果准确率不低但 MCC 接近 0,基本可以判定模型没有捕捉到有效信号,只是拟合了类别先验。这种情况下继续调网络层数、加注意力头,投入产出比很低。
还有一层要提醒:分类指标好,和能不能支撑一个交易策略是两件事。真实执行涉及手续费、滑点、成交概率和持仓时间,这些都不在分类指标里。本文讨论的全部是建模与工程验证层面的内容,不构成任何投资建议,把它当成一个带时序结构的分类任务来对待就好。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据管道:从逐笔快照到模型能吃的张量
模型代码通常几十行就能写完,数据管道反而占掉大部分时间。原因很直接:LOB 数据是时间序列,任何一步处理不当都会引入未来信息,而这类泄漏在指标上表现得非常"漂亮",很难靠肉眼发现。
2.1 归一化必须只用训练集的统计量
标准化是最常见的泄漏来源。很多人习惯对整个数据集算均值和标准差,然后统一变换。在时间序列任务里,这等于让训练阶段提前知道了未来的分布。
正确顺序是:先按时间切出训练段,只在训练段上计算每个特征的均值和标准差,然后用这组统计量去变换验证段和测试段。测试段出现超出训练段范围的极端值是完全正常的,不应该因此回头调整统计量。
python复制import numpy as np
def fit_scaler(x_train):
mean = x_train.mean(axis=(0, 1), keepdims=True)
std = x_train.std(axis=(0, 1), keepdims=True)
std = np.where(std < 1e-8, 1.0, std)
return mean, std
def apply_scaler(x, mean, std):
return (x - mean) / std
# x_train, x_val, x_test 形状为 (样本数, 时间步, 特征数)
mean, std = fit_scaler(x_train)
x_train = apply_scaler(x_train, mean, std)
x_val = apply_scaler(x_val, mean, std)
x_test = apply_scaler(x_test, mean, std)
这段代码看着平淡,但里面有两个判断点。一是 std 的保护:某些档位在部分时间段可能长期为 0,除以 0 会产生 inf,训练时表现为损失突然变成 nan。二是 axis=(0, 1) 的选择,表示在样本维和时间维上统计,保留特征维,这样每个特征有自己的统计量。
如果你打算用滚动窗口做在线更新,那就更要注意:统计量只能来自当前窗口之前的数据,窗口内的样本不能参与计算。
2.2 滑窗切分与"相邻样本"陷阱
LOB 输入通常是固定长度的滑窗,比如用过去 100 个时间片预测下一个标签。窗口以步长 s 向前滑动,s 越小样本越多,但相邻样本的重叠也越多。
重叠本身不是错,问题出在数据划分上。如果先滑窗再随机划分训练集和验证集,几乎必然出现"验证集样本和训练集样本共享 99 个时间片"的情况。模型在训练时已经见过这些时间片,验证指标自然虚高。
稳妥做法是按时间顺序切分:前 70% 时间做训练,中间 15% 做验证,最后 15% 做测试,切分点放在时间轴上,而不是样本索引上。这样能保证验证集和测试集的时间段完全在训练段之后。
还有一个细节:切分点附近要留一点间隔。假设预测跨度 k 是 10,那训练段最后一个样本的标签依赖到训练段结束后第 10 个时间片。如果验证段紧接着开始,就会有一小段信息重叠。留出 k 个时间片的缓冲,能把这个缝隙补上。
2.3 一个能跑通的最小管道
在动手写模型之前,先让数据管道独立跑通,并且能被检查。我一般会按这个顺序验证:
- 读入原始快照,确认行数、列数、时间戳是否单调递增。
- 检查缺失和重复:同一时间戳是否出现多次,是否存在长时间断档。
- 生成 40 维特征矩阵,打印前 5 行和后 5 行,确认数值范围合理。
- 按 k 和 α 生成标签,打印三类占比。
- 滑窗切分,打印训练/验证/测试的样本数、时间范围和标签分布。
- 在训练集上取一个 batch,确认形状是
(batch, 时间步, 特征数),标签形状是(batch,)。
第 5 步的标签分布特别值得看。如果训练集三类比例是 30/40/30,而测试集是 10/80/10,那模型在测试集上的表现会明显偏离预期,这不是模型的问题,是分布漂移。遇到这种情况,要么调整切分方式,要么在评估时按类别分别报告。
python复制import numpy as np
def make_windows(features, labels, window=100, stride=1):
xs, ys = [], []
for end in range(window, len(features)):
start = end - window
xs.append(features[start:end])
ys.append(labels[end - 1])
return np.asarray(xs, dtype=np.float32), np.asarray(ys, dtype=np.int64)
这个函数很朴素,但它把"输入窗口"和"标签位置"的关系固定下来了:窗口覆盖 [start, end),标签取 end - 1 位置对应的未来变化。不同论文对标签对齐的约定不完全一样,关键是全流程保持一致,并且写清楚注释。后面如果发现指标异常好,第一件事就是回来检查这个对齐有没有错位一位。
3. DeepLOB 的结构取舍:卷积、Inception、LSTM 各管一段
DeepLOB 之所以被广泛当作 LOB 建模的基线,不是因为它在所有场景下都最优,而是它的结构划分和订单簿数据的形态比较契合。理解了每一段在做什么,才知道换数据时该改哪里。
3.1 输入形状与前三层卷积的意图
标准输入是 (batch, 1, 100, 40),可以理解成一张 100×40 的单通道"图"。横轴是时间,纵轴是档位特征。
前三层卷积的分工大致是这样:
- 第一层用较窄的卷积核在时间方向做小范围卷积,主要捕捉相邻时间片之间的局部变化,同时压缩时间维度。
- 第二层继续在时间和特征方向做卷积,进一步提取跨档位的组合关系,比如买一卖一价差和邻近档位数量之间的联动。
- 第三层用覆盖全部特征维的卷积核,把 40 维特征融合成一个更抽象的表示,时间维度保持不变。
这个设计的直觉是:订单簿的局部形态(比如某一档突然挂出大单)是有意义的短时模式,卷积适合抓这类模式;而跨越较长时段的价格趋势,交给后面的循环层。
实际实现时有几个地方容易出错:
- 卷积核在特征维上的尺寸要小心。如果第三层的核宽度不是 40,输出会保留多余的特征维,后面接 LSTM 时形状就对不上。
- 池化会改变时间长度。100 步经过几次池化后可能变成 25 步,LSTM 的输入长度要跟着改。
- 批归一化的位置。放在卷积之后、激活之前是常见做法,但要注意训练和推理模式切换。忘了
model.eval()是验证指标异常的常见原因。
3.2 Inception 模块和 LSTM 的分工
Inception 模块在这里的作用是并行使用不同尺度的卷积核,让网络同时看到短时和稍长时的局部模式。相比单一大核卷积,它的参数量更可控,也更适合捕捉订单簿里尺度不一的结构。
LSTM 接在卷积之后,输入是 (batch, 时间步, 通道数)。它负责建模时间上的依赖,把前面提取的局部特征串成序列表示。最后的全连接层把 LSTM 最后一个时间步的隐状态映射到三类输出。
这里有一个取舍值得说清楚:LSTM 只取最后一个时间步,还是做池化。取最后一步的实现简单,但如果序列末尾恰好是噪声较大的片段,表示质量会受影响。对时间维做平均池化或注意力加权,通常更稳一些,代价是多一点实现复杂度。
另外一个现实问题是训练速度。LSTM 在时间维上是串行的,序列长度 100 时单步耗时不算夸张,但批量训练时 GPU 利用率往往上不去。如果只是做研究验证,这个开销可以接受;如果要跑大量超参组合,就要考虑把序列长度降下来,或者换成纯卷积/纯注意力的结构。
3.3 一份可运行的 PyTorch 实现
下面这份实现是教学向的简化版本,重点是形状对齐,不是追求和原论文逐层一致。
python复制import torch
import torch.nn as nn
class DeepLOB(nn.Module):
def __init__(self, feat_dim=40, seq_len=100, num_classes=3):
super().__init__()
self.conv = nn.Sequential(
nn.Conv2d(1, 32, kernel_size=(1, 2), stride=(1, 2)),
nn.LeakyReLU(0.01),
nn.BatchNorm2d(32),
nn.Conv2d(32, 32, kernel_size=(4, 1)),
nn.LeakyReLU(0.01),
nn.BatchNorm2d(32),
nn.Conv2d(32, 32, kernel_size=(4, 1)),
nn.LeakyReLU(0.01),
nn.BatchNorm2d(32),
)
self.inception = nn.Sequential(
nn.Conv2d(32, 64, kernel_size=(1, 1)),
nn.LeakyReLU(0.01),
nn.BatchNorm2d(64),
nn.Conv2d(64, 64, kernel_size=(3, 1), padding=(1, 0)),
nn.LeakyReLU(0.01),
nn.BatchNorm2d(64),
)
self.lstm = nn.LSTM(input_size=64 * (feat_dim // 2),
hidden_size=64,
batch_first=True)
self.head = nn.Sequential(
nn.Linear(64, num_classes)
)
def forward(self, x):
# x: (B, 1, T, F)
x = self.conv(x) # (B, 32, T-9, F//2)
x = self.inception(x) # (B, 64, T-9, F//2)
b, c, t, f = x.shape
x = x.permute(0, 2, 1, 3).reshape(b, t, c * f)
out, _ = self.lstm(x)
return self.head(out[:, -1, :])
这段代码里,permute 加 reshape 是形状转换的关键:把通道维和特征维合并,作为 LSTM 的输入特征。合并顺序必须和 input_size 的计算一致,否则会在运行时抛出维度不匹配。
还有一点:feat_dim // 2 依赖第一层卷积在特征方向的步长为 2。如果改了 stride 或 kernel_size,这个表达式必须跟着改。我见过不少"跑不通"的案例,追到最后就是这里写死了一个数。
4. Transformer 方案:注意力在订单簿上抓的是什么
Transformer 进 LOB 建模的思路很自然:订单簿里不同时间片的重要性并不相同,某些时刻的挂单变化对后续价格影响更大。自注意力机制天生适合做这种加权,问题是序列长度、特征维度和样本量三者之间的平衡。
4.1 位置编码和时间维度的关系
Transformer 本身对顺序不敏感,必须显式注入位置信息。常见的正弦位置编码在这里够用,但要注意两点。
第一,编码的长度要覆盖最大序列长度。序列长度 100 就意味着编码表至少 100 行,做滑动窗口时如果窗口长度会变化,要么统一补齐,要么按批内的实际长度截取。
第二,绝对位置编码假设"第 1 步和第 2 步的关系"和"第 50 步和第 51 步的关系"一样。订单簿数据未必满足这个假设,比如开盘和收盘附近的动态差别很大。如果数据里带有明显的时间段特征,可以考虑把时间特征(如距离开盘的时间、当前时段的波动水平)作为额外特征拼进去,让模型自己学。
python复制import math
import torch
import torch.nn as nn
class PositionalEncoding(nn.Module):
def __init__(self, d_model, max_len=512, dropout=0.1):
super().__init__()
pe = torch.zeros(max_len, d_model)
pos = torch.arange(0, max_len).unsqueeze(1).float()
div = torch.exp(torch.arange(0, d_model, 2).float()
* (-math.log(10000.0) / d_model))
pe[:, 0::2] = torch.sin(pos * div)
pe[:, 1::2] = torch.cos(pos * div)
self.register_buffer("pe", pe.unsqueeze(0))
self.dropout = nn.Dropout(dropout)
def forward(self, x):
return self.dropout(x + self.pe[:, :x.size(1)])
register_buffer 这个细节值得强调。位置编码是常量,不参与梯度更新,但需要跟随模型移动到 GPU。用普通属性赋值的话,model.to(device) 不会带上它,运行时就会报设备不一致。
4.2 轻量编码器实现与几个关键参数
LOB 数据的特征维度只有 40,序列长度通常 100 左右,这个规模下不需要很深的 Transformer。2 到 4 层编码器一般就够,d_model 取 64 到 128 之间比较合适。层数堆得太多,在小样本上很容易过拟合。
python复制class LOBTransformer(nn.Module):
def __init__(self, feat_dim=40, d_model=64, nhead=4,
num_layers=3, num_classes=3, dropout=0.1):
super().__init__()
self.proj = nn.Linear(feat_dim, d_model)
self.pos = PositionalEncoding(d_model, dropout=dropout)
layer = nn.TransformerEncoderLayer(
d_model=d_model, nhead=nhead,
dim_feedforward=d_model * 4,
dropout=dropout, batch_first=True,
activation="gelu")
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)
h = self.pos(h)
h = self.encoder(h)
h = self.norm(h.mean(dim=1))
return self.head(h)
几个参数的实际影响:
nhead必须能整除d_model。d_model=64配nhead=4或 8 都行,配 5 会直接报错。dim_feedforward通常是d_model的 2 到 4 倍。调大能提升表达能力,同时显著增加显存占用。dropout在小样本场景下非常关键。0.1 是起步值,如果验证损失很早就开始上升,可以往 0.2 到 0.3 调。- 池化方式 这里用了时间维平均。如果更关注序列末端,可以改成取最后一步;如果想让模型自己选择重要时间片,可以加一个可学习的注意力池化层。
4.3 两个方案放在同一批数据上怎么比
比较 DeepLOB 和 Transformer,最容易犯的错是分别调参然后比结果。这样比出来的差异,很可能来自调参投入不同,而不是结构本身。
更公平的做法是固定这几个变量:同一份特征矩阵、同一套标签、同样的训练/验证/测试切分、同样的优化器和学习率搜索范围、同样的早停规则。然后各自跑若干组随机种子,报告均值和方法波动。
我在实际对比时会重点关注三件事:
| 对比维度 | 观察方式 | 值得警惕的信号 |
|---|---|---|
| 收敛速度 | 前若干轮验证损失下降曲线 | Transformer 前期明显更慢,可能需要 warmup |
| 稳定性 | 不同随机种子的指标方差 | 方差远大于均值差异时,结论不可靠 |
| 资源开销 | 单轮训练耗时、显存峰值 | 序列加长后注意力开销增长明显 |
| 长序列表现 | 把窗口从 100 拉到 200 再看 | 若 Transformer 优势只在长窗口出现,要确认样本量够 |
Transformer 常见的坑是训练初期不稳定,表现为损失剧烈震荡。加学习率 warmup 和梯度裁剪通常能缓解。DeepLOB 相对温和,但 LSTM 部分对序列长度敏感,窗口拉长后训练时间增长得比较线性。
结论层面,我更倾向于这样表述:在小窗口、样本量有限的场景下,两者的差距往往在噪声范围内,DeepLOB 训练更省事;窗口拉长、需要捕捉远距离依赖时,Transformer 的潜力更明显,但对数据和调参的要求也更高。这个判断需要用自己的数据验证,不要直接套用。
5. 训练与评估:把指标看对,比调模型更值钱
前面几节讨论的是"怎么搭",这一节讨论"怎么判断搭得好不好"。在 LOB 任务上,评估环节出问题的概率不比建模低。
5.1 类别不平衡与损失函数
三分类样本比例失衡是常态。交叉熵在这种情况下会让模型偏向多数类,表现就是混淆矩阵里不变类那一行很亮,涨跌两列几乎全空。
几个可选项:
- 加权交叉熵。按类别频率的倒数给权重,实现最简单。缺点是权重需要手动调,极端权重会让少数类预测过度。
- Focal Loss。降低易分样本的权重,让模型关注难的样本。在噪声大的数据上效果不一定稳定,需要试。
- 重采样。对少数类做过采样或对多数类做欠采样。时间序列任务里要小心,过采样不能简单复制,否则会造成同一段窗口在训练集里反复出现。
一个实用建议:先用加权交叉熵把基线跑出来,看混淆矩阵是否还严重偏向某一类,再决定要不要换 Focal Loss。不要一上来就上复杂损失,那样连问题出在哪都判断不了。
加权交叉熵的实现要注意权重张量要放到和标签相同的设备上:
python复制import torch
import torch.nn as nn
# 假设训练集三类样本数为 [n0, n1, n2]
counts = torch.tensor([n0, n1, n2], dtype=torch.float32)
weights = counts.sum() / (3 * counts)
criterion = nn.CrossEntropyLoss(weight=weights.to(device))
weights 的计算方式不唯一,只要保证少数类权重大于多数类即可。想要更平滑的效果,可以对权重开方后再用。
5.2 验证集切分和早停策略
前面说过要按时间切分。这里补充一个更细的实践:验证集也要能反映时间上的稳定性。
如果验证集只是连续的一段,模型可能刚好在那一段表现好。更稳妥的做法是把验证段再切成若干连续子段,分别算指标,看波动有多大。如果某一段的宏 F1 明显低于其他段,说明模型对市场状态变化敏感,这时候看整体平均是会骗人的。
早停的判据也要选对。用整体准确率做早停,容易停在"偏向多数类"的位置。用宏平均 F1 或验证损失更合理。耐心轮数不要设太小,LOB 数据的损失曲线经常先平后降, patience 设成 5 到 10 比较常见。
还有一个容易忽略的点:早停用的是验证集,最终报告必须用测试集。如果反复用测试集调参,测试集就变成了验证集,报出来的数字没有意义。这个流程纪律比任何模型技巧都重要。
5.3 从分类指标走到一个粗糙的策略检验
分类指标好,不代表信号有经济意义。做一个非常粗糙的检验,能快速筛掉一批"指标好看但没用"的模型。
思路很简单:对测试集的每个样本,模型给出三类概率,取最大类作为预测。预测为涨就假设做多,预测为跌就假设做空,预测为不变就不动。然后按预测跨度 k 计算持有期收益,累加得到一条净值曲线。
这个检验非常粗糙,没有考虑手续费、滑点、成交概率和仓位管理,结论只能当作参考。但它能回答一个关键问题:模型的预测方向和未来收益是否正相关。如果这条曲线扣掉成本后完全走平甚至向下,那分类指标再高也没必要继续投入。
注意:这个检验只用于判断信号方向是否存在,不能当作策略回测结果,也不构成任何投资建议。
做这个检验时,建议同时输出两个版本:不计成本和计入一个保守的成本假设。两者差距越大,说明模型越依赖高频交易,对执行环节的要求越高。这个信息对后续是否继续深入很有价值。
6. 排查顺序:从报错到指标异常怎么定位
跑这类模型遇到的问题,大致分三类:跑不起来、跑起来但指标不对、指标对了但结果不稳定。排查顺序如果搞反,很容易在错误的层面浪费大量时间。
6.1 形状、显存与设备不一致
形状类错误通常报得很明确,比如 Expected input batch_size (...) to match target batch_size (...)。看到这类报错,先打印输入和标签的实际形状,再回去核对网络里每一层的输出维度。
常见的三个来源:
- 卷积核在特征维上没覆盖完整。第三层卷积如果核宽不是
feat_dim,输出会保留多余维度,后面接 LSTM 的input_size就对不上。 permute和reshape的顺序不一致。先 permute 再 reshape 得到的内存排布,和先 reshape 再 permute 完全不同。合并通道和特征维时,要保证计算input_size的方式和实际排布一致。- 标签形状是
(B, 1)而不是(B,)。交叉熵损失对这两者要求不同,前者需要 squeeze。
显存问题在 Transformer 上更常见。序列长度从 100 拉到 200,注意力矩阵是 4 倍增长。如果显存吃紧,优先降 batch_size 和 dim_feedforward,其次考虑梯度累积。
设备不一致的报错通常是 Expected all tensors to be on the same device。除了模型和输入,别忘了损失函数的权重张量、位置编码的 buffer、以及手动创建的任何常量张量。
6.2 损失下降但验证指标不动
这个现象很典型,通常对应三种情况。
第一种是标签泄漏或者对齐错误。 如果训练损失降得异常快,验证损失也很低,但一看混淆矩阵发现三类几乎全预测成一类,那就要怀疑标签生成环节。检查方式是:把标签和原始中间价变化序列并排打印前几十个样本,人工核对方向是否一致。
第二种是分布漂移。 训练段和验证段的市场状态不同,模型在训练段学到的模式和验证段不匹配。这时候训练损失会持续下降,验证损失很早就开始上升。处理方式包括:缩短训练窗口、增加滚动重训、或者在特征里加入能反映状态变化的量。
第三种是过拟合。 参数量相对样本量太大。DeepLOB 的参数量在几十万级别,如果训练样本只有几万条,过拟合几乎必然发生。可以尝试减小隐藏维度、加大 dropout、加权重衰减,或者干脆用更小的模型。
判断顺序建议是:先查对齐,再查分布,最后才调正则化。因为前两个是数据问题,调正则化解决不了。
6.3 上线前值得逐条确认的清单
如果打算把这套东西接入日常研究流程,而不是停在 notebook 里,下面这些点值得逐条过一遍。
数据层
- 快照时间戳是否严格单调,是否存在重复和断档。
- 特征生成是否只用了当前及历史数据,没有引用未来时间片。
- 标准化统计量是否只来自训练段,并且保存下来供推理复用。
- 标签生成的对齐规则是否写在代码注释里,是否有单元测试覆盖。
训练层
- 训练/验证/测试是否按时间切分,切分点是否留了 k 步缓冲。
- 早停判据是否用的是宏 F1 或验证损失,而不是整体准确率。
- 随机种子是否固定,多次运行的结果方差是否在可接受范围。
- 是否保存了完整的配置:超参、特征列顺序、标签参数、数据时间范围。
评估层
- 是否报告了混淆矩阵和 MCC,而不只是准确率。
- 是否按验证集的连续子段分别算了指标,看过波动。
- 是否做过至少一次粗糙的方向性检验。
工程层
- 推理时的输入预处理是否和训练时完全一致。这一条最容易出问题,尤其是特征列顺序和标准化参数。
- 模型文件是否带版本信息,能否追溯是哪份数据、哪套参数训练出来的。
- 是否有输入校验:形状、数值范围、是否存在
nan或inf。
注意:推理阶段的特征顺序和训练阶段不一致,是不会报错的。模型照常输出概率,只是结果没有意义。这类问题只能靠显式的列名校验来防。
最后说一个我自己的习惯:任何一次训练跑完,先不看指标,先看三样东西——损失曲线的形状、混淆矩阵的分布、以及预测类别的时序分布。如果模型在某个时间段连续几百步都预测同一类,那基本可以判断它没有在响应输入变化。这个检查比任何单一指标都直接。
这套流程走下来,DeepLOB 和 Transformer 的差异反而没那么关键了。真正决定结果质量的,是标签定义是否合理、切分是否干净、评估是否诚实。模型结构可以换,这三件事换不了。
