📝

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 重新加权损失,把损失替换为焦点损失,或者保持损失不变、事后移动决策阈值。它们经常被当作可以互换的方案,但事实并非如此。三者会产生不同的精确率/召回率权衡,更重要的是会产生不同的扣除成本后 PnL,因为交易更在意精确率而不是召回率——错过一笔交易没有成本,做错一笔交易却要付出价差和手续费。

本文在相同的、经过清洗的 BTC 和 ETH 小时数据滚动前向折叠上测量这三种方法,然后讨论由此引出的两个问题:为什么 min_child_weight 是 Hessian 阈值而不是行数(这也是重新加权损失后“正确”取值会变化的原因),以及 XGBoost、LightGBM 和 CatBoost 实际上有什么不同。

方法 精确率 召回率 交易数 扣除成本后 PnL 折减 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 会用精确率换取召回率;换成 PnL 的语言,就是增加更多边际交易,而每笔都要支付价差。在精确率受约束的前提下最大化召回率,相当于“少交易,但交易时要做对”。

有两条规则可以保持这一过程诚实:在时间上位于训练窗口之后的验证数据上拟合阈值,并在每个折叠中重新拟合。基于整个样本只选择一次的阈值,属于前瞻偏差分类法所列出的前瞻泄漏。按折叠重新拟合还提供了一个免费的诊断:如果最优阈值在不同折叠之间大幅跳动,说明校准不稳定,阈值正在拟合噪声。

为什么 min_child_weight 是 Hessian 总和,而不是行数

曲率支撑的树分裂

梯度提升会构建一个加性集成。在第 tt 步,它添加一棵树 ftf_t 来最小化正则化目标;XGBoost 用二阶泰勒展开对其进行近似:

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 约束的是 这个总和,而不是叶节点中的行数。对于对数损失,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(互斥特征捆绑)将互斥的稀疏特征打包到一个箱空间特征中。它对 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 对数损失,相同折叠

定性部分是库本身的事实。最后两行才真正回答“应该使用哪一个”,而它们必须来自在你自己的数据上运行的实验——调优良好的实现之间差异通常小到由数据集形状决定。

切换库主要就是改名。下面只写一个训练函数,并列出有差异的参数,而不是把代码复制三份:

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

上面的每个特征在构造上都是因果的——只使用滚动和扩展操作,不使用全样本统计。这是有意为之:对整个序列进行 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 交易见解、市场分析和平台更新。

我们尊重您的隐私。您可以随时退订。