Model Pruning for Low-Latency Trading Inference
Artikel DeepLOB kami menutup bahagian deployment dengan tiga poin — ONNX bersama TensorRT, kuantisasi INT8, FPGA — tanpa membincangkan mana-mana daripadanya. Artikel ini melengkapkan rawatan bagi masalah pertama di bawah ketiga-tiganya: model lebih besar daripada yang diperlukan. Pruning rangkaian neural membuang parameter berlebihan, dan dakwaan menarik dalam literatur bukan sekadar bahawa ia menjimatkan memori, tetapi bahawa subrangkaian yang mengekalkan 10-20% weight boleh menyamai ketepatan model dense.
Bahawa latency penting telah dibuktikan oleh blog ini — ZigBolt pada laluan messaging dan cukai IPC dengan aritmetik break-even — manakala pemodelan spread sudah memiliki trade-off cepat tetapi sedikit lebih buruk berbanding lambat tetapi lebih baik, lengkap dengan jadual gradient boosting berbanding deep learning yang mempunyai baris latency inferensi. Yang tidak dibincangkan ialah bagaimana menjadikan model tertentu lebih kecil. Daripada semua peringkat dalam gelung quoting, inferensi model ialah satu-satunya yang sepenuhnya di bawah kawalan kita; kaki pengangkutan pula dibincangkan, dengan nombor p50/p95/p99 yang boleh diulang, dalam komunikasi data untuk algotrading.
Apa yang artikel ini ialah: matematik dan kod berfungsi untuk magnitude pruning, structured pruning, Iterative Magnitude Pruning, movement pruning, knowledge distillation, dan sparsity separa berstruktur NVIDIA 2:4, yang digunakan pada MLP trading.
Apa yang bukan: hasil yang diukur. Setiap artikel empirikal di blog ini membawa baris provenance atau repo pendamping, dan artikel ini belum mempunyai kedua-duanya. Lengkung sparsity-versus-accuracy-versus-latency di bawah dinyatakan sebagai eksperimen untuk dijalankan, bukan sebagai jadual untuk dipetik. Anggap semua di sini sebagai kaedah, dan nombornya masih menunggu.
Apa yang Diperoleh daripada Pruning

Kekangannya ialah saiz. Pertimbangkan model mid-frequency — MLP 4 lapisan dengan 2048 unit tersembunyi di atas ciri order book:
Untuk , , , , nilainya kira-kira 12.6 juta parameter — sekitar 48 MB dalam float32. L2 biasanya 1-4 MB, jadi weight tidak muat; weight distrim dari lokasi yang lebih jauh pada setiap forward pass. Prune 95% daripadanya dan anda mendapat kira-kira 630K parameter berkesan dan 2.4 MB, yang memang muat.
Sama ada itu diterjemahkan menjadi masa wall-clock bergantung pada sama ada kernel memory-bound, dan itu ialah persoalan arithmetic intensity, bukan persoalan saiz. Tangga kelajuan enjin backtest menerapkan model roofline (Williams, Waterman & Patterson) melalui contoh terukur dan bukannya menyatakan faktor penalti; framing yang sama berlaku di sini, dan disiplin yang sama perlu digunakan: ukur byte yang dipindahkan sebelum mendakwa speedup.
Asas Pruning

Unstructured Pruning
Pendekatan paling mudah: tetapkan weight individu kepada sifar berdasarkan magnitudnya. Diberi matriks weight , bina mask binari supaya:
dengan ialah threshold yang dipilih untuk mencapai tahap sparsity yang dikehendaki:
Matriks yang dipangkas ialah , dengan sebagai hasil darab Hadamard. Intuisinya ialah weight yang hampir sifar menyumbang sedikit kepada output layer.
Masalahnya, dinyatakan secara terus kerana nombor sparsity mudah disalahbaca: sparsity tidak berstruktur tidak menghasilkan speedup pada perkakasan standard. Matriks dengan 90% sifar masih mengeluarkan bilangan multiply-accumulate yang sama melainkan anda beralih kepada sparse kernel atau perkakasan dengan sokongan sparsity. Apabila kod di bawah mencetak Sparsity: 90.0%, itu ialah kiraan sifar — bukan apa-apa 10x, dan pada dense CPU GEMM ia juga bukan 1.01x. Laluan yang benar-benar menjimatkan masa ialah structured pruning (matriks lebih kecil) dan sparsity separa berstruktur 2:4 (sokongan perkakasan), kedua-duanya di bawah.
Structured Pruning
Structured pruning membuang neuron, channel atau attention head secara keseluruhan. Untuk linear layer dengan , membuang neuron menyifarkan baris ke- dan elemen ke- :
Neuron dengan norma terkecil dibuang dahulu. Inilah varian yang menghasilkan matriks benar-benar lebih kecil — tetapi hanya jika anda membina semula layer. Menyifarkan baris dan membiarkan tensor dalam bentuk asal tidak mengubah kiraan FLOP; langkah rebuild dalam bahagian implementasi menukar mask kepada matriks .
Bagi convolutional layer, analognya ialah filter pruning. Diberi , kepentingan output filter ialah:
Membuang filter menghapuskan seluruh output channel, lalu mengurangkan FLOP secara berkadar.
Hipotesis Lottery Ticket

Pada 2019, Frankle dan Carbin memperkenalkan Lottery Ticket Hypothesis (LTH): dalam rangkaian dense yang diinisialisasi secara rawak terdapat subrangkaian sparse — "winning ticket" — yang, apabila dilatih daripada inisialisasi asalnya, menyamai ketepatan rangkaian penuh dalam bilangan iterasi yang setanding.
Secara formal, pertimbangkan yang diinisialisasi dengan . Selepas latihan mencapai convergence, kita memperoleh dan menerbitkan mask pruning . LTH menyatakan wujud sedemikian rupa sehingga:
dengan . Eksperimen asal dilakukan pada MNIST dan CIFAR-10, di mana winning ticket mengekalkan 10-20% parameter. Tiada apa-apa daripada itu berpindah kepada data order book secara andaian — ciri LOB tidak stationari dan label hampir noise, iaitu rejim yang berbeza daripada klasifikasi imej tepat pada perkara yang mungkin penting.
Iterative Magnitude Pruning (IMP)
Ticket ditemui melalui IMP:
- Inisialisasikan rangkaian dengan .
- Latih hingga convergence, memperoleh .
- Prune weight dengan magnitud terkecil, menghasilkan mask .
- Reset weight yang masih hidup kepada nilainya dalam (rewinding).
- Ulangi dari langkah 2 dengan rangkaian bermask.
Setiap pusingan prune pecahan (biasanya 20%), jadi selepas pusingan parameter masih hidup. Selepas 10 pusingan pada , kira-kira 10.7% masih tinggal.
Tiga Hipotesis tentang Model Trading, Tiada Satupun Diuji
Memang menggoda untuk berhujah bahawa LTH sepatutnya berfungsi dengan sangat baik pada data pasaran. Tiga hujah sedemikian sering muncul; ketiga-tiganya ialah hipotesis, dan menyatakannya sebagai fakta ialah mod kegagalan yang blog ini wujud untuk dielakkan.
- Isyarat kewangan bersifat sparse. Kebanyakan snapshot order book ialah noise, jadi subrangkaian sparse mungkin semula jadi sejajar dengan isyarat sparse. Boleh diuji: bandingkan IMP dengan random mask yang mempunyai sparsity sama; jika sparsity itu sendiri melakukan kerja, random mask tidak sepatutnya jauh ketinggalan.
- Winning ticket menggeneralisasi merentasi regime. Ini tuntutan empirikal tentang pasaran tanpa sitasi di belakangnya, dan yang paling menarik antara tiga ini. Ia boleh diuji terus terhadap label regime daripada pengesanan regime dengan HMM: cari ticket dalam regime A, latih semula dalam regime B, dan bandingkan dengan ticket yang ditemui secara native dalam B.
- Sparsity bertindak sebagai regularisasi. Kapasiti berkesan yang lebih rendah mungkin mengurangkan pemadanan kepada noise microstructure — yang akan kelihatan apabila jurang out-of-sample model prune lebih kecil daripada model dense, bukan hanya setanding.
Implementasi: Pruning MLP Trading

Model Asas
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, dengan Rebuild yang Menjadikannya Nyata
Masking baris hanyalah separuh kerja. Separuh yang menghasilkan speedup ialah membina semula setiap layer pada bentuk yang telah dikurangkan — ini bermakna menyebarkan pembuangan ke hadapan: membuang baris layer juga membuang lajur layer dan channel mana-mana BatchNorm1d di antaranya.
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
Dua perkara perlu diperiksa sebelum mempercayainya, dengan semangat yang sama seperti equivalence gate yang dijalankan oleh bahagian lain blog:
- Bentuk.
rebuiltsepatutnya menunjukkan dimensi tersembunyi pada — 512 untukfraction=0.75, — serta bilangan parameter yang berkurang secara kuadratik, kerana kedua-dua dimensi matriks dalaman mengecil. - Output. Dalam mod
eval(),rebuilt(x)mesti sepadan dengan output model bermask yang tanparebuiltdalam toleransi floating-point pada batch sama. Jika tidak, propagasi lajur salah, dan setiap nombor selepas itu mengukur model yang berbeza daripada sangkaan anda.
Ujian row-survival menganggap baris bermask tepat sifar dan baris hidup tidak sifar. Itu berlaku untuk output ln_structured; ia tidak semestinya berlaku jika prosedur lain menghasilkan neuron hidup yang benar-benar semuanya sifar, jadi sahkan bilangan survivor terhadap fraction yang diminta dan jangan mempercayai ujian norma secara buta.
Iterative Magnitude Pruning (Pencarian Lottery Ticket)
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 ialah bahan mentah untuk lengkung sparsity-versus-accuracy yang artikel ini berhutang kepada anda. eval_fn mesti benar-benar out-of-sample, pada split yang dipurge — larian IMP yang dinilai in-sample akan melaporkan lengkung cantik yang tidak bermakna.
Mengukurnya

Latency diukur dengan konvensyen harness yang sama seperti bahagian lain blog — warmup dikecualikan, best-of-N, p50/p95/p99 dilaporkan dan bukannya purata — dan protokol itu, bersama kodnya, terdapat dalam Polars berbanding pandas. Tiga perkara khusus untuk pruning:
- Benchmark model yang dibina semula, bukan model bermask. Model bermask pada batch size 1 mengukur bentuk dense.
- Laporkan batch size. Batch 1 (quoting loop) dan batch 256 (research sweep) berada pada sisi berbeza garis memory-bound/compute-bound, dan pruning membantu kedua-duanya dengan cara berlainan.
- Laporkan ketepatan pada split yang sama, horizon yang sama, dengan definisi label dinyatakan. Jadual latency tanpa lajur ketepatan yang sepadan ialah hujah untuk memadamkan model itu terus.
Teknik Lanjutan

Pruning dengan Knowledge Distillation
Daripada melakukan pruning dan fine-tuning secara berasingan, gunakan model dense asal sebagai guru. Student yang dipangkas meminimumkan gabungan task loss dan KL divergence daripada agihan output guru:
dengan dan ialah logit guru dan student, ialah suhu, dan mengimbangi objektif. Faktor menskala semula gradient distillation, yang sebaliknya mengecil sebagai .
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
Daripada melakukan pruning mengikut magnitud mutlak, movement pruning (Sanh et al., 2020) membuang weight yang bergerak menuju sifar semasa training. Skor kepentingan mengumpulkan hasil darab gradient-weight:
Weight dengan skor negatif dipangkas. Hujah untuknya berbanding magnitude pruning khususnya berkaitan fine-tuning: apabila anda menyesuaikan model yang telah dipra-latih, agihan magnitud dibentuk oleh tugas pra-latihan, jadi magnitud ialah isyarat kepentingan yang lapuk dan arah pergerakan ialah isyarat yang lebih segar. Bagi model trading yang dilatih semula pada rolling window, keadaan itu lebih lazim daripada latihan dari kosong.
NVIDIA 2:4 Structured Sparsity
GPU NVIDIA Ampere dan lebih baharu menyokong structured sparsity 2:4 dalam perkakasan: daripada setiap 4 weight bersebelahan, tepat 2 mesti sifar.
Ini satu-satunya bentuk sparsity halus yang benar-benar diberi ganjaran oleh perkakasan, sebab itu ia lebih penting daripada nombor 90%-sifar daripada unstructured pruning. Kekangan ini setempat dan bukannya global — ia tidak peduli dua yang mana daripada setiap empat kekal — jadi ia jauh lebih lemah daripada menetapkan mask global, walaupun 50% ialah satu-satunya tahap sparsity yang tersedia.
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()
Merealisasikan speedup memerlukan laluan inferens menggunakan sparse tensor core — eksport ONNX bersama binaan TensorRT, atau torch.sparse.to_sparse_semi_structured. Mengeksport model bermask 2:4 melalui runtime dense memberi kos ketepatan tetapi tiada manfaat.
Deployment Production

Pengesahan
Model prune ialah model baharu, bukan model lama yang dimampatkan, dan ia melalui acceptance gate yang sama seperti calon lain: retraining rolling dan pengesahan semula out-of-sample menurut walk-forward optimization, dengan pembetulan kesan pemilihan daripada nisbah Sharpe terdeflasi. Pembetulan itu bukan pilihan di sini — IMP menghasilkan turutan model calon, jadi tahap sparsity yang kelihatan terbaik merentasi sepuluh pusingan dipilih melalui carian, dan Sharpe-nya perlu dideflasikan mengikut bilangan percubaan berkesan. Peraturan rata seperti "tolak jika Sharpe jatuh lebih daripada 5%" tidak bertahan di bawah aritmetik itu, sebab itu anda tidak akan menemuinya dalam artikel ini.
Penumpukan Kuantisasi
Pruning boleh digabungkan dengan kuantisasi. Model yang 90% sparse dan dikuantisasi kepada INT8 mempunyai nisbah pemampatan:
Model 48 MB menjadi 1.2 MB. Itu ialah tuntutan storage dan tidak lebih daripada itu. Sama ada model 1.2 MB menghasilkan keputusan yang sama ialah soalan berasingan dengan jawapannya sendiri, dan perangkap ketepatan GPU ialah sebab untuk bertanya: di blog ini, fp32 sahaja telah terbukti menghasilkan ralat relatif 211 dalam pengiraan backtest yang kelihatan sepenuhnya munasabah. INT8 ialah pengurangan yang jauh lebih agresif daripada itu. Hantar model yang dikuantisasi-dan-diprune hanya di belakang parity gate berangka terhadap model dense fp32 — kadar persetujuan keputusan dan delta PnL pada tempoh held-out, bukan jaminan.
Pemantauan
Model prune boleh lebih sensitif terhadap peralihan agihan. Perkara yang wajar dipantau:
- Activation sparsity: jika neuron yang masih hidup kebanyakannya mengeluarkan sifar, model berkesan lebih kecil daripada yang dirancang dan mungkin merosot.
- Norma gradient semasa retraining: gradient yang meletup menunjukkan subrangkaian yang terselamat diminta mengimbangi secara terlalu agresif untuk perkara yang dibuang.
- Entropi ramalan: model prune yang menjadi terlalu yakin pada data microstructure bising berkemungkinan sedang memadankan regime latihan.
Kesimpulan

Kaedah-kaedah ini sudah kukuh dan, sehingga sweep dijalankan, itulah sahaja tuntutan artikel ini. Unstructured pruning memberi nombor sparsity tanpa kelajuan. Structured pruning memberi kelajuan jika — dan hanya jika — anda membina semula layer dan bukannya memasknya. Lottery Ticket Hypothesis mencadangkan model padat sudah wujud di dalam model yang overparameterized, walaupun itu telah ditunjukkan pada benchmark imej dan bukan pada data order book, dan tiga sebab mengapa ia "sepatutnya" berfungsi pada data pasaran di atas ialah hipotesis yang disertakan eksperimen, bukan dapatan.
Heuristik praktikal daripada literatur ialah melatih model besar dan kemudian prune, bukannya mereka bentuk model kecil dari awal: model besar meneroka loss landscape dengan lebih berkesan, dan pruning mengekalkan laluan yang penting. Sama ada itu berlaku untuk model trading, pada tahap sparsity mana, dan dengan kos ketepatan apa, hanya memerlukan satu sweep IMP — dan artikel ini patut dibaca semula selepas sweep tersebut, dengan nombor di dalamnya.
Penulis
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.