Model Pruning for Low-Latency Trading Inference
我们的 DeepLOB 文章在部署部分以三个要点结束——ONNX 加 TensorRT、INT8 量化、FPGA——却没有展开其中任何一个。本文补上三者背后的第一个问题:模型比实际需要的大。神经网络剪枝会移除冗余参数,而文献中有趣的主张并不是它能节省内存,而是只保留 10--20% 权重的子网络也能达到稠密模型的准确率。
博客已经说明延迟为何重要——消息传输路径上的 ZigBolt,以及带有盈亏平衡计算的 IPC 税——而价差建模已经讨论了“稍慢但更好”与“更快但稍差”的取舍,其中的梯度提升与深度学习对比表还包含推理延迟一行。它们都没有涉及如何把一个给定模型做小。在报价循环的各个阶段中,模型推理是完全由我们控制的部分;传输路径则已在算法交易的数据通信中用可复现的 p50/p95/p99 数字覆盖。
**本文内容:**将幅度剪枝、结构化剪枝、迭代幅度剪枝、移动剪枝、知识蒸馏和 NVIDIA 2:4 半结构化稀疏性应用于交易 MLP 的数学与可运行代码。
**本文不包含:**实测结果。博客中的每篇实证文章都有来源说明或配套仓库,而本文目前两者都没有。下面的稀疏度—准确率—延迟曲线是待执行的实验,不是可以引用的表格。请把本文内容视为方法,把数字视为待定结果。
剪枝能带来什么

约束只有一个:大小。考虑一个中频模型——一个针对订单簿特征、包含 2048 个隐藏单元的 4 层 MLP:
当 、、、 时,大约有 1260 万个参数,在 float32 中约为 48 MB。L2 缓存通常只有 1--4 MB,因此权重无法装入;每次前向传播都要从更远的内存流式读取。剪掉 95% 后,约剩 63 万个有效参数和 2.4 MB,这就能装下了。
这是否能转化为实际耗时,取决于内核是否受内存带宽限制;这是算术强度问题,而非大小问题。回测引擎速度阶梯通过一个实测例子应用 Roofline 模型(Williams、Waterman 和 Patterson),而不是直接断言惩罚因子。这里同样适用这个框架,也应遵循同样的纪律:在宣称加速之前先测量移动了多少字节。
剪枝基础

非结构化剪枝
最简单的方法:依据单个权重的幅度将其设为零。给定权重矩阵 ,创建二值掩码 ,使得:
其中 是为达到目标稀疏度 而选择的阈值:
剪枝后的矩阵为 ,其中 是 Hadamard 乘积。直觉是,接近零的权重对层输出的贡献很小。
**问题要直说,因为稀疏度数字很容易被误读:**非结构化稀疏性不会自动转化为标准硬件上的加速。一个 90% 为零的矩阵,仍然会发出相同数量的乘加操作,除非切换到稀疏内核或支持稀疏性的硬件。当下面的代码打印 Sparsity: 90.0% 时,它只是零值的计数——不是任何意义上的 10 倍加速,在稠密 CPU GEMM 上甚至也不是 1.01 倍。真正能节省时间的路径是结构化剪枝(更小的矩阵)和 2:4 半结构化稀疏性(硬件支持),两者如下所述。
结构化剪枝
结构化剪枝会移除整个神经元、通道或注意力头。对于 的线性层 ,移除神经元 就是将 的第 行和 的第 个元素置零:
范数最小的神经元最先被移除。这是能够真正产生更小矩阵的变体——但前提是你确实重建了各层。将行置零、让张量保持原来的形状,并不会改变 FLOP 数;实现部分的重建步骤才会把掩码转换成 矩阵。
对于卷积层,对应的做法是滤波器剪枝。给定 ,输出滤波器 的重要性为:
移除滤波器 会消除整个输出通道,使 FLOP 按比例减少。
彩票假说

2019 年,Frankle 和 Carbin 提出了彩票假说(LTH):在一个随机初始化的稠密网络中,存在一个稀疏子网络——一张“中奖彩票”——从原始初始化开始训练后,能在相近的迭代次数内达到完整网络的准确率。
形式化地说,考虑用 初始化的 。训练收敛后得到 ,并推导出剪枝掩码 。LTH 认为存在某个 ,使得:
并且 。原始实验在 MNIST 和 CIFAR-10 上进行,中奖彩票保留了 10--20% 的参数。不能想当然地认为这一结论能迁移到订单簿数据——LOB 特征非平稳,标签几乎是噪声;这与图像分类处于不同状态,而差异恰好可能影响结果。
迭代幅度剪枝(IMP)
通过 IMP 找到中奖彩票:
- 用 初始化网络。
- 训练至收敛,得到 。
- 剪掉幅度最小的 权重,创建掩码 。
- 将幸存权重重置为 中的值(回退)。
- 在带掩码的网络上从第 2 步重复。
每轮剪掉比例为 的权重(通常为 20%),因此经过 轮后仍有 的参数幸存。在 的情况下进行 10 轮后,大约剩下 10.7%。
关于交易模型的三个假说,全部尚未检验
很容易认为 LTH 应该特别适合市场数据。常见的论据有三个;它们都是假说,把它们陈述成事实正是本博客要避免的失败模式。
- 金融信号是稀疏的。 订单簿快照的大部分是噪声,因此稀疏子网络可能天然与稀疏信号对齐。可检验的方法是将 IMP 与相同稀疏度的随机掩码比较;如果起作用的是稀疏性本身,随机掩码不应落后太多。
- 中奖彩票能跨市场状态泛化。 这是一个关于市场的实证主张,背后没有引用,也是三个论据中最有趣的一个。可以直接用基于 HMM 的市场状态检测中的市场状态标签检验:在状态 A 中找到中奖彩票,在状态 B 中重新训练,再与在 B 中原生找到的中奖彩票比较。
- 稀疏性具有正则化作用。 更低的有效容量可能减少对微观结构噪声的拟合——这应表现为剪枝模型的样本外差距小于稠密模型,而不只是与之相当。
实现:剪枝交易 MLP

基础模型
import torch
import torch.nn as nn
import torch.nn.utils.prune as prune
from copy import deepcopy
class TradingMLP(nn.Module):
"""MLP for mid-price direction prediction from order book features."""
def __init__(self, input_dim=100, hidden_dim=2048,
num_layers=4, output_dim=3):
super().__init__()
layers = []
dims = [input_dim] + [hidden_dim] * (num_layers - 1) + [output_dim]
for i in range(len(dims) - 1):
layers.append(nn.Linear(dims[i], dims[i + 1]))
if i < len(dims) - 2:
layers.append(nn.BatchNorm1d(dims[i + 1]))
layers.append(nn.ReLU())
layers.append(nn.Dropout(0.1))
self.network = nn.Sequential(*layers)
def forward(self, x):
return self.network(x)
def count_parameters(self):
return sum(p.numel() for p in self.parameters())
model = TradingMLP(input_dim=100, hidden_dim=2048,
num_layers=4, output_dim=3)
print(f"Total parameters: {model.count_parameters():,}")
非结构化幅度剪枝
def apply_unstructured_pruning(model, sparsity=0.9):
"""Apply global unstructured L1 pruning to all Linear layers."""
parameters_to_prune = []
for name, module in model.named_modules():
if isinstance(module, nn.Linear):
parameters_to_prune.append((module, 'weight'))
prune.global_unstructured(
parameters_to_prune,
pruning_method=prune.L1Unstructured,
amount=sparsity,
)
return model
def compute_sparsity(model):
"""Fraction of zero weights. Note: a *count*, not a speedup."""
total, zeros = 0, 0
for name, param in model.named_parameters():
if 'weight' in name:
total += param.numel()
zeros += (param == 0).sum().item()
return zeros / total if total > 0 else 0
pruned_model = apply_unstructured_pruning(deepcopy(model), sparsity=0.9)
print(f"Sparsity: {compute_sparsity(pruned_model):.1%}")
结构化剪枝:通过重建让它真正生效
掩码行只是工作的一半。真正带来加速的另一半,是以缩小后的形状重建每一层——这意味着把移除操作向前传播:删除第 层的第 行,也要删除第 层的第 列,以及两层之间任意 BatchNorm1d 的第 个通道。
def apply_structured_pruning(model, fraction=0.75):
"""Mask entire neurons by L2-norm of their weight rows."""
for name, module in model.named_modules():
if isinstance(module, nn.Linear) and module.out_features > 10:
prune.ln_structured(
module, name='weight', amount=fraction, n=2, dim=0
)
return model
def rebuild_pruned_mlp(model):
"""
Physically shrink a structurally pruned TradingMLP.
Walks the Sequential once. For each Linear: drop the input columns
the previous layer no longer emits, then drop its own dead output
rows. BatchNorm1d channels follow the preceding Linear's survivors.
"""
new_layers = []
keep_in = None # surviving output indices of the previous Linear
for layer in model.network:
if isinstance(layer, nn.Linear):
if prune.is_pruned(layer):
prune.remove(layer, 'weight')
W, b = layer.weight.data, layer.bias.data
keep_out = (W.norm(dim=1) > 0).nonzero(as_tuple=True)[0]
W = W[keep_out]
if keep_in is not None:
W = W[:, keep_in]
new = nn.Linear(W.shape[1], W.shape[0])
new.weight.data = W.clone()
new.bias.data = b[keep_out].clone()
new_layers.append(new)
keep_in = keep_out
elif isinstance(layer, nn.BatchNorm1d):
new = nn.BatchNorm1d(len(keep_in))
new.weight.data = layer.weight.data[keep_in].clone()
new.bias.data = layer.bias.data[keep_in].clone()
new.running_mean = layer.running_mean[keep_in].clone()
new.running_var = layer.running_var[keep_in].clone()
new.num_batches_tracked = layer.num_batches_tracked.clone()
new_layers.append(new)
else: # ReLU, Dropout -- shape-agnostic, reuse as is
new_layers.append(layer)
rebuilt = deepcopy(model)
rebuilt.network = nn.Sequential(*new_layers)
return rebuilt
在相信这一点之前,需要按博客其余部分的等价性检查惯例验证两件事:
- 形状。
rebuilt应显示 的隐藏维度——当fraction=0.75、 时为 512——参数数量也应呈二次下降,因为内部矩阵的两个维度都缩小了。 - 输出。 在
eval()模式下,同一批数据上,rebuilt(x)必须在浮点容差内匹配掩码模型未经rebuilt的输出。如果不匹配,说明列传播有误,后续所有数字测量的都不是你以为的那个模型。
行存活测试假设被掩码的行恰好为零,而存活行不为零。这对 ln_structured 的输出成立;如果其他过程产生了真正全零的存活神经元,则不成立。因此应根据请求的剪枝比例断言存活者数量,不要盲目信任范数测试。
迭代幅度剪枝(彩票搜索)
def lottery_ticket_search(model_cls, model_kwargs, train_fn, eval_fn,
rounds=10, prune_rate=0.2, device='cpu'):
"""
Iterative Magnitude Pruning to find a winning ticket.
Parameters
----------
model_cls : class -- model constructor
model_kwargs : dict -- constructor arguments
train_fn : callable -- train_fn(model) trains the model in-place
eval_fn : callable -- eval_fn(model) returns out-of-sample accuracy
rounds : int -- number of pruning rounds
prune_rate : float -- fraction of surviving weights pruned per round
"""
model_init = model_cls(**model_kwargs).to(device)
theta_0 = deepcopy(model_init.state_dict())
mask = {}
for name, param in model_init.named_parameters():
if 'weight' in name:
mask[name] = torch.ones_like(param, dtype=torch.bool)
results = []
for round_idx in range(rounds):
model = model_cls(**model_kwargs).to(device)
state = deepcopy(theta_0)
for name in mask:
state[name] = state[name] * mask[name].float()
model.load_state_dict(state)
train_fn(model)
acc = eval_fn(model)
surviving = sum(m.sum().item() for m in mask.values())
total = sum(m.numel() for m in mask.values())
sparsity = 1.0 - surviving / total
results.append({
'round': round_idx,
'accuracy': acc,
'sparsity': sparsity,
'surviving_params': int(surviving)
})
print(f"Round {round_idx}: acc={acc:.4f}, "
f"sparsity={sparsity:.1%}")
all_weights = []
for name, param in model.named_parameters():
if name in mask:
alive = param.data.abs()[mask[name]]
all_weights.append(alive.flatten())
all_weights = torch.cat(all_weights)
k = int(len(all_weights) * prune_rate)
if k == 0:
break
threshold = all_weights.kthvalue(k).values.item()
for name, param in model.named_parameters():
if name in mask:
mask[name] = mask[name] & (
param.data.abs() >= threshold
)
return results, mask
results 是本文所欠缺的稀疏度—准确率曲线的原始材料。eval_fn 必须真正使用样本外、经过清洗的分割;在样本内评分的 IMP 运行会给出漂亮却毫无意义的曲线。
测量

延迟测量遵循博客其他文章相同的基准工具约定——排除预热,取 N 次中的最佳值,报告 p50/p95/p99 而不是平均值——协议和代码见 Polars 与 pandas。剪枝有三点需要特别注意:
- 对重建后的模型做基准测试,而不是掩码模型。批大小为 1 的掩码模型测量的仍是稠密形状。
- 报告批大小。批大小 1(报价循环)和批大小 256(研究扫描)位于内存受限/计算受限分界线的不同侧,剪枝对它们的帮助不同。
- 在相同分割、相同预测时域上报告准确率,并说明标签定义。没有对应准确率列的延迟表,反而是在论证应该彻底删除这个模型。
高级技术

使用知识蒸馏进行剪枝
不要孤立地剪枝和微调,而应将原始稠密模型作为教师。剪枝后的学生模型最小化任务损失与教师输出分布 KL 散度的组合:
其中 和 是教师和学生的 logits, 是温度, 平衡两个目标。 因子会重新缩放蒸馏梯度,否则蒸馏梯度会按 缩小。
def distillation_loss(student_logits, teacher_logits, labels,
temperature=3.0, alpha=0.5):
"""Combined task + distillation loss."""
task_loss = nn.CrossEntropyLoss()(student_logits, labels)
soft_student = nn.functional.log_softmax(
student_logits / temperature, dim=-1
)
soft_teacher = nn.functional.softmax(
teacher_logits / temperature, dim=-1
)
kd_loss = nn.functional.kl_div(
soft_student, soft_teacher, reduction='batchmean'
)
return (1 - alpha) * task_loss + alpha * (temperature ** 2) * kd_loss
移动剪枝
移动剪枝(Sanh 等,2020)不是按照绝对幅度剪枝,而是剪掉训练过程中趋向零的权重。重要性分数累加梯度与权重的乘积:
分数为负的权重会被剪掉。相对于幅度剪枝,它的论据具体针对微调:适配预训练模型时,幅度分布由预训练任务塑造,因此幅度是过时的重要性信号,而运动方向是更新的信号。对于在滚动窗口上重新训练的交易模型,这比从头训练更常见。
NVIDIA 2:4 结构化稀疏性
Ampere 及更新的 NVIDIA GPU 在硬件中支持 2:4 结构化稀疏性:每 4 个连续权重中必须恰好有 2 个为零。
这是硬件真正会奖励的细粒度稀疏形式,因此比非结构化剪枝中的 90% 零值更重要。该约束是局部而非全局的——它不关心每四个权重中哪两个保留——所以比固定全局掩码弱得多,尽管可用的稀疏度水平只有 50%。
from torch.ao.pruning import WeightNormSparsifier
sparsifier = WeightNormSparsifier(
sparsity_level=0.5,
sparse_block_shape=(1, 4),
zeros_per_block=2,
)
sparsifier.prepare(
model, config=[{"tensor_fqn": "network.0.weight"}]
)
sparsifier.step()
sparsifier.squash_mask()
要实现加速,推理路径必须使用稀疏张量核心——导出 ONNX 并构建 TensorRT,或使用 torch.sparse.to_sparse_semi_structured。通过稠密运行时导出 2:4 掩码模型,只会付出准确率代价,得不到任何收益。
生产部署

验证
剪枝模型是一个新模型,不是压缩后的旧模型,因此要像其他候选模型一样通过验收:按照滚动前向优化进行滚动重训和样本外重新验证,并使用折减 Sharpe 比率修正选择效应。这项修正不是可选项——IMP 会生成一系列候选模型,因此十轮中看起来最好的稀疏度是在搜索中选出的,其 Sharpe 必须按有效试验次数折减。像“Sharpe 下降超过 5% 就拒绝”这样的固定规则经不起这个计算,所以本文不会给出这种规则。
量化叠加
剪枝可以与量化组合。一个稀疏度 90%、再量化为 INT8 的模型,其压缩比为:
一个 48 MB 的模型会变成 1.2 MB。这只是存储层面的说法,仅此而已。1.2 MB 的模型是否会做出相同决策,是另一个有独立答案的问题;GPU 精度陷阱正是我们必须提出这个问题的原因:在本博客中,仅 fp32 就曾在一个看起来完全合理的回测计算中产生 211 的相对误差。INT8 是比这激进得多的缩减。只有在相对于 fp32 稠密模型通过量化的一致性门槛后,才能部署量化加剪枝模型——应检查决策一致率和留出期 PnL 差异,而不是凭保证上线。
监控
剪枝模型可能对分布漂移更加敏感。值得监控:
- 激活稀疏度:如果存活神经元大多输出零,则有效模型比预期更小,而且可能正在退化。
- 重训期间的梯度范数:梯度爆炸说明存活子网络被要求过度补偿被移除的部分。
- 预测熵:在嘈杂的微观结构数据上变得过度自信的剪枝模型,很可能是在拟合训练状态。
结论

这些方法已经相当成熟;在完成扫描之前,本文也只主张这些方法本身。非结构化剪枝给你一个稀疏度数字,却不给速度。结构化剪枝可以带来速度,但前提是——且只有在——你重建各层,而不是只给它们加掩码。彩票假说认为紧凑模型已经存在于过参数化模型内部,但这一点是在图像基准上证明的,并未在订单簿数据上证明;上文给出的三个“它应该适用于市场数据”的理由也都是带有实验的假说,而不是研究结果。
文献中的实用启发是:先训练大模型再逐步剪小,而不是一开始就设计小模型。大模型能更有效地探索损失景观,剪枝则保留真正起作用的路径。它是否适用于交易模型、适用何种稀疏度、要付出多大准确率代价,只需一次 IMP 扫描就能回答——而在扫描完成、数据写入本文之后,应当重新阅读本文。
Authors
Trading-systems engineer
Trading-systems engineer building bots since 2017: cross-exchange arbitrage (connected up to 30 venues), cointegration-based pairs arbitrage across spot and futures, scalping, news and sentiment-driven strategies, trend algorithms, and portfolio management and balancing algorithms. Also builds sub-millisecond order execution, big-data warehouses, backtesting engines, AI agents, and trading interfaces (incl. open-source profitmaker.cc). Stack: JS/TS, Python, Rust/Zig/Go, DevOps, backend, frontend, architecture.