前回は、PCA を使って、高次元データを少ない軸に言い換える方法を見ました。
今回は教師あり学習に戻り、アンサンブル学習 を扱います。英語では ensemble learning と呼ばれます。
アンサンブル学習は、ざっくり言えば「1つのモデルだけに決めさせるのではなく、複数のモデルの判断を組み合わせて予測する方法」です。
ファインマン風に言えば、アンサンブル学習は「1人の専門家に全部を任せず、少し見方の違う複数人の意見を集めて、最後により安定した判断を出す仕組み」です。ただし、全員が同じ勘違いをしているなら多数決をしてもあまり意味がありません。大切なのは、モデル同士にほどよい違いを持たせることです。
目次
- アンサンブル学習で何をしたいのか
- この記事で使うデータについて
- Voting:複数モデルに投票させる
- Bagging と Pasting:データの見せ方を変えて学習する
- ランダムフォレスト:木をたくさん作って平均する
- Boosting:前のモデルの弱点を次のモデルが補う
- AdaBoost:間違えたサンプルを重く見る
- AdaBoost の計算を小さな例で追う
- GBDT:残差ではなく負の勾配を学習する
- XGBoost:目的関数に正則化と2階微分を入れる
- LightGBM:リーフ単位でもっと速く学習する
- Stacking:予測結果を次の特徴量にする
- どう使い分けるか
- まとめ
1. アンサンブル学習で何をしたいのか
機械学習では、1つのモデルだけを見ると、どうしてもそのモデル特有のクセが出ます。
たとえば決定木は、データに細かく合わせすぎると過学習しやすいモデルです。一方で、ロジスティック回帰のような線形モデルは、境界が単純すぎて複雑な形を拾いにくいことがあります。
アンサンブル学習では、複数の予測器を組み合わせます。分類なら多数決や確率の平均、回帰なら予測値の平均や足し合わせを使います。
代表的な方法は次の通りです。
- Voting:種類の違うモデルに投票させる。
- Bagging:データをランダムに取り出して、同じ種類のモデルをたくさん作る。
- ランダムフォレスト:Bagging を決定木に使い、特徴量の選び方にもランダム性を入れる。
- Boosting:前のモデルが苦手だった部分を、次のモデルが順番に補う。
- Stacking:第1層のモデルの予測値を、新しい特徴量として第2層のモデルに渡す。
アンサンブルが強くなりやすい理由は、各モデルの誤差が完全には同じでないからです。あるモデルが外した点を、別のモデルが当ててくれるなら、組み合わせた結果は安定します。
反対に、すべてのモデルが同じデータ、同じ特徴量、同じ考え方で学習し、同じ場所で間違えるなら、いくら数を増やしても改善は小さくなります。
全体像が見えたところで、この記事で使うデータを用意します。
2. この記事で使うデータについて
この記事では、外部ファイルに依存しないように、Scikit-learn で生成できる小さな架空データを使います。
- 分類の例では、キャンペーンに反応するかどうかを表す架空の顧客データ
customer_jpを使います。 - 回帰の例では、売上を予測する架空の小売データ
sales_jpを使います。 - どちらも説明用の合成データであり、実在の顧客や店舗の記録ではありません。
まず、分類用のデータを作ります。
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.datasets import make_moons
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import make_pipeline
from sklearn.metrics import accuracy_score, mean_squared_error
from sklearn.calibration import CalibratedClassifierCV
from sklearn.linear_model import LogisticRegression
from sklearn.svm import SVC
from sklearn.tree import DecisionTreeClassifier, DecisionTreeRegressor
from sklearn.ensemble import (
AdaBoostClassifier,
BaggingClassifier,
GradientBoostingClassifier,
GradientBoostingRegressor,
RandomForestClassifier,
StackingClassifier,
VotingClassifier,
)
X_customer_raw, y_customer = make_moons(
n_samples=500,
noise=0.28,
random_state=42,
)
customer_jp = pd.DataFrame({
"閲覧行動スコア": X_customer_raw[:, 0],
"購買関心スコア": X_customer_raw[:, 1],
"キャンペーン反応": np.where(y_customer == 1, "反応あり", "反応なし"),
})
customer_features = ["閲覧行動スコア", "購買関心スコア"]
X_customer = customer_jp[customer_features]
y_customer_label = y_customer
X_train, X_test, y_train, y_test = train_test_split(
X_customer,
y_customer_label,
test_size=0.30,
stratify=y_customer_label,
random_state=42,
)
print(customer_jp.head())
各列の意味は次の通りです。
| 列名 | 意味 |
|---|---|
閲覧行動スコア |
サイトやアプリ上での行動を、説明用に1つの数値へまとめた架空スコアです。 |
購買関心スコア |
商品への関心度を、説明用に1つの数値へまとめた架空スコアです。 |
キャンペーン反応 |
キャンペーンに反応したかどうかを表すラベルです。 |
点の形も見ておきます。
colors = np.where(y_customer_label == 1, "tab:orange", "tab:blue")
plt.figure(figsize=(6, 4))
plt.scatter(
X_customer["閲覧行動スコア"],
X_customer["購買関心スコア"],
c=colors,
s=28,
alpha=0.75,
)
plt.xlabel("閲覧行動スコア")
plt.ylabel("購買関心スコア")
plt.title("架空のキャンペーン反応データ")
plt.show()
このデータは、2つのクラスが少し曲がった形で混ざっています。線形モデルだけでは境界を引きにくく、決定木だけでは細かく合わせすぎる可能性があります。つまり、アンサンブルの効果を見やすい題材です。
データができたので、まず一番素朴な組み合わせ方である Voting から見ていきます。
3. Voting:複数モデルに投票させる
Voting は、複数の分類器の予測を集めて、最終的なクラスを決める方法です。
3.1 硬投票
硬投票では、各モデルが出したクラスラベルをそのまま数えます。
たとえば3つのモデルが次のように予測したとします。
| モデル | 予測 |
|---|---|
| ロジスティック回帰 | 反応あり |
| SVM | 反応あり |
| 決定木 | 反応なし |
この場合、2票を得た 反応あり が最終予測になります。
数式では、各モデルの予測を $\hat y_1,\hat y_2,\ldots,\hat y_M$ とすると、次のように書けます。
$$
\hat y=\operatorname{mode}
\left(\hat y_1,\hat y_2,\ldots,\hat y_M\right)
$$
Scikit-learn では VotingClassifier を使います。
logistic_model = make_pipeline(
StandardScaler(),
LogisticRegression(max_iter=1000, random_state=42),
)
svm_model = make_pipeline(
StandardScaler(),
CalibratedClassifierCV(
estimator=SVC(gamma="scale"),
cv=3,
ensemble=False,
),
)
tree_model = DecisionTreeClassifier(max_depth=5, random_state=42)
voting_hard = VotingClassifier(
estimators=[
("logistic", logistic_model),
("svm", svm_model),
("tree", tree_model),
],
voting="hard",
)
models = {
"ロジスティック回帰": logistic_model,
"SVM": svm_model,
"決定木": tree_model,
"硬投票": voting_hard,
}
for name, model in models.items():
model.fit(X_train, y_train)
y_pred = model.predict(X_test)
print(name, round(accuracy_score(y_test, y_pred), 3))
ここでは、ロジスティック回帰、SVM、決定木という性格の違うモデルを組み合わせています。
StandardScaler を Pipeline に入れているのは、ロジスティック回帰と SVM が特徴量のスケールに影響を受けやすいからです。CalibratedClassifierCV は、SVM の判断を確率として扱えるようにするために使っています。決定木はスケールにあまり敏感ではありませんが、異なる種類のモデルをまとめて扱うときは、それぞれの前処理をパイプラインに閉じ込めておくと扱いやすくなります。
3.2 軟投票
軟投票では、各モデルが出した確率を平均してから、最も確率が高いクラスを選びます。
クラス $k$ に対する平均確率は次のように書けます。
$$
\bar p_k(x)=\frac{1}{M}\sum_{m=1}^{M}p_{mk}(x)
$$
最終予測は次の通りです。
$$
\hat y=\arg\max_k\bar p_k(x)
$$
モデルに重みを付けるなら、次のような加重平均にできます。
$$
\bar p_k(x)=\frac{\sum_{m=1}^{M}w_mp_{mk}(x)}
{\sum_{m=1}^{M}w_m}
$$
軟投票は、単なる多数決より情報を多く使います。あるモデルが「反応ありの確率 0.51」と弱く言っているのか、「反応ありの確率 0.99」と強く言っているのかを区別できるからです。
voting_soft = VotingClassifier(
estimators=[
("logistic", logistic_model),
("svm", svm_model),
("tree", tree_model),
],
voting="soft",
)
voting_soft.fit(X_train, y_train)
soft_pred = voting_soft.predict(X_test)
soft_proba = voting_soft.predict_proba(X_test[:5])
print("軟投票の正解率:", round(accuracy_score(y_test, soft_pred), 3))
print("先頭5件の予測確率:")
print(np.round(soft_proba, 3))
Voting は理解しやすい方法ですが、モデルの数を増やすだけでは限界があります。次は、同じ種類のモデルをたくさん作る Bagging を見ていきます。
4. Bagging と Pasting:データの見せ方を変えて学習する
Bagging は、同じ学習アルゴリズムを使いながら、各モデルに少しずつ違う訓練データを見せる方法です。
正式には Bootstrap Aggregating の略です。
- Bagging:元の訓練データから、重複を許してランダムにサンプルを取り出す。
- Pasting:元の訓練データから、重複を許さずランダムにサンプルを取り出す。
たとえば決定木は、訓練データが少し変わるだけで木の形が大きく変わることがあります。これは単体では不安定さにつながりますが、Bagging ではむしろ利用できます。少し違う木をたくさん作り、それらを平均すれば、1本の木より安定した予測になりやすいからです。直感的には、ばらつきの大きい推定値でも、たくさん平均すれば全体のぶれは小さくなる、という統計の考え方に近いです。
bagging_model = BaggingClassifier(
estimator=DecisionTreeClassifier(random_state=42),
n_estimators=120,
max_samples=0.80,
bootstrap=True,
n_jobs=-1,
random_state=42,
)
pasting_model = BaggingClassifier(
estimator=DecisionTreeClassifier(random_state=42),
n_estimators=120,
max_samples=0.80,
bootstrap=False,
n_jobs=-1,
random_state=42,
)
for name, model in {
"Bagging": bagging_model,
"Pasting": pasting_model,
}.items():
model.fit(X_train, y_train)
y_pred = model.predict(X_test)
print(name, round(accuracy_score(y_test, y_pred), 3))
bootstrap=True にすると Bagging、bootstrap=False にすると Pasting です。
Bagging では、各モデルの訓練に使われなかったサンプルが自然に残ります。重複ありで $m$ 件から $m$ 回サンプルを取り出すと、ある1件が一度も選ばれない確率は $(1-1/m)^m$ で、$m$ が大きくなると約 $1/e\approx0.368$ に近づきます。つまり平均すれば全体の約37%のサンプルは、どのモデルの訓練にも使われずに残ります。これを Out-of-Bag、略して OOB と呼びます。OOB サンプルを使うと、別に検証データを用意しなくても、訓練中に性能の目安を確認できます。
bagging_oob = BaggingClassifier(
estimator=DecisionTreeClassifier(random_state=42),
n_estimators=120,
max_samples=0.80,
bootstrap=True,
oob_score=True,
n_jobs=-1,
random_state=42,
)
bagging_oob.fit(X_train, y_train)
bagging_oob_pred = bagging_oob.predict(X_test)
print("OOB スコア:", round(bagging_oob.oob_score_, 3))
print("テスト正解率:", round(accuracy_score(y_test, bagging_oob_pred), 3))
Bagging の代表例がランダムフォレストです。次は、決定木にさらにランダム性を入れる方法を見ます。
5. ランダムフォレスト:木をたくさん作って平均する
ランダムフォレストは、決定木をたくさん集めたアンサンブルです。
Bagging と同じようにサンプルをランダムに選ぶだけでなく、各ノードで分割候補にする特徴量もランダムに選びます。もし全ての特徴量を常に見られるままにすると、非常に強い特徴量が1つある場合、ほとんどの木が最初に同じ特徴量で分割してしまい、結果として木同士が似た形になりやすくなります。分割候補の特徴量も絞ることで、木同士の違いをさらに増やせます。
単体の決定木は、深く育てると訓練データに合わせすぎることがあります。ランダムフォレストでは、そのような木を多数作り、最後に投票します。1本1本は少し荒くても、集めると全体として安定しやすくなります。
forest_model = RandomForestClassifier(
n_estimators=200,
max_features="sqrt",
oob_score=True,
n_jobs=-1,
random_state=42,
)
forest_model.fit(X_train, y_train)
forest_pred = forest_model.predict(X_test)
feature_importance = pd.Series(
forest_model.feature_importances_,
index=customer_features,
).sort_values(ascending=False)
print("ランダムフォレストのテスト正解率:", round(accuracy_score(y_test, forest_pred), 3))
print("OOB スコア:", round(forest_model.oob_score_, 3))
print("特徴量重要度:")
print(feature_importance.round(3))
ランダムフォレストの feature_importances_ は、どの特徴量が分割にどれくらい使われたかを見るための目安です。ただし、重要度はデータや特徴量の種類に影響を受けます。絶対的な因果関係を示すものではなく、モデル内部での使われ方を読むための値だと考えると安全です。
ここまでの方法は、複数のモデルを比較的独立に作る考え方でした。次は、前のモデルの失敗を次のモデルが引き継ぐ Boosting を見ていきます。
6. Boosting:前のモデルの弱点を次のモデルが補う
Boosting は、弱いモデルを順番に足していき、全体として強いモデルにする方法です。
ここでいう弱いモデルとは、単体ではそれほど高性能ではないが、ランダムに当てるよりは少し良いモデルのことです。たとえば、深さ1の決定木、つまり1つの質問だけで分ける木は 決定株 と呼ばれ、Boosting の説明によく使われます。
Bagging は、複数のモデルを並列に作りやすい方法です。一方、Boosting は順番が大切です。後のモデルは、前までのモデルがどこで外したかを見てから作られるからです。
分類でよく知られているのが AdaBoost、回帰や分類で広く使われているのが GBDT や XGBoost です。
boosting_models = {
"AdaBoost": AdaBoostClassifier(
estimator=DecisionTreeClassifier(max_depth=1, random_state=42),
n_estimators=120,
learning_rate=0.45,
random_state=42,
),
"GradientBoosting": GradientBoostingClassifier(
n_estimators=120,
learning_rate=0.08,
max_depth=2,
random_state=42,
),
}
for name, model in boosting_models.items():
model.fit(X_train, y_train)
y_pred = model.predict(X_test)
print(name, round(accuracy_score(y_test, y_pred), 3))
ここで learning_rate は、各弱学習器の影響をどれくらい控えめに足すかを決める値です。小さくすると1本あたりの変化は穏やかになりますが、一般には多くの木が必要になります。
Boosting の雰囲気が見えたところで、まず AdaBoost を少し丁寧に見ます。
7. AdaBoost:間違えたサンプルを重く見る
AdaBoost は、前の弱分類器が間違えたサンプルの重みを大きくし、次の弱分類器により強く意識させる方法です。
最初は、すべての訓練サンプルに同じ重みを置きます。訓練データを
$$
T={(x_1,y_1),(x_2,y_2),\ldots,(x_m,y_m)}
$$
とすると、初期の重み分布は次の通りです。
$$
D_1=(w_{11},w_{12},\ldots,w_{1m}),
\qquad w_{1i}=\frac{1}{m}
$$
第 $t$ ラウンドでは、重み分布 $D_t$ を使って弱分類器 $G_t(x)$ を学習します。加重誤差率は次のように計算します。
$$
e_t=\sum_{i=1}^{m}w_{ti}I(G_t(x_i)\ne y_i)
$$
誤差が小さい弱分類器ほど、最終判断で大きな発言権を持ちます。その係数が $\alpha_t$ です。
$$
\alpha_t=\frac{1}{2}\log\frac{1-e_t}{e_t}
$$
この形は、誤差 $e_t$ の大きさに応じて発言権を自然に調節します。$e_t$ が $0$ に近づくほど(よく当たる弱分類器ほど)$\alpha_t$ は大きくなり、発言権が強まります。$e_t=0.5$(コイン投げと同じ程度の精度)のとき $\alpha_t=0$ となり、最終判断にはほとんど影響しません。もし $e_t>0.5$(ランダムより悪い)なら $\alpha_t$ は負になり、その弱分類器の判断をむしろ反転させて使う形になります。
$\alpha_t$ が決まったら、サンプル重みを更新します。
$$
w_{t+1,i}
=\frac{w_{ti}}{Z_t}
\exp\left[-\alpha_ty_iG_t(x_i)\right]
$$
ここで $Z_t$ は、重みの合計を1に戻すための正規化係数です。
$$
Z_t=\sum_{i=1}^{m}w_{ti}
\exp\left[-\alpha_ty_iG_t(x_i)\right]
$$
$y_iG_t(x_i)=1$ なら正解なので重みは下がります。$y_iG_t(x_i)=-1$ なら不正解なので重みは上がります。
最後は、弱分類器を重み付きで足し合わせます。
$$
f(x)=\sum_{t=1}^{T}\alpha_tG_t(x)
$$
$$
G(x)=\operatorname{sign}\left(\sum_{t=1}^{T}\alpha_tG_t(x)\right)
$$
Scikit-learn では次のように書けます。
ada_model = AdaBoostClassifier(
estimator=DecisionTreeClassifier(max_depth=1, random_state=42),
n_estimators=80,
learning_rate=0.5,
random_state=42,
)
ada_model.fit(X_train, y_train)
ada_pred = ada_model.predict(X_test)
print("AdaBoost の正解率:", round(accuracy_score(y_test, ada_pred), 3))
print("先頭5個の弱学習器の重み:")
print(np.round(ada_model.estimator_weights_[:5], 3))
式だけだと少し抽象的なので、次は小さな1次元データで、重みがどう動くかを追います。
8. AdaBoost の計算を小さな例で追う
次のような10個のサンプルを考えます。ラベルは $1$ と $-1$ の2値です。
| $X$ | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
|---|---|---|---|---|---|---|---|---|---|---|
| $Y$ | 1 | 1 | 1 | -1 | -1 | -1 | 1 | 1 | 1 | -1 |
| $D_1$ | 0.1 | 0.1 | 0.1 | 0.1 | 0.1 | 0.1 | 0.1 | 0.1 | 0.1 | 0.1 |
弱分類器は、しきい値 $v$ を使った単純なルールにします。
- $x<v$ なら $1$、それ以外なら $-1$
- $x>v$ なら $1$、それ以外なら $-1$
この中から、加重誤差が最も小さいものを毎回選びます。
x_line = np.arange(10)
y_line = np.array([1, 1, 1, -1, -1, -1, 1, 1, 1, -1])
weights = np.full(len(x_line), 1 / len(x_line))
def stump_predict(x_values, threshold, direction):
if direction == "left_positive":
return np.where(x_values < threshold, 1, -1)
return np.where(x_values > threshold, 1, -1)
def find_best_stump(x_values, labels, sample_weights):
candidates = []
for threshold in np.arange(0.5, 9.5, 1.0):
for direction in ["left_positive", "right_positive"]:
pred = stump_predict(x_values, threshold, direction)
error = sample_weights[pred != labels].sum()
candidates.append((error, threshold, direction, pred))
return min(candidates, key=lambda item: item[0])
history = []
for round_index in range(1, 4):
error, threshold, direction, pred = find_best_stump(x_line, y_line, weights)
alpha = 0.5 * np.log((1 - error) / error)
weights = weights * np.exp(-alpha * y_line * pred)
weights = weights / weights.sum()
history.append({
"round": round_index,
"threshold": float(threshold),
"direction": direction,
"error": round(float(error), 4),
"alpha": round(float(alpha), 4),
"weights": np.round(weights, 5).tolist(),
})
for row in history:
print(row)
このコードを実行すると、各ラウンドで選ばれたしきい値、誤差、$\alpha$、更新後の重みが確認できます。
1回目は、しきい値 $v=2.5$ のルールが選ばれます。これは $x=6,7,8$ を間違えるので、誤差は $0.3$ です。
$$
\alpha_1=\frac12\log\frac{1-0.3}{0.3}\approx0.4236
$$
間違えられた $x=6,7,8$ の重みは大きくなります。次のラウンドでは、モデルがそこをより強く意識します。
2回目は $v=8.5$、3回目は $v=5.5$ のようなルールが選ばれます。最終的には、次の形で弱分類器を足し合わせます。
$$
f(x)=0.4236G_1(x)+0.6496G_2(x)+0.7520G_3(x)
$$
AdaBoost はサンプルの重みを動かしていました。次に見る GBDT は、残差や損失関数の勾配を使って、次の木が何を学ぶべきかを決めます。
9. GBDT:残差ではなく負の勾配を学習する
GBDT は Gradient Boosting Decision Tree の略です。日本語では勾配ブースティング木、または勾配ブースティング決定木と呼ばれます。
まず、回帰の場面で考えると直感的です。
現在のモデルの予測を $f_{m-1}(x_i)$、正解を $y_i$ とすると、残差は次の通りです。
$$
r_{mi}=y_i-f_{m-1}(x_i)
$$
次の木は、この残差を予測するように学習します。つまり、まだ説明できていないズレを次の木が補います。
平方損失を使うと、損失関数は次のように書けます。
$$
L(y,f(x))=\frac12(y-f(x))^2
$$
この損失を $f(x)$ で微分し、符号を反転したものは次の通りです。
$$
-\frac{\partial L(y,f(x))}{\partial f(x)}=y-f(x)
$$
つまり、平方損失の負の勾配は残差そのものです。
一般には、次の値を新しい木の学習目標にします。
$$
r_{mi}=-\left[
\frac{\partial L(y_i,f(x_i))}{\partial f(x_i)}
\right]{f=f{m-1}}
$$
回帰用の架空データで、単体の決定木と勾配ブースティングを比べてみます。
rng = np.random.default_rng(42)
n_sales = 240
ad_cost = rng.uniform(20, 120, size=n_sales)
price_index = rng.normal(100, 8, size=n_sales)
season_angle = rng.uniform(0, 2 * np.pi, size=n_sales)
previous_sales = (
420
+ 3.2 * ad_cost
- 2.4 * (price_index - 100)
+ 35 * np.sin(season_angle)
+ rng.normal(0, 25, size=n_sales)
)
sales = (
90
+ 0.68 * previous_sales
+ 2.1 * ad_cost
- 3.5 * (price_index - 100)
+ 48 * np.sin(season_angle)
+ rng.normal(0, 30, size=n_sales)
)
sales_jp = pd.DataFrame({
"広告費_万円": ad_cost,
"価格指数": price_index,
"季節スコア": np.sin(season_angle),
"前月売上_万円": previous_sales,
"売上_万円": sales,
})
sales_features = ["広告費_万円", "価格指数", "季節スコア", "前月売上_万円"]
X_sales = sales_jp[sales_features]
y_sales = sales_jp["売上_万円"]
X_sales_train, X_sales_test, y_sales_train, y_sales_test = train_test_split(
X_sales,
y_sales,
test_size=0.30,
random_state=42,
)
single_tree = DecisionTreeRegressor(max_depth=3, random_state=42)
gbrt = GradientBoostingRegressor(
n_estimators=160,
learning_rate=0.06,
max_depth=2,
random_state=42,
)
for name, model in {
"単体の決定木": single_tree,
"GBDT": gbrt,
}.items():
model.fit(X_sales_train, y_sales_train)
pred = model.predict(X_sales_test)
rmse = np.sqrt(mean_squared_error(y_sales_test, pred))
print(name, "RMSE:", round(rmse, 2))
各列の意味は次の通りです。
| 列名 | 意味 |
|---|---|
広告費_万円 |
1か月の広告費を万円単位で表した架空の値です。 |
価格指数 |
通常価格を100としたときの相対的な価格水準です。 |
季節スコア |
季節性を表すための説明用スコアです。 |
前月売上_万円 |
前月の売上を万円単位で表した架空の値です。 |
売上_万円 |
予測したい当月売上です。 |
GBDT は、最初から複雑な1本の木で全部を説明しようとするのではなく、小さな木を少しずつ足してズレを減らします。
分類でも考え方は同じです。損失関数が変わるため、単純な残差そのものではなく、現在の予測に対する負の勾配を使います。
次は、GBDT をさらに実用向けに発展させた XGBoost の考え方を見ます。
10. XGBoost:目的関数に正則化と2階微分を入れる
XGBoost は、勾配ブースティングを高速かつ実用的に使うための代表的なライブラリです。
前節の GBDT が「損失関数の負の勾配に木を合わせる」だけだったのに対し、XGBoost はその仕組みに、木の複雑さへのペナルティ(正則化)と、損失関数をより正確に近似するための2階微分情報を加えたもの、と考えると全体像をつかみやすいです。
ここでは、実装の細部すべてではなく、考え方の中心だけを押さえます。
XGBoost では、1本の木を次のように表します。
$$
f(x)=w_{q(x)}
$$
$q(x)$ は、サンプル $x$ がどの葉に落ちるかを表す関数です。$w$ は葉の重み、つまりその葉の予測値です。
第 $t$ ラウンドまでの予測は、これまでの木の足し合わせです。
$$
\hat y_i^{(t)}=\sum_{k=1}^{t}f_k(x_i)
=\hat y_i^{(t-1)}+f_t(x_i)
$$
10.1 目的関数と正則化
第 $t$ ラウンドで最小化したい目的関数は、次のように書けます。
$$
\operatorname{Obj}^{(t)}
=\sum_{i=1}^{n}
L\left(y_i,\hat y_i^{(t-1)}+f_t(x_i)\right)
+\Omega(f_t)+C
$$
ここで $C$ は、今回追加する木 $f_t$ とは関係しない定数です。
木の複雑さを抑える正則化項は、よく次の形で書かれます。
$$
\Omega(f)=\gamma T+\frac12\lambda|w|^2
$$
展開すると次の通りです。
$$
\Omega(f)=\gamma T+\frac12\lambda\sum_{j=1}^{T}w_j^2
$$
$T$ は葉の数です。$\gamma$ は葉を増やすことへのペナルティ、$\lambda$ は葉の重みを大きくしすぎることへのペナルティです。
10.2 二階近似
XGBoost では、損失関数を Taylor 展開で2次まで近似します。
$$
L\left(y_i,\hat y_i^{(t-1)}+f_t(x_i)\right)
\approx
L\left(y_i,\hat y_i^{(t-1)}\right)
+g_if_t(x_i)
+\frac12h_if_t^2(x_i)
$$
ここで $g_i$ は一階勾配、$h_i$ は二階勾配です。
$$
g_i=\frac{\partial L(y_i,\hat y_i^{(t-1)})}
{\partial\hat y_i^{(t-1)}}
$$
$$
h_i=\frac{\partial^2L(y_i,\hat y_i^{(t-1)})}
{\partial(\hat y_i^{(t-1)})^2}
$$
各葉 $j$ に入ったサンプル集合を $I_j$ とし、勾配をまとめると、
$$
G_j=\sum_{i\in I_j}g_i,
\qquad
H_j=\sum_{i\in I_j}h_i
$$
となります。このとき、葉の最適な重みは次のように求まります。
$$
w_j^*=-\frac{G_j}{H_j+\lambda}
$$
木構造 $q$ の良さは、次の値で評価できます。
$$
\widetilde{\operatorname{Obj}}^{(t)}(q)
=-\frac12\sum_{j=1}^{T}\frac{G_j^2}{H_j+\lambda}
+\gamma T
$$
分割候補を比べるときのゲインは次の通りです。
$$
\operatorname{Gain}
=\frac12\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
$$
この式は、「左と右に分けることで、目的関数がどれくらい良くなるか」を測っています。$\gamma$ を引いているので、少し良くなるだけの分割は採用されにくくなります。
XGBoost を実際に使う場合は、別途 xgboost パッケージをインストールします。
pip install xgboost
この記事の分類データに対しては、次のように書けます。未インストールの環境でも記事のコード全体が止まらないように、ここではインストール有無を確認してから実行します。
import importlib.util
if importlib.util.find_spec("xgboost") is None:
print("xgboost は未インストールです。`pip install xgboost` 後にこのセルを実行してください。")
else:
from xgboost import XGBClassifier
xgb_model = XGBClassifier(
n_estimators=120,
max_depth=3,
learning_rate=0.08,
reg_lambda=1.0,
subsample=0.9,
colsample_bytree=0.9,
eval_metric="logloss",
random_state=42,
)
xgb_model.fit(X_train, y_train)
xgb_pred = xgb_model.predict(X_test)
print("XGBoost の正解率:", round(accuracy_score(y_test, xgb_pred), 3))
XGBoost は便利ですが、パラメータも多いモデルです。精度が出やすい一方で、学習率、木の深さ、木の本数、正則化、サンプリング率を調整しないと過学習することもあります。
次は、同じ勾配ブースティングの考え方を、木の育て方の工夫でさらに高速化した LightGBM を見ていきます。
11. LightGBM:リーフ単位でもっと速く学習する
LightGBM は Light Gradient Boosting Machine の略で、Microsoft が開発した勾配ブースティングのライブラリです。XGBoost と同じく GBDT の考え方をベースにしていますが、学習を速く、省メモリにするための工夫が異なります。
XGBoost との一番大きな違いは、木の育て方です。多くの実装は、木を レベル単位で、つまり同じ深さのノードをまとめて1段ずつ成長させます。これに対して LightGBM は、木を リーフ単位で育てます。分割のたびに、全リーフの中から損失が最も減ると見込まれるリーフだけを選んで分割するため、同じ分割回数でも損失を効率よく減らしやすくなります。
レベル単位の成長は、木の形が左右対称に近くなります。リーフ単位の成長は、損失が大きく減る場所を優先して深く分割するため木の形は不揃いになりやすい一方、同じ葉の数であれば全体の損失をより小さくしやすいとされています。ただし、不揃いに深くなりやすい分、データ数が少ないと過学習しやすくなるため、葉の数を抑えるパラメータの調整が重要になります。
木の育て方に加えて、LightGBM は次のような工夫も採用しています。
- ヒストグラムベースの分割探索:連続値の特徴量をあらかじめ決めた数のビン(区間)に区切ってから分割点を探します。値を1つずつ並べ替えて探すより計算量が少なく、メモリ消費も抑えられます。
- GOSS(Gradient-based One-Side Sampling):勾配の大きいサンプル、つまりまだうまく予測できていないサンプルは残しつつ、勾配の小さいサンプルの一部を無作為に間引いてから学習に使う方法です。
- EFB(Exclusive Feature Bundling):同時に非ゼロの値をほとんど取らない特徴量同士(ワンホットエンコードした列など)を1つの特徴量にまとめ、実質的な特徴量の数を減らす方法です。
いずれも、精度をできるだけ落とさずに学習を速くするための工夫です。
LightGBM を使う場合は、別途 lightgbm パッケージをインストールします。
pip install lightgbm
XGBoost のときと同じく、未インストールの環境でも記事のコード全体が止まらないように、インストール有無を確認してから実行します。
import importlib.util
if importlib.util.find_spec("lightgbm") is None:
print("lightgbm は未インストールです。`pip install lightgbm` 後にこのセルを実行してください。")
else:
from lightgbm import LGBMClassifier
lgbm_model = LGBMClassifier(
n_estimators=120,
num_leaves=15,
learning_rate=0.08,
random_state=42,
verbosity=-1,
)
lgbm_model.fit(X_train, y_train)
lgbm_pred = lgbm_model.predict(X_test)
print("LightGBM の正解率:", round(accuracy_score(y_test, lgbm_pred), 3))
ここでの num_leaves は、レベル単位の成長における max_depth と同じように、木の複雑さを抑えるための主なパラメータです。この記事のデータは500件程度と小さいため、num_leaves を大きくしすぎると訓練データに合わせすぎることがあります。データ量が多い場合は、num_leaves を増やしても過学習しにくくなります。
XGBoost と LightGBM のどちらが常に優れているというわけではありません。データ量やデータの性質によって向き不向きが変わるため、実務では両方を試して比べることも珍しくありません。
ここまでの Boosting 系の方法は、木を1本ずつ順番に足していく考え方でした。最後に、複数のモデルの予測結果そのものを新しい特徴量として使う Stacking を見ていきます。
12. Stacking:予測結果を次の特徴量にする
Stacking は、複数の第1層モデルに予測させ、その予測結果を新しい特徴量として第2層のモデルに渡す方法です。
第1層のモデルを基礎モデル、第2層のモデルをメタモデルと呼ぶことがあります。
たとえば、ロジスティック回帰、SVM、ランダムフォレストの予測を並べると、各サンプルに対して「それぞれのモデルがどう見たか」という新しい特徴量ができます。メタモデルは、それらの使い分け方を学習します。
重要なのは、メタモデルに渡す訓練用予測を、基礎モデルが学習に使っていないデータから作ることです。そうしないと、基礎モデルが訓練データを覚えた結果をメタモデルが見てしまい、性能を過大評価しやすくなります。
Scikit-learn の StackingClassifier は、cv を指定すると、交差検証の各分割で学習に使わなかったデータに対する予測(Out-of-Fold 予測)を使ってメタモデルを学習します。
stacking_model = StackingClassifier(
estimators=[
(
"logistic",
make_pipeline(
StandardScaler(),
LogisticRegression(max_iter=1000, random_state=42),
),
),
(
"svm",
make_pipeline(
StandardScaler(),
SVC(gamma="scale"),
),
),
(
"forest",
RandomForestClassifier(
n_estimators=120,
max_depth=4,
random_state=42,
),
),
],
final_estimator=LogisticRegression(max_iter=1000, random_state=42),
cv=5,
)
stacking_model.fit(X_train, y_train)
stacking_pred = stacking_model.predict(X_test)
print("Stacking の正解率:", round(accuracy_score(y_test, stacking_pred), 3))
ここでは cv=5 を指定しているので、メタモデルの学習には5分割の Out-of-Fold 予測が使われます。実務で最終評価用のテストデータを別に確保している場合は、基礎モデルやメタモデルの選択は訓練データの中だけで行い、最後にテストデータで一度だけ評価するのが基本です。
主な方法を一通り見たので、最後に使い分けを整理します。
13. どう使い分けるか
アンサンブル学習には、万能の正解があるわけではありません。データの大きさ、特徴量の種類、必要な説明性、計算時間によって選び方が変わります。
| 方法 | 向いている場面 | 注意点 |
|---|---|---|
| Voting | すでに性格の違うモデルがいくつかあり、手早く組み合わせたいとき | 似たモデルばかりだと効果が小さい |
| Bagging | 不安定なモデル、特に決定木の分散を下げたいとき | モデル数が増えるので計算量も増える |
| ランダムフォレスト | 表形式データで、強いベースラインを作りたいとき | 1本の木より説明は複雑になる |
| AdaBoost | 単純な弱学習器を順番に改善したいとき | 外れ値やノイズに引っ張られることがある |
| GBDT | 表形式データで高い精度を狙いたいとき | パラメータ調整と過学習対策が重要 |
| XGBoost | GBDT 系をより実用的に使いたいとき | ライブラリ導入とパラメータ理解が必要 |
| LightGBM | データ量や特徴量が多く、学習を高速化したいとき |
num_leaves など複雑さの調整が必要 |
| Stacking | 異なるモデルの得意不得意をメタモデルに学ばせたいとき | 情報漏洩を避ける設計が重要 |
直感的には、次のように考えると整理しやすいです。
- まず安定した強いベースラインが欲しいなら、ランダムフォレストや GBDT を試す。
- 複数のモデルをすでに作っているなら、Voting や Stacking で組み合わせる。
- 決定木が過学習気味なら、Bagging やランダムフォレストで平均する。
- 少しずつ誤差を減らしたいなら、Boosting 系を使う。
アンサンブルは強力ですが、モデルを増やせば必ず良くなるわけではありません。大切なのは、どの誤差を減らしたいのかを考えることです。
14. まとめ
今回の内容をまとめます。
- アンサンブル学習は、複数のモデルの予測を組み合わせる方法である。
- Voting は、複数モデルのクラスラベルや予測確率を集約する。
- 硬投票は多数決、軟投票は確率平均を使う。
- Bagging は、ランダムに作った訓練データで同じ種類のモデルを多数学習する。
- Pasting は、重複なしでサンプルを取り出す点が Bagging と違う。
- ランダムフォレストは、決定木の Bagging に特徴量選択のランダム性を加えた方法である。
- Boosting は、前のモデルの弱点を次のモデルが順番に補う。
- AdaBoost は、間違えたサンプルの重みを大きくし、次の弱分類器に注目させる。
- GBDT は、損失関数の負の勾配を次の木の学習目標にする。
- XGBoost は、2階近似と正則化を使って、木の重みや分割を効率よく評価する。
- LightGBM は、木をリーフ単位で成長させ、ヒストグラムベースの分割探索によって学習を高速化する。
- Stacking は、第1層モデルの予測を新しい特徴量として、第2層のメタモデルに学習させる。
- 強いアンサンブルほど、情報漏洩、過学習、計算量、説明性に注意する必要がある。
アンサンブル学習は、「強い1人」を探すというより、「少しずつ違う判断をどう組み合わせるか」を設計する考え方です。
Voting や Bagging は、複数の判断をまとめて安定させます。Boosting は、前の失敗を次に渡して少しずつ改善します。Stacking は、モデル同士の見方の違いをもう一段上のモデルに学習させます。
この違いを押さえておくと、単に RandomForestClassifier や XGBClassifier を使うだけでなく、「なぜこのモデルを使うのか」「どこで過学習しそうか」「どの評価方法が必要か」まで考えやすくなります。