用於報酬方向的 XGBoost:類別不平衡與決策閾值
訓練一個分類器來預測下一小時的報酬是否超過 +0.5%,你得到的並不是平衡的二元問題。在平靜的加密貨幣市場狀態中,70--80% 的 K 線低於這個閾值,因此模型即使從不預測任何正類,也能得到 75% 的準確率。在這裡,準確率沒有用;預設的 0.5 決策邊界也一樣。沒有人刻意選擇這個邊界——它只是 predict() 恰好使用的數值。
標準做法有三種:用 scale_pos_weight 重新加權損失、將損失替換為焦點損失,或保持損失不變、事後移動決策閾值。它們通常被當作可以互換的方案,但事實並非如此。三者會產生不同的精確率/召回率取捨,更重要的是會產生不同的扣除成本後損益,因為交易更在乎精確率,而不是召回率——錯過一筆交易不會產生任何成本,做錯一筆交易卻要付出價差和手續費。
本文在 BTC 與 ETH 小時資料的相同清除式走勢向前驗證折疊上測量這三種方法,接著討論由此延伸的兩件事:為什麼 min_child_weight 是 Hessian 閾值而不是行數(這也是重新加權損失後「正確」數值會改變的原因),以及 XGBoost、LightGBM 和 CatBoost 實際上有何差異。
| 方法 | 精確率 | 召回率 | 交易次數 | 扣除成本後損益 | 修正後 Sharpe |
|---|---|---|---|---|---|
| 基準(無修正,thr=0.5) | — | — | — | — | — |
scale_pos_weight = n_neg/n_pos |
— | — | — | — | — |
| 焦點損失(γ=2,α=0.25) | — | — | — | — | — |
| 閾值最佳化(最低精確率 0.55) | — | — | — | — | — |
每一列使用相同的折疊、相同的特徵和相同的成本模型。Sharpe 已按照嘗試的設定數量進行修正,參見修正後 Sharpe 與多重檢定;成本遵循滑價與成本模型。
處理報酬預測中的類別不平衡

方法 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,
...
)
這會放大正類樣本的梯度,告訴模型:錯分一個正類樣本的代價,是錯分一個負類樣本的 倍。
這個副作用很容易被忽略:它也會放大 Hessian。由於 min_child_weight 是葉節點中 Hessian 總和 的閾值(見下文),重新加權損失會悄悄改變樹分裂的積極程度。在 scale_pos_weight=1 時調好的 min_child_weight,到了 scale_pos_weight=3 時不再代表同一件事。兩者要一起重新調整,而不是先後分開調整。
第二個副作用:輸出機率不再經過校準。predict_proba 回傳的是與真實機率單調相關、但不等於真實機率的數值,因此任何基於這些數字的下游持倉配置都會出錯。
方法 2:焦點損失
焦點損失無論類別為何,都會降低容易樣本的權重,將訓練集中到困難且模糊的樣本上。對報酬預測來說,這是一個很有吸引力的框架,因為 +0.5% K 線與 +0.4% K 線之間的邊界大多是噪音——不過在標籤如此嘈雜時,「專注於模糊案例」和「專注於不可學習案例」其實是同一個指令,這正是應該測量而不是假設的原因。
其中 是對真實類別的預測機率, 平衡類別權重,而 (通常為 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, ...)
自訂目標需要梯度和正值 Hessian,葉權重公式才能構成下降步驟。焦點損失的精確二階導數並非處處為正,因此實作通常會採用代理;但如果代理差了一個比例因子,就會改變步長,並透過 Hessian 總和改變 min_child_weight 的修剪行為。只有比較表能顯示這在實務上是否會造成代價。
方法 3:閾值最佳化
保持損失不變,訓練經過校準的模型,再在驗證集上移動決策閾值:
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]
對交易而言,精確率下限才是關鍵。只最大化 F1 會用精確率換取召回率;用損益的語言來說,這代表更多只在邊際上值得做的交易,而且每一筆都要支付價差。在精確率受約束的前提下最大化召回率,對應的是「少交易,但出手時要做對」。
有兩條規則可以維持這個方法的誠實性:在時間順序上位於訓練視窗之後的驗證資料上擬合閾值,並且每個折疊都重新擬合。一次在整個樣本上選出的閾值,是前瞻偏差分類法所列出的前瞻洩漏。每折重新擬合還提供一個免費的診斷:如果最佳閾值在不同折疊間大幅跳動,代表校準不穩定,閾值正在擬合噪音。
為什麼 min_child_weight 是 Hessian 總和,而不是行數

梯度提升會建立加法式集成。在第 步,它加入一棵樹 來最小化正則化目標;XGBoost 使用二階 Taylor 展開來近似:
其中 、,並且
其中 是葉節點數量, 是葉權重, 是每個葉節點的懲罰, 是 L2 項。求解最佳葉權重可得 。
Hessian 總和位於分母中,重點就在這裡。min_child_weight 對這個總和設置閾值,而不是對葉節點中的行數設置閾值。對於 log loss, 在 時最大,當預測趨近確定時則向零崩潰。因此,充滿高信心分類 K 線的葉節點具有很小的 Hessian 總和,會被修剪;而只包含少數真正模糊 K 線的葉節點反而可能存活。
對於嘈雜的金融標籤,這正是你想要的行為,也解釋了幾個實務後果:
- 提高
min_child_weight會修剪建立在少數不確定觀測值上的葉節點——恰好是最可能擬合噪音的觀測值。這比min_child_samples式的行數計算更精準。 - 任何重新縮放損失的做法都會重新縮放 Hessian。
scale_pos_weight、自訂目標和樣本權重都會改變有效的min_child_weight,即使你輸入的數字沒有變。 - 隨著提升過程繼續、預測變得更明確,Hessian 會整體縮小,因此固定的
min_child_weight在後續輪次會更積極地修剪。這是一種內建的退火效應,也正是較低學習率搭配更多樹與較少、較大步驟的表現不同的原因。
LightGBM 的 min_child_samples 是行數,也就是一個真正不同、只是名稱相近的參數。它對應的 Hessian 參數是 min_sum_hessian_in_leaf。只靠匹配參數名稱在兩個函式庫之間搬移設定,是意外改變模型的常見方式。
XGBoost、LightGBM 與 CatBoost:工程差異

三者都實作梯度提升決策樹。差異在於樹的建構方式,而這些差異才真正會反映在訓練時間與樣本外分數上。
XGBoost 採用**逐層(廣度優先)**生長:在進入更深層之前,會先分裂同一深度的所有葉節點。這會產生平衡的樹,讓 max_depth 成為有意義的複雜度控制項,也使調參更可預測。但它也會浪費工作量,去分裂幾乎沒有剩餘損失可降低的葉節點。
LightGBM 採用逐葉生長:不論葉節點位於哪裡,都分裂能帶來最大損失降低的那一個。用較少的分裂次數就能達到相同的訓練損失,但樹會變得很深且不平衡,因此 max_depth 不再是正確的控制旋鈕,num_leaves 才是。另有兩個技巧推動了它的速度:
- GOSS(基於梯度的單邊採樣)保留所有大梯度樣本,並隨機子採樣小梯度樣本,再提高倖存樣本的權重以保持梯度估計無偏。在金融資料上,大梯度樣本就是模型目前預測錯誤的 K 線——而標籤噪音也集中在這裡,因此 GOSS 恰好把採樣集中在噪音最嚴重的區域。值得與普通子採樣比較,而不要直接假設它更好。
- EFB(互斥特徵綁定)將互斥的稀疏特徵打包成單一的 bin-space 特徵。它對 one-hot 編碼有效;對密集連續特徵幾乎沒有用,而技術特徵矩陣大多正是這類特徵。
CatBoost 採用**對稱(oblivious)**樹:同一深度的每個節點都使用相同的分裂條件。這是很強的正則化器,也讓推論非常快——樹會變成一次索引查找——代價則是每棵樹的表達能力較低。它有兩個可區別的機制:
- 有序提升。 標準提升會用在該樣本上訓練過的模型計算該樣本的殘差,造成殘差偏差,即「預測偏移」。CatBoost 會在隨機排列中,只用排在該樣本之前的樣本所擬合的模型,估計每個樣本的殘差。這個結構天然適合時間序列的思路,在資料有限時尤其重要。
- 有序目標統計用於類別編碼,只從先前樣本計算目標統計,避免樸素平均編碼引入的目標洩漏。當特徵集帶有交易所、資產層級或市場狀態標籤時,這就是使用 CatBoost 的正當理由。
| 特性 | XGBoost | LightGBM | CatBoost |
|---|---|---|---|
| 樹的生長方式 | 逐層 | 逐葉 | 對稱(oblivious) |
| 複雜度控制項 | max_depth |
num_leaves |
depth |
| 葉大小限制 | min_child_weight(Hessian) |
min_child_samples(行數) |
min_data_in_leaf(行數) |
| 類別特徵 | 手動編碼 | 基本支援 | 原生、有序 TS |
| 正則化 | L1/L2 + gamma | L1/L2 + num_leaves | L2 + random strength |
| 自訂損失 | 彈性 | 彈性 | 有些限制 |
| 訓練時間,本資料集 | — | — | |
| OOS log loss,相同折疊 | — | — |
定性列是函式庫本身的事實。最後兩列才是回答「我該使用哪一個」的唯一資料,而且必須來自你自己的資料執行結果——調校良好的實作之間通常差異小到由資料集形狀決定。
切換函式庫基本上就是重新命名。以下只有一個訓練函式,並列出有差異的參數,而不是把程式碼複製三份:
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
| 概念 | XGBoost | LightGBM | CatBoost |
|---|---|---|---|
| 樹的數量 | n_estimators |
n_estimators |
iterations |
| L2 懲罰 | reg_lambda |
reg_lambda |
l2_leaf_reg |
| 欄位採樣 | colsample_bytree |
colsample_bytree |
rsm |
| 類別不平衡 | scale_pos_weight |
scale_pos_weight |
auto_class_weights='Balanced' |
| 提前停止 | early_stopping_rounds |
lgb.early_stopping() callback |
early_stopping_rounds |
跨折疊的 SHAP 重要性:Alpha 衰減偵測器

本部落格已經介紹過梯度提升模型上的 SHAP,包括 TreeExplainer、摘要圖以及如何解讀它們。尚未涵蓋的是縱向使用 SHAP:每個走勢向前驗證折疊使用一個解釋器,追蹤每個特徵隨時間變化的平均絕對歸因。
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)
輸出是一個折疊 × 特徵矩陣,其中有三種可辨識的形狀:
- 單調下降——特徵的優勢正在衰減。它可能是應移除的候選特徵,也可能值得調查市場結構發生了什麼變化。
- 高變異、沒有趨勢——這個特徵是噪音,模型會在某些市場狀態中抓住它。這與平台分析對參數量化的訊號相同,只是現在套用到特徵上。
- 市場狀態切換造成的階躍變化——重要性在某個特定折疊下降,之後維持低位。通常可以追溯到交易所、上市或費用結構事件,而不是 alpha 衰減。
陷阱在於,當模型整體信心發生變化時,不同折疊間的平均絕對 SHAP 並不可直接比較:一個到處都預測接近 0.5 的模型,會同時為每個特徵產生較小的歸因。在比較前,將每個折疊的特徵重要性正規化,使其總和為 1;這樣讀到的是相對重要性的變化,而不是信心的變化。
為什麼是樹:簡要說明

Grinsztajn、Oyallon 和 Varoquaux(NeurIPS 2022)在 45 個表格資料集上將樹集成與深度學習進行比較,並分離出三個有利於樹模型的結構性質——這三點都描述了金融資料:
- 不規則的目標函數。 報酬並不平滑;它們有不連續、狀態變化和閾值效應。軸對齊分裂可以捕捉這些特徵,而不必近似一個平滑曲面。
- 沒有資訊量的特徵。 Alpha 管道會產生數百個候選特徵,其中大多數是噪音。樹在每次分裂時進行選擇;神經網路則把容量分散到所有輸入,將參數花在噪音上。
- 非旋轉不變性。 成交量不能與波動率互換。神經網路預設具有旋轉不變性,會將特徵的線性組合視為與原始特徵等價——對於具有不同語義的特徵,這顯然是錯的。
至於這項取捨的實務面——資料量、延遲、特徵工程投入、可解釋性和市場狀態適應——本部落格已在使用機器學習進行價差建模中提供完整的決策表。
特徵工程

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 的影響已在前瞻偏差分類法中測量。
金融資料上 XGBoost 的超參數範圍

目標不是最大的樣本內表現,而是最大的樣本外穩定性。先處理正則化,再處理複雜度:
| 參數 | 典型範圍 | 用途 |
|---|---|---|
max_depth |
3--7 | 限制交互作用階數。更深的樹能建模高階交互作用,但過擬合更快。從 4 開始。 |
min_child_weight |
5--100 | 葉節點中的最小 Hessian 總和。每次變更 scale_pos_weight 或目標函數時都要重新調整。 |
learning_rate |
0.005--0.05 | 收縮率。較低的值需要更多樹,但泛化更好。 |
subsample |
0.5--0.8 | 每棵樹的行採樣。增加隨機性,降低過擬合。 |
colsample_bytree |
0.5--0.8 | 每棵樹的欄位採樣。特徵高度相關時尤其重要。 |
gamma |
0.5--5.0 | 分裂所需的最小損失降低量。充當修剪閾值。 |
reg_alpha(L1) |
0.01--1.0 | 葉權重上的 L1。促進稀疏性。 |
reg_lambda(L2) |
0.1--10.0 | 葉權重上的 L2。避免葉值過大。 |
至於搜尋程序本身——TPE、研究持久化,以及為什麼貝葉斯搜尋勝過座標下降——請參閱 Optuna 與座標下降。無論你在那裡使用多少次試驗,都必須在之後進行 Sharpe 修正時納入。
本文刻意留給其他文章的內容

梯度提升模型周邊的支架已在其他文章中搭配測量結果說明:
- 驗證切分。 走勢向前驗證最佳化涵蓋錨定視窗與滾動視窗,以及效能退化率;使用機器學習進行價差建模中的
purged_walk_forward則提供了帶有實際清除/禁用間隔的切分器。不要在沒有清除的情況下上線——重疊的前瞻視窗目標會跨越折疊邊界洩漏。 - 過擬合控制。 平台分析和回測過擬合的機率量化了訓練/測試差距與跨折疊特徵穩定性。
- 從預測到 PnL。 使用固定 bps 成本的向量化
signal * shifted-return回測,會從少數幸運交易中製造 Sharpe——請參閱回測目標函數設計和PnL 每活躍時間指標。任何搜尋後的 Sharpe 都要按試驗次數進行修正:修正後 Sharpe,以及誠實的負結果,後者說明優勢不真實時會是什麼樣子。 - 交易成本。 滑價成本模型和maker-taker 費用。一個準確率 53% 的方向模型,成敗取決於你選擇的成本模型。
這裡提出的另外三個陷阱仍是假設而非結果:只用已上市資產訓練造成的倖存者偏差;原始報酬目標的非平穩性,其中 beta 殘差報酬可能表現更好;以及重新訓練頻率,SHAP 跨折疊診斷正好可以作為自然的漂移監測器。
結論

報酬方向分類是不平衡問題,決策閾值也從來沒有人刻意選過;三種標準修正方案並不可互換。scale_pos_weight 只需一行,但會使機率失去校準,並悄悄改變 min_child_weight。焦點損失需要 Hessian,而大多數實作都會對它做近似。閾值最佳化讓模型保持不變,並把交易真正關心的約束——精確率——放回它應在的位置,但前提是閾值必須在訓練之後的資料上逐折疊重新擬合。
哪一個方案勝出,是關於你的資料、閾值和成本模型的實證問題。頂部的表格是某個資料集的答案;在自己的資料上執行一次,成本低於花時間猜測參數。
延伸閱讀:
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.