Multi-Task Learning for Simultaneous Price, Volume, and Volatility Prediction
โดยทั่วไป Multi-task learning (MTL) มักถูกนำเสนอด้วยคำกล่าวว่า: การแชร์ encoder ระหว่างเป้าหมายที่สัมพันธ์กันจะช่วยให้งานหลักดีขึ้น ในการเทรด เป้าหมายที่สัมพันธ์กันเห็นได้ชัด — ผลตอบแทน ปริมาณ และความผันผวนที่เกิดขึ้นจริงล้วนมาจาก order flow เดียวกัน — แต่แทบไม่เคยทดสอบคำกล่าวนี้ คำถามที่น่าสนใจไม่ใช่ว่างานต่าง ๆ สัมพันธ์กันหรือไม่ แต่คือ gradient ที่แชร์กัน เห็นพ้องกัน หรือไม่ และเกิดอะไรขึ้นใน fold ที่พวกมันไม่เห็นพ้อง
บทความนี้ยกสองเรื่องขึ้นมาเป็นแกนกลาง ซึ่งบทความ MTL ส่วนใหญ่มักปฏิบัติต่อมันเป็นเพียงเชิงอรรถ:
- การปรับสมดุล loss คือการทดลอง ไม่ใช่รายละเอียด น้ำหนักคงที่ การถ่วงน้ำหนักความไม่แน่นอนแบบ Kendall และ GradNorm คือโมเดลสามแบบที่แตกต่างกัน รันทั้งสามแบบบน fold เดียวกัน และรายงานน้ำหนักที่เรียนรู้พร้อม metric ของงานหลักสำหรับแต่ละแบบ
- ตรวจวัด negative transfer ได้ก่อนเห็น metric cosine similarity ระหว่าง gradient ของงานบน encoder ที่แชร์จะบอกคุณระหว่างการฝึกว่า auxiliary tasks กำลังดึง representation ไปในทิศทางที่งานหลักต้องการหรือไม่ ใส่เครื่องหมายให้ cosine แล้วตรวจว่าทิศทางนั้นทำนายผลลัพธ์ใน fold นั้นได้หรือไม่
ทุกอย่างที่เหลือใน pipeline — กระบวนการความผันผวน ลูปการฝึก การควบคุม leakage และ validation protocol — มีอธิบายไว้แล้วที่อื่นในบล็อกนี้ และใส่ลิงก์ไว้แทนการอนุมานใหม่
การตั้งค่า

เมื่อมีฟีเจอร์อินพุต (OHLCV, technical indicators, order flow) มีเป้าหมายสามอย่าง:
- งานที่ 1 (หลัก): ผลตอบแทนช่วงถัดไป
- งานที่ 2 (เสริม): log volume ของช่วงถัดไป
- งานที่ 3 (เสริม): realized volatility ของช่วงถัดไป
โมเดลหลายงานสร้างผลลัพธ์ทั้งสามพร้อมกัน และความเสี่ยงของหลายงานคือผลรวมถ่วงน้ำหนักของความเสี่ยงรายงาน:
บทความทั้งหมดเกี่ยวกับ และสิ่งที่ gradient ของแต่ละงานทำต่อกัน
ทำไมการฝึกร่วมกันอาจช่วยได้ ในหนึ่งย่อหน้า Auxiliary tasks บังคับให้ representation ที่แชร์กันอธิบายปรากฏการณ์ตลาดมากกว่าหนึ่งอย่าง ซึ่งเป็นทั้งการควบคุม capacity และ inductive bias ในเวลาเดียวกัน และเนื่องจากปริมาณกับความผันผวนถูกสังเกตโดยตรง ขณะที่ "expected return" ไม่ได้ถูกสังเกต หัวข้อเสริมจึงให้สัญญาณ gradient ที่สะอาดกว่าหัวข้อหลัก เหตุผลสำหรับโมเดลเดียวที่ปล่อยเอาต์พุตหลายแบบมีอธิบายอย่างละเอียด — พร้อมเครื่องมือด้าน interpretability — ใน temporal fusion transformers สำหรับการพยากรณ์หลายช่วงเวลา ซึ่งใช้เหตุผล shared-encoder-many-heads เดียวกันกับ quantiles หลายช่วงเวลา
สถาปัตยกรรมโดยย่อ
การแชร์พารามิเตอร์แบบแข็ง: encoder ร่วม ป้อนหัวเฉพาะงาน หัว ดังนั้น นี่คือเวอร์ชันที่วัดในบทความนี้ เพราะเป็นเวอร์ชันที่ gradient conflict บน นิยามได้ชัดเจน
การแชร์พารามิเตอร์แบบนุ่ม ให้แต่ละงานมี encoder ของตัวเองพร้อม coupling penalty — พารามิเตอร์มากขึ้น ยืดหยุ่นมากขึ้น และไม่มีเวกเตอร์พารามิเตอร์ร่วมเพียงชุดเดียวให้วัด conflict เครือข่าย Cross-stitch อยู่ตรงกลาง โดยผสมฟีเจอร์รายงานผ่านเมทริกซ์ที่เรียนรู้ได้ ในแต่ละระดับ ทั้งสองแบบควรลองหาก hard sharing แสดง conflict และทั้งสองอยู่นอกขอบเขตการวัดด้านล่าง
การทดลองสำคัญ: วิธีปรับสมดุล loss สามแบบ

loss แบบ naive ไวต่อสเกล หาก loss ของผลตอบแทนอยู่ราว และ loss ของปริมาณอยู่ราว ปริมาณจะครอง gradient และหัวผลตอบแทนจะอดสัญญาณ มีคำตอบสามแบบ:
น้ำหนักคงที่ ตั้ง หลัง standardize เป้าหมายทุกตัว นี่คือ baseline ที่ซื่อสัตย์ — หากชนะ วิธีแบบปรับตัวก็เป็นเพียงพิธีกรรม
การถ่วงน้ำหนักความไม่แน่นอน (Kendall et al., 2018) เรียนรู้สเกล noise แบบ homoscedastic ต่องาน:
งานที่ไม่แน่นอนสูงจะถูกลดน้ำหนักโดยอัตโนมัติ ส่วนพจน์ ป้องกันคำตอบแบบง่าย โปรดสังเกตว่า นี้เป็นอุปกรณ์ถ่วงน้ำหนัก loss ตอนฝึก ไม่ใช่ predictive interval — สำหรับความไม่แน่นอนที่ใช้กำหนดขนาด position ได้จริง ดู conformal prediction
GradNorm (Chen et al., 2018) ปรับสมดุล ขนาด gradient แทนสเกล loss ในแต่ละขั้น: คำนวณ และค่าเฉลี่ย คำนวณอัตราการฝึกสัมพัทธ์ แล้วอัปเดต จากนั้นทุกงานจะฝึกด้วยอัตราใกล้เคียงกันโดยไม่ขึ้นกับสเกล loss
โค้ดเฉพาะของ MTL คือหัวต่าง ๆ, forward ที่คืนค่าเป็นลิสต์ และการรวม loss ส่วน stack ของ Linear/BatchNorm/ReLU/Dropout, boilerplate ของ Adam/cosine/clip และลูป epoch เป็นรูปแบบมาตรฐานที่แสดงใน DeepLOB จึงละไว้ที่นี่
import torch
import torch.nn as nn
class MultiTaskTradingModel(nn.Module):
"""Hard parameter sharing: one encoder, K heads."""
def __init__(self, encoder: nn.Module, repr_dim: int, n_tasks: int = 3):
super().__init__()
self.shared_encoder = encoder # any MLP/CNN/GRU trunk
self.task_heads = nn.ModuleList(
nn.Linear(repr_dim, 1) for _ in range(n_tasks)
)
def forward(self, x):
h = self.shared_encoder(x)
return [head(h).squeeze(-1) for head in self.task_heads]
def shared_repr(self, x):
return self.shared_encoder(x)
class UncertaintyWeightedLoss(nn.Module):
"""Kendall et al. (2018) homoscedastic weighting."""
def __init__(self, n_tasks: int = 3):
super().__init__()
self.log_vars = nn.Parameter(torch.zeros(n_tasks)) # log(sigma^2)
def forward(self, losses: list) -> torch.Tensor:
return sum(
torch.exp(-self.log_vars[i]) * loss + self.log_vars[i]
for i, loss in enumerate(losses)
)
def get_weights(self) -> list:
with torch.no_grad():
return [torch.exp(-lv).item() for lv in self.log_vars]
UncertaintyWeightedLoss มีพารามิเตอร์ จึงต้องใส่ใน optimizer ร่วมกับโมเดล: optim.Adam(list(model.parameters()) + list(uw.parameters()), ...) การลืมเรื่องนี้เป็นวิธีที่พบบ่อยที่สุดในการ "รัน uncertainty weighting" แต่จริง ๆ แล้วเงียบ ๆ กลับรันน้ำหนักคงที่
สิ่งที่ต้องรายงาน
สำหรับแต่ละวิธี ในแต่ละ fold: น้ำหนักงานสุดท้ายที่เรียนรู้ metric ของงานหลัก และ — เพราะวิธีถ่วงน้ำหนักเป็นตัวเลือกของโมเดล — จำนวนวิธีที่เปรียบเทียบก่อนเลือกหนึ่งวิธี
| วิธี | metric งานหลักเทียบกับงานเดี่ยว | |||
|---|---|---|---|---|
| คงที่ () | 1.00 | 1.00 | 1.00 | — |
| ถ่วงน้ำหนักความไม่แน่นอน | — | — | — | — |
| GradNorm | — | — | — | — |
สามวิธีคูณด้วยหลาย fold ก็เป็นการค้นหาโมเดลขนาดเล็กแล้ว การปรับปรุงใด ๆ ที่รายงานตรงนี้ต้องผ่านการแก้ multiple-testing ตามที่อธิบายใน deflated Sharpe และ multiple testing ก่อนจึงจะมีความหมาย
Negative Transfer: ใส่เครื่องหมายให้ Gradient

นี่คือส่วนที่ควรเก็บไว้ Negative transfer เกิดขึ้นเมื่อ auxiliary tasks ทำให้งานหลักแย่ลง และมี diagnostic โดยตรง: มุมระหว่าง gradient ของงานในพื้นที่พารามิเตอร์ร่วม
วัดบน encoder ร่วมเท่านั้น — หัวแต่ละงานเป็นของงานนั้นโดยการออกแบบ และจะ "เห็นพ้อง" กันอย่างไร้ความหมายเสมอ
import torch.nn.functional as F
def shared_grad(model, x, y, task_idx, criterion=nn.MSELoss()):
"""Gradient of task `task_idx` w.r.t. the shared encoder, flattened."""
model.zero_grad(set_to_none=True)
loss = criterion(model(x)[task_idx], y)
loss.backward()
return torch.cat([
p.grad.detach().flatten()
for p in model.shared_encoder.parameters()
if p.grad is not None
])
def task_conflict(model, x, y_by_task, task_names):
"""Pairwise cosine similarity between per-task shared-encoder gradients."""
grads = {
name: shared_grad(model, x, y_by_task[name], i)
for i, name in enumerate(task_names)
}
return {
(a, b): F.cosine_similarity(
grads[a].unsqueeze(0), grads[b].unsqueeze(0)
).item()
for i, a in enumerate(task_names)
for b in task_names[i + 1:]
}
เรียกใช้สิ่งนี้กับ held-out batch ตาม cadence คงที่ระหว่างการฝึก ไม่ใช่แค่ครั้งเดียวตอนจบ คู่หนึ่งอาจเริ่มสอดคล้องแล้วแยกออกเมื่อ encoder เชี่ยวชาญขึ้น ตัวเลขเดียวตอนจบการฝึกจะซ่อนสิ่งนี้ไว้
สิ่งที่ควรมองหา — และควรเผยแพร่ไม่ว่าจะได้ผลแบบใด:
| คู่ | cos sim ช่วงต้นการฝึก | cos sim ช่วงท้ายการฝึก | MTL ช่วยงานหลักหรือไม่? |
|---|---|---|---|
| ผลตอบแทน ↔ ปริมาณ | — | — | — |
| ผลตอบแทน ↔ ความผันผวน | — | — | — |
| ปริมาณ ↔ ความผันผวน | — | — | — |
หาก gradient ของปริมาณและความผันผวนสอดคล้องกัน แต่ทั้งคู่ขัดแย้งกับ gradient ของผลตอบแทน ข้อสรุปที่ถูกต้องคือ auxiliary tasks สองงานรวมกันเป็นบล็อกที่สอดคล้องกัน ซึ่งงานผลตอบแทนไม่ได้เป็นสมาชิก — วิธีแก้คือจัดกลุ่มงาน ไม่ใช่เพิ่ม capacity เมื่อ conflict เกิดขึ้นจริง วิธีมาตรฐานคือ PCGrad (Yu et al., 2020) ซึ่งฉาย gradient ที่ขัดแย้งลงบนระนาบปกติของ gradient อีกตัว; CAGrad (Liu et al., 2021) ซึ่งค้นหาทิศทางลงที่ไม่ทำร้ายงานใด; หรือการทิ้ง auxiliary task ทั้งหมด
สังเกตสิ่งที่จงใจไม่มี: พล็อต t-SNE ของ representation ร่วมที่ลงสีตามค่าเป้าหมาย มันเป็นของตกแต่ง — ตัวเลข cosine ด้านบนบอกทุกอย่างที่ embedding จะสื่อ และบอกเป็นตัวเลข
โปรโตคอลการตรวจสอบ

การวัดด้านบนไร้ค่าภายใต้โปรโตคอลที่หย่อนยาน และ MTL ทำให้กับดักทั่วไปแย่ลงเพราะมีเป้าหมายให้ leakage สามเป้าหมายแทนที่จะเป็นหนึ่ง
ข้อมูลจริง ไม่ใช่ simulator เป้าหมายต้องมาจากข้อมูล OHLCV/trade จริง GARCH toy ที่ hardcode จะสร้างความผันผวนซึ่งสัมพันธ์กับผลตอบแทน โดยการก่อสร้าง ซึ่งเป็นสิ่งที่กำลังทดสอบพอดี — การทดลองจะกำลังวัดตัวสร้างของมันเอง หากต้องการกระบวนการความผันผวนที่ fit แล้ว การพยากรณ์ GARCH volatility สำหรับคริปโต จะ fit GARCH(1,1) ด้วย maximum likelihood บน BTC/ETH จริงและตรวจสอบ standardized residuals ส่วน GARCH แบบไม่สมมาตรและ leverage effect อธิบายว่าทำไม simulator การตอบสนองแบบ Gaussian symmetric จึงระบุความผันผวนของคริปโตผิดตั้งแต่ต้น ข้อมูลสังเคราะห์ปกป้องได้ก็ต่อเมื่อให้ ground truth ที่ควบคุมได้ — correlation ของงานที่ผู้เขียนกำหนดและพยายามกู้คืน — ซึ่งเป็นการทดลองคนละแบบกับที่นี่
ฟิต scaler จาก train เท่านั้น ฟิต feature scaler และ target scaler ทั้งสามภายในแต่ละ training fold แล้วนำไปใช้กับ validation การทำ fit_transform ระดับ global ก่อน split จะรั่ว moments ของ test set เข้าไปใน training ความผิดพลาดนี้มีบันทึกไว้ใน taxonomy ของ look-ahead bias
Fold walk-forward แบบ purge และ embargo chronological split เดียว 80/20 แยกไม่ออกว่าการปรับปรุงของ MTL มาจาก fold effect หรือไม่ — นี่คือข้อโต้แย้งทั้งหมดของ walk-forward optimization ซึ่งแสดงว่า 3 split ให้ข้อสรุป 3 แบบ ให้นำ generator purged_walk_forward แบบ expanding-window จาก spread modeling ด้วย machine learning กลับมาใช้: มันทิ้งช่องว่าง horizon rows ทั้งสองข้างของแต่ละ boundary ซึ่งสำคัญเพราะหน้าต่าง realized volatility ที่ซ้อนกันรั่วข้าม boundary แม้ target ผลตอบแทนจะไม่รั่ว
Baseline แบบคลาสสิก เครือข่าย MTL ที่ชนะเครือข่าย single-task สามเครือข่ายยังพิสูจน์อะไรไม่ได้ หากโมเดล gradient boosting หรือ ridge แยกตามเป้าหมายชนะทั้งสี่โมเดล ให้ฟิตโมเดลหนึ่งตัวต่อเป้าหมายด้วย LightGBM หรือ ridge บน fold และฟีเจอร์เดียวกัน แล้วรายงานในตารางเดียวกัน
| โมเดล | metric งานหลัก | หมายเหตุ |
|---|---|---|
| Ridge ต่อเป้าหมาย | — | baseline แบบคลาสสิก |
| LightGBM ต่อเป้าหมาย | — | baseline แบบคลาสสิก |
| MLP แบบ single-task ต่อเป้าหมาย | — | สามเครือข่ายแยกกัน |
| MTL วิธี loss ที่ดีที่สุด | — | หนึ่งเครือข่าย สามหัว |
เมื่อใด MTL จึงคุ้มค่าที่นี่

เงื่อนไขที่ MTL ควรชนะ เขียนเป็นสมมติฐานให้ตรวจสอบกับ fold ด้านบน ไม่ใช่รายการตรวจสอบ:
- ป้ายกำกับเสริมสะอาดกว่าป้ายกำกับหลัก ปริมาณถูกสังเกตโดยตรง แต่ "expected return" ไม่ได้ หากหัวผลตอบแทนกำลัง fit noise เป็นส่วนใหญ่ สัญญาณ gradient จากหัวเสริมคือส่วนเดียวของ objective ที่วางปัญหาได้ดี
- ข้อมูลฝึกมีจำกัดเมื่อเทียบกับ capacity ของ encoder ดังนั้นข้อจำกัดเสริมจึงทำหน้าที่ regularization จริง แทนที่จะแย่งพารามิเตอร์กันเท่านั้น
- เวลาแฝงของการอนุมานสำคัญ และ forward pass เดียวชนะสามครั้ง
และข้อโต้แย้งฝั่งตรงข้ามก็ตรวจสอบได้เช่นกัน: หากค่า cos_sim(return, ·) ติดลบอย่างต่อเนื่อง encoder ร่วมกำลังถูกดึงออกจากงานหลัก และหัวเสริมเป็นภาษี ไม่ใช่ regularizer
บทสรุป

ผลตอบแทน ปริมาณ และความผันผวนมาจาก microstructure เดียวกัน ดังนั้น representation ร่วมจึงเป็น prior ที่สมเหตุผล — แต่ prior ไม่ใช่ผลลัพธ์ สิ่งที่การตั้งค่านี้พิสูจน์ได้จริงมีสองอย่าง: วิธีถ่วงน้ำหนัก loss ที่ข้อมูลชอบ (รายงานน้ำหนักที่เรียนรู้ ไม่ใช่แค่เรียกชื่อผู้ชนะ) และ gradient ของงานบน encoder ร่วมสอดคล้องกันหรือไม่ โดยวัดตลอดการฝึก ไม่ใช่อนุมานจากการที่เป้าหมายสัมพันธ์กัน
หาก fold walk-forward แบบ purge แสดงว่าเครือข่าย MTL ไม่สามารถชนะโมเดล gradient boosting แยกตามเป้าหมาย นั่นคือผลลัพธ์และควรเผยแพร่ตามนั้น — แม่แบบคือ ผลลัพธ์เชิงลบอย่างซื่อสัตย์ ผลลัพธ์เชิงลบเกี่ยวกับ negative transfer ก็ยังเป็นผลลัพธ์เกี่ยวกับ negative transfer
ผู้เขียน
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.