XGBoost for Return Direction: Class Imbalance and Decision Thresholds
หากฝึกตัวจำแนกเพื่อทำนายว่าผลตอบแทนของชั่วโมงถัดไปเกิน +0.5% หรือไม่ นั่นไม่ใช่ปัญหาไบนารีที่สมดุล ในระบอบคริปโตที่เงียบ 70--80% ของแท่งอยู่ต่ำกว่าเกณฑ์ โมเดลจึงได้ความแม่นยำ 75% เพียงไม่ทำนายผลบวกเลย ความแม่นยำไม่มีประโยชน์ที่นี่ เช่นเดียวกับขอบเขตตัดสินใจเริ่มต้น 0.5 ที่ไม่มีใครเลือกโดยเจตนา — มันเป็นเพียงค่าที่ predict() ใช้
มีการตอบสนองมาตรฐานสามประการ: ปรับน้ำหนักการสูญเสียด้วย scale_pos_weight แทนที่การสูญเสียด้วยการสูญเสียโฟกัส หรือปล่อยการสูญเสียไว้ตามลำพังและย้ายเกณฑ์การตัดสินใจตามความเป็นจริง พวกเขามักจะนำเสนอว่าใช้แทนกันได้ พวกเขาไม่ได้ พวกเขาสร้างการแลกเปลี่ยนที่แม่นยำ/การเรียกคืนที่แตกต่างกัน และที่สำคัญกว่านั้นคือ PnL หลังต้นทุนที่แตกต่างกัน เนื่องจากการซื้อขายให้ความสำคัญกับความแม่นยำมากกว่าการเรียกคืน — การซื้อขายที่พลาดไปไม่มีค่าใช้จ่ายใดๆ การซื้อขายที่ไม่ดีมีค่าใช้จ่ายส่วนต่างบวกค่าธรรมเนียม
บทความนี้จะวัดทั้งสามข้อมูลบนข้อมูล BTC และ ETH รายชั่วโมงแบบ Walk-forward ที่ถูกล้างข้อมูลเดียวกัน จากนั้นจะครอบคลุมสองสิ่งที่ตามมา: เหตุใด min_child_weight จึงเป็นเกณฑ์ Hessian แทนที่จะเป็นจำนวนแถว (ซึ่งเป็นสาเหตุที่ค่า "ถูกต้อง" เปลี่ยนไปเมื่อคุณชั่งน้ำหนักการสูญเสียใหม่) และจุดที่ XGBoost, LightGBM และ CatBoost แตกต่างกันจริง ๆ
| วิธีการ | ความแม่นยำ | จำ | การค้า | PnL หลังต้นทุน | กิ่วชาร์ป |
|---|---|---|---|---|---|
| เส้นฐาน (ไม่มีการแก้ไข thr=0.5) | — | — | — | — | — |
scale_pos_weight = n_neg/n_pos |
— | — | — | — | — |
| การสูญเสียโฟกัส (γ=2, α=0.25) | — | — | — | — | — |
| การเพิ่มประสิทธิภาพเกณฑ์ (ความแม่นยำขั้นต่ำ 0.55) | — | — | — | — | — |
พับเท่ากัน คุณลักษณะเดียวกัน รูปแบบต้นทุนเดียวกันสำหรับทุกแถว Sharpe ถูกกิ่วตามจำนวนการกำหนดค่าที่พยายามต่อ Sharpe ที่ deflate และการทดสอบหลายครั้ง ต้นทุนเป็นไปตาม แบบจำลองการเลื่อนไหลและต้นทุน
การจัดการความไม่สมดุลของคลาสในการทำนายผลตอบแทน

วิธีที่ 1: scale_pos_weight
วิธีที่ง่ายที่สุด ตั้งค่า scale_pos_weight = n_negative / n_positive:
n_pos = y_train.sum()
n_neg = len(y_train) - n_pos
scale_pos_weight = n_neg / n_pos # e.g., 3.0 if 75% negative
model = xgb.XGBClassifier(
scale_pos_weight=scale_pos_weight,
...
)
วิธีนี้จะปรับขนาดการไล่ระดับสีสำหรับตัวอย่างระดับบวก โดยบอกแบบจำลองว่าการจัดประเภทตัวอย่างที่เป็นบวกผิดนั้น จะแย่กว่าการจัดประเภทตัวอย่างเชิงลบผิดครั้ง
ผลข้างเคียงนั้นพลาดได้ง่าย: มันยังปรับขนาด Hessians ด้วย เนื่องจาก min_child_weight เป็นเกณฑ์สำหรับ ผลรวมของ Hessians ในใบไม้ (ดูด้านล่าง) การชั่งน้ำหนักการสูญเสียใหม่จะเปลี่ยนความรุนแรงของการแยกต้นไม้อย่างรุนแรง min_child_weight ที่ปรับที่ scale_pos_weight=1 จะไม่มีความหมายเหมือนกันใน scale_pos_weight=3 อีกต่อไป ประกอบทั้งสองเข้าด้วยกันใหม่ไม่ต่อเนื่องกัน
ผลข้างเคียงที่สอง: ความน่าจะเป็นของเอาต์พุตไม่ได้รับการปรับเทียบอีกต่อไป predict_proba ส่งคืนเสียงโมโนโทนในความน่าจะเป็นที่แท้จริงแต่ไม่เท่ากับค่านั้น ซึ่งทำให้การกำหนดขนาดตำแหน่งดาวน์สตรีมตามตัวเลขเหล่านั้นผิด
วิธีที่ 2: การสูญเสียโฟกัส
การลดการสูญเสียโฟกัสเป็นตัวอย่างง่ายๆ โดยไม่คำนึงถึงชั้นเรียน และมุ่งเน้นการฝึกอบรมกับตัวอย่างที่ยากและคลุมเครือ นั่นเป็นกรอบที่น่าสนใจสำหรับการทำนายผลตอบแทน โดยที่ขอบเขตระหว่างแถบ +0.5% และแถบ +0.4% ส่วนใหญ่จะเป็นจุดรบกวน — แม้ว่า "การมุ่งเน้นไปที่กรณีที่คลุมเครือ" และ "การมุ่งเน้นไปที่กรณีที่ไม่สามารถเรียนรู้ได้" จะเป็นคำสั่งเดียวกันเมื่อป้ายกำกับมีเสียงดังมาก ซึ่งเป็นเหตุผลที่ต้องวัดมากกว่าคิดไปเอง
โดย คือความน่าจะเป็นที่ทำนายให้คลาสจริง ใช้ปรับสมดุลน้ำหนักคลาส และ (โดยทั่วไป 1--3) ควบคุมระดับการลดน้ำหนักตัวอย่างที่ง่าย
def focal_loss_objective(y_true, y_pred, gamma=2.0, alpha=0.25):
"""
Custom focal loss for XGBoost. Returns gradient and hessian.
"""
p = 1.0 / (1.0 + np.exp(-y_pred)) # sigmoid
g1 = alpha * y_true * (1 - p)**gamma * (gamma * p * np.log(p + 1e-9) + p - 1)
g2 = (1 - alpha) * (1 - y_true) * p**gamma * (
-gamma * (1 - p) * np.log(1 - p + 1e-9) - p
)
grad = -(g1 + g2)
hess = np.maximum(grad * (1 - grad), 1e-6)
return grad, hess
model = xgb.XGBClassifier(objective=focal_loss_objective, ...)
objective แบบกำหนดเองต้องมี gradient และ Hessian ที่เป็นบวก เพื่อให้สูตรน้ำหนักใบไม้เป็นขั้นตอนลงจริง อนุพันธ์อันดับสองที่แน่นอนของ focal loss ไม่เป็นบวกทุกจุด จึงมีการใช้ค่าประมาณ — แต่ค่าประมาณที่ต่างกันด้วยตัวคูณสเกลจะเปลี่ยนขนาดขั้น และผ่านผลรวม Hessian ก็เปลี่ยนสิ่งที่ min_child_weight ตัดทิ้ง ตารางเปรียบเทียบเท่านั้นที่จะบอกว่าต้นทุนในทางปฏิบัติมีหรือไม่
วิธีที่ 3: การปรับเกณฑ์ให้เหมาะสม
ปล่อย loss ไว้ ฝึกโมเดลที่ผ่าน calibration แล้วเลื่อนเกณฑ์ตัดสินใจบนชุด validation:
from sklearn.metrics import precision_recall_curve
def optimize_threshold(y_true, y_proba, min_precision=0.55):
"""
Find the threshold maximizing F1 subject to a minimum precision.
"""
precisions, recalls, thresholds = precision_recall_curve(y_true, y_proba)
f1_scores = 2 * (precisions * recalls) / (precisions + recalls + 1e-9)
valid = precisions[:-1] >= min_precision
if not valid.any():
return 0.5 # fallback
best_idx = np.argmax(f1_scores[:-1] * valid)
return thresholds[best_idx]
ขีดล่างของ precision คือสิ่งสำคัญต่อการเทรด การเพิ่ม F1 สูงสุดเพียงอย่างเดียวแลก precision กับ recall ซึ่งในแง่ PnL หมายถึงการเทรดชายขอบมากขึ้นและจ่าย spread ทุกครั้ง การกำหนด precision แล้วเพิ่ม recall ภายใต้ข้อจำกัดแปลเป็น "เทรดน้อยลง แต่ถูกต้องเมื่อเทรด"
มีกฎสองข้อเพื่อรักษาความซื่อสัตย์: fit เกณฑ์บนข้อมูล validation ที่อยู่หลังหน้าต่างฝึกตามลำดับเวลา และ fit ใหม่ต่อพับ เกณฑ์ที่เลือกครั้งเดียวจากตัวอย่างทั้งหมดคือการรั่วไหลแบบ look-ahead ตามที่จัดหมวดไว้ใน อนุกรมวิธานของ look-ahead bias การ fit ใหม่ต่อพับยังเป็นตัววินิจฉัยฟรี — หากเกณฑ์ที่เหมาะสมกระโดดไปมาระหว่างพับ calibration ไม่เสถียรและเกณฑ์กำลัง fit สัญญาณรบกวน
ทำไม min_child_weight จึงเป็นผลรวม Hessian ไม่ใช่จำนวนแถว

Gradient boosting สร้าง ensemble แบบบวก ที่ขั้น จะเพิ่มต้นไม้ ซึ่งลด objective ที่มี regularization โดย XGBoost ประมาณค่าด้วย Taylor expansion อันดับสอง:
ด้วย , และ
โดย คือจำนวนใบไม้ คือน้ำหนักใบไม้ คือค่าปรับต่อใบไม้ และ คือพจน์ L2 การแก้หาน้ำหนักใบไม้ที่เหมาะสมให้
ผลรวม Hessian อยู่ในตัวส่วน และนี่คือประเด็น min_child_weight กำหนดเกณฑ์ที่ ผลรวมนั้น ไม่ใช่จำนวนแถวในใบไม้ สำหรับ log loss, มีค่าสูงสุดที่ และลดลงสู่ศูนย์เมื่อ prediction เข้าใกล้ความแน่นอน ดังนั้นใบไม้ที่เต็มไปด้วยแท่งซึ่งจำแนกอย่างมั่นใจจะมีผลรวม Hessian เล็กและถูก prune ขณะที่ใบไม้ที่มีแท่งคลุมเครือจริงเพียงไม่กี่แท่งอาจอยู่รอด
สำหรับ label การเงินที่มีสัญญาณรบกวน นี่คือพฤติกรรมที่ต้องการ และอธิบายผลในทางปฏิบัติ:
- การเพิ่ม
min_child_weightจะ prune ใบไม้ที่อาศัย observation ไม่แน่นอน จำนวนน้อย — ซึ่งเป็นกลุ่มที่มีโอกาส fit noise มากที่สุด นี่เป็นเครื่องมือที่คมกว่าการนับแถวแบบmin_child_samples - สิ่งใดก็ตามที่ rescale loss จะ rescale Hessian ด้วย
scale_pos_weight, objective แบบกำหนดเอง และ sample weight ล้วนเปลี่ยนmin_child_weightที่มีผล แม้ตัวเลขที่พิมพ์จะไม่เปลี่ยน - เมื่อ boosting ดำเนินต่อและ prediction คมขึ้น Hessian จะหดตัวทั่วทั้งโมเดล ดังนั้น
min_child_weightคงที่จะ prune รุนแรงขึ้นในรอบหลัง นี่คือผล annealing ในตัว และเป็นเหตุที่ learning rate ต่ำกับต้นไม้จำนวนมากทำงานต่างจากขั้นใหญ่จำนวนน้อย
min_child_samples ของ LightGBM คือจำนวนแถว จึงเป็นพารามิเตอร์ที่แตกต่างจริงแม้ชื่อคล้ายกัน ส่วนตัวเทียบเท่า Hessian คือ min_sum_hessian_in_leaf การย้าย configuration ระหว่างสองไลบรารีด้วยการจับคู่ชื่อเป็นวิธีที่ทำให้เปลี่ยนโมเดลโดยไม่ตั้งใจได้บ่อย
XGBoost เทียบกับ LightGBM และ CatBoost: ความแตกต่างทางวิศวกรรม

ทั้งสามไลบรารีใช้ gradient boosted decision tree ความแตกต่างอยู่ที่การสร้างต้นไม้ และสิ่งนี้เองที่ปรากฏในเวลาฝึกและคะแนนนอกตัวอย่าง
XGBoost เติบโตแบบ level-wise (breadth-first): แบ่งใบไม้ทั้งหมดที่ความลึกหนึ่งก่อนลงลึกกว่า ทำให้ต้นไม้สมดุล ทำให้ max_depth เป็นตัวควบคุมความซับซ้อนที่มีความหมาย และปรับแต่งได้คาดเดาง่ายกว่า แต่ยังเสียงานกับการแบ่งใบไม้ที่เหลือ loss ให้ลดได้น้อย
LightGBM เติบโตแบบ leaf-wise: แบ่งใบไม้ที่ให้การลด loss มากที่สุดไม่ว่ามันอยู่ที่ใด การแบ่งน้อยครั้งก็ได้ training loss เท่าเดิม แต่ต้นไม้จะลึกและไม่สมดุล ดังนั้น max_depth ไม่ใช่ปุ่มควบคุมที่เหมาะ — num_leaves ต่างหาก มีเคล็ดลับอีกสองอย่างที่ทำให้เร็ว:
- GOSS (Gradient-based One-Side Sampling) เก็บ instance ที่มี gradient สูงทั้งหมดและสุ่มตัวอย่าง instance ที่มี gradient ต่ำ โดยเพิ่มน้ำหนักผู้รอดชีวิตเพื่อให้ค่า gradient ไม่ลำเอียง ในข้อมูลการเงิน instance ที่ gradient สูงคือแท่งที่โมเดลผิดอยู่ — และเป็นจุดที่ noise ของ label อยู่ด้วย ดังนั้น GOSS จึงสุ่มตัวอย่างตรงบริเวณที่ noise แย่ที่สุด ควรตรวจสอบเทียบกับ subsampling ธรรมดาแทนการสันนิษฐาน
- EFB (Exclusive Feature Bundling) บรรจุฟีเจอร์ sparse ที่ไม่เกิดร่วมกันลงในฟีเจอร์เดียวใน bin-space มีประสิทธิภาพกับ one-hot encoding แต่แทบไม่มีประโยชน์กับฟีเจอร์ต่อเนื่องหนาแน่น ซึ่งเป็นส่วนใหญ่ของเมทริกซ์ฟีเจอร์เทคนิค
CatBoost เติบโตเป็นต้นไม้ symmetric (oblivious): ทุก node ที่ความลึกเดียวกันใช้เงื่อนไขแบ่งเดียวกัน นี่เป็น regularizer ที่แข็งแรงและทำให้ inference เร็วมาก — ต้นไม้กลายเป็นการค้นดัชนี — แลกกับความสามารถในการแสดงออกต่อต้นไม้ กลไกเด่นสองอย่างคือ:
- Ordered boosting boosting มาตรฐานคำนวณ residual ของตัวอย่างด้วยโมเดลที่ฝึกบนตัวอย่างนั้น ทำให้ residual มี bias — หรือ "prediction shift" CatBoost ประมาณ residual ของแต่ละตัวอย่างด้วยโมเดลที่ fit เฉพาะตัวอย่างก่อนหน้าใน permutation สุ่ม โครงสร้างนี้เหมาะกับการคิดแบบ time series และสำคัญที่สุดเมื่อข้อมูลมีจำกัด
- Ordered target statistics สำหรับ categorical encoding คำนวณ target statistics จากตัวอย่างก่อนหน้าเท่านั้น จึงหลีกเลี่ยง target leakage ที่ mean-encoding แบบง่ายนำเข้ามา นี่คือเหตุผลที่ซื่อสัตย์ในการใช้ CatBoost เมื่อชุดฟีเจอร์มี label ของ exchange ระดับ asset หรือ regime
| Property | XGBoost | LightGBM | CatBoost |
|---|---|---|---|
| Tree growth | Level-wise | Leaf-wise | Symmetric (oblivious) |
| Complexity knob | max_depth |
num_leaves |
depth |
| Leaf-size guard | min_child_weight (Hessian) |
min_child_samples (rows) |
min_data_in_leaf (rows) |
| Categorical features | Manual encoding | Basic support | Native, ordered TS |
| Regularization | L1/L2 + gamma | L1/L2 + num_leaves | L2 + random strength |
| Custom loss | Flexible | Flexible | Somewhat limited |
| Train time, this dataset | — | — | |
| OOS log loss, same folds | — | — |
แถวเชิงคุณภาพเป็นข้อเท็จจริงของไลบรารี สองแถวสุดท้ายเท่านั้นที่ตอบว่า "ควรใช้ตัวไหน" และต้องมาจากการรันบนข้อมูลของคุณเอง — ความแตกต่างระหว่าง implementation ที่ปรับดีแล้วมักเล็กพอที่รูปร่างชุดข้อมูลจะเป็นตัวตัดสิน
การเปลี่ยนไลบรารีส่วนใหญ่เป็นเพียงการเปลี่ยนชื่อ ใช้ฟังก์ชันฝึกหนึ่งตัวและระบุพารามิเตอร์ที่แตกต่างแทนการทำซ้ำ:
import xgboost as xgb
def train_xgb_model(X_train, y_train, X_val, y_val, class_weight_ratio=1.0):
"""Train XGBoost classifier for return direction prediction."""
model = xgb.XGBClassifier(
n_estimators=2000,
max_depth=5,
learning_rate=0.01,
subsample=0.7,
colsample_bytree=0.7,
min_child_weight=10, # Hessian sum, not row count
gamma=1.0,
reg_alpha=0.1,
reg_lambda=1.0,
scale_pos_weight=class_weight_ratio,
objective='binary:logistic',
eval_metric='logloss',
tree_method='hist',
random_state=42,
early_stopping_rounds=50,
)
model.fit(X_train, y_train, eval_set=[(X_val, y_val)], verbose=False)
return model
| Concept | XGBoost | LightGBM | CatBoost |
|---|---|---|---|
| Tree count | n_estimators |
n_estimators |
iterations |
| L2 penalty | reg_lambda |
reg_lambda |
l2_leaf_reg |
| Column sampling | colsample_bytree |
colsample_bytree |
rsm |
| Class imbalance | scale_pos_weight |
scale_pos_weight |
auto_class_weights='Balanced' |
| Early stopping | early_stopping_rounds |
lgb.early_stopping() callback |
early_stopping_rounds |
ความสำคัญของ SHAP ข้ามพับในฐานะตัวตรวจจับการเสื่อมของอัลฟา

บล็อกนี้ครอบคลุม SHAP บนโมเดลการเพิ่มเกรเดียนต์ แล้ว — TreeExplainer กราฟสรุป และวิธีอ่านกราฟ สิ่งที่ยังไม่ได้กล่าวถึงคือการใช้ SHAP ตามเวลา: ใช้ตัวอธิบายหนึ่งตัวต่อพับ walk-forward และติดตามค่า attribution สัมบูรณ์เฉลี่ยของแต่ละฟีเจอร์เมื่อเวลาผ่านไป
def shap_over_time(models, test_sets, feature_names) -> pd.DataFrame:
"""
Track SHAP-based feature importance across walk-forward folds.
Rows are folds, columns are features.
"""
importance_over_time = []
for fold_idx, (model, X_test) in enumerate(zip(models, test_sets)):
explainer = shap.TreeExplainer(model)
shap_values = explainer.shap_values(X_test)
mean_abs_shap = np.abs(shap_values).mean(axis=0)
importance_over_time.append(
pd.Series(mean_abs_shap, index=feature_names, name=fold_idx)
)
return pd.DataFrame(importance_over_time)
ผลลัพธ์คือเมทริกซ์พับ × ฟีเจอร์ และมีรูปแบบที่แยกได้สามแบบ:
- ลดลงแบบโมโนโทน — ความได้เปรียบของฟีเจอร์กำลังเสื่อมลง เป็นผู้สมัครให้ลบออก หรือใช้ตรวจสอบสิ่งที่เปลี่ยนในโครงสร้างตลาด
- ความแปรปรวนสูง ไม่มีแนวโน้ม — ฟีเจอร์เป็นสัญญาณรบกวนที่โมเดลเกาะในบางระบอบ นี่คือสัญญาณเดียวกับที่ การวิเคราะห์ plateau วัดสำหรับพารามิเตอร์ แต่นำมาใช้กับฟีเจอร์
- การเปลี่ยนขั้นเมื่อเปลี่ยนระบอบ — ความสำคัญลดลงที่พับหนึ่งและยังต่ำต่อไป มักติดตามได้ถึงเหตุการณ์ของตลาด การจดทะเบียน หรือโครงสร้างค่าธรรมเนียม มากกว่าการเสื่อมของอัลฟา
กับดักคือค่า SHAP สัมบูรณ์เฉลี่ยเปรียบเทียบข้ามพับไม่ได้เมื่อความมั่นใจรวมของโมเดลเปลี่ยน: โมเดลที่ทำนายเข้าใกล้ 0.5 ทุกแห่งจะให้ attribution เล็กลงสำหรับทุกฟีเจอร์พร้อมกัน ทำให้ความสำคัญของแต่ละพับรวมกันเป็น 1 ก่อนเปรียบเทียบ เพื่ออ่านการเปลี่ยนแปลงของความสำคัญ สัมพัทธ์ แทนการเปลี่ยนแปลงของความมั่นใจ
ทำไมต้องใช้ต้นไม้โดยสรุป

Grinsztajn, Oyallon และ Varoquaux (NeurIPS 2022) ทดสอบกลุ่มต้นไม้เทียบกับ deep learning บนชุดข้อมูลตาราง 45 ชุด และแยกคุณสมบัติเชิงโครงสร้างสามข้อที่เอื้อต่อต้นไม้ — ทั้งสามข้ออธิบายข้อมูลการเงินได้:
- ฟังก์ชันเป้าหมายไม่สม่ำเสมอ ผลตอบแทนไม่ราบเรียบ มีจุดไม่ต่อเนื่อง การเปลี่ยนระบอบ และผลของเกณฑ์ การแบ่งตามแกนจับสิ่งเหล่านี้ได้โดยไม่ต้องประมาณพื้นผิวเรียบ
- ฟีเจอร์ที่ไม่มีข้อมูล สายงานอัลฟาสร้างตัวเลือกหลายร้อยตัวและส่วนใหญ่เป็นสัญญาณรบกวน ต้นไม้เลือกในแต่ละการแบ่ง ส่วนโครงข่ายประสาทกระจายความสามารถไปยังอินพุตทั้งหมดและใช้พารามิเตอร์กับสัญญาณรบกวน
- ไม่คงรูปต่อการหมุน ปริมาณไม่สามารถแทนที่ด้วยความผันผวนได้ โครงข่ายประสาทมีความคงรูปต่อการหมุนโดยปริยาย จึงถือว่าการผสมเชิงเส้นของฟีเจอร์เทียบเท่ากับฟีเจอร์เดิม — ซึ่งผิดสำหรับฟีเจอร์ที่มีความหมายเฉพาะต่างกัน
สำหรับด้านปฏิบัติของการแลกเปลี่ยนนี้ — ขนาดข้อมูล เวลาแฝง ความพยายามด้านวิศวกรรมฟีเจอร์ การตีความ และการปรับตามระบอบ — บล็อกมีตารางตัดสินใจฉบับเต็มใน การสร้างแบบจำลอง spread ด้วยแมชชีนเลิร์นนิง แล้ว
วิศวกรรมฟีเจอร์

import pandas as pd
import numpy as np
def build_features(df: pd.DataFrame) -> pd.DataFrame:
"""
Build trading features from OHLCV data.
Expects columns: open, high, low, close, volume, timestamp
"""
feat = pd.DataFrame(index=df.index)
feat['return_1'] = df['close'].pct_change(1)
feat['return_5'] = df['close'].pct_change(5)
feat['return_15'] = df['close'].pct_change(15)
feat['return_60'] = df['close'].pct_change(60)
log_ret = np.log(df['close'] / df['close'].shift(1))
feat['volatility_20'] = log_ret.rolling(20).std()
feat['volatility_60'] = log_ret.rolling(60).std()
feat['vol_ratio'] = feat['volatility_20'] / feat['volatility_60']
feat['parkinson_vol'] = np.sqrt(
(1 / (4 * np.log(2)))
* (np.log(df['high'] / df['low']) ** 2).rolling(20).mean()
)
feat['volume_sma_ratio'] = df['volume'] / df['volume'].rolling(20).mean()
feat['volume_std_20'] = df['volume'].rolling(20).std()
feat['obv'] = (np.sign(df['close'].diff()) * df['volume']).cumsum()
feat['obv_slope'] = feat['obv'].diff(5) / feat['obv'].shift(5)
feat['high_low_range'] = (df['high'] - df['low']) / df['close']
feat['close_position'] = (df['close'] - df['low']) / (df['high'] - df['low'])
feat['gap'] = df['open'] / df['close'].shift(1) - 1
delta = df['close'].diff()
gain = delta.clip(lower=0).rolling(14).mean()
loss = (-delta.clip(upper=0)).rolling(14).mean()
feat['rsi_14'] = 100 - 100 / (1 + gain / loss)
ema_12 = df['close'].ewm(span=12).mean()
ema_26 = df['close'].ewm(span=26).mean()
feat['macd'] = (ema_12 - ema_26) / df['close']
feat['macd_signal'] = feat['macd'].ewm(span=9).mean()
feat['macd_hist'] = feat['macd'] - feat['macd_signal']
for window in [10, 20, 50]:
sma = df['close'].rolling(window).mean()
feat[f'distance_sma_{window}'] = (df['close'] - sma) / sma
std = df['close'].rolling(window).std()
feat[f'bb_position_{window}'] = (df['close'] - sma) / (2 * std)
return feat
ฟีเจอร์ทั้งหมดข้างต้นเป็นเหตุเป็นผลโดยการออกแบบ — ใช้เฉพาะการคำนวณแบบ rolling และ expanding ไม่มีสถิติจากทั้งตัวอย่าง นี่เป็นความตั้งใจ: การทำ z-score ทั้งซีรีส์คือการรั่วไหลที่พบบ่อยที่สุดในสายงานประเภทนี้ และผลของมันต่อ Sharpe ที่รายงานไว้ถูกวัดใน อนุกรมวิธานของ look-ahead bias
ช่วงไฮเปอร์พารามิเตอร์ของ XGBoost สำหรับข้อมูลการเงิน

เป้าหมายไม่ใช่ประสิทธิภาพสูงสุดในตัวอย่าง แต่คือเสถียรภาพสูงสุดนอกตัวอย่าง การทำ regularization มาก่อน ความซับซ้อนมาทีหลัง:
| Parameter | Typical range | Purpose |
|---|---|---|
max_depth |
3--7 | จำกัดลำดับของปฏิสัมพันธ์ ต้นไม้ลึกจำลองปฏิสัมพันธ์ลำดับสูงแต่ overfit เร็วกว่า เริ่มที่ 4 |
min_child_weight |
5--100 | ผลรวม Hessian ขั้นต่ำในใบไม้ ปรับใหม่ทุกครั้งที่เปลี่ยน scale_pos_weight หรือ objective |
learning_rate |
0.005--0.05 | การหดตัว ค่าต่ำต้องใช้ต้นไม้มากขึ้นแต่ generalize ได้ดีกว่า |
subsample |
0.5--0.8 | สุ่มแถวต่อต้นไม้ เพิ่มความสุ่มและลด overfitting |
colsample_bytree |
0.5--0.8 | สุ่มคอลัมน์ต่อต้นไม้ สำคัญเมื่อมีฟีเจอร์ที่สัมพันธ์กันมาก |
gamma |
0.5--5.0 | การลด loss ขั้นต่ำสำหรับการแบ่ง ทำหน้าที่เป็นเกณฑ์ pruning |
reg_alpha (L1) |
0.01--1.0 | L1 บนน้ำหนักใบไม้ ส่งเสริมความเบาบาง |
reg_lambda (L2) |
0.1--10.0 | L2 บนน้ำหนักใบไม้ ป้องกันค่าใบไม้ขนาดใหญ่ |
สำหรับขั้นตอนการค้นหาเอง — TPE การคงอยู่ของ study และเหตุผลที่การค้นหาแบบเบย์ชนะ coordinate descent — ดู Optuna เทียบกับ coordinate descent จำนวน trial ที่ใช้ที่นั่นต้องนำไปใช้ในการลด Sharpe ภายหลังด้วย
สิ่งที่บทความนี้ตั้งใจฝากไว้ให้โพสต์อื่น

โครงสร้างรอบโมเดล gradient boosting มีการอธิบายพร้อมการวัดผลไว้แล้วที่นี่:
- การแบ่ง validation Walk-forward optimization สำหรับหน้าต่าง anchored เทียบกับ rolling และอัตราการเสื่อม;
purged_walk_forwardใน การสร้างแบบจำลอง spread ด้วยแมชชีนเลิร์นนิง สำหรับตัวแบ่งที่มีช่องว่าง purge/embargo จริง อย่าเผยแพร่โดยไม่มี purge — เป้าหมายของหน้าต่างอนาคตที่ทับซ้อนกันจะรั่วข้ามขอบพับ - การควบคุม overfitting ช่องว่าง train/test และเสถียรภาพฟีเจอร์ข้ามพับ วัดไว้ใน การวิเคราะห์ plateau และ ความน่าจะเป็นของ backtest overfitting
- จาก prediction ไป PnL backtest แบบเวกเตอร์
signal * shifted-returnที่มีต้นทุน bps คงที่จะสร้าง Sharpe จากการซื้อขายโชคดีไม่กี่ครั้ง — ดู การออกแบบ objective function และ PnL ต่อเวลาที่ active ลด Sharpe หลังการค้นหาตามจำนวน trial: Sharpe ที่ deflate และ ผลลบอย่างซื่อสัตย์ เพื่อดูกรณีที่ edge ไม่จริง - ต้นทุนธุรกรรม โมเดลต้นทุน slippage และ ค่าธรรมเนียม maker-taker โมเดลทิศทางที่แม่นยำ 53% จะอยู่หรือตายขึ้นกับโมเดลต้นทุนที่เลือก
ยังมีข้อผิดพลาดอีกสามข้อที่ระบุไว้เป็นสมมติฐาน ไม่ใช่ผลลัพธ์: survivorship bias จากการฝึกเฉพาะสินทรัพย์ที่จดทะเบียน; ความไม่อยู่กับที่ของเป้าหมายผลตอบแทนดิบ ซึ่งผลตอบแทนที่เหลือจาก beta อาจทำได้ดีกว่า; และจังหวะการฝึกใหม่ ซึ่งการวินิจฉัย SHAP ข้ามพับเป็นตัวติดตาม drift ตามธรรมชาติ
บทสรุป

การจำแนกทิศทางผลตอบแทนเป็นปัญหาที่คลาสไม่สมดุลและมีเกณฑ์ตัดสินใจที่ไม่มีใครเลือก การแก้มาตรฐานสามวิธีใช้แทนกันไม่ได้ scale_pos_weight ใช้เพียงบรรทัดเดียว แต่ทำให้ความน่าจะเป็นขาด calibration และเลื่อน min_child_weight อย่างเงียบๆ Focal loss ต้องใช้ Hessian ที่การใช้งานส่วนใหญ่ประมาณค่า ส่วนการปรับเกณฑ์ปล่อยโมเดลไว้และวางข้อจำกัดที่สำคัญต่อการเทรด — ความแม่นยำ — ไว้ในที่ที่ควรอยู่ แต่ต้องปรับเกณฑ์ใหม่ทุกพับด้วยข้อมูลที่เกิดหลังการฝึก
วิธีใดชนะเป็นคำถามเชิงประจักษ์เกี่ยวกับข้อมูล เกณฑ์ และโมเดลต้นทุนของคุณ ตารางด้านบนคือคำตอบสำหรับชุดข้อมูลหนึ่งชุด การรันกับข้อมูลของคุณมีต้นทุนน้อยกว่าเวลาปรับแต่งที่ใช้เดา
อ่านเพิ่มเติม:
- Grinsztajn et al., "Why do tree-based models still outperform deep learning on typical tabular data?" (NeurIPS 2022)
- Wang et al., "Imbalance-XGBoost: leveraging weighted and focal losses for binary label-imbalanced classification"
- Chen & Guestrin, "XGBoost: A Scalable Tree Boosting System" (KDD 2016)
- Ke et al., "LightGBM: A Highly Efficient Gradient Boosting Decision Tree" (NeurIPS 2017)
- Prokhorenkova et al., "CatBoost: unbiased boosting with categorical features" (NeurIPS 2018)
- XGBoost documentation: Notes on Parameter Tuning
ผู้เขียน
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.