決定木から勾配ブースティングへ ― LightGBM/XGBoostの「一行」を解剖
tabularで主役のGBDTを、決定木から積み上げて解剖します。分割の論理、バギングとブースティングの違い、勾配ブースティング=損失の勾配方向に弱学習器を足すという直感、主要ハイパーパラメータの意味。Playground系データとSOLNAのChurnで動かします。
by Akisame
LGBMClassifier().fit() の一行は、内部で何を最小化しているのか。答えは、損失の勾配が指す方向に浅い決定木を一本ずつ足していく、関数空間の勾配降下です。この記事では、決定木の分割の論理から積み上げ、バギングとの違い、勾配ブースティングの直感、そして主要ハイパラの意味までを開き、最後に実務データのChurn予測へ移します。
写経の段階では LGBMClassifier() は「とりあえず強いやつ」で済みます。解読の段階では、それが「損失を関数空間で勾配降下している」装置だと言え、ハイパラを「何を制御する旋回か」で選べる。今回はその差を埋めます。
この回の到達目標
- 決定木が分割をどう選ぶか(不純度・情報利得)を言える。
- バギング(並列に平均)とブースティング(逐次に補正)を区別できる。
- 勾配ブースティングが何を最小化しているかを、第4回の勾配降下の延長として直感で説明できる。
- 主要ハイパラ(
learning_rate・木の数・複雑さ・正則化・サブサンプル)の役割を言える。 - 公開データで効く設定が、実務データでなぜ壊れうるか(分布シフト・供給遅延)を指摘できる。
前提(前回まで)
- 第3回:線形回帰を最小二乗から作り直し、
y = Xwを行列で読めること。 - 第4回:分類=確率を出すこと、最尤推定、そして勾配降下法(解析解が無いとき反復で解く)の最小限。今回はこの勾配降下を「関数空間」へ拡張します。
- 第5回:過学習・正則化・交差検証、そして前処理をCVの外でやるリークと時間リーク。GBDTは過学習しやすいので、第5回のCV設計が前提になります。
決定木 ― 分割の論理(不純度・情報利得)
決定木は、特徴量に「しきい値」を引いてデータを2つに割る操作の繰り返しです。問題は「どの特徴量の、どのしきい値で割るか」をどう決めるか。基準になるのが不純度(impurity)です。
あるノードに含まれるサンプルのクラス比率を とするとき、分類でよく使う不純度は2つです。
どちらも「ノードが1クラスに偏るほど小さく、半々のとき最大」になる量です。分割は、親ノードの不純度から、子ノードの不純度の加重和を引いた情報利得(information gain)を最大化するように選びます。
回帰木なら不純度の代わりに分散(二乗誤差)を使い、分割後に各子ノード内の二乗誤差が最も減る切り方を選びます。
scikit-learn の DecisionTreeClassifier の一行は、この「全特徴量×全しきい値を試して、利得が最大の分割を採る」を再帰的に回しているだけです。不純度の計算自体は数行で書けます。
import numpy as np
def gini(y):
# y: あるノードに入った正解ラベルの配列
_, counts = np.unique(y, return_counts=True)
p = counts / counts.sum()
return 1.0 - np.sum(p ** 2)
def best_split(X_col, y):
# 1つの特徴量について、利得が最大になるしきい値を探す最小構成
parent = gini(y)
best = (None, -np.inf)
for t in np.unique(X_col):
left, right = y[X_col <= t], y[X_col > t]
if len(left) == 0 or len(right) == 0:
continue
n = len(y)
child = (len(left) / n) * gini(left) + (len(right) / n) * gini(right)
gain = parent - child
if gain > best[1]:
best = (t, gain)
return best # (しきい値, 情報利得)1本の木は、これを深さ方向に繰り返すだけで作れます。だから木はスケーリング不要(しきい値は順序しか見ない)で、欠損や外れ値に比較的頑健です。代わりに、1本の木は分割の偶然に左右されやすく、単体では汎化が不安定(バリアンスが高い)という弱点があります。ここから先は、その弱点をどう潰すかの話です。
アンサンブルの二系統(バギング=RF / ブースティング)
弱い木を束ねて強くする方法は、大きく二系統あります。違いは「並列か逐次か」です。
バギング(bagging、代表がランダムフォレスト)は、データをブートストラップで何通りもサンプリングし、独立に木を育て、最後に平均(分類なら多数決)します。各木が別々の偶然を引くので、平均すると偶然が打ち消し合い、バリアンスが下がります。木同士に依存関係はなく、並列に学習できます。
ブースティング(boosting)は逆で、逐次に学習します。まず弱い木を1本作り、その予測がまだ外している部分を次の木が重点的に補正する。これを繰り返して、少しずつ誤りを削っていきます。バイアスを主に下げる方向で、木同士は前の結果に依存するため並列化できません。
| バギング(RF) | ブースティング(GBDT) | |
|---|---|---|
| 学習の順序 | 並列・独立 | 逐次・依存 |
| 主に下げるもの | バリアンス | バイアス(+正則化でバリアンス) |
| 各木の役割 | 同じ問題を別の偶然で解く | 前の残り誤差を補正する |
| 過学習の出方 | 木を増やしても比較的安定 | 木を増やしすぎると過学習 |
表形式データ(tabular)で主役になっているのは後者、勾配ブースティングです。
勾配ブースティングの直感(残差の勾配方向に足していく)
ここが今回の核心で、fit() の一行の裏側にある数学です。第4回では、パラメータ を勾配の逆方向に更新しました。勾配ブースティングは、これを「関数そのもの」に対してやります。
予測関数を とし、木を一本ずつ足して更新します。
は後で出てくる learning_rate(ステップ幅)です。では新しい木 は何にフィットさせるのか。損失 を現在の予測 で偏微分した、負の勾配(擬似残差)です。
損失が二乗誤差 のとき、この負の勾配はちょうど普通の残差 になります。これが「残差の方向に木を足していく」と言われる理由です。残差は特別な操作ではなく、二乗誤差を選んだときの勾配にすぎません。分類で交差エントロピーを選べば勾配の形は変わりますが、「負の勾配に木をフィットして足す」という骨格は同じです。
最小構成で書くと、二乗誤差の勾配ブースティングはこれだけです。
import numpy as np
from sklearn.tree import DecisionTreeRegressor
def gradient_boost(X, y, n_estimators=100, lr=0.1, max_depth=3):
F = np.full_like(y, y.mean(), dtype=float) # 初期予測は平均
trees = []
for _ in range(n_estimators):
residual = y - F # 二乗誤差では負の勾配=残差
h = DecisionTreeRegressor(max_depth=max_depth)
h.fit(X, residual) # 残差(=勾配方向)に弱学習器をフィット
F += lr * h.predict(X) # 関数空間を lr 歩ぶん更新
trees.append(h)
return treesLGBMRegressor や XGBRegressor は、これに二次の勾配(ヘシアン)を使った効率的な木の作り方、ヒストグラム化、葉ごとの正則化を足したものだと理解すると、ライブラリが急に透明になります。LightGBMは葉単位(leaf-wise)に深く伸ばし、XGBoostは標準では階層単位(level-wise)に揃えて伸ばす、という育て方の違いがありますが、最小化しているものは同じ「関数空間の損失」です。
なぜtabularで強いか(前処理の頑健さ・非線形・交互作用)
線形モデルが「特徴量を直線で足し合わせる」のに対し、木は「しきい値で領域に切る」ので、前処理とモデリングの両面で表形式データに噛み合います。
理由は3つに整理できます。第一に、分割は値の順序しか見ないのでスケーリングが要らない。標準化を忘れても結果が変わりません。第二に、領域を細かく切ることで非線形をそのまま近似できる。対数変換や多項式特徴を手で作らなくても、しきい値の組み合わせで曲がった境界を表せます。第三に、深さ方向の分割が交互作用を自動で拾う。「会員ランクが高く、かつ最終購入から日が浅い人」のような条件の掛け算を、特徴量を手作りしなくても木が見つけます。
これは強みであると同時に、第5回で見た過学習のしやすさと裏表です。表現力が高いぶん、放っておくと訓練データの偶然まで覚えます。そこを抑えるのがハイパラです。
主要ハイパラの意味
ハイパラは「精度を上げる魔法の数字」ではなく、「モデルの複雑さと学習の速さを制御する旋回」です。役割で覚えると、第8回のチューニングが手探りでなくなります。
learning_rate / 木の数
learning_rate(、)は1本の木をどれだけ信用して足すかのステップ幅です。小さくするほど一歩が慎重になり、汎化は良くなるが、同じところまで到達するのに木の数(n_estimators)が増えます。両者はトレードオフで、実務では learning_rate を小さめに固定し、n_estimators は大きめに取ったうえで早期終了(early stopping)で打ち切るのが定石です。木を増やしすぎると、バギングと違って過学習する点に注意します。
num_leaves・max_depth / 正則化・サブサンプル
1本の木の複雑さを決めるのが num_leaves(LightGBM)や max_depth(XGBoost)です。大きいほど細かく切れて表現力が上がるが、過学習に近づきます。min_data_in_leaf(葉の最小サンプル数)は、少数サンプルだけの葉を作らせない歯止めです。reg_alpha(L1)・reg_lambda(L2)は葉の重みへの正則化で、第5回の正則化と同じ発想がここでも効きます。subsample(行のサンプリング)・colsample_bytree(列のサンプリング)は、各ステップで一部のデータ・特徴だけを使う確率的勾配ブースティングで、木同士の相関を下げてバリアンスを抑えます。
import lightgbm as lgb
from sklearn.model_selection import train_test_split
# 公開データ(Playground系の表形式分類を想定)を読み込んだ後の最小構成
X_tr, X_va, y_tr, y_va = train_test_split(X, y, test_size=0.2, random_state=42)
params = dict(
objective="binary",
learning_rate=0.05, # 小さめ+木は多め+早期終了が定石
num_leaves=31, # 木の複雑さ。大きすぎると過学習
min_data_in_leaf=50, # 少数サンプルの葉を作らせない歯止め
feature_fraction=0.8, # 列サブサンプル(colsample)
bagging_fraction=0.8, # 行サブサンプル(subsample)
bagging_freq=1,
lambda_l2=1.0, # 葉の重みへのL2正則化
seed=42, # 再現性:seedとバージョンは必ず固定
)
model = lgb.LGBMClassifier(n_estimators=2000, **params)
model.fit(
X_tr, y_tr,
eval_set=[(X_va, y_va)],
callbacks=[lgb.early_stopping(stopping_rounds=100)], # 増やしすぎを自動で打ち切る
)公開データでこの最小構成を回し、learning_rate と num_leaves を一つずつ動かして検証スコアの動きを観察すると、各旋回が何を制御しているかが体感できます。コンペならここでスコアを詰めますが、本連載の主題はそこではありません。問題は、この設定をそのまま自社データに持ち込めるか、です。
SOLNAへ ― Churn予測と、分布シフト・特徴量供給遅延
過去の受託案件をもとに設計した架空のD2CブランドSOLNAでのChurn予測は、subscriptions の解約状態を目的変数に、orders から作るRFM的な特徴(最終購入からの経過日数・購入頻度・累計金額)と customers の属性、subscriptions の plan・n_cycles を説明変数にする、という形になります。手法は公開データと同じGBDTです。違いはデータの性質で、ここに「コンペで効く≠実務で効く」が集中します。
# SOLNA Churn の特徴量づくり(最小構成・コメント主体)
# subscriptions(解約状態)を目的、orders/customers を説明に
import pandas as pd
REF_DATE = pd.Timestamp("2026-04-01") # 予測を打つ基準日(学習・推論で固定)
# orders から基準日「以前」だけで集計(未来情報を入れない)
past = orders[orders["order_at"] < REF_DATE]
rfm = (past.groupby("customer_id")
.agg(recency_days=("order_at", lambda s: (REF_DATE - s.max()).days),
frequency=("order_id", "count"),
monetary=("amount", "sum")))
# 目的変数:基準日から一定期間内に解約したか(churned_flag/churn_at から作る)
# 注意:churn_at は「未来」の情報。特徴量には絶対に混ぜない(リーク)
y = make_churn_label(subscriptions, ref_date=REF_DATE, horizon_days=90)
X = rfm.join(customers.set_index("customer_id")[["acquisition_channel",
"region", "membership_tier"]])この移植で壊れる点を、該当箇所ごとに3つ挙げます。
第一に時間リークです。公開コンペのランダムKFoldをそのままSOLNAに使うと、未来の注文から作った特徴で過去の解約を当てることになり、検証スコアだけが不当に良くなります。第5回のとおり、基準日で時系列に切る検証(TimeSeriesSplit 相当)にします。churn_at のような未来の列を特徴に混ぜないことも同じ事故の防止です。
第二に特徴量の供給遅延です。学習時には「最終購入からの経過日数」が最新で取れても、推論を打つ瞬間にはデータ連携の遅れで前日ぶんしか来ていない、ということが実務では起きます。学習時にだけ存在する鮮度の特徴で当てているモデルは、本番で性能が落ちます。「推論時に実際に手に入る情報だけで学習する」のが原則です。
第三に分布シフトと新カテゴリです。コンペのデータは固定でクリーンですが、SOLNAは動きます。価格改定や新商品で解約の傾向自体が変わり(分布シフト)、新しい acquisition_channel や plan が推論時に現れます(学習時に無いカテゴリ)。さらに、最近獲得したばかりの顧客は解約する時間がまだ経っていない未成熟コホートで、これを「解約していない人」として学習に入れるとラベルが歪みます。基準日と観測期間の設計でこれを除くか、別扱いにします。
なお、GBDTが返す特徴量重要度を「この変数が解約の原因だ」と読みたくなりますが、重要度が高い=因果がある、ではありません。重要度の3種の違いと、因果との切り分けは第8回で主題化し、深掘りは因果編へ送ります。ここでは「予測に効く変数と、介入して効く変数は別」という線引きだけ置いておきます。
写経と解読の差(この回の場合)
写経では LGBMClassifier(n_estimators=2000).fit(...) が動き、それなりのスコアが出ます。解読では、その一行が「二乗誤差なら残差、分類なら交差エントロピーの勾配に木をフィットして関数空間を learning_rate 歩ずつ降りている」と言え、num_leaves を上げたら何が起きるかを過学習の言葉で説明でき、その設定が動くSOLNAでなぜ壊れるかを時間リーク・供給遅延・分布シフトで指摘できる。差はここにあります。
対応書籍と読みどころ
- 門脇ら『Kaggleで勝つデータ分析の技術』:GBDTの章。
learning_rateと木の数のトレードオフ、num_leaves・正則化・サブサンプルの効かせ方、early stopping の使い方が、tabular実装のことばとしてまとまっています。本連載の実装のバックボーン。
PRMLへの一歩
GBDTはPRML本体には独立した章として出てきません。ただしPRML 14章(モデルの結合・アンサンブル)が、複数の弱い学習器を組み合わせる考え方の土台で、ブースティングもこの地図の上に置けます。本連載では、GBDTを「関数空間での損失最小化(=第4回の勾配降下の拡張)」として位置づけます。第9回の読書地図で、14章をこの回に対応づけます。
章末チェックリスト
- 決定木の分割基準(不純度・情報利得)を自分の言葉で言える
- バギングとブースティングを「並列/逐次」「バリアンス/バイアス」で区別できる
- 勾配ブースティングが負の勾配に木をフィットしていること、二乗誤差では残差になる理由を説明できる
-
learning_rateと木の数のトレードオフ、num_leaves・正則化・サブサンプルの役割を言える - SOLNAへの移植で、時間リーク・特徴量供給遅延・分布シフト・未成熟コホート・新カテゴリを点検した
- seedとライブラリのバージョンを固定し、再現できる状態にした
ここまでが「GBDTが何をしているか・どう移すか」というHOWです。次回は、そのモデルが出すスコアを、評価指標と確率の較正(予測確率を実際の発生率に合わせること)で「意思決定」に翻訳します。何を測り、どの予測を信じて投資判断に使うかという全体像は、対になる記述・予測編も合わせてどうぞ。