用于收益方向预测的 XGBoost:类别不平衡与决策阈值
训练一个分类器来预测下一小时收益是否超过 +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,
...
)
这会放大正类样本的梯度,告诉模型:把正类样本误分类的代价,是把负类样本误分类的 倍。
这个副作用很容易被忽略:它也会放大 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 会用精确率换取召回率;换成 PnL 的语言,就是增加更多边际交易,而每笔都要支付价差。在精确率受约束的前提下最大化召回率,相当于“少交易,但交易时要做对”。
有两条规则可以保持这一过程诚实:在时间上位于训练窗口之后的验证数据上拟合阈值,并在每个折叠中重新拟合。基于整个样本只选择一次的阈值,属于前瞻偏差分类法所列出的前瞻泄漏。按折叠重新拟合还提供了一个免费的诊断:如果最优阈值在不同折叠之间大幅跳动,说明校准不稳定,阈值正在拟合噪声。
为什么 min_child_weight 是 Hessian 总和,而不是行数

梯度提升会构建一个加性集成。在第 步,它添加一棵树 来最小化正则化目标;XGBoost 用二阶泰勒展开对其进行近似:
其中 ,,并且
其中 是叶节点数量, 是叶权重, 是每个叶节点的惩罚, 是 L2 项。求解最优叶权重可得 。
Hessian 总和位于分母中,这正是关键。min_child_weight 约束的是 这个总和,而不是叶节点中的行数。对于对数损失, 在 时最大,而当预测趋于确定时会迅速接近零。因此,充满高置信度分类 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)
输出是一个“折叠 × 特征”矩阵,其中有三种可区分的形状:
- 单调下降——特征的优势正在衰减。它可能应该被移除,也可能值得调查市场结构发生了什么变化。
- 高方差、无趋势——模型在某些市场状态下抓住了一个噪声特征。这与平台分析对参数量化的信号相同,只是这里应用在特征上。
- 市场状态切换导致的阶跃变化——重要性在某个折叠处下降,并保持在较低水平。通常可以追溯到交易所、上市或费用结构事件,而非 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
上面的每个特征在构造上都是因果的——只使用滚动和扩展操作,不使用全样本统计。这是有意为之:对整个序列进行 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.