Model Pruning for Low-Latency Trading Inference
Nosso artigo DeepLOB termina sua seção de implantação com três marcadores – ONNX mais TensorRT, quantização INT8, FPGA – e nenhum tratamento de nenhum deles. Este artigo é o tratamento que falta para o primeiro problema subjacente a todos os três: o modelo é maior do que precisa ser. A poda da rede neural remove parâmetros redundantes, e a afirmação interessante na literatura não é que isso economize memória, mas que uma sub-rede contendo 10-20% dos pesos pode corresponder à precisão do modelo denso.
Que a latência importa é um caso que o blog já fez - ZigBolt no caminho de mensagens e o imposto IPC com a aritmética do ponto de equilíbrio - e modelagem de spread já possui a troca entre rápido, mas um pouco pior, versus lento, mas melhor, completa com uma tabela de aumento de gradiente versus aprendizado profundo que possui uma linha de latência de inferência. O que nenhum deles cobre é como tornar um determinado modelo menor. Dos estágios de um ciclo de cotação, a inferência do modelo é aquela que está totalmente sob nosso controle; os trechos de transporte são cobertos, com números p50/p95/p99 reproduzíveis, em comunicação de dados para algotrading.
O que este artigo é: a matemática e o código de trabalho para poda de magnitude, poda estruturada, poda de magnitude iterativa, poda de movimento, destilação de conhecimento e dispersão semiestruturada NVIDIA 2:4, aplicado a um MLP comercial.
O que não é: um resultado medido. Cada artigo empírico neste blog traz uma linha de proveniência ou um repositório complementar, e este ainda não traz nenhum dos dois. A curva de dispersão versus precisão versus latência é apresentada abaixo como um experimento a ser executado, não como uma tabela a ser citada. Trate tudo aqui como método e os números como pendentes.
O que a poda compra para você

A restrição é de tamanho um. Considere um modelo de frequência média - um MLP de 4 camadas com 2.048 unidades ocultas sobre recursos de carteira de pedidos:
Para , , , , isso representa aproximadamente 12,6 milhões de parâmetros – cerca de 48 MB em float32. L2 normalmente tem de 1 a 4 MB, portanto os pesos não cabem; eles são transmitidos de mais longe em cada passe para frente. Remova 95% deles e você terá aproximadamente 630 mil parâmetros efetivos e 2,4 MB, o que cabe.
Se isso se traduz em tempo de relógio depende se o kernel está vinculado à memória, e essa é uma questão de intensidade aritmética e não de tamanho. A escada de velocidade do motor de backtest trabalha o modelo da linha do teto (Williams, Waterman & Patterson) por meio de um exemplo medido em vez de afirmar um fator de penalidade; o mesmo enquadramento se aplica aqui, e a mesma disciplina deve: medir os bytes movidos antes de reivindicar a aceleração.
Fundamentos de poda

Poda não estruturada
A abordagem mais simples: defina os pesos individuais como zero com base em sua magnitude. Dada uma matriz de peso , crie uma máscara binária tal que:
onde é um limite escolhido para atingir o nível de dispersão desejado :
A matriz podada é , com o produto Hadamard. A intuição é que pesos próximos de zero contribuem pouco para a saída da camada.
O problema, declarado claramente porque é fácil interpretar mal o número de dispersão: a dispersão não estruturada não se traduz em aceleração no hardware padrão. Uma matriz com 90% de zeros ainda emite o mesmo número de acumulações multiplicadas, a menos que você mude para kernels esparsos ou hardware com suporte à dispersão. Quando o código abaixo for impresso Sparsity: 90.0%, isso é uma contagem de zeros - não é 10x nada, e em uma CPU GEMM densa também não é 1,01x. Os caminhos que ganham tempo são a poda estruturada (matrizes menores) e a dispersão semiestruturada 2:4 (suporte de hardware), ambos abaixo.
Poda Estruturada
A poda estruturada remove neurônios, canais ou cabeças de atenção inteiras. Para uma camada linear com , removendo neurônio zera o -ésima linha de e o -º elemento de :
Neurônios com o menor -norma vá primeiro. Esta é a variante que produz matrizes genuinamente menores — mas somente se você realmente reconstruir as camadas. Zerar linhas e deixar o tensor em sua forma original não altera nada na contagem de FLOP; a etapa de reconstrução na seção de implementação é o que converte a máscara em um matriz.
Para camadas convolucionais, o análogo é a poda de filtro. Dado , a importância do filtro de saída é:
Removendo filtro elimina um canal de saída inteiro, reduzindo FLOPs proporcionalmente.
A hipótese do bilhete de loteria

Em 2019, Frankle e Carbin introduziram a Hipótese do Bilhete de Loteria (LTH): dentro de uma rede densa inicializada aleatoriamente existe uma sub-rede esparsa - um "bilhete vencedor" - que, treinada a partir de sua inicialização original, corresponde à precisão da rede completa em um número comparável de iterações.
Formalmente, considere inicializado com . Depois de treinar para convergência obtemos e derivar uma máscara de poda . O LTH afirma que existe tal que:
com . Os experimentos originais foram no MNIST e no CIFAR-10, onde os bilhetes vencedores retiveram de 10 a 20% dos parâmetros. Nada sobre isso é transferido para os dados do livro de pedidos por suposição - os recursos LOB não são estacionários e o rótulo é quase ruído, o que é um regime diferente da classificação de imagens exatamente nas maneiras que podem ser importantes.
Poda Iterativa de Magnitude (IMP)
O ticket é encontrado pelo IMP:
- Inicialize a rede com .
- Treine para a convergência, obtendo .
- Podar o de pesos com menor magnitude, criando máscara .
- Redefina os pesos sobreviventes para seus valores em (retrocedendo).
- Repita a partir do passo 2 com a rede mascarada.
Cada fração de ameixas redondas (normalmente 20%), então depois rodadas dos parâmetros sobrevivem. Após 10 rodadas em , cerca de 10,7% permanecem.
Três hipóteses sobre modelos de negociação, nenhuma delas testada
É tentador argumentar que o LTH deveria funcionar especialmente bem com base em dados de mercado. Surgem três desses argumentos; todos os três são hipóteses, e afirmá-los como fatos é o modo de falha que este blog existe para evitar.
- Os sinais financeiros são esparsos. A maior parte de um instantâneo da carteira de pedidos é ruído, portanto, uma sub-rede esparsa pode estar naturalmente alinhada com um sinal esparso. Testável: compare o IMP com uma máscara aleatória de mesma dispersão; se a dispersão em si estiver fazendo o trabalho, a máscara aleatória não deverá ficar muito atrás.
- Os bilhetes vencedores são generalizados entre os regimes. Esta é uma afirmação empírica sobre mercados sem nenhuma citação por trás dela, e é a mais interessante das três. Ele pode ser testado diretamente em relação aos rótulos de regime de detecção de regime com HMMs: encontre o ticket no regime A, treine-o novamente no regime B e compare com um ticket encontrado nativamente em B.
- A dispersão regulariza. A menor capacidade efetiva pode reduzir o ruído de ajuste ao microestrutura - o que apareceria como a lacuna fora da amostra do modelo podado sendo menor do que a do modelo denso, e não apenas comparável.
Implementação: Removendo um MLP comercial

O modelo básico
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():,}")
Poda de magnitude não estruturada
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%}")
Poda Estruturada, com a Reconstrução que Torna Real
Mascarar linhas é metade do trabalho. A metade que produz a aceleração é reconstruir cada camada em sua forma reduzida - o que significa propagar a remoção adiante: descartando linha de camada também descarta coluna de camada e canal de qualquer BatchNorm1d entre eles.
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
Duas coisas para verificar antes de confiar nisso, no mesmo espírito que a equivalência rege o resto do blog:
- Formas.
rebuiltdeve mostrar dimensões ocultas em - 512 parafraction=0.75, — e uma contagem de parâmetros que caiu quadraticamente, uma vez que ambas as dimensões das matrizes interiores diminuíram. - Saídas. Em
eval()modo,rebuilt(x)deve corresponder ao modelo mascaradorebuilt- saída livre para tolerância de ponto flutuante no mesmo lote. Caso contrário, a propagação da coluna está errada e cada número downstream mede um modelo diferente do que você pensa.
O teste de sobrevivência de linha assume que uma linha mascarada é exatamente zero e uma linha ativa não é. Isso vale para ln_structured saída; não seria válido se algum outro procedimento produzisse um neurônio vivo genuinamente zero, portanto, afirme a contagem de sobreviventes em relação à fração solicitada, em vez de confiar cegamente no teste de norma.
Poda de magnitude iterativa (pesquisa de bilhetes de loteria)
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 é a matéria-prima para a curva de dispersão versus precisão que este artigo lhe deve. eval_fn tem que estar genuinamente fora da amostra, em divisões eliminadas - uma execução de IMP pontuada na amostra reportará uma bela curva que não significa nada.
Medindo

A latência é medida com a mesma convenção de chicote do resto do blog – aquecimento excluído, melhor de N, p50/p95/p99 relatado em vez de média – e esse protocolo, com o código, está em Polars vs pandas. Três pontos específicos para poda:
- Compare o modelo reconstruído, não o mascarado. Um modelo mascarado no tamanho de lote 1 mede a forma densa.
- Informar o tamanho do lote. O lote 1 (loop de cotação) e o lote 256 (varredura de pesquisa) ficam em lados diferentes da linha vinculada à memória/limitada à computação, e a poda os ajuda de maneira diferente.
- Precisão do relatório na mesma divisão, no mesmo horizonte, com a definição do rótulo declarada. Uma tabela de latência sem a coluna de precisão correspondente é um argumento para excluir totalmente o modelo.
Técnicas Avançadas

Poda com Destilação de Conhecimento
Em vez de podar e ajustar isoladamente, use o modelo denso original como professor. O aluno podado minimiza uma combinação de perda de tarefas e divergência KL da distribuição de resultados do professor:
onde e são logits de professores e alunos, é a temperatura e equilibra os objetivos. O fator redimensiona os gradientes de destilação, que de outra forma encolhem à medida que .
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
Poda de Movimento
Em vez de podar por magnitude absoluta, a poda de movimento (Sanh et al., 2020) poda pesos que se movem em direção a zero durante o treinamento. A pontuação de importância acumula o produto gradiente-peso:
Pesos com pontuações negativas são podados. O argumento a favor da poda de magnitude é especificamente sobre o ajuste fino: quando você adapta um modelo pré-treinado, a distribuição de magnitude foi moldada pela tarefa de pré-treinamento, então a magnitude é um sinal de importância obsoleto e a direção do deslocamento é mais recente. Para um modelo de negociação retreinado em janelas roláveis, essa é a situação mais comum do que treinar do zero.
NVIDIA 2:4 Esparsidade Estruturada
As GPUs NVIDIA Ampere e posteriores suportam dispersão estruturada 2:4 em hardware: de cada 4 pesos contíguos, exatamente 2 devem ser zero.
Esta é a única forma de dispersão refinada que o hardware realmente recompensa, e é por isso que é mais importante do que o número de 90% de zeros da poda não estruturada. A restrição é local e não global — não importa quais dois em cada quatro sobrevivem — por isso é uma restrição muito mais fraca do que fixar uma máscara global, embora 50% seja o único nível de dispersão disponível.
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()
Perceber a aceleração requer que o caminho de inferência use os núcleos tensores esparsos - uma exportação ONNX mais construção TensorRT, ou torch.sparse.to_sparse_semi_structured. Exportar um modelo com máscara 2:4 por meio de um tempo de execução denso oferece o custo de precisão e nenhum benefício.
Implantação de produção

Validação
Um modelo podado é um modelo novo, não um modelo antigo compactado, e passa pelo mesmo portão de aceitação que qualquer outro candidato: reciclagem contínua e revalidação fora da amostra por otimização walk-forward, com a correção do efeito de seleção da razão de Sharpe deflacionada. Essa correção não é opcional aqui – o IMP gera uma sequência de modelos candidatos, portanto, o nível de dispersão que parece melhor em dez rodadas foi escolhido na pesquisa, e seu Sharpe precisa ser esvaziado pelo número efetivo de tentativas. Uma regra simples como “rejeite se Sharpe cair mais de 5%” não sobrevive a essa aritmética, e é por isso que você não encontrará nenhuma neste artigo.
Empilhamento de quantização
A poda compõe com quantização. Um modelo 90% esparso e quantizado para INT8 tem uma taxa de compressão de:
Um modelo de 48 MB passa a ter 1,2 MB. Essa é uma reivindicação de armazenamento e nada mais. Se o modelo de 1,2 MB produz as mesmas decisões é uma questão separada com sua própria resposta, e a armadilha de precisão da GPU é a razão para perguntar: neste blog, foi demonstrado que fp32 sozinho produz um erro relativo de 211 em um cálculo de backtest que parecia totalmente razoável. INT8 é uma redução muito mais agressiva do que isso. Envie um modelo quantizado e podado apenas atrás de uma porta de paridade quantificada em relação ao modelo denso do FP32 – taxa de acordo de decisão e delta de PnL em um período de espera, não uma garantia.
Monitoramento
Os modelos podados podem ser mais sensíveis à mudança de distribuição. Vale a pena assistir:
- Disparidade de ativação: se os neurônios sobreviventes emitem principalmente zeros, o modelo efetivo é menor do que o pretendido e provavelmente degradante.
- Normas de gradiente durante o retreinamento: a explosão de gradientes sugere que a sub-rede sobrevivente está sendo solicitada a compensar de forma muito agressiva o que foi removido.
- Entropia de previsão: um modelo podado que se torna excessivamente confiante em dados de microestrutura ruidosos provavelmente se ajusta ao regime de treinamento.
Conclusão

Os métodos estão bem estabelecidos e, até que a varredura seja executada, isso é tudo o que este artigo afirma. A poda não estruturada fornece um número de dispersão e nenhuma velocidade. A poda estruturada oferece velocidade se – e somente se – você reconstruir as camadas em vez de mascará-las. A hipótese do bilhete de loteria sugere que o modelo compacto já existe dentro do modelo superparametrizado, embora isso tenha sido demonstrado em benchmarks de imagens e não em dados de livros de pedidos, e as três razões pelas quais ele "deveria" funcionar nos dados de mercado fornecidos acima são hipóteses com experimentos anexados, não descobertas.
A heurística prática da literatura é treinar grandes e podar, em vez de projetar pequenos desde o início: o modelo grande explora o cenário de perdas de forma mais eficaz, e a poda preserva os caminhos que importavam. Se isso vale para um modelo de negociação, com que dispersão e com que custo de precisão, basta uma varredura de IMP – e este artigo deve ser lido novamente após essa varredura, com números nele.
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.