📝

Draft article

This draft is visible to admins and superusers only. Sign in with an authorized account.

← 返回文章列表
August 25, 2026
5 分鐘閱讀

用於報酬方向的 XGBoost:類別不平衡與決策閾值

用於報酬方向的 XGBoost:類別不平衡與決策閾值
#machine-learning
#xgboost
#gradient-boosting
#quant
#prediction

訓練一個分類器來預測下一小時的報酬是否超過 +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,
    ...
)

這會放大正類樣本的梯度,告訴模型:錯分一個正類樣本的代價,是錯分一個負類樣本的 kk 倍。

這個副作用很容易被忽略:它也會放大 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 線之間的邊界大多是噪音——不過在標籤如此嘈雜時,「專注於模糊案例」和「專注於不可學習案例」其實是同一個指令,這正是應該測量而不是假設的原因。

FL(pt)=αt(1pt)γlog(pt)\text{FL}(p_t) = -\alpha_t (1 - p_t)^{\gamma} \log(p_t)

其中 ptp_t 是對真實類別的預測機率,αt\alpha_t 平衡類別權重,而 γ\gamma(通常為 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 總和,而不是行數

由曲率支撐的樹分裂

梯度提升會建立加法式集成。在第 tt 步,它加入一棵樹 ftf_t 來最小化正則化目標;XGBoost 使用二階 Taylor 展開來近似:

L(t)i=1n[gift(xi)+12hift2(xi)]+Ω(ft)\mathcal{L}^{(t)} \approx \sum_{i=1}^{n} \Bigl[ g_i f_t(\mathbf{x}_i) + \frac{1}{2} h_i f_t^2(\mathbf{x}_i) \Bigr] + \Omega(f_t)

其中 gi=l/y^i(t1)g_i = \partial l / \partial \hat{y}_i^{(t-1)}hi=2l/(y^i(t1))2h_i = \partial^2 l / \partial (\hat{y}_i^{(t-1)})^2,並且

Ω(f)=γT+12λj=1Twj2\Omega(f) = \gamma T + \frac{1}{2}\lambda \sum_{j=1}^{T} w_j^2

其中 TT 是葉節點數量,wjw_j 是葉權重,γ\gamma 是每個葉節點的懲罰,λ\lambda 是 L2 項。求解最佳葉權重可得 wj=ijgi/(ijhi+λ)w_j^* = -\sum_{i \in j} g_i / (\sum_{i \in j} h_i + \lambda)

Hessian 總和位於分母中,重點就在這裡。min_child_weight 對這個總和設置閾值,而不是對葉節點中的行數設置閾值。對於 log loss,hi=pi(1pi)h_i = p_i(1 - p_i)p=0.5p = 0.5 時最大,當預測趨近確定時則向零崩潰。因此,充滿高信心分類 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)

輸出是一個折疊 × 特徵矩陣,其中有三種可辨識的形狀:

  1. 單調下降——特徵的優勢正在衰減。它可能是應移除的候選特徵,也可能值得調查市場結構發生了什麼變化。
  2. 高變異、沒有趨勢——這個特徵是噪音,模型會在某些市場狀態中抓住它。這與平台分析對參數量化的訊號相同,只是現在套用到特徵上。
  3. 市場狀態切換造成的階躍變化——重要性在某個特定折疊下降,之後維持低位。通常可以追溯到交易所、上市或費用結構事件,而不是 alpha 衰減。

陷阱在於,當模型整體信心發生變化時,不同折疊間的平均絕對 SHAP 並不可直接比較:一個到處都預測接近 0.5 的模型,會同時為每個特徵產生較小的歸因。在比較前,將每個折疊的特徵重要性正規化,使其總和為 1;這樣讀到的是相對重要性的變化,而不是信心的變化。

為什麼是樹:簡要說明

非線性市場景觀中的決策樹

Grinsztajn、Oyallon 和 Varoquaux(NeurIPS 2022)在 45 個表格資料集上將樹集成與深度學習進行比較,並分離出三個有利於樹模型的結構性質——這三點都描述了金融資料:

  1. 不規則的目標函數。 報酬並不平滑;它們有不連續、狀態變化和閾值效應。軸對齊分裂可以捕捉這些特徵,而不必近似一個平滑曲面。
  2. 沒有資訊量的特徵。 Alpha 管道會產生數百個候選特徵,其中大多數是噪音。樹在每次分裂時進行選擇;神經網路則把容量分散到所有輸入,將參數花在噪音上。
  3. 非旋轉不變性。 成交量不能與波動率互換。神經網路預設具有旋轉不變性,會將特徵的線性組合視為與原始特徵等價——對於具有不同語義的特徵,這顯然是錯的。

至於這項取捨的實務面——資料量、延遲、特徵工程投入、可解釋性和市場狀態適應——本部落格已在使用機器學習進行價差建模中提供完整的決策表。

特徵工程

將市場訊號轉換為特徵

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 修正時納入。

本文刻意留給其他文章的內容

研究前沿的大門

梯度提升模型周邊的支架已在其他文章中搭配測量結果說明:

這裡提出的另外三個陷阱仍是假設而非結果:只用已上市資產訓練造成的倖存者偏差;原始報酬目標的非平穩性,其中 beta 殘差報酬可能表現更好;以及重新訓練頻率,SHAP 跨折疊診斷正好可以作為自然的漂移監測器。

結論

已解決的提升決策景觀

報酬方向分類是不平衡問題,決策閾值也從來沒有人刻意選過;三種標準修正方案並不可互換。scale_pos_weight 只需一行,但會使機率失去校準,並悄悄改變 min_child_weight。焦點損失需要 Hessian,而大多數實作都會對它做近似。閾值最佳化讓模型保持不變,並把交易真正關心的約束——精確率——放回它應在的位置,但前提是閾值必須在訓練之後的資料上逐折疊重新擬合。

哪一個方案勝出,是關於你的資料、閾值和成本模型的實證問題。頂部的表格是某個資料集的答案;在自己的資料上執行一次,成本低於花時間猜測參數。


延伸閱讀:

免責宣告:本文提供的資訊僅用於教育和參考目的,不構成財務、投資或交易建議。加密貨幣交易涉及重大損失風險。

Authors

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 交易見解、市場分析和平台更新。

我們尊重您的隱私。您可以隨時退訂。