K_DM Book
用語集
対象者 標準
対象者別の入口 →

対象者の設定はこのブラウザの中にだけ保存されます。

言語 JA

XGBoost

中級

02.4.10

XGBoost

最終更新 2020-05-20 読了時間 7 分
まとめ
XGBoost(eXtreme Gradient Boosting)は、勾配ブースティングの「残差に木を当てて足す」流れはそのままに、木を 1 本足すときの目的関数へ正則化項 \(\Omega = \gamma T + \frac{1}{2}\lambda \sum w_j^2\) を加えて、葉の数 \(T\) と葉の重み \(w_j\) の大きさにペナルティを課す実装です。損失を 2 次までテイラー展開するため、各葉の最適な重み \(w_j^* = -G_j / (H_j + \lambda)\) と分割の価値(ゲイン)が閉じた式で求まり、ゲインが \(\gamma\) を超える分割だけを採用します。欠損値を自動で左右どちらかに流す仕組みや列サンプリングも備え、XGBClassifier では learning_rate、max_depth、n_estimators、reg_alpha / reg_lambda が主な調整対象です。このページではこの 3 つを 1 つずつ動かし、Train と Test の ROC-AUC がどこで開くかを見ます。

XGBoost は 勾配ブースティング の高速・正則化版です。疑似残差に木を当てて学習率を掛けて足す流れを先に押さえておくと、このページの式が「その木 1 本の作り方に正則化を入れたもの」として読めます。

直感 #

勾配ブースティングで 100 本目の木を足すとき、その木は「残差をどれだけ減らせるか」だけを見て葉を増やし、訓練データの雑音まで細かく拾ってしまいます。XGBoost は木を 1 本足すたびに「残差の減り」から「葉 1 枚につき \(\gamma\)、葉の値の 2 乗に \(\lambda\)」の罰金を引いた差額で分割を判断します。罰金を払っても元が取れる分割だけが残るので、1〜2 件の雑音にしか合わない小さな葉は作られません。会社が新しい支店を出すときに「売上の増加」から「支店の維持費」を引いて黒字になる場所だけ出店する、という判断と同じです。さらに損失を 2 次までテイラー展開して、1 次勾配 \(g_i\) と 2 次勾配 \(h_i\) の和だけから葉の重みとゲインを式 1 本で計算するため、どんな損失関数でも同じコードで速く動きます。

何が起きているか(ステップで見る) #

コードを読む前に、この手法が図の上で何をしているかを追ってみましょう。「再生」を押すか、スライダーを動かすと 1 手順ずつ進みます。

見るポイント:灰色の線が前の段階。1 段階で直されるのは残差(縦線)が大きい場所から。

式で見ると分割は何で決まるか #

正則化付き目的関数 #

ステップ再生で見た「残差に木を当てる」1 段階を、XGBoost は次の目的関数の最小化として行います。\(t\)番目の木を追加する際、以下を最小化します。

$$ \mathcal{L}^{(t)} = \sum_{i=1}^{n} l\bigl(y_i,\; \hat{y}_i^{(t-1)} + f_t(\mathbf{x}_i)\bigr) + \Omega(f_t) $$

ここで\(l\)は微分可能な損失関数、\(f_t\)は新たに追加する木、\(\Omega\)は木の複雑さに対する正則化項です。

$$ \Omega(f) = \gamma\, T + \frac{1}{2}\lambda \sum_{j=1}^{T} w_j^2 $$

\(T\)は葉の数、\(w_j\)は各葉の重み、\(\gamma\)は葉の数に対するペナルティ、\(\lambda\)はL2正則化の強さです。XGBClassifier では\(\gamma\)が gamma、\(\lambda\)が reg_lambda(既定値 1)に当たり、下の実験で動かす reg_alpha は葉の重みの絶対値に掛かる L1 項\(\alpha \sum |w_j|\)を追加します。

2次テイラー展開による近似 #

損失関数を\(f_t(\mathbf{x}_i)\)について2次までテイラー展開すると、定数項を除いて以下の近似が得られます。

$$ \tilde{\mathcal{L}}^{(t)} \approx \sum_{i=1}^{n} \left[ g_i\, f_t(\mathbf{x}_i) + \frac{1}{2} h_i\, f_t(\mathbf{x}_i)^2 \right] + \Omega(f_t) $$$$ g_i = \frac{\partial\, l(y_i, \hat{y}^{(t-1)})}{\partial\, \hat{y}^{(t-1)}}, \qquad h_i = \frac{\partial^2 l(y_i, \hat{y}^{(t-1)})}{\partial\, (\hat{y}^{(t-1)})^2} $$

\(g_i\)は1次勾配、\(h_i\)は2次勾配(ヘッセ行列の対角成分)です。二乗誤差なら\(g_i = \hat{y}_i - y_i\)(残差の符号反転)、\(h_i = 1\)で、ログ損失なら\(g_i = p_i - y_i\)、\(h_i = p_i(1 - p_i)\)です。2次情報を使うことで、損失の曲率が大きいサンプルには小さい歩幅を取れ、通常の勾配ブースティング(1次のみ)より効率的な最適化が可能になります。

最適な葉の重みと目的関数値 #

葉\(j\)に属するサンプルの集合を\(I_j\)とし、\(G_j = \sum_{i \in I_j} g_i\)、\(H_j = \sum_{i \in I_j} h_i\)とすると、最適な葉の重みは以下で求まります。

$$ w_j^* = -\frac{G_j}{H_j + \lambda} $$

このとき目的関数の最小値は

$$ \tilde{\mathcal{L}}^* = -\frac{1}{2} \sum_{j=1}^{T} \frac{G_j^2}{H_j + \lambda} + \gamma\, T $$

です。この値 \(\tilde{\mathcal{L}}^\) が小さいほど、その木構造が損失をよく減らすことを意味します。\(\lambda\)が分母にあるので、サンプルの少ない葉(\(H_j\)が小さい葉)ほど重み\(w_j^\)が強く縮み、1〜2 件しか入らない葉が極端な値を出すのを防ぎます。

分割ゲイン #

ノードを左右に分割するかどうかは、分割前後の目的関数の差(ゲイン)で判断します。

$$ \text{Gain} = \frac{1}{2}\left[\frac{G_L^2}{H_L + \lambda} + \frac{G_R^2}{H_R + \lambda} - \frac{(G_L + G_R)^2}{H_L + H_R + \lambda}\right] - \gamma $$

\(G_L, H_L\)は左の子ノード、\(G_R, H_R\)は右の子ノードの勾配統計量です。括弧の中は「分けた後の 2 つの葉のスコアの合計」から「分ける前の 1 つの葉のスコア」を引いたもので、分割でどれだけ目的関数が下がるかを表します。Gain が正のときのみ分割を採用し、\(\gamma\)が大きいほど分割が抑制されます。直感の節で言った「支店の維持費」が\(\gamma\)です。

高速化技法 #

論文には上の式のほかに 3 つの実装上の工夫があります。1 つ目はスパース対応分割です。欠損値を持つサンプルを左右どちらに送るかを分割ごとに学習し、欠損値を埋めずにそのまま fit に渡せるようにします。2 つ目は列サンプリングで、ランダムフォレストの考え方を取り入れて colsample_bytree や colsample_bynode で各木・各分割に使う特徴量を間引き、過学習の抑制と計算速度の向上を両立します。3 つ目は加重量子化スケッチで、数百万件のデータでは全候補点を列挙できないため、2 次勾配\(h_i\)で重み付けした分位点を近似的に求めて分割候補を絞ります。tree_method="hist" を指定すると、LightGBM と同様のヒストグラム近似も使えます。

参考リンク

Chen, T., & Guestrin, C. (2016). XGBoost: A Scalable Tree Boosting System. Proceedings of the 22nd ACM SIGKDD International Conference on Knowledge Discovery and Data Mining, 785–794.

Python で確かめる #

ここからは XGBClassifier を 1000 件・20 特徴の人工データに当て、基準の ROC-AUC を出した後、learning_rate、max_depth、reg_alpha を 1 つずつ動かして Train と Test の ROC-AUC を折れ線で並べます。2 本の線が開く場所が過学習の始まりで、正則化がそれをどこまで抑えるかを見ます。

ライブラリと実験データ #

1
2
3
4
5
6
import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.metrics import roc_auc_score
from xgboost import XGBClassifier

二値分類用の合成データを作成し、学習・テストに分割します。20 特徴のうち 10 個が有効、5 個は冗長、5 個は雑音です。

1
2
3
4
5
6
7
X, y = make_classification(
    n_samples=1000, n_features=20, n_informative=10,
    n_redundant=5, random_state=42
)
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.3, random_state=42
)

基本的な学習と評価 #

深さ 5 の木を 100 本、学習率 0.1 で積み上げ、テストデータの確率から ROC-AUC を出します。これが以降の実験の基準値です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
model = XGBClassifier(
    n_estimators=100,
    max_depth=5,
    learning_rate=0.1,
    random_state=42,
    eval_metric="logloss",
)
model.fit(X_train, y_train)
y_prob = model.predict_proba(X_test)[:, 1]
print(f"ROC-AUC: {roc_auc_score(y_test, y_prob):.4f}")

learning_rate の影響 #

learning_rate を 0.01 から 0.5 まで変え、木の本数は 200 本に固定して、1 本あたりの歩幅が Train と Test の ROC-AUC にどう効くかを見ます。小さいほど 1 本の寄与が控えめになって精度は上がりやすい反面、必要な本数が増えます。0.01 では 200 本でも学習が足りず、0.5 では訓練データに素早く合いすぎて Test が落ちる、という両側の失敗が 1 枚の図に出るはずです。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
rates = [0.01, 0.05, 0.1, 0.3, 0.5]
train_scores, test_scores = [], []
for lr in rates:
    m = XGBClassifier(
        n_estimators=200, max_depth=5,
        learning_rate=lr, random_state=42,
        eval_metric="logloss",
    )
    m.fit(X_train, y_train)
    train_scores.append(roc_auc_score(y_train, m.predict_proba(X_train)[:, 1]))
    test_scores.append(roc_auc_score(y_test, m.predict_proba(X_test)[:, 1]))

plt.figure(figsize=(8, 4))
plt.plot(rates, train_scores, "o-", label="Train")
plt.plot(rates, test_scores, "s--", label="Test")
plt.xlabel("learning_rate")
plt.ylabel("ROC-AUC")
plt.title("learning_rate vs ROC-AUC")
plt.legend()
plt.grid(True, alpha=0.3)
plt.show()

learning_rateとROC-AUCの関係

0.01 では 200 本では足りず Train も Test も低く、0.05〜0.1 で Test が最も高くなります。0.3 以降は Train が 1.0 に張り付く一方で Test はわずかに下がり、歩幅が大きすぎて訓練データのノイズまで追った状態です。


max_depth の影響 #

max_depth を 2 から 15 まで変え、木 1 本の深さが Train と Test の ROC-AUC にどう効くかを見ます。深さ 15 の木は葉が最大 32768 枚で、700 件の訓練データなら 1 件ずつ別の葉に入れられるので、Train は 1.0 に張り付きます。Train と Test のスコア差が開くほど過学習が進んでいる、と読みます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
depths = [2, 3, 5, 7, 10, 15]
train_scores, test_scores = [], []
for d in depths:
    m = XGBClassifier(
        n_estimators=100, max_depth=d,
        learning_rate=0.1, random_state=42,
        eval_metric="logloss",
    )
    m.fit(X_train, y_train)
    train_scores.append(roc_auc_score(y_train, m.predict_proba(X_train)[:, 1]))
    test_scores.append(roc_auc_score(y_test, m.predict_proba(X_test)[:, 1]))

plt.figure(figsize=(8, 4))
plt.plot(depths, train_scores, "o-", label="Train")
plt.plot(depths, test_scores, "s--", label="Test")
plt.xlabel("max_depth")
plt.ylabel("ROC-AUC")
plt.title("max_depth vs ROC-AUC (Train vs Test)")
plt.legend()
plt.grid(True, alpha=0.3)
plt.show()

max_depthとROC-AUCの関係(Train vs Test)

Train の実線は深さ 5 以降で 1.0 に貼り付き、Test の破線は 3〜5 で頭打ちになってそれ以降は横ばいか下がります。700 件のデータで深さ 10 以上の木を使う意味はなく、max_depth=3〜5 が妥当な範囲です。


正則化パラメーターの影響 #

reg_alpha(L1)と reg_lambda(L2)は、本文の \(\Omega\) にある葉の重み \(w_j\) へのペナルティの強さです。L2 の \(\lambda\) は既定値 1 で常に効いているので、ここでは既定値 0 の L1 項 reg_alpha を 0 から 10 まで動かし、葉の重みを 0 へ縮めることが Train と Test の ROC-AUC にどう効くかを見ます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
alphas = [0, 0.01, 0.1, 1.0, 10.0]
train_scores, test_scores = [], []
for a in alphas:
    m = XGBClassifier(
        n_estimators=100, max_depth=5,
        learning_rate=0.1, reg_alpha=a,
        random_state=42, eval_metric="logloss",
    )
    m.fit(X_train, y_train)
    train_scores.append(roc_auc_score(y_train, m.predict_proba(X_train)[:, 1]))
    test_scores.append(roc_auc_score(y_test, m.predict_proba(X_test)[:, 1]))

plt.figure(figsize=(8, 4))
plt.plot(alphas, train_scores, "o-", label="Train")
plt.plot(alphas, test_scores, "s--", label="Test")
plt.xscale("symlog", linthresh=0.01)
plt.xlabel("reg_alpha (L1)")
plt.ylabel("ROC-AUC")
plt.title("reg_alpha (L1) vs ROC-AUC")
plt.legend()
plt.grid(True, alpha=0.3)
plt.show()

L1正則化とROC-AUCの関係

reg_alpha が 0〜1.0 の範囲では Test はほぼ変わらず、Train だけがわずかに下がります。10 まで強めると葉の重みが潰れすぎて Train も Test も下がり、学習不足に入ります。このデータでは max_depth=5 でも過学習が軽いので、L1 正則化の恩恵は小さく、reg_alpha は深い木や雑音の多いデータで効く引数だと分かります。


特徴量の重要度 #

基準モデル model について、20 個の特徴量がどれだけ損失を減らしたかを棒グラフにします。n_informative=10 なので、上位 10 本程度が高く、雑音の 5 列が低く出るのが期待される形です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
importances = model.feature_importances_
indices = np.argsort(importances)[::-1]

plt.figure(figsize=(10, 5))
plt.bar(range(len(importances)), importances[indices], color="#2563eb", alpha=0.8)
plt.xlabel("Feature Index")
plt.ylabel("Importance (gain)")
plt.title("XGBoost Feature Importance")
plt.xticks(range(len(importances)), [f"f{i}" for i in indices], fontsize=9)
plt.show()

XGBoost特徴量重要度

max_depthを大きくしすぎると、学習データへの過学習が急速に進みます。上のグラフでTrainスコアが1.0に近づく一方でTestスコアが下がる現象が確認できます。まずはmax_depth=3〜6の範囲で試し、early_stopping_roundsを併用して不要な木の追加を防いでください。

チューニングの優先順位は learning_rate → max_depth → n_estimators → reg_alpha/reg_lambda の順が効率的です。learning_rate を 0.1 から 0.05 に半分にしたら n_estimators を 2 倍にする、という組み合わせで調整してください。探索の自動化には GridSearchCV や Optuna が使えます。

まとめ #

XGBoost は、木を 1 本足すときの目的関数に正則化項 \(\gamma T + \frac{1}{2}\lambda \sum w_j^2\) を入れ、損失の 2 次テイラー展開から葉の重み \(w_j^* = -G_j/(H_j+\lambda)\) と分割ゲインを式 1 本で求める勾配ブースティングの実装です。ゲインが \(\gamma\) を超える分割だけを採用するので、雑音にしか合わない葉は作られません。実験では learning_rate は 0.05〜0.1 で Test が最高、max_depth は 5 を超えると Train だけが 1.0 に貼り付き、reg_alpha は 1.0 までなら Test を保ったまま Train を下げました。調整は learning_rate と n_estimators をセットで決め、max_depth を Train と Test の差で選び、それでも差が残るときに reg_alpha / reg_lambda を足す順が効率的です。feature_importances_ の Gain を見れば、どの列が目的関数を減らしたかが分かり、特徴量選択や解釈に使えます。

  • Gradient Boosting(回帰) — 勾配ブースティングの基礎
  • LightGBM — ヒストグラム近似と Leaf-wise 成長で大規模データを高速に学習する実装
  • CatBoost — カテゴリ変数に強い勾配ブースティング
  • SHAP — 予測を特徴量ごとに分解
  • PDP / ICE — 特徴量と予測の関係を可視化

推定器数と XGBoost #

スライダーで木の本数を 1 本から 200 本まで動かすと、予測の線が階段状から滑らかになり、train_mse は下がり続けるのに test_mse はある本数から上がり始める様子を確認できます。