Knowledge Distillation: Compressing Trading Models for Low-Latency Deployment
機械学習主導の取引における精度とレイテンシーの緊張関係については、すでにこのブログで答えを示している。機械学習によるスプレッド・モデリングが勧めるのは2段階分割だ。高速な勾配ブースティングモデルがレイテンシーの厳しいリアルタイム・クォートを担当し、ディープモデルは非同期で動作して二次シグナルを供給するか、パラメータを調整する。2つのモデル、2つのクロック、1つのシステムだ。
知識蒸留は、同じ緊張関係に対する別の答えである。低速モデルを高速モデルと並行稼働させる代わりに、オフラインで一度だけ使って高速モデルを訓練する。生徒モデルはハードラベルだけでなく、結果に対する教師モデルの完全な確率分布を学習し、その後教師モデルはホットパスから完全に離れる。推論時に動くモデルは1つだけで、非同期結合も鮮度の劣化ウィンドウもない。
どちらの答えが勝つかは実証で決まり、この記事はまだその問いに答えていない。以下では仕組みを説明し、それを決める測定項目も明示する。ここにベンチマーク結果はない。本来なら数値が入る場所には、何を実行すべきかを示すマーカーを置いている。
最初に前提を1つ修正しておく。DeepLOBとオーダーブックのディープラーニングが示すように、高い分類精度が自動的に利益へつながるわけではない。予測した値動きはビッド・アスク・スプレッドを超えなければならない。したがって「教師の方向精度を維持する」ことを蒸留設定の最適化対象にするのは間違いである。
教師・生徒フレームワーク

Hinton、Vinyals、Dean(2015)による元の定式化は単純だ。教師モデル (大きく、遅く、正確)と、生徒モデル (小さく、速く、これから訓練する)がある。生徒は2つのシグナルから同時に学ぶ。
- ハードターゲット:正解ラベル (例:価格が上昇したか下落したか)
- ソフトターゲット:全クラスに対する教師の出力確率分布
生徒の損失関数はこの2つを組み合わせる。
ここで と は教師と生徒のロジット、 はソフトマックス関数、 は温度パラメータ、 は2つの損失成分のバランスを制御する。
ソフトターゲットが取引に重要な理由
3クラスの上昇/横ばい/下落というミッドプライスの定式化、 による閾値処理、そしてその結果として不均衡が生じるため精度ではなく重み付きF1を報告する理由は、すべてDeepLOBで設定されている。ここでもそのラベル方式を前提にする。蒸留に固有のポイントは、教師が argmax の前に出力するものだ。ハードな「上昇」は1ビットしか持たないが、0.72/0.21/0.07 なら、値動きが止まる可能性や、ほぼ確実に反転しないことまで表現できる。クラス間にあるこの構造が追加の学習シグナルであり、ソフトターゲットで学習した生徒がラベルだけで学習した同じ生徒より一般化しやすい理由でもある。
ただし、その確信度が何を意味しないかには注意が必要だ。ソフトマックス出力は校正された不確実性ではない。0.55 と 0.85 をポジション・サイジングの入力として扱うのは、取引のためのコンフォーマル予測が拒む近道である。そこでは区間幅、エッジ比率、区間がゼロをまたぐときのノートレード・フィルターからサイジングを導くが、生のソフトマックスから得られるものはない。ここでサイジングを主張するには、教師に対する生徒の校正(信頼性図、ECE)を測定し、蒸留によってそれが保たれることを示さなければならない。その結果はまだこの記事にはない。
温度とソフトターゲット

温度パラメータ は確率分布の「柔らかさ」を制御する。ロジット に対する温度付きソフトマックスは次のとおり。
(標準ソフトマックス)では分布は尖っており、支配的なクラスが確率質量の大部分を占める。 が大きくなると分布は平坦になり、ロジットの相対的な大きさがより明確に現れる。
| 温度 | 効果 | 用途 |
|---|---|---|
| 標準ソフトマックス、尖った分布 | 通常の推論 | |
| 中程度に平滑化 | 一般的な蒸留 | |
| 強い平滑化 | 教師の確信度が非常に高い場合 | |
| ほぼ一様 | ほとんど有用でなく、シグナルを消す |
取引モデルには中程度の温度が望ましい、というもっともらしい議論はある。金融の予測は画像分類よりはるかに確信度が低いため、教師は 0.99/0.005/0.005 ではなく 0.55/0.30/0.15 を出力するかもしれず、シグナルが消える前に平滑化できる尖りがそもそも少ないからだ。ただしこれは議論であって発見ではない。範囲は実データのスイープから得て、重み付きF1で評価しなければならず、市場レジームによって変わる可能性もある。
の係数は、KLダイバージェンス項で高温度時に勾配の大きさが小さくなることを補償する。これがなければ、 の上昇とともに蒸留損失は無視できるほど小さくなる。
グリッドサーチによる温度選択
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import DataLoader
from sklearn.metrics import f1_score
def distillation_loss(
student_logits: torch.Tensor,
teacher_logits: torch.Tensor,
labels: torch.Tensor,
temperature: float,
alpha: float,
) -> torch.Tensor:
"""Combined hard-target + soft-target distillation loss."""
hard_loss = F.cross_entropy(student_logits, labels)
soft_teacher = F.log_softmax(teacher_logits / temperature, dim=-1)
soft_student = F.log_softmax(student_logits / temperature, dim=-1)
soft_loss = F.kl_div(
soft_student,
soft_teacher,
log_target=True,
reduction="batchmean",
)
return alpha * hard_loss + (1.0 - alpha) * (temperature ** 2) * soft_loss
def search_temperature(
teacher: nn.Module,
student_factory, # callable returning a fresh student
train_loader: DataLoader,
val_loader: DataLoader,
temperatures: list[float] = [1, 2, 3, 5, 8, 12],
alpha: float = 0.3,
epochs: int = 30,
lr: float = 1e-3,
device: str = "cuda",
):
"""Grid search over temperature, scored by weighted F1 (not accuracy:
the up/flat/down label scheme is heavily imbalanced toward flat)."""
best_f1, best_T, best_student = 0.0, 1.0, None
for T in temperatures:
student = student_factory().to(device)
optimizer = torch.optim.AdamW(student.parameters(), lr=lr)
for epoch in range(epochs):
student.train()
for X, y in train_loader:
X, y = X.to(device), y.to(device)
with torch.no_grad():
teacher_logits = teacher(X)
student_logits = student(X)
loss = distillation_loss(
student_logits, teacher_logits, y, T, alpha
)
optimizer.zero_grad()
loss.backward()
optimizer.step()
student.eval()
preds, targets = [], []
with torch.no_grad():
for X, y in val_loader:
preds.append(student(X.to(device)).argmax(dim=-1).cpu())
targets.append(y)
f1 = f1_score(
torch.cat(targets), torch.cat(preds), average="weighted"
)
print(f"T={T:>4.1f} val_weighted_f1={f1:.4f}")
if f1 > best_f1:
best_f1, best_T, best_student = f1, T, student
print(f"\nBest temperature: T={best_T}, val_weighted_f1={best_f1:.4f}")
return best_T, best_student
アンサンブルを単一モデルへ蒸留する

クオンツのアンサンブルは帰納バイアスを混ぜ合わせる。オーダーブック特徴量に対する勾配ブースティング木、直近ティックに対する1D-CNN、複数時間足ウィンドウに対するTransformer、マクロ要因に対する線形モデルだ。平均すればどの単体モデルより安定するが、4つすべてを動かすとレイテンシーとコストが倍増する。これは機械学習によるスプレッド・モデリングの2段階分割が、遅いモデルを非同期のサイドチャネルへ降格させて扱う状況である。蒸留なら、4つすべてをホットパスの1つの生徒モデルへ集約できる。
アンサンブル教師の出力は、各メンバーのソフトマックス出力の平均である。
ここで はアンサンブルのメンバー数だ。生徒はこの平均分布に対して訓練する。
class EnsembleTeacher(nn.Module):
"""Wraps K models, returns averaged logits for distillation."""
def __init__(self, models: list[nn.Module]):
super().__init__()
self.models = nn.ModuleList(models)
@torch.no_grad()
def forward(self, x: torch.Tensor) -> torch.Tensor:
logits = torch.stack([m(x) for m in self.models], dim=0)
return logits.mean(dim=0) # average logits, not softmax
class TradingStudent(nn.Module):
"""Lightweight MLP for sub-millisecond inference."""
def __init__(self, input_dim: int, hidden: int = 64, n_classes: int = 3):
super().__init__()
self.net = nn.Sequential(
nn.Linear(input_dim, hidden),
nn.ReLU(),
nn.BatchNorm1d(hidden),
nn.Linear(hidden, hidden),
nn.ReLU(),
nn.BatchNorm1d(hidden),
nn.Linear(hidden, n_classes),
)
def forward(self, x: torch.Tensor) -> torch.Tensor:
return self.net(x)
パラメータ数の非対称性こそが要点だ。60特徴・3クラスのタスクなら、隠れユニット64の2層MLPは約8,000パラメータで済む一方、アンサンブル全体では数百万に達する。
生徒が保持するもの、失うもの
これは最も重要な実証上の問いだが、この記事は答えていない。直感的には、生徒は分布内ではアンサンブルを追跡するものの、アンサンブルの多様性が機能するストレス局面では性能が落ちると考えられる。しかし保持率の数値が意味を持つのは、実際のオーダーブックデータで測定し、レジームごとに分割し、重み付きF1として報告した場合だけだ。平穏な日に持ちこたえ清算カスケードで崩れる生徒と、緩やかに劣化する生徒は別の製品であり、集計値だけでは区別できない。
その測定に対して、先に断定するのではなく次の3つの緩和策を検証する価値がある。
- ストレス局面を蒸留セットに含める。ギャップが開くと予想されるレジームを生徒に見せるためだ。
- 特徴量ベースの蒸留 — 最終出力だけでなく中間表現も一致させる。
- 生徒に補助的なレジームヘッドを付ける。共有トランクにレジームを意識した特徴量を強制する。
自己蒸留:生徒が教師になるとき

自己蒸留とは、モデルが自分自身から知識を蒸留する手法である。
Born-Again Networks(BAN)
教師と同一のアーキテクチャで生徒を訓練する。「生まれ変わった」生徒は元のモデルを上回ることが多く、その過程を繰り返す。
各世代は前世代のソフトターゲットで訓練し、通常は数世代後に改善が飽和する。取引モデルではアーキテクチャ上のコストがない。新しい特徴量もデータも不要で、訓練手順が変わるだけだ。そのため検証は安価であり、未検証のまま報告する言い訳もない。
深さ方向の自己蒸留
中間層に補助分類器を取り付ける。最深部の出口を浅い出口の教師にする。推論時には出口を選べる。レイテンシーを下げるなら浅い出口、精度を最大化するなら深い出口だ。
これは取引システムに最も適した考え方だ。出口の深さが実行時のレイテンシー調整つまみになるため、訓練時に単一アーキテクチャへ固定せず、1つの訓練済みネットワークで複数の予算をカバーできる。板が速く動くときは浅い出口を選んで悪化した事後分布を受け入れ、静かなときは全深度の計算に対価を払う。出口ごとの精度曲線とレイテンシー曲線はどちらも測定可能であり、両者の交差がこの調整つまみを持つ価値を決める。
class SelfDistillingNet(nn.Module):
"""Network with early-exit classifiers for variable-latency inference."""
def __init__(self, input_dim: int, n_classes: int = 3):
super().__init__()
self.block1 = nn.Sequential(
nn.Linear(input_dim, 128), nn.ReLU(), nn.BatchNorm1d(128)
)
self.block2 = nn.Sequential(
nn.Linear(128, 64), nn.ReLU(), nn.BatchNorm1d(64)
)
self.block3 = nn.Sequential(
nn.Linear(64, 32), nn.ReLU(), nn.BatchNorm1d(32)
)
self.exit1 = nn.Linear(128, n_classes)
self.exit2 = nn.Linear(64, n_classes)
self.exit3 = nn.Linear(32, n_classes) # final exit
def forward(
self, x: torch.Tensor, exit_layer: int = 3
) -> torch.Tensor:
h1 = self.block1(x)
if exit_layer == 1:
return self.exit1(h1)
h2 = self.block2(h1)
if exit_layer == 2:
return self.exit2(h2)
h3 = self.block3(h2)
return self.exit3(h3)
def forward_all_exits(self, x: torch.Tensor):
"""Return logits from all exits (for self-distillation training)."""
h1 = self.block1(x)
h2 = self.block2(h1)
h3 = self.block3(h2)
return self.exit1(h1), self.exit2(h2), self.exit3(h3)
def self_distillation_step(
model: SelfDistillingNet,
x: torch.Tensor,
y: torch.Tensor,
temperature: float = 4.0,
alpha: float = 0.5,
) -> torch.Tensor:
"""One training step with self-distillation from deepest exit."""
logits_1, logits_2, logits_3 = model.forward_all_exits(x)
loss_hard = F.cross_entropy(logits_3, y)
loss_distill_1 = distillation_loss(
logits_1, logits_3.detach(), y, temperature, alpha
)
loss_distill_2 = distillation_loss(
logits_2, logits_3.detach(), y, temperature, alpha
)
return loss_hard + 0.5 * loss_distill_1 + 0.5 * loss_distill_2
推論予算はどこから来るのか

蒸留が意味を持つのは、推論を厳しい予算内に収める必要がある場合だけだ。NICからユーザースペース、カーネルバイパス、合計100 µs未満、FPGAと共有メモリを必要とする10 µs未満の層まで、ティックから取引までの完全なレイダーはアルゴリズム取引におけるデータと通信ですでに整理されている。そのレイダーで空いている行がモデル推論であり、蒸留はそこを埋めようとしている。
残りの行をモデルクラス別のレイテンシー表で埋めようとしてはいけない。機械学習によるスプレッド・モデリングにはGBMとディープラーニングの比較がすでにあり、数値以上に重要な注意点も示されている。レイテンシーは実装依存であり、同じLightGBMモデルでもPythonからは1行あたり数十マイクロ秒、コンパイル済み予測器からは数マイクロ秒しかかからない。ここでレイテンシーを主張するなら、フレームワーク、コア、バッチサイズを明記しなければならない。そうでなければノイズである。
GPUについては特に注意が必要だ。デバイスが役に立つには、起動ごとの固定オーバーヘッドを先に償却しなければならない。1行推論はルーフラインの稜線からはるか左側にあり、そこではGPUが有利になることはない。GPUが採算に合うときでは、バッチスイープでこの償却曲線を正しく測定し、ディスクリートPCIeカードが稜線をさらに右へ押すことも含めている。記憶から引用した定数を信じず、こちらを読むべきだ。
蒸留後の量子化
蒸留した生徒はさらに圧縮できる。INT8重み(AVX-512 VNNIを使うCPUではおよそ2倍)、乗算を加算へ変える二値/三値重み、そしてほぼゼロの計算を飛ばす枝刈りだ。
「生徒はすでにコンパクトな表現を学習しているため、蒸留してから量子化すれば量子化だけの場合より精度を保てる」という魅力的な主張がある。しかし、それを根拠に出荷してはいけない。GPU精度の罠は低い数値精度に対するこのブログの立場を示している。もっともらしいゴミを黙って返したからこそ、高速経路を出荷可能にしたのは主張ではなく、定量化した等価性ゲート(約定のずれ、bps単位のPnL差分)だった。INT8の生徒は、FP32の生徒に対してそのゲートを測定するまで別のモデルである。
import torch.quantization as quant
def quantize_student(student: nn.Module, calibration_loader: DataLoader):
"""Post-training static quantization for CPU deployment."""
student.cpu()
student.eval()
student.qconfig = quant.get_default_qconfig("x86")
student_prepared = quant.prepare(student)
with torch.no_grad():
for X, _ in calibration_loader:
student_prepared(X)
student_quantized = quant.convert(student_prepared)
return student_quantized
FPGA運用:蒸留からビットストリームまでのパイプライン

FPGAはレイテンシー・レイダーの10 µs未満の層にあたり、Tbricks/BroadridgeのレビューではカーネルバイパスNICと並ぶ本番構成として扱っている。決定論的なレイテンシー、OSジッターがないこと、ネットワークスタックとの同居が特徴だ。このブログでまだ扱っていないのは、蒸留モデルをFPGAへ載せる方法である。
DeepLOBの本番運用ノートはONNX/TensorRT、INT8量子化、FPGA運用を3つの選択肢として挙げるが、そこで止まっている。3つ目を展開すると次のようになる。
1. Train ensemble teacher (offline, GPU cluster, hours/days)
|
2. Distill to small MLP student (offline, single GPU, minutes)
|
3. Quantize student to INT8 / fixed-point (offline, CPU)
|
4. Convert to HLS (High-Level Synthesis) or RTL
|
5. Synthesize FPGA bitstream (offline, hours)
|
6. Deploy to FPGA card in production server
|
7. Inference: market data -> FPGA -> trading signal
拘束条件は、モデルがデバイスの論理要素、つまりLUT、DSPスライス、ブロックRAMに収まらなければならないことだ。測定値ではなく桁のオーダーを示す予算として、隠れユニット64、INT8重みの2層MLPなら、推論1回あたり約8,000回の積和演算、重み約16 KB程度であり、中級パーツのごく一部に収まる。ここで蒸留が効く。アンサンブル教師はどの予算でも収まらないが、生徒は上限からほど遠い。
PyTorch/ONNXから合成可能なハードウェアへの変換を自動化するツールには、AMD/Xilinx Vitis AI、CERN発のhls4ml、Xilinx ResearchのFINNがある。
例:hls4mlへの変換
import hls4ml
import onnx
dummy_input = torch.randn(1, 60) # 60 input features
torch.onnx.export(student, dummy_input, "student.onnx", opset_version=13)
hls_config = hls4ml.utils.config_from_onnx_model(
onnx.load("student.onnx"),
granularity="name",
default_precision="ap_fixed<16,8>",
default_reuse_factor=1, # full parallelism
)
hls_model = hls4ml.converters.convert_from_onnx_model(
"student.onnx",
hls_config=hls_config,
output_dir="hls_student",
backend="VivadoAccelerator",
board="alveo-u250",
)
hls_model.compile()
hls_model.build(csim=True, synth=True)
hls_model.report()
hls_model.report()だけが、モデル、ボード、精度、再利用係数が決まったときのリソースとレイテンシーの数値について信頼できる情報源である。default_reuse_factorだけでも数値は大きく変わる。実行せずに「典型的な」合成表を引用するのは推測にすぎない。
実務上の考慮事項

教師ロジットの事前計算
蒸留には訓練セット全体に対する教師の予測が必要だ。これは意図的に支払う価値のある一度限りのオフラインコストである。アンサンブルを一度だけ実行してロジットを永続化し、そのキャッシュに対して生徒を訓練する。そうすれば温度スイープやアーキテクチャ探索で教師の順伝播を追加実行する必要がなく、上で示したスイープを実用的に行える。
蒸留に固有のモニターは1つだけ
特徴量パイプラインの健全性、z-scoreパラメータがドリフトすることを踏まえたローリング正規化、入力分布シフトの監視、レジームをトリガーとする再訓練は、すべてDeepLOBの本番運用セクションで扱っており、ここでもそのまま適用できる。
蒸留に固有のモニターは、ライブデータ上の教師・生徒KLダイバージェンスである。教師はオフラインに残しておき、ライブ入力の一部を通して分布を比較する。KLの上昇は、蒸留していないレジームで生徒の近似が劣化していることを意味する。ラベルを待たないため、精度が悪化する前に検知できる。再訓練の閾値は、正常と既知の劣化の期間で観測したKLに対して校正しなければならない。事前に選ぶだけでは恣意的である。
蒸留しない場合
- 教師がすでに小さい(線形モデル、浅いGBM):圧縮が得られないのにパイプラインの段階だけ増える。
- レイテンシーが制約でない(日次リバランス、引け時シグナル):教師をそのまま運用する。
- 速度より解釈可能性が重要:蒸留ネットワークは、置き換えた木アンサンブルより説明しにくい。
- 2段階分割がすでに機能している:スプレッド・モデリングのアーキテクチャの非同期低速モデルが成果を出しているなら、動作中のシステムを置き換える前に、蒸留が測定比較でそれを上回らなければならない。
まとめ

蒸留は、2段階の高速/低速分割に対する一貫した代替案である。オフラインで許容できる範囲の最良の教師を訓練し、そのソフトターゲット構造をホットパスに収まる小さな生徒へ移し、量子化してCPUまたはFPGAに配置する。深さ方向の変種はさらに進み、レイテンシーを訓練時の固定値ではなく実行時の選択にする。
この記事が意図的に主張していないのは、これらのどれかがブログですでに公開している方法を上回るということだ。その判定には、実際のオーダーブックデータで3つの測定が必要になる。レジーム別に分けた生徒対アンサンブルの重み付きF1保持曲線、温度スイープ、そしてGPU精度の罠の形式によるINT8同等性ゲートである。それらが揃うまでは、これは手法の説明であって、運用を勧めるものではない。
参考文献
-
Hinton, G., Vinyals, O., & Dean, J. (2015). ニューラルネットワークにおける知識の蒸留. arXiv:1503.02531
-
Furlanello, T., Lipton, Z. C., Tschannen, M., Itti, L., & Anandkumar, A. (2018). Born-Again Neural Networks. ICML. arXiv:1805.04770
-
Zhang, L., Song, J., Gao, A., Chen, J., Bao, C., & Ma, K. (2019). 自分自身が教師になる:自己蒸留によって畳み込みニューラルネットワークの性能を改善する. ICCV. arXiv:1905.08094
-
Romero, A., Ballas, N., Kahou, S. E., Chassang, A., Gatta, C., & Bengio, Y. (2015). FitNets: Thin Deep Netsのためのヒント. ICLR. arXiv:1412.6550
-
Gou, J., Yu, B., Maybank, S. J., & Tao, D. (2021). 知識蒸留:サーベイ. International Journal of Computer Vision, 129, 1789-1819. arXiv:2006.05525
-
Duarte, J., et al. (2018). 素粒子物理学向けFPGAにおけるディープニューラルネットワークの高速推論(hls4ml). Journal of Instrumentation, 13, P07027. arXiv:1804.06913
-
Umuroglu, Y., et al. (2017). FINN:高速でスケーラブルな二値化ニューラルネットワーク推論のためのフレームワーク. FPGA '17. arXiv:1612.07119
-
Zhang, Z., Zohren, S., & Roberts, S. (2019). DeepLOB:指値注文板のためのディープ畳み込みニューラルネットワーク. IEEE Transactions on Signal Processing, 67(11), 3001-3012. arXiv:1808.03668
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.