Model Pruning for Low-Latency Trading Inference
Our DeepLOB article ends its deployment section with three bullets — ONNX plus TensorRT, INT8 quantization, FPGA — and no treatment of any of them. This article is the missing treatment of the first problem underneath all three: the model is bigger than it needs to be. Neural network pruning removes redundant parameters, and the interesting claim in the literature is not that this saves memory but that a subnetwork holding 10-20% of the weights can match the dense model's accuracy.
That the latency matters at all is a case the blog has already made — ZigBolt on the messaging path and the IPC tax with the break-even arithmetic — and spread modeling already owns the fast-but-slightly-worse versus slow-but-better trade-off, complete with a gradient-boosting-versus-deep-learning table that has an inference-latency row. What none of them cover is how you make a given model smaller. Of the stages in a quoting loop, model inference is the one fully under our control; the transport legs are covered, with reproducible p50/p95/p99 numbers, in data communication for algotrading.
What this article is: the math and working code for magnitude pruning, structured pruning, Iterative Magnitude Pruning, movement pruning, knowledge distillation, and NVIDIA 2:4 semi-structured sparsity, applied to a trading MLP.
What it is not: a measured result. Every empirical article on this blog carries a provenance line or a companion repo, and this one carries neither yet. The sparsity-versus-accuracy-versus-latency curve is stated below as an experiment to run, not as a table to quote. Treat everything here as the method, and the numbers as pending.
What Pruning Buys You
The constraint is a size one. Consider a mid-frequency model — a 4-layer MLP with 2048 hidden units over order book features:
For , , , , that is roughly 12.6 million parameters — about 48 MB in float32. L2 is typically 1-4 MB, so the weights do not fit; they are streamed from further out on every forward pass. Prune 95% of them and you are at roughly 630K effective parameters and 2.4 MB, which does fit.
Whether that translates into wall-clock time depends on whether the kernel is memory-bound, and that is an arithmetic-intensity question rather than a size question. The backtest engine speed ladder works the roofline model (Williams, Waterman & Patterson) through a measured example rather than asserting a penalty factor; the same framing applies here, and the same discipline should: measure the bytes moved before claiming the speedup.
Pruning Fundamentals
Unstructured Pruning
The simplest approach: set individual weights to zero based on their magnitude. Given a weight matrix , create a binary mask such that:
where is a threshold chosen to achieve the desired sparsity level :
The pruned matrix is , with the Hadamard product. The intuition is that weights near zero contribute little to the layer's output.
The problem, stated plainly because it is easy to misread the sparsity number: unstructured sparsity does not translate into a speedup on standard hardware. A matrix with 90% zeros still issues the same number of multiply-accumulates unless you switch to sparse kernels or hardware with sparsity support. When the code below prints Sparsity: 90.0%, that is a count of zeros — it is not a 10x anything, and on a dense CPU GEMM it is not a 1.01x either. The paths that do buy time are structured pruning (smaller matrices) and 2:4 semi-structured sparsity (hardware support), both below.
Structured Pruning
Structured pruning removes entire neurons, channels, or attention heads. For a linear layer with , removing neuron zeroes the -th row of and the -th element of :
Neurons with the smallest -norm go first. This is the variant that produces genuinely smaller matrices — but only if you actually rebuild the layers. Zeroing rows and leaving the tensor at its original shape changes nothing about the FLOP count; the rebuild step in the implementation section is what converts the mask into a matrix.
For convolutional layers the analog is filter pruning. Given , the importance of output filter is:
Removing filter eliminates an entire output channel, reducing FLOPs proportionally.
The Lottery Ticket Hypothesis
In 2019, Frankle and Carbin introduced the Lottery Ticket Hypothesis (LTH): within a randomly initialized dense network there exists a sparse subnetwork — a "winning ticket" — that, trained from its original initialization, matches the accuracy of the full network in a comparable number of iterations.
Formally, consider initialized with . After training to convergence we obtain and derive a pruning mask . The LTH states there exists such that:
with . The original experiments were on MNIST and CIFAR-10, where winning tickets retained 10-20% of parameters. Nothing about that transfers to order book data by assumption — LOB features are non-stationary and the label is nearly noise, which is a different regime from image classification in exactly the ways that might matter.
Iterative Magnitude Pruning (IMP)
The ticket is found by IMP:
- Initialize the network with .
- Train to convergence, obtaining .
- Prune the of weights with smallest magnitude, creating mask .
- Reset surviving weights to their values in (rewinding).
- Repeat from step 2 with the masked network.
Each round prunes fraction (typically 20%), so after rounds of the parameters survive. After 10 rounds at , roughly 10.7% remain.
Three Hypotheses About Trading Models, None of Them Tested
It is tempting to argue that LTH should work especially well on market data. Three such arguments come up; all three are hypotheses, and stating them as facts is the failure mode this blog exists to avoid.
- Financial signals are sparse. Most of an order book snapshot is noise, so a sparse subnetwork might be naturally aligned with a sparse signal. Testable: compare IMP against a same-sparsity random mask; if sparsity itself is doing the work, the random mask should not be far behind.
- Winning tickets generalize across regimes. This one is an empirical claim about markets with no citation behind it, and it is the most interesting of the three. It is directly testable against the regime labels from regime detection with HMMs: find the ticket in regime A, retrain it in regime B, and compare against a ticket found natively in B.
- Sparsity regularizes. Lower effective capacity may reduce fitting to microstructure noise — which would show up as the pruned model's out-of-sample gap being smaller than the dense model's, not merely comparable.
Implementation: Pruning a Trading MLP
The Base Model
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():,}")
Unstructured Magnitude Pruning
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%}")
Structured Pruning, With the Rebuild That Makes It Real
Masking rows is half the job. The half that produces the speedup is rebuilding each layer at its reduced shape — which means propagating the removal forward: dropping row of layer also drops column of layer and channel of any BatchNorm1d between them.
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
Two things to check before trusting this, in the same spirit as the equivalence gates the rest of the blog runs:
- Shapes.
rebuiltshould show hidden dimensions at — 512 forfraction=0.75, — and a parameter count that has fallen quadratically, since both dimensions of the interior matrices shrink. - Outputs. In
eval()mode,rebuilt(x)must match the masked model'srebuilt-free output to floating-point tolerance on the same batch. If it does not, the column propagation is wrong, and every downstream number is measuring a different model than you think.
The row-survival test assumes a masked row is exactly zero and a live row is not. That holds for ln_structured output; it would not hold if some other procedure produced a genuinely all-zero live neuron, so assert the survivor count against the requested fraction rather than trusting the norm test blindly.
Iterative Magnitude Pruning (Lottery Ticket Search)
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 is the raw material for the sparsity-versus-accuracy curve this article owes you. eval_fn has to be genuinely out-of-sample, on purged splits — an IMP run scored in-sample will report a beautiful curve that means nothing.
Measuring It
Latency is measured with the same harness convention as the rest of the blog — warmup excluded, best-of-N, p50/p95/p99 reported rather than a mean — and that protocol, with the code, is in Polars vs pandas. Three points specific to pruning:
- Benchmark the rebuilt model, not the masked one. A masked model at batch size 1 measures the dense shape.
- Report the batch size. Batch 1 (quoting loop) and batch 256 (research sweep) sit on different sides of the memory-bound/compute-bound line, and pruning helps them differently.
- Report accuracy on the same split, at the same horizon, with the label definition stated. A latency table without the matching accuracy column is an argument for deleting the model entirely.
Advanced Techniques
Pruning with Knowledge Distillation
Rather than pruning and fine-tuning in isolation, use the original dense model as a teacher. The pruned student minimizes a combination of task loss and the KL divergence from the teacher's output distribution:
where and are teacher and student logits, is the temperature, and balances the objectives. The factor rescales the distillation gradients, which otherwise shrink as .
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
Movement Pruning
Rather than pruning by absolute magnitude, movement pruning (Sanh et al., 2020) prunes weights that are moving toward zero during training. The importance score accumulates the gradient-weight product:
Weights with negative scores are pruned. The argument for it over magnitude pruning is specifically about fine-tuning: when you adapt a pre-trained model, the magnitude distribution was shaped by the pre-training task, so magnitude is a stale importance signal and direction-of-travel is a fresher one. For a trading model retrained on rolling windows, that is the more common situation than training from scratch.
NVIDIA 2:4 Structured Sparsity
Ampere-and-later NVIDIA GPUs support 2:4 structured sparsity in hardware: out of every 4 contiguous weights, exactly 2 must be zero.
This is the one form of fine-grained sparsity that the hardware actually rewards, which is why it matters more than the 90%-zeros number from unstructured pruning. The constraint is local rather than global — it does not care which two of each four survive — so it is a much weaker restriction than fixing a global mask, though 50% is the only sparsity level on offer.
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()
Realizing the speedup requires the inference path to use the sparse tensor cores — an ONNX export plus TensorRT build, or torch.sparse.to_sparse_semi_structured. Exporting a 2:4-masked model through a dense runtime gives you the accuracy cost and none of the benefit.
Production Deployment
Validation
A pruned model is a new model, not a compressed old one, and it goes through the same acceptance gate as any other candidate: rolling retraining and out-of-sample revalidation per walk-forward optimization, with the selection-effect correction from the deflated Sharpe ratio. That correction is not optional here — IMP generates a sequence of candidate models, so the sparsity level that looks best across ten rounds was chosen under search, and its Sharpe needs deflating by the effective number of trials. A flat rule like "reject if Sharpe drops more than 5%" does not survive that arithmetic, which is why you will not find one in this article.
Quantization Stacking
Pruning composes with quantization. A model 90% sparse and quantized to INT8 has a compression ratio of:
A 48 MB model becomes 1.2 MB. That is a storage claim and nothing more. Whether the 1.2 MB model produces the same decisions is a separate question with its own answer, and the GPU precision trap is the reason to ask it: on this blog, fp32 alone has been shown to produce a relative error of 211 in a backtest computation that looked entirely reasonable. INT8 is a far more aggressive reduction than that. Ship a quantized-and-pruned model only behind a quantified parity gate against the fp32 dense model — decision agreement rate and PnL delta on a held-out period, not an assurance.
Monitoring
Pruned models can be more sensitive to distribution shift. Worth watching:
- Activation sparsity: if surviving neurons emit mostly zeros, the effective model is smaller than intended and probably degrading.
- Gradient norms during retraining: exploding gradients suggest the surviving subnetwork is being asked to compensate too aggressively for what was removed.
- Prediction entropy: a pruned model that becomes overconfident on noisy microstructure data is likely fitting the training regime.
Conclusion
The methods are well established and, until the sweep runs, that is all this article claims. Unstructured pruning gives you a sparsity number and no speed. Structured pruning gives you speed if — and only if — you rebuild the layers rather than mask them. The Lottery Ticket Hypothesis suggests the compact model already exists inside the overparameterized one, though that has been demonstrated on image benchmarks and not on order book data, and the three reasons it "should" work on market data given above are hypotheses with experiments attached, not findings.
The practical heuristic from the literature is to train large and prune down rather than design small from the start: the large model explores the loss landscape more effectively, and pruning preserves the pathways that mattered. Whether that holds for a trading model, at what sparsity, and at what accuracy cost, is one IMP sweep away — and this article should be read again after that sweep, with numbers in it.
Tác Giả
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.