← กลับไปยังบทความ
August 14, 2026
อ่าน 5 นาที

การตัดแต่งโมเดลสำหรับการอนุมานการเทรดที่มีเวลาแฝงต่ำ

การตัดแต่งโมเดลสำหรับการอนุมานการเทรดที่มีเวลาแฝงต่ำ
#model-compression
#pruning
#lottery-ticket
#latency
#deployment

บทความ DeepLOB จบส่วนการนำไปใช้งานจริงด้วยสามแนวทาง — ONNX ร่วมกับ TensorRT, การทำ quantization เป็น INT8 และ FPGA — แต่ไม่ได้อธิบายแนวทางใดเลย บทความนี้จึงเติมเต็มปัญหาพื้นฐานที่อยู่เบื้องหลังทั้งสามแนวทาง: โมเดลใหญ่เกินความจำเป็น การตัดแต่งเครือข่ายประสาทเพื่อลบพารามิเตอร์ที่ซ้ำซ้อน และข้ออ้างที่น่าสนใจในวรรณกรรมว่า นอกจากจะช่วยประหยัดหน่วยความจำแล้ว โครงข่ายย่อยที่มีน้ำหนักเพียง 10-20% อาจให้ความแม่นยำเทียบเท่าโมเดลแบบหนาแน่น

บล็อกนี้อธิบายไปแล้วว่าเวลาแฝงสำคัญอย่างไร — ZigBolt ครอบคลุมเส้นทางส่งข้อความ และ ภาษี IPC อธิบายพร้อมคณิตศาสตร์จุดคุ้มทุน — ส่วน การสร้างแบบจำลองสเปรด ครอบคลุมการแลกเปลี่ยนระหว่างตัวเลือกที่เร็วกว่าแต่แย่ลงเล็กน้อยกับตัวเลือกที่ช้ากว่าแต่ดีกว่า พร้อมตารางเปรียบเทียบ gradient boosting กับ deep learning ที่มีแถวเวลาแฝงของการอนุมาน สิ่งที่ยังไม่ครอบคลุมคือ เราจะทำให้โมเดลหนึ่งเล็กลงได้อย่างไร ในลูปการเสนอราคา ขั้นตอนการอนุมานโมเดลอยู่ภายใต้การควบคุมของเราโดยตรง ส่วนช่วงการขนส่งพร้อมตัวเลข p50/p95/p99 ที่ทำซ้ำได้อธิบายไว้ใน การสื่อสารข้อมูลสำหรับการซื้อขายอัลกอริทึม

บทความนี้คืออะไร: คณิตศาสตร์และโค้ดที่ใช้งานได้สำหรับการตัดแต่งตามขนาด การตัดแต่งแบบมีโครงสร้าง Iterative Magnitude Pruning การตัดแต่งตามการเคลื่อนที่ การกลั่นความรู้ และความเบาบางแบบกึ่งมีโครงสร้าง 2:4 ของ NVIDIA ที่นำมาใช้กับ MLP สำหรับการเทรด

บทความนี้ไม่ใช่: ผลลัพธ์ที่วัดแล้ว บทความเชิงประจักษ์ทุกบทความในบล็อกมีบรรทัดระบุที่มา หรือมี repo ประกอบ แต่บทความนี้ยังไม่มีทั้งสองอย่าง เส้นโค้งความเบาบางเทียบกับความแม่นยำและเวลาแฝงด้านล่างเป็นการทดลองที่ต้องรัน ไม่ใช่ตารางสำหรับอ้างตัวเลข ให้ถือว่าทุกอย่างในที่นี้เป็นวิธีการ ส่วนตัวเลขยังรอการวัด

การตัดแต่งโมเดลให้อะไรแก่คุณ

เครือข่ายประสาทขนาดกะทัดรัดพอดีกับชั้นแคชความเร็วสูง

ข้อจำกัดคือขนาด ลองพิจารณาโมเดลความถี่ระดับกลาง — MLP 4 ชั้นที่มีหน่วยซ่อน 2048 หน่วยบนฟีเจอร์ของสมุดคำสั่งซื้อขาย:

Parameters=dinh+h2(L1)+hdout\text{Parameters} = d_{\text{in}} \cdot h + h^2 \cdot (L - 1) + h \cdot d_{\text{out}}

สำหรับ din=100d_{\text{in}} = 100, h=2048h = 2048, L=4L = 4, dout=3d_{\text{out}} = 3 จะมีพารามิเตอร์ประมาณ 12.6 ล้านตัว หรือราว 48 MB ใน float32 โดยทั่วไป L2 มีขนาด 1-4 MB ดังนั้นน้ำหนักจึงใส่ไม่พอดีและต้องสตรีมจากหน่วยความจำที่ไกลกว่าในทุก forward pass หากตัดออก 95% จะเหลือพารามิเตอร์ที่ใช้งานจริงราว 630K และ 2.4 MB ซึ่งพอดีกับแคช

การแปลงนี้เป็นเวลานาฬิกาจริงหรือไม่นั้นขึ้นกับว่า kernel ถูกจำกัดด้วยหน่วยความจำหรือไม่ ซึ่งเป็นคำถามด้าน arithmetic intensity ไม่ใช่ขนาด บันไดความเร็วของเครื่องมือทดสอบย้อนหลัง ใช้ roofline model ผ่านตัวอย่างที่วัดจริง แทนการอ้าง penalty factor; กรอบเดียวกันนี้ใช้ที่นี่ และควรมีวินัยแบบเดียวกัน: วัดไบต์ที่เคลื่อนย้ายก่อนอ้างการเร่งความเร็ว

พื้นฐานของการตัดแต่งโมเดล

การตัดแต่งแบบมีโครงสร้างและไร้โครงสร้างบนโครงข่ายประสาท

การตัดแต่งแบบไร้โครงสร้าง

วิธีที่ง่ายที่สุดคือกำหนดน้ำหนักแต่ละตัวเป็นศูนย์ตามขนาดของมัน เมื่อมีเมทริกซ์น้ำหนัก WRm×n\mathbf{W} \in \mathbb{R}^{m \times n} ให้สร้าง binary mask M{0,1}m×n\mathbf{M} \in \{0, 1\}^{m \times n} ดังนี้:

Mij={1if Wijτ0if Wij<τM_{ij} = \begin{cases} 1 & \text{if } |W_{ij}| \geq \tau \\ 0 & \text{if } |W_{ij}| < \tau \end{cases}

โดย τ\tau คือ threshold ที่เลือกเพื่อให้ได้ระดับ sparsity ss ที่ต้องการ:

s=1M0mns = 1 - \frac{\|\mathbf{M}\|_0}{m \cdot n}

เมทริกซ์ที่ตัดแต่งคือ W=WM\mathbf{W}' = \mathbf{W} \odot \mathbf{M} โดย \odot คือผลคูณแบบ Hadamard แนวคิดคือ น้ำหนักที่ใกล้ศูนย์มีส่วนต่อเอาต์พุตของเลเยอร์เพียงเล็กน้อย

ปัญหาที่ต้องพูดตรง ๆ เพราะตัวเลขความเบาบางอ่านผิดได้ง่าย: ความเบาบางแบบไม่มีโครงสร้างไม่ได้แปลว่าจะเร็วขึ้นบนฮาร์ดแวร์มาตรฐาน เมทริกซ์ที่มีศูนย์ 90% ยังคงออกคำสั่งคูณและสะสมจำนวนเท่าเดิม เว้นแต่จะเปลี่ยนไปใช้ sparse kernels หรือฮาร์ดแวร์ที่รองรับความเบาบาง เมื่อโค้ดพิมพ์ Sparsity: 90.0% นั่นหมายถึงจำนวนศูนย์ ไม่ใช่การเร็วขึ้น 10 เท่า และบน dense CPU GEMM ก็ไม่ได้เร็วขึ้น 1.01 เท่าเช่นกัน วิธีที่ช่วยประหยัดเวลาคือการตัดแต่งแบบมีโครงสร้าง (ทำให้เมทริกซ์เล็กลง) และความเบาบางแบบกึ่งมีโครงสร้าง 2:4 (ซึ่งมีฮาร์ดแวร์รองรับ)

การตัดแต่งแบบมีโครงสร้าง

การตัดแต่งแบบมีโครงสร้างจะลบ neuron, channel หรือ attention head ทั้งชุด สำหรับเลเยอร์เชิงเส้น y=Wx+b\mathbf{y} = \mathbf{W}\mathbf{x} + \mathbf{b} ที่มี WRm×n\mathbf{W} \in \mathbb{R}^{m \times n} การลบ neuron jj จะทำให้แถวที่ jj ของ W\mathbf{W} และสมาชิกที่ jj ของ b\mathbf{b} เป็นศูนย์:

importance(j)=Wj,:2=k=1nWjk2\text{importance}(j) = \|\mathbf{W}_{j,:}\|_2 = \sqrt{\sum_{k=1}^{n} W_{jk}^2}

neuron ที่มี 2\ell_2-norm เล็กที่สุดจะถูกลบก่อน วิธีนี้สร้างเมทริกซ์ที่เล็กลงจริง — แต่ต้องสร้างเลเยอร์ใหม่ให้มีขนาดเล็กลงจริง ๆ การทำแถวเป็นศูนย์แล้วคง shape เดิมไม่ลดจำนวน FLOP; ขั้นตอน rebuild ในส่วนการนำไปใช้จะแปลง mask เป็นเมทริกซ์ 512×n512 \times n

สำหรับเลเยอร์คอนโวลูชัน วิธีเทียบเท่าคือการตัดแต่งฟิลเตอร์ เมื่อมี WRCout×Cin×k×k\mathbf{W} \in \mathbb{R}^{C_{\text{out}} \times C_{\text{in}} \times k \times k} ความสำคัญของฟิลเตอร์เอาต์พุต ii คือ:

importance(i)=Wi,:,:,:F=c,h,wWi,c,h,w2\text{importance}(i) = \|\mathbf{W}_{i,:,:,:}\|_F = \sqrt{\sum_{c,h,w} W_{i,c,h,w}^2}

การลบฟิลเตอร์ ii จะลบเอาต์พุตแชนเนลทั้งชุด และลด FLOP ตามสัดส่วน

สมมติฐาน Lottery Ticket

โครงข่ายย่อยที่ชนะขนาดเล็กถูกแยกออกจากโครงข่ายประสาทขนาดใหญ่

ในปี 2019 Frankle และ Carbin เสนอสมมติฐาน Lottery Ticket (LTH): ภายใน dense network ที่เริ่มต้นด้วยค่าสุ่มมีโครงข่ายย่อยแบบเบาบาง หรือ "winning ticket" ซึ่งเมื่อฝึกจากค่าเริ่มต้นเดิมจะมีความแม่นยำเทียบเท่า full network ด้วยจำนวนรอบการทำซ้ำใกล้เคียงกัน

อย่างเป็นทางการ ให้พิจารณา f(x;θ0)f(\mathbf{x}; \boldsymbol{\theta}_0) ที่เริ่มต้นด้วย θ0Dθ\boldsymbol{\theta}_0 \sim \mathcal{D}_\theta หลังฝึกจนลู่เข้า เราได้ θ\boldsymbol{\theta}^* และสร้าง pruning mask m\mathbf{m} LTH ระบุว่ามี m\mathbf{m} ที่ทำให้:

f(x;mθ0)trainθmwhereacc(θm)acc(θ)f(\mathbf{x}; \mathbf{m} \odot \boldsymbol{\theta}_0) \xrightarrow{\text{train}} \boldsymbol{\theta}_m^* \quad \text{where} \quad \text{acc}(\boldsymbol{\theta}_m^*) \geq \text{acc}(\boldsymbol{\theta}^*)

โดย m0θ0\|\mathbf{m}\|_0 \ll |\boldsymbol{\theta}_0| การทดลองเดิมใช้ MNIST และ CIFAR-10 ซึ่ง winning tickets เหลือพารามิเตอร์ 10-20% แต่ไม่ควรสรุปว่าจะใช้กับสมุดคำสั่งซื้อขายได้ — ฟีเจอร์ LOB ไม่คงที่ และป้ายกำกับมีลักษณะใกล้เคียง noise ซึ่งแตกต่างจากการจำแนกรูปภาพในประเด็นสำคัญ

Iterative Magnitude Pruning (IMP)

ticket ถูกค้นหาด้วย IMP:

  1. เริ่มต้น network ด้วย θ0\boldsymbol{\theta}_0
  2. ฝึกจนลู่เข้าเพื่อให้ได้ θ\boldsymbol{\theta}^*
  3. ตัดแต่งน้ำหนัก p%p\% ที่มีขนาดต่ำสุดและสร้าง mask m\mathbf{m}
  4. รีเซ็ตน้ำหนักที่เหลือกลับเป็นค่าจาก θ0\boldsymbol{\theta}_0 (rewinding)
  5. ทำซ้ำจากขั้นที่ 2 ด้วย network ที่มี mask

แต่ละรอบตัดแต่งสัดส่วน pp (โดยทั่วไป 20%) ดังนั้นหลัง nn รอบจะเหลือพารามิเตอร์ (1p)n(1-p)^n หลัง 10 รอบที่ p=0.2p = 0.2 จะเหลือประมาณ 10.7%

สามสมมติฐานเกี่ยวกับโมเดลการเทรด ซึ่งยังไม่มีข้อใดถูกทดสอบ

เป็นเรื่องน่าดึงดูดที่จะอ้างว่า LTH ควรทำงานได้ดีกับข้อมูลตลาดเป็นพิเศษ มีเหตุผลในลักษณะนี้อยู่สามข้อ แต่ทั้งสามข้อเป็นเพียงสมมติฐาน และการกล่าวให้เป็นข้อเท็จจริงคือความผิดพลาดแบบที่บล็อกนี้ตั้งใจหลีกเลี่ยง

  1. สัญญาณทางการเงินมีความเบาบาง ภาพ snapshot ส่วนใหญ่ของสมุดคำสั่งซื้อขายเป็น noise ดังนั้นโครงข่ายย่อยแบบเบาบางอาจสอดคล้องกับสัญญาณแบบเบาบางโดยธรรมชาติ ทดสอบได้โดยเปรียบเทียบ IMP กับ random mask ที่มีความเบาบางเท่ากัน หากความเบาบางเป็นสิ่งที่ทำให้เกิดผลจริง random mask ก็ควรตามหลังไม่มาก
  2. Winning tickets ใช้ได้ข้าม regime นี่เป็นข้ออ้างเชิงประจักษ์เกี่ยวกับตลาดที่ยังไม่มีแหล่งอ้างอิงรองรับ และเป็นข้อที่น่าสนใจที่สุดในสามข้อ สามารถทดสอบโดยตรงกับป้ายกำกับ regime จาก การตรวจจับ regime ด้วย HMM: ค้นหา ticket ใน regime A แล้วฝึกใหม่ใน regime B จากนั้นเปรียบเทียบกับ ticket ที่ค้นหาโดยตรงใน B
  3. ความเบาบางทำหน้าที่เป็น regularization ความจุที่ใช้งานจริงลดลงอาจลดการปรับเข้ากับ noise ของ microstructure ซึ่งควรปรากฏเป็นช่องว่างนอกกลุ่มตัวอย่างของโมเดลที่ตัดแต่งแล้ว เล็กกว่า ของโมเดลหนาแน่น ไม่ใช่เพียงใกล้เคียงกัน

การนำไปใช้: ตัดแต่ง Trading MLP

ชั้นประสาทถูกแปลงด้วย mask แบบเบาบางที่แม่นยำ

โมเดลพื้นฐาน

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():,}")

การตัดแต่ง magnitude แบบไร้โครงสร้าง

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%}")

การตัดแต่งแบบมีโครงสร้าง พร้อม rebuild ที่ทำให้เกิดผลจริง

การทำ mask ให้แถวเป็นเพียงครึ่งหนึ่งของงาน ครึ่งที่ทำให้เร็วขึ้นคือการสร้างแต่ละเลเยอร์ใหม่ให้มีรูปร่างเล็กลง — ซึ่งหมายถึงต้องส่งต่อการลบไปข้างหน้า: เมื่อลบแถว jj ของเลเยอร์ ii ก็ต้องลบคอลัมน์ jj ของเลเยอร์ i+1i+1 และแชนเนล jj ของ BatchNorm1d ที่อยู่ระหว่างนั้นด้วย

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

มีสองสิ่งที่ต้องตรวจสอบก่อนเชื่อถือผลลัพธ์นี้ ตามแนวคิดเดียวกับด่านตรวจสอบความเท่าเทียมที่บทความอื่นในบล็อกใช้:

  • รูปร่าง rebuilt ควรแสดงมิติของหน่วยซ่อนเป็น (1fraction)h(1 - \text{fraction}) \cdot h — 512 สำหรับ fraction=0.75, h=2048h = 2048 — และจำนวนพารามิเตอร์ควรลดลงแบบกำลังสอง เพราะทั้งสองมิติของเมทริกซ์ภายในมีขนาดเล็กลง
  • เอาต์พุต ในโหมด eval() ค่า rebuilt(x) ต้องตรงกับเอาต์พุตของโมเดลที่มี mask แต่ยังไม่ได้ rebuilt ภายในค่าคลาดเคลื่อนของเลขทศนิยม เมื่อใช้ batch เดียวกัน หากไม่ตรง แสดงว่าการส่งต่อคอลัมน์ผิดพลาด และตัวเลข downstream ทุกตัวกำลังวัดโมเดลคนละตัวกับที่คุณคิด

การทดสอบการคงอยู่ของแถวตั้งสมมติฐานว่าแถวที่ถูก mask เป็นศูนย์พอดี และแถวที่ยังใช้งานไม่เป็นศูนย์ ซึ่งเป็นจริงสำหรับเอาต์พุตของ ln_structured; แต่จะไม่จริงหากขั้นตอนอื่นสร้าง neuron ที่ยังใช้งานแต่เป็นศูนย์ทั้งแถว ดังนั้นให้ตรวจสอบจำนวนตัวที่เหลือเทียบกับสัดส่วนที่ร้องขอ แทนการเชื่อผลการทดสอบ norm โดยไม่ตรวจสอบ

Iterative Magnitude Pruning (ค้นหา 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 คือข้อมูลดิบสำหรับเส้นโค้งความเบาบางเทียบกับความแม่นยำที่บทความนี้ยังติดค้างผู้อ่านไว้ eval_fn ต้องวัดนอกกลุ่มตัวอย่างจริงบน split ที่ purge แล้ว — การรัน IMP ที่ให้คะแนนในกลุ่มตัวอย่างจะรายงานเส้นโค้งสวยงามแต่ไม่มีความหมาย

การวัดผล

เส้นขอบประสิทธิภาพนามธรรมที่สมดุลความเบาบาง ความแม่นยำ และเวลาแฝง

วัดเวลาแฝงด้วยธรรมเนียมของ harness เดียวกับบทความอื่นในบล็อก — ไม่รวมช่วง warmup ใช้ค่าที่ดีที่สุดจาก N ครั้ง และรายงาน p50/p95/p99 แทนค่าเฉลี่ย — โดยมีขั้นตอนและโค้ดอยู่ใน Polars เทียบกับ pandas มีสามประเด็นเฉพาะสำหรับการตัดแต่ง:

  • วัด benchmark ของโมเดลที่ สร้างใหม่ ไม่ใช่โมเดลที่มีเพียง mask โมเดลที่มี mask เมื่อใช้ batch size 1 ยังคงวัดรูปร่างแบบหนาแน่น
  • รายงาน batch size ด้วย Batch 1 (ลูปการเสนอราคา) และ batch 256 (การกวาดการทดลองเพื่อการวิจัย) อยู่คนละฝั่งของเส้นแบ่งระหว่าง memory-bound กับ compute-bound และการตัดแต่งให้ประโยชน์กับสองกรณีไม่เท่ากัน
  • รายงานความแม่นยำบน split เดียวกัน ที่ horizon เดียวกัน พร้อมระบุคำจำกัดความของ label ตารางเวลาแฝงที่ไม่มีคอลัมน์ความแม่นยำประกอบก็เท่ากับเสนอให้ลบโมเดลทิ้งทั้งหมด

เทคนิคขั้นสูง

เส้นทางบีบอัดเครือข่ายขั้นสูงรวมเป็นโมเดลเดียวที่มีประสิทธิภาพ

การตัดแต่งร่วมกับการกลั่นความรู้

แทนที่จะตัดแต่งและ fine-tune แยกกัน ให้ใช้โมเดลหนาแน่นดั้งเดิมเป็น teacher ส่วน student ที่ถูกตัดแต่งจะลดฟังก์ชันสูญเสียแบบผสมระหว่าง task loss กับ KL divergence จากการแจกแจงเอาต์พุตของ teacher:

L=(1α)Ltask(y,y^s)+αT2DKL(σ(ztT)σ(zsT))\mathcal{L} = (1 - \alpha) \cdot \mathcal{L}_{\text{task}}(\mathbf{y}, \hat{\mathbf{y}}_s) + \alpha \cdot T^2 \cdot D_{\text{KL}}\left(\sigma\left(\frac{\mathbf{z}_t}{T}\right) \| \sigma\left(\frac{\mathbf{z}_s}{T}\right)\right)

โดย zt\mathbf{z}_t และ zs\mathbf{z}_s คือ logits ของ teacher และ student, TT คือ temperature และ α\alpha ใช้ถ่วงดุลวัตถุประสงค์ทั้งสอง ปัจจัย T2T^2 จะปรับสเกล gradient ของการกลั่นความรู้ ซึ่งหากไม่มีปัจจัยนี้จะหดลงเป็น 1/T21/T^2

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

แทนที่จะตัดแต่งตามขนาดสัมบูรณ์ movement pruning (Sanh et al., 2020) จะตัดแต่งน้ำหนักที่กำลังเคลื่อนเข้าใกล้ศูนย์ระหว่างการฝึก คะแนนความสำคัญสะสมผลคูณระหว่าง gradient กับน้ำหนัก:

Sij(t)=Sij(t1)+Wij(t)Wij(t1)sign(Wij(t)LWij(t))S_{ij}^{(t)} = S_{ij}^{(t-1)} + \left| W_{ij}^{(t)} - W_{ij}^{(t-1)} \right| \cdot \text{sign}\left(W_{ij}^{(t)} \cdot \frac{\partial \mathcal{L}}{\partial W_{ij}^{(t)}}\right)

น้ำหนักที่มีคะแนนเป็นลบจะถูกตัดแต่ง เหตุผลที่เลือกวิธีนี้แทนการตัดแต่งตามขนาดเกี่ยวข้องกับ fine-tuning โดยเฉพาะ: เมื่อปรับโมเดลที่ฝึกไว้ล่วงหน้า การแจกแจงของขนาดถูกกำหนดโดยงานก่อนฝึก ดังนั้นขนาดจึงเป็นสัญญาณความสำคัญที่ล้าสมัย ส่วนทิศทางการเคลื่อนที่เป็นสัญญาณที่ใหม่กว่า สำหรับโมเดลการเทรดที่ฝึกใหม่บนหน้าต่างแบบเลื่อน สถานการณ์นี้พบได้บ่อยกว่าการฝึกจากศูนย์

ความเบาบางแบบมีโครงสร้าง 2:4 ของ NVIDIA

GPU NVIDIA รุ่น Ampere และรุ่นใหม่กว่ารองรับความเบาบางแบบมีโครงสร้าง 2:4 ในฮาร์ดแวร์: จากน้ำหนักที่ต่อเนื่องกันทุก 4 ตัว ต้องมีศูนย์พอดี 2 ตัว

i,W[4i:4i+4]0=2\forall i, \quad \|\mathbf{W}_{[4i:4i+4]}\|_0 = 2

นี่คือรูปแบบความเบาบางแบบละเอียดรูปแบบเดียวที่ฮาร์ดแวร์ให้ประโยชน์จริง จึงสำคัญกว่าตัวเลขศูนย์ 90% จากการตัดแต่งแบบไม่มีโครงสร้าง ข้อจำกัดนี้เป็นแบบเฉพาะที่ ไม่ใช่ระดับ global — ไม่สนใจว่า สองตัวใด ในทุกกลุ่มสี่ตัวจะเหลืออยู่ — จึงเป็นข้อจำกัดที่อ่อนกว่าการกำหนด global mask มาก แม้ว่าระดับความเบาบางที่มีให้เลือกจะมีเพียง 50% ก็ตาม

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()

การจะได้ความเร็วเพิ่มจริงต้องให้เส้นทางการอนุมานใช้ sparse tensor cores — ผ่านการ export เป็น ONNX แล้ว build ด้วย TensorRT หรือใช้ torch.sparse.to_sparse_semi_structured การ export โมเดลที่มี mask 2:4 แล้วรันผ่าน dense runtime จะทำให้เสียความแม่นยำโดยไม่ได้ประโยชน์ด้านความเร็วเลย

การนำไปใช้งานจริงในระบบผลิต

โมเดลการอนุมานขนาดเล็กเข้าสู่ช่องทางฮาร์ดแวร์ที่มีเวลาแฝงต่ำ

การตรวจสอบ

โมเดลที่ถูกตัดแต่งคือโมเดลใหม่ ไม่ใช่โมเดลเดิมที่ถูกบีบอัด และต้องผ่านด่านยอมรับเดียวกับ candidate อื่นทั้งหมด: ฝึกใหม่แบบหน้าต่างเลื่อนและตรวจสอบซ้ำนอกกลุ่มตัวอย่างตาม การเพิ่มประสิทธิภาพแบบ walk-forward พร้อมแก้ผลจากการคัดเลือกตาม deflated Sharpe ratio การแก้นี้ไม่ใช่สิ่งที่เลือกทำหรือไม่ทำก็ได้ — IMP สร้างโมเดล candidate เป็น ลำดับ ดังนั้นระดับความเบาบางที่ดูดีที่สุดจากสิบรอบถูกเลือกผ่านการค้นหา และค่า Sharpe ต้อง deflate ตามจำนวนการทดลองที่มีผลจริง กฎตายตัวอย่าง "ปฏิเสธถ้า Sharpe ลดลงเกิน 5%" ไม่ผ่านคณิตศาสตร์นี้ จึงไม่มีการใช้กฎดังกล่าวในบทความนี้

การซ้อน quantization

การตัดแต่งสามารถใช้ร่วมกับ quantization ได้ โมเดลที่มีความเบาบาง 90% และทำ quantization เป็น INT8 มีอัตราการบีบอัดดังนี้:

CR=1(1s)boriginalbquantized=10.1328=40×\text{CR} = \frac{1}{(1 - s)} \cdot \frac{b_{\text{original}}}{b_{\text{quantized}}} = \frac{1}{0.1} \cdot \frac{32}{8} = 40\times

โมเดลขนาด 48 MB จะเหลือ 1.2 MB นี่เป็นเพียงข้ออ้างด้านพื้นที่จัดเก็บเท่านั้น ไม่ได้บอกอย่างอื่น การที่โมเดลขนาด 1.2 MB จะให้การตัดสินใจแบบเดียวกันหรือไม่เป็นคำถามอีกข้อที่ต้องมีคำตอบแยกต่างหาก และ กับดักความแม่นยำของ GPU คือเหตุผลที่ต้องถามคำถามนี้: บล็อกนี้แสดงให้เห็นแล้วว่า fp32 เพียงอย่างเดียวทำให้เกิด relative error เท่ากับ 211 ในการคำนวณ backtest ที่ดูสมเหตุสมผลทุกประการ INT8 เป็นการลดความละเอียดที่รุนแรงกว่านั้นมาก ให้ deploy โมเดลที่ทำ quantization และตัดแต่งแล้วก็ต่อเมื่อผ่านด่าน parity ที่วัดเป็นตัวเลขเทียบกับโมเดลหนาแน่น fp32 — ต้องดูอัตราการตัดสินใจตรงกันและส่วนต่าง PnL ในช่วงที่กันไว้นอกการฝึก ไม่ใช่คำรับรองลอย ๆ

การติดตามผล

โมเดลที่ถูกตัดแต่งอาจไวต่อการเปลี่ยนแปลงของการแจกแจงมากขึ้น สิ่งที่ควรติดตามมีดังนี้:

  • ความเบาบางของ activation หาก neuron ที่เหลือปล่อยค่าเป็นศูนย์เกือบทั้งหมด โมเดลที่ใช้งานจริงจะเล็กกว่าที่ตั้งใจและน่าจะกำลังเสื่อมลง
  • Gradient norms ระหว่างการฝึกใหม่ gradient ที่พุ่งระเบิดบ่งชี้ว่าโครงข่ายย่อยที่เหลือถูกบังคับให้ชดเชยส่วนที่ถูกลบอย่างรุนแรงเกินไป
  • เอนโทรปีของการทำนาย โมเดลที่ถูกตัดแต่งแล้วมีความมั่นใจเกินจริงกับข้อมูล microstructure ที่มี noise มักกำลังปรับเข้ากับ regime ของชุดฝึก

บทสรุป

เครือข่ายประสาทกะทัดรัดที่มีประสิทธิภาพคลี่คลายจากความไม่แน่นอนแบบหนาแน่น

วิธีการเหล่านี้เป็นที่ยอมรับกันดี และจนกว่าจะรันการกวาดการทดลอง บทความนี้อ้างเพียงเท่านั้น การตัดแต่งแบบไม่มีโครงสร้างให้ตัวเลขความเบาบางแต่ไม่ให้ความเร็วเพิ่ม การตัดแต่งแบบมีโครงสร้างให้ความเร็วเพิ่มก็ต่อเมื่อ — และเฉพาะเมื่อ — สร้างเลเยอร์ใหม่ให้เล็กลง แทนที่จะเพียงใส่ mask สมมติฐาน Lottery Ticket เสนอว่าโมเดลขนาดกะทัดรัดมีอยู่แล้วภายในโมเดลที่มีพารามิเตอร์มากเกินไป แม้จะแสดงให้เห็นบน benchmark รูปภาพ ไม่ใช่ข้อมูลสมุดคำสั่งซื้อขาย และเหตุผลสามข้อที่กล่าวว่าโมเดลนี้ "ควร" ใช้ได้กับข้อมูลตลาดเป็นเพียงสมมติฐานที่ผูกกับการทดลอง ไม่ใช่ข้อค้นพบ

หลักปฏิบัติจากวรรณกรรมคือฝึกโมเดลให้ใหญ่แล้วค่อยตัดแต่ง แทนการออกแบบให้เล็กตั้งแต่ต้น: โมเดลใหญ่สำรวจ loss landscape ได้มีประสิทธิภาพกว่า และการตัดแต่งจะรักษาเส้นทางที่มีความสำคัญไว้ สำหรับโมเดลการเทรด หลักการนี้จะใช้ได้หรือไม่ ที่ระดับความเบาบางเท่าไร และต้องแลกด้วยความแม่นยำเท่าไร จะรู้ได้จากการกวาด IMP เพียงครั้งเดียว — และควรกลับมาอ่านบทความนี้อีกครั้งหลังการกวาดนั้น โดยมีตัวเลขจริงอยู่ในบทความ

ข้อจำกัดความรับผิดชอบ: ข้อมูลที่ให้ไว้ในบทความนี้มีไว้เพื่อการศึกษาและให้ข้อมูลเท่านั้น และไม่ถือเป็นคำแนะนำทางการเงิน การลงทุน หรือการเทรด การเทรดสกุลเงินดิจิทัลมีความเสี่ยงสูงที่จะขาดทุน

ผู้เขียน

Eugen Soloviov
Eugen Soloviov

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.

Newsletter

ก้าวนำหน้าตลาด

สมัครรับจดหมายข่าวของเราเพื่อรับข้อมูลเชิงลึกการเทรดด้วย AI เฉพาะ การวิเคราะห์ตลาด และการอัปเดตแพลตฟอร์ม

เราเคารพความเป็นส่วนตัวของคุณ ยกเลิกการสมัครได้ทุกเมื่อ