Model Pruning for Low-Latency Trading Inference
Il nostro articolo su DeepLOB termina la sezione di distribuzione con tre punti elenco: ONNX più TensorRT, quantizzazione INT8, FPGA, e nessun trattamento di nessuno di essi. Questo articolo è la trattazione mancante del primo problema sottostante a tutti e tre: il modello è più grande di quanto dovrebbe essere. La potatura della rete neurale rimuove i parametri ridondanti e l'affermazione interessante in letteratura non è che ciò risparmi memoria ma che una sottorete che detiene il 10-20% dei pesi può eguagliare la precisione del modello denso.
Che la latenza sia importante è un caso che il blog ha già sostenuto — ZigBolt sul percorso della messaggistica e la tassa IPC con l'aritmetica del pareggio — e spread modeling possiede già il compromesso veloce ma leggermente peggiore rispetto a lento ma migliore, completo di una tabella di potenziamento del gradiente rispetto a apprendimento profondo che ha una riga di latenza di inferenza. Ciò che nessuno di loro copre è come rimpicciolire un dato modello. Delle fasi di un ciclo di quotazioni, l'inferenza del modello è quella completamente sotto il nostro controllo; le tratte di trasporto sono trattate, con numeri p50/p95/p99 riproducibili, in data communications for algotrading.
Cos'è questo articolo: il codice matematico e operativo per la potatura della grandezza, la potatura strutturata, la potatura iterativa della grandezza, la potatura del movimento, la distillazione della conoscenza e la sparsità semi-strutturata NVIDIA 2:4, applicata a un MLP commerciale.
Cosa non è: un risultato misurato. Ogni articolo empirico su questo blog riporta una linea di provenienza o un repository associato, e questo non ne contiene ancora nessuno. La curva di scarsità rispetto a accuratezza rispetto a latenza è indicata di seguito come un esperimento da eseguire, non come una tabella da citare. Tratta tutto qui come metodo e i numeri come in sospeso.
Cosa ti compra la potatura

Il vincolo è una dimensione. Considera un modello a media frequenza: un MLP a 4 strati con 2048 unità nascoste sulle caratteristiche del portafoglio ordini:
Per , , , , ovvero circa 12,6 milioni di parametri, ovvero circa 48 MB in float32. L2 è in genere 1-4 MB, quindi i pesi non si adattano; vengono trasmessi in streaming da più lontano ad ogni passaggio in avanti. Eliminane il 95% e avrai circa 630.000 parametri effettivi e 2,4 MB, il che va bene.
Se ciò si traduca in tempo reale dipende dal fatto che il kernel sia legato alla memoria, e questa è una questione di intensità aritmetica piuttosto che una questione di dimensioni. La scala di velocità del motore di backtest funziona con il modello della linea del tetto (Williams, Waterman e Patterson) attraverso un esempio misurato piuttosto che affermando un fattore di penalità; qui si applica la stessa struttura e la stessa disciplina dovrebbe: misurare i byte spostati prima di dichiarare l'aumento di velocità.
Fondamenti di potatura

Potatura non strutturata
L'approccio più semplice: impostare i pesi individuali su zero in base alla loro grandezza. Data una matrice dei pesi , crea una maschera binaria tale che:
Dove è una soglia scelta per raggiungere il livello di scarsità desiderato :
La matrice potata lo è , con il prodotto Hadamard. L'intuizione è che i pesi vicini allo zero contribuiscono poco all'output dello strato.
Il problema, indicato chiaramente perché è facile interpretare erroneamente il numero di scarsità: la scarsità non strutturata non si traduce in un aumento di velocità sull'hardware standard. Una matrice con il 90% di zeri emette comunque lo stesso numero di moltiplicazioni-accumuli a meno che non si passi a kernel sparsi o hardware con supporto di scarsità. Quando viene stampato il codice seguente Sparsity: 90.0%, ovvero un conteggio di zeri: non è un 10x, e su una CPU GEMM densa non è nemmeno un 1.01x. I percorsi che fanno guadagnare tempo sono la potatura strutturata (matrici più piccole) e la sparsità semi-strutturata 2:4 (supporto hardware), entrambi di seguito.
Potatura strutturata
La potatura strutturata rimuove interi neuroni, canali o teste di attenzione. Per uno strato lineare con , rimuovendo il neurone azzera il -esima fila di e il -esimo elemento di :
Neuroni con il più piccolo -la norma va per prima. Questa è la variante che produce matrici veramente più piccole, ma solo se si ricostruiscono effettivamente gli strati. Azzerare le righe e lasciare il tensore nella sua forma originale non cambia nulla nel conteggio FLOP; il passaggio di ricostruzione nella sezione di implementazione è ciò che converte la maschera in a matrice.
Per gli strati convoluzionali l'analogo è la potatura del filtro. Dato , l'importanza del filtro di uscita È:
Rimozione del filtro elimina un intero canale di uscita, riducendo proporzionalmente i FLOP.
L'ipotesi del biglietto della lotteria

Nel 2019, Frankle e Carbin hanno introdotto la Lottery Ticket Hypothesis (LTH): all'interno di una fitta rete inizializzata in modo casuale esiste una sottorete sparsa - un "biglietto vincente" - che, addestrato dalla sua inizializzazione originale, corrisponde alla precisione dell'intera rete in un numero comparabile di iterazioni.
Formalmente, considera inizializzato con . Dopo l'allenamento alla convergenza otteniamo e ricavare una maschera di potatura . La LTH afferma che esiste tale che:
con . Gli esperimenti originali erano su MNIST e CIFAR-10, dove i biglietti vincenti conservavano il 10-20% dei parametri. Niente di ciò si trasferisce ai dati del portafoglio ordini per presupposto: le caratteristiche LOB non sono stazionarie e l'etichetta è quasi rumorosa, che è un regime diverso dalla classificazione delle immagini esattamente nei modi che potrebbero avere importanza.
Sfoltimento iterativo della grandezza (IMP)
Il biglietto lo trova l'IMP:
- Inizializzare la rete con .
- Allenarsi alla convergenza, ottenendo .
- Potare il dei pesi di grandezza minima, creando la maschera .
- Reimpostare i pesi sopravvissuti ai rispettivi valori in (riavvolgimento).
- Ripetere dal passaggio 2 con la rete mascherata.
Ogni frazione di prugna rotonda (tipicamente il 20%), quindi dopo giri dei parametri sopravvivono. Dopo 10 round a , ne rimane circa il 10,7%.
Tre ipotesi sui modelli di trading, nessuna testata
Si è tentati di sostenere che LTH dovrebbe funzionare particolarmente bene sui dati di mercato. Emergono tre argomenti di questo tipo; tutte e tre sono ipotesi, e affermarle come fatti è la modalità di fallimento che questo blog intende evitare.
- I segnali finanziari sono scarsi. La maggior parte dell'istantanea del book degli ordini è rumore, quindi una sottorete sparsa potrebbe essere naturalmente allineata con un segnale sparso. Testabile: confrontare IMP con una maschera casuale con la stessa scarsità; se è la scarsità stessa a svolgere il lavoro, la maschera casuale non dovrebbe essere molto indietro.
- I biglietti vincenti si generalizzano tra i regimi. Questa è un'affermazione empirica sui mercati senza alcuna citazione alle spalle, ed è la più interessante delle tre. È direttamente verificabile rispetto alle etichette di regime di rilevamento del regime con HMM: trova il ticket nel regime A, riqualificalo nel regime B e confrontalo con un ticket trovato nativamente in B.
- La scarsità si regolarizza. Una capacità effettiva inferiore può ridurre l'adattamento al rumore della microstruttura, il che risulterebbe dal fatto che il gap fuori campione del modello ridotto è più piccolo di quello del modello denso, non semplicemente paragonabile.
Implementazione: potatura di una MLP commerciale

Il modello base
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():,}")
Potatura della grandezza non strutturata
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%}")
Potatura strutturata, con la ricostruzione che la rende reale
Mascherare le righe è metà del lavoro. La metà che produce l'accelerazione è ricostruire ogni strato nella sua forma ridotta, il che significa propagare la rimozione in avanti: eliminare la riga di strato elimina anche la colonna di strato e canale di qualsiasi BatchNorm1d tra loro.
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
Due cose da verificare prima di fidarsi di questo, nello stesso spirito con cui viene eseguito il resto del blog:
- Forme.
rebuiltdovrebbe mostrare dimensioni nascoste in — 512 perfraction=0.75, – e un conteggio dei parametri che è diminuito quadraticamente, poiché entrambe le dimensioni delle matrici interne si riducono. - Uscite. In
eval()modalità,rebuilt(x)deve corrispondere a quello del modello mascheratorebuilt-uscita libera con tolleranza in virgola mobile sullo stesso batch. In caso contrario, la propagazione della colonna è errata e ogni numero a valle misura un modello diverso da quanto pensi.
Il test di sopravvivenza delle righe presuppone che una riga mascherata sia esattamente zero e una riga attiva non lo sia. Questo vale per ln_structured produzione; non varrebbe se qualche altra procedura producesse un neurone vivo veramente tutto zero, quindi asserisci il conteggio dei sopravvissuti rispetto alla frazione richiesta piuttosto che fidarti ciecamente del test della norma.
Eliminazione iterativa della grandezza (ricerca dei biglietti della lotteria)
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 è la materia prima per la curva di scarsità/accuratezza che questo articolo ti deve. eval_fn deve essere veramente fuori campione, su divisioni eliminate: un'analisi IMP valutata nel campione riporterà una bella curva che non significa nulla.
Misurarlo

La latenza viene misurata con la stessa convenzione di cablaggio del resto del blog (riscaldamento escluso, best-of-N, p50/p95/p99 riportato anziché una media) e tale protocollo, con il codice, si trova in Polars vs pandas. Tre punti specifici della potatura:
- Confronta il modello ricostruito, non quello mascherato. Un modello mascherato di dimensione batch 1 misura la forma densa.
- Segnalare la dimensione del lotto. Il batch 1 (ciclo di quotazione) e il batch 256 (sweep di ricerca) si trovano su lati diversi della linea legata alla memoria/al computer e la potatura li aiuta in modo diverso.
- Riporta l'accuratezza sulla stessa suddivisione, allo stesso orizzonte, con la definizione dell'etichetta dichiarata. Una tabella di latenza senza la colonna di precisione corrispondente è un argomento per eliminare completamente il modello.
##Tecniche avanzate

Potatura con distillazione della conoscenza
Invece di potare e perfezionare in modo isolato, usate il modello denso originale come insegnante. Lo studente sfrondato minimizza una combinazione di perdita di compiti e divergenza KL dalla distribuzione dei risultati dell'insegnante:
Dove E sono i registri degli insegnanti e degli studenti, è la temperatura e bilancia gli obiettivi. IL fattore ridimensiona i gradienti di distillazione, che altrimenti si restringono come .
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
Potatura del movimento
Invece di ridurre in base alla grandezza assoluta, la potatura del movimento (Sanh et al., 2020) riduce i pesi che si spostano verso lo zero durante l’allenamento. Il punteggio di importanza accumula il prodotto del peso del gradiente:
I pesi con punteggi negativi vengono eliminati. L'argomento a favore della potatura della magnitudo riguarda specificamente la messa a punto: quando si adatta un modello pre-addestrato, la distribuzione della magnitudo è stata modellata dall'attività di pre-addestramento, quindi la magnitudo è un segnale di importanza stantio e la direzione di viaggio è più fresca. Per un modello di trading riqualificato su finestre mobili, questa è la situazione più comune rispetto alla formazione da zero.
Sparsità strutturata NVIDIA 2:4
Le GPU NVIDIA di Ampere e successive supportano la scarsità strutturata 2:4 nell'hardware: su ogni 4 pesi contigui, esattamente 2 devono essere zero.
Questa è l'unica forma di scarsità a grana fine che l'hardware effettivamente premia, motivo per cui è più importante del numero di zeri del 90% derivante dalla potatura non strutturata. Il vincolo è locale piuttosto che globale – non importa quale due su quattro sopravvivono – quindi è una restrizione molto più debole rispetto alla fissazione di una maschera globale, sebbene il 50% sia l’unico livello di scarsità offerto.
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()
Per realizzare l'accelerazione è necessario che il percorso di inferenza utilizzi i tensor core sparsi: un'esportazione ONNX più una build TensorRT oppure torch.sparse.to_sparse_semi_structured. L'esportazione di un modello mascherato 2:4 attraverso un runtime denso offre il costo della precisione e nessun vantaggio.
Distribuzione della produzione

Convalida
Un modello sfoltito è un modello nuovo, non un vecchio compresso, e passa attraverso lo stesso cancello di accettazione di qualsiasi altro candidato: riqualificazione continua e riconvalida fuori campione per ottimizzazione walk-forward, con la correzione dell'effetto di selezione da l'indice di Sharpe sgonfio. Questa correzione non è facoltativa in questo caso: IMP genera una sequenza di modelli candidati, quindi il livello di scarsità che appare migliore in dieci round è stato scelto durante la ricerca e il suo Sharpe deve essere sgonfiato in base al numero effettivo di prove. Una regola fissa come "rifiuta se lo Sharpe scende più del 5%" non sopravvive a quell'aritmetica, motivo per cui non ne troverai una in questo articolo.
Stack di quantizzazione
La potatura si compone con la quantizzazione. Un modello sparso al 90% e quantizzato su INT8 ha un rapporto di compressione di:
Un modello da 48 MB diventa 1,2 MB. Questa è una richiesta di archiviazione e niente di più. Se il modello da 1,2 MB produca le stesse decisioni è una domanda separata con una propria risposta e la trappola di precisione della GPU è il motivo per chiederlo: su questo blog, fp32 da solo ha dimostrato di produrre un errore relativo di 211 in un calcolo di backtest che sembrava del tutto ragionevole. INT8 è una riduzione molto più aggressiva di quella. Fornire un modello quantizzato e ridotto solo dietro un cancello di parità quantificata rispetto al modello denso FP32: tasso di accordo decisionale e delta PnL su un periodo di attesa, non una garanzia.
Monitoraggio
I modelli ridotti possono essere più sensibili allo spostamento della distribuzione. Vale la pena guardare:
- Sparsità di attivazione: se i neuroni sopravvissuti emettono per lo più zeri, il modello efficace è più piccolo del previsto e probabilmente degradante.
- Norme sui gradienti durante la riqualificazione: i gradienti esplosivi suggeriscono che alla sottorete sopravvissuta viene chiesto di compensare in modo troppo aggressivo per ciò che è stato rimosso.
- Entropia di previsione: un modello ridotto che diventa troppo sicuro di sé sui dati microstrutturali rumorosi è probabilmente adatto al regime di addestramento.
Conclusione

I metodi sono ben consolidati e, finché non viene eseguita la scansione, questo è tutto ciò che afferma questo articolo. La potatura non strutturata ti dà un numero di scarsità e nessuna velocità. La potatura strutturata ti dà velocità se, e solo se, ricostruisci i livelli anziché mascherarli. L'ipotesi del biglietto della lotteria suggerisce che il modello compatto esiste già all'interno di quello sovraparametrizzato, sebbene ciò sia stato dimostrato su benchmark di immagini e non sui dati del portafoglio ordini, e le tre ragioni per cui "dovrebbe" funzionare sui dati di mercato sopra indicati sono ipotesi con esperimenti allegati, non risultati.
L’euristica pratica ricavata dalla letteratura è quella di addestrare in grande e sfoltire piuttosto che progettare in piccolo fin dall’inizio: il modello di grandi dimensioni esplora il panorama delle perdite in modo più efficace e la potatura preserva i percorsi che contavano. Se ciò valga per un modello di trading, con quale scarsità e con quale costo in termini di accuratezza, è sufficiente una spazzata IMP - e questo articolo dovrebbe essere letto di nuovo dopo quella scansione, con i numeri in esso.
Autori
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.