リターン方向予測の XGBoost:クラス不均衡と決定しきい値
次の 1 時間のリターンが +0.5% を超えるかを予測する分類器を訓練しても、バランスの取れた二値問題を作ったことにはなりません。暗号資産市場が静かな局面では、K 線の 70--80% がこのしきい値を下回るため、モデルは一度も正例を予測しなくても 75% の正解率を得られます。ここでは正解率は役に立ちません。デフォルトの 0.5 という決定境界も同じです。誰かが意図的に選んだ値ではなく、predict() がたまたま使う値だからです。
標準的な対応は 3 つあります。scale_pos_weight で損失を再重み付けする、損失を焦点損失に置き換える、または損失はそのままにして後から決定しきい値を動かす方法です。通常は互換性のある選択肢として説明されますが、実際には互換ではありません。精度と再現率のトレードオフが異なるだけでなく、コスト控除後の PnL も異なります。取引では再現率より精度がはるかに重要だからです。取引を逃してもコストはかかりませんが、悪い取引にはスプレッドと手数料がかかります。
この記事では、BTC と ETH の 1 時間データについて、同じパージ済みウォークフォワード分割で 3 つの方法をすべて測定します。そのうえで、そこから導かれる 2 つの点を扱います。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 では同じ意味を持ちません。この 2 つは順番にではなく、一緒に再調整してください。
2 つ目の副作用は、出力確率が校正されなくなることです。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 の観点ではスプレッドを毎回支払う限界的な取引を増やすことになります。精度を制約し、その範囲内で再現率を最大化するのは、「取引は減らすが、取引するときは正しく行う」に対応します。
この手続きを正しく保つには、2 つのルールがあります。訓練期間の後に時系列上で位置する検証データでしきい値を当てはめること、そして分割ごとに再フィットすることです。サンプル全体で一度だけ選んだしきい値は、先読みバイアスの分類に整理されている種類の先読みリークです。分割ごとの再フィットには無料の診断効果もあります。最適なしきい値が分割ごとに大きく変わるなら、校正が不安定で、しきい値がノイズをフィットしているということです。
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 です。名前を合わせて 2 つのライブラリ間で設定を移植すると、意図せずモデルを変えてしまいます。
XGBoost、LightGBM、CatBoost のエンジニアリング上の違い

3 つとも勾配ブースト決定木を実装しています。違いは木の構築方法にあり、それが訓練時間とサンプル外スコアに実際に現れます。
XGBoost はレベル単位(幅優先)で成長します。ある深さのすべての葉を、さらに深く進む前に分割します。これによりバランスのよい木ができ、max_depth が意味のある複雑度の制御になり、調整の予測もしやすくなります。その一方で、残りの損失をほとんど減らせない葉まで分割する計算は無駄になります。
LightGBM は葉単位で成長します。葉がどこにあるかに関係なく、損失を最も大きく減らせる葉を分割します。同じ訓練損失に達するまでの分割数は少なくなりますが、木は深く不均衡になるため、max_depth は適切な制御ノブではなくなり、num_leaves を使います。速度を高めるもう 2 つの工夫があります。
- GOSS(Gradient-based One-Side Sampling)は、大きな勾配を持つインスタンスをすべて残し、小さな勾配のインスタンスをランダムにサブサンプリングします。そして勾配推定を不偏に保つため、生き残ったインスタンスの重みを増やします。金融データでは、大きな勾配を持つインスタンスはモデルが現在間違えている K 線であり、ラベルノイズがある場所でもあります。つまり GOSS はノイズが最もひどい場所に正確にサンプリングを集中させます。単純なサブサンプリングと比較して確認する価値があります。
- EFB(Exclusive Feature Bundling)は、互いに排他的な疎な特徴量を 1 つのビン空間特徴量に詰め込みます。one-hot エンコーディングには有効ですが、技術特徴量行列の大部分を占める密な連続特徴量にはほとんど役立ちません。
CatBoost は対称(oblivious)木を成長させます。同じ深さのすべてのノードが同じ分割条件を使います。これは強い正則化になり、推論も非常に速くなります(木がインデックス検索になるため)。その代わり、1 本の木あたりの表現力は下がります。特徴的な仕組みは 2 つあります。
- 順序付きブースティング。 通常のブースティングは、そのサンプル自身で訓練されたモデルを使ってサンプルの残差を計算するため、残差にバイアス、つまり「予測シフト」が生じます。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 対数損失、同じ分割 | — | — |
定性的な行はライブラリの事実です。最後の 2 行だけが「どれを使うべきか」に答えますが、それは自分のデータで実行して得る必要があります。よく調整された実装同士の差は、通常、データセットの形状で決まるほど小さいからです。
ライブラリを切り替える作業の大半は名前の変更です。差分のあるパラメータを列挙した、1 つの訓練関数を示します。3 通りに複製する必要はありません。
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 の縦断的な使い方です。ウォークフォワード分割ごとに 1 つの explainer を用意し、各特徴量の平均絶対帰属を時間に沿って追跡します。
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)
出力は分割 × 特徴量の行列で、そこには区別できる 3 つの形があります。
- 単調な低下――その特徴量のエッジが減衰しています。削除候補にするか、市場構造の何が変わったのかを調べます。
- 高分散で傾向がない――モデルが一部の市場状態で飛びついているノイズです。これはプラトー分析がパラメータについて定量化するものと同じシグナルを、特徴量に適用したものです。
- 市場状態の切り替えによる段差――特定の分割で重要度が下がり、そのまま低い状態が続きます。通常は Alpha の減衰ではなく、取引所、上場、または手数料体系のイベントにたどれます。
注意点は、モデル全体の確信度が変化すると平均絶対 SHAP を分割間で比較できないことです。どこでも 0.5 に近い予測をするモデルは、すべての特徴量について一度に小さな帰属を生成します。比較する前に各分割の重要度の合計を 1 に正規化してください。そうすれば確信度の変化ではなく、相対的な重要度の変化を読めます。
木を選ぶ理由:簡潔に

Grinsztajn、Oyallon、Varoquaux(NeurIPS 2022)は 45 の表形式データセットで木アンサンブルと深層学習を比較し、木に有利な 3 つの構造的性質を切り分けました。3 つとも金融データに当てはまります。
- 不規則なターゲット関数。 リターンは滑らかではありません。不連続性、市場状態の変化、しきい値効果があります。軸に沿った分割なら、滑らかな曲面を近似せずにこれらを捉えられます。
- 情報を持たない特徴量。 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 スコア化はこの種のパイプラインで最も一般的なリークであり、報告された 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、study の永続化、そしてベイズ探索が座標降下に勝る理由——については、Optuna と座標降下を参照してください。そこで使った試行回数は、後で Sharpe を折減するときにも必ず引き継ぎます。
この記事が意図的に他の記事へ委ねる内容

勾配ブースティングモデルの周辺にある枠組みは、実測結果とともにすでに別の記事で扱っています。
- 検証分割。 ウォークフォワード最適化では、アンカーウィンドウとローリングウィンドウ、劣化率を扱います。機械学習によるスプレッドモデリングの
purged_walk_forwardは、実際のパージ/エンバーゴ間隔を持つ分割器です。パージなしで出荷してはいけません。重複する前方ウィンドウのターゲットが分割境界を越えて漏洩するからです。 - 過学習の制御。 トレーニング/テストギャップと折り横断の特徴量安定性は、プラトー分析とバックテスト過学習確率で定量化しています。
- 予測から PnL へ。 一律の bps コストを使うベクトル化
signal * shifted-returnバックテストは、数回の幸運な取引から Sharpe を作り出します。目的関数の設計とPnL/アクティブ時間を参照してください。検索後の Sharpe は試行回数に対して折減します。折減 Sharpe、そしてエッジが本物でないときの姿については正直な負の結果を参照してください。 - 取引コスト。 スリッページのコストモデルとメイカー・テイカー手数料。正解率 53% の方向モデルは、どのコストモデルを選ぶかで生死が決まります。
ここでさらに挙げる 3 つの落とし穴は、結果ではなく仮説として述べます。上場済み資産だけで訓練することによる生存者バイアス、ベータ残差リターンのほうがよく振る舞う可能性がある生のリターン目標の非定常性、そして SHAP の分割横断診断が自然なドリフト監視になる再訓練頻度です。
結論

リターン方向の分類は、誰も選んでいない決定しきい値を持つ不均衡問題です。3 つの標準的な修正方法は互換ではありません。scale_pos_weight は 1 行で済みますが、確率を非校正にし、min_child_weight を静かに動かします。焦点損失には、多くの実装が近似している Hessian が必要です。しきい値最適化はモデルをそのままにし、取引に関係する制約、つまり精度を適切な場所に置きます。ただし、訓練後のデータで分割ごとにしきい値を再フィットする場合に限ります。
どれが勝つかは、データ、しきい値、コストモデルに関する実証的な問いです。冒頭の表は 1 つのデータセットに対する答えです。自分のデータで実行するほうが、推測しながら調整に費やす時間より安く済みます。
さらに読む:
- Grinsztajn ほか、「なぜ木ベースモデルは典型的な表形式データで深層学習を依然として上回るのか?」(NeurIPS 2022)
- Wang ほか、「Imbalance-XGBoost:二値ラベルの不均衡分類に重み付き損失と焦点損失を活用する」
- Chen と Guestrin、「XGBoost:スケーラブルな木ブースティングシステム」(KDD 2016)
- Ke ほか、「LightGBM:高効率な勾配ブースティング決定木」(NeurIPS 2017)
- Prokhorenkova ほか、「CatBoost:カテゴリカル特徴量による公平なブースティング」(NeurIPS 2018)
- XGBoost ドキュメント:パラメータチューニングの注意点
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.