Day 093 — LightGBM① 実装 — XGBoostとの違い・高速化の理由

2026-07-13 青 / Phase 4 コーディング leaf-wise / num_leaves

📚 背景知識(読んでから問題へ)

🔵
Day 091-092 で XGBoost を学びました。今日はもう1つの3大GBDTライブラリ、LightGBM(Microsoft製)を扱います。同じ「勾配ブースティング」の仲間ですが、木の育て方が根本的に違います。
用語直感的な意味
level-wise(XGBoostのデフォルト)同じ深さの階層を横一列に全部伸ばしてから、次の階層に進む。バランスの取れた形の木になる
leaf-wise(LightGBMのデフォルト)「損失を最も減らせる葉」を1つ選んで、そこだけ優先的に伸ばす。形はいびつになりやすいが、同じ葉の枚数でより損失を減らせる
num_leavesleaf-wise成長でのメインの複雑さコントロール。XGBoostのmax_depthに相当する役割だが「深さ」ではなく「葉の枚数」で直接制御する
ヒストグラムベースの分割探索連続値をあらかじめ有限個のbinに離散化し、bin境界だけを分割候補にすることで探索を高速化する仕組み
lgb.DatasetLightGBM専用のデータ構造。XGBoostのDMatrixに相当

🌳 木の育て方の違い(同じ葉4枚でも形が変わる)

level-wise(XGBoost)

葉4枚・全階層が均等

すべての枝を同じ深さまで揃えて伸ばす。バランスは良いが、効果の薄い枝にも同じだけ計算コストを使う

leaf-wise(LightGBM)

葉4枚・一番効く枝だけ深く

「一番損失を減らせる葉」だけを優先的に伸ばす。同じ葉4枚でも形はいびつ。効率は良いが過学習しやすい

⚠️
どちらも「葉は4枚」だが、leaf-wiseは1つの枝だけ深く伸びている。これが「同じnum_leavesでもLightGBMの方が過学習しやすい」と言われる理由——目安として num_leaves < 2^max_depth にするのが安全。

🎯 問題

Day 091-092 と同じ「賃貸物件の家賃予測」データを使います。同じ train/test 分割にすることで、XGBoostとLightGBMの結果を直接比較できるようにします。

使用データDay091-092と同じ n=200
import numpy as np
import pandas as pd

np.random.seed(42)
n = 200

area_m2 = np.random.normal(45, 15, n).clip(15, 100)
age_years = np.random.randint(0, 35, n)
station_dist_min = np.random.normal(8, 4, n).clip(1, 25)

rent_man_yen = (
    3.0
    + area_m2 * 0.25
    - age_years * 0.05
    - station_dist_min * 0.15
    + np.random.normal(0, 1.0, n)
).clip(3.0, None)

df = pd.DataFrame({
    "area_m2": area_m2,
    "age_years": age_years,
    "station_dist_min": station_dist_min,
    "rent_man_yen": rent_man_yen,
})
カラム名意味
area_m2専有面積(平米)float
age_years築年数int
station_dist_min最寄り駅からの徒歩時間(分)float
rent_man_yen家賃(万円・目的変数)float

タスク

1
train_test_split(test_size=0.2, random_state=42) で分割する(Day 091と全く同じ分割になる)
2
訓練・検証データを lgb.Dataset に変換する
3
learning_rate=0.1, num_leaves=15lgb.train(early stopping 20ラウンド)を実行し、best_iteration を確認する
4
テストRMSEを計算し、Day 091 のXGBoostのテストRMSEと比較する
5
time.perf_counter() でXGBoostとLightGBMの学習時間を計測・比較し、n=200では差がほぼ出ない理由を考える
6
num_leaves[7, 15, 31, 63] で変えて lgb.cv を実行し、ベストな値と大きくしすぎた時に何が起きるかを確認する

📊 XGBoost vs LightGBM(出力例・同一データ n=200)

乱数シードにより多少前後するが、n=200という小さいデータでは精度・速度ともに大差がつきにくいことを実測で確認する。

テストRMSE(万円・小さいほど良い)

XGBoost
約1.0x 万円
level-wise
LightGBM
約1.0x 万円
leaf-wise

学習時間(ms・小さいほど良い)

XGBoost
約15ms
n=200では誤差レベル
LightGBM
約12ms
n=200では誤差レベル
💡
LightGBMの「高速」は数万〜数百万行の大規模データや高カーディナリティなカテゴリ変数で真価を発揮する。n=200ではプロセス起動・ロギングのオーバーヘッドが支配的で、体感できる速度差はほぼ出ない。

📉 num_leaves を大きくすると何が起きるか(出力例)

num_leaves = 7
CV RMSE 約1.09
やや単純
num_leaves = 15
CV RMSE 約1.06
ベスト付近
num_leaves = 31
CV RMSE 約1.15
悪化し始める
num_leaves = 63
CV RMSE 約1.28
過学習
⚠️
n=200という少ないデータでnum_leavesを63まで上げると、1本の木がデータの細部(ノイズ)まで覚え込み、CVスコアがはっきり悪化する。leaf-wiseは「効きやすい分、過学習にも直結しやすい」ことが数値で確認できる。

💡 ヒント

ヒント1方向性

XGBoostのmax_depthは「深さ」で複雑さを制限するが、LightGBMのnum_leavesは「葉の枚数」で直接制限する。leaf-wise成長は「一番効果のある場所」を優先的に伸ばすため、同じ計算量(同じ葉の枚数)でlevel-wiseより損失を減らせるが、その分「無制限に伸ばすとあっという間に過学習する」性質も強く持つ。num_leavesを大きくしすぎた時にCVスコアがどう動くかに注目する。

ヒント2アプローチ
  • Dataset変換: lgb.Dataset(X_train, label=y_train) / 検証側は lgb.Dataset(X_test, label=y_test, reference=train_set)
  • 学習: lgb.train(params, train_set, num_boost_round=1000, valid_sets=[valid_set], callbacks=[lgb.early_stopping(20), lgb.log_evaluation(0)])
  • 予測: bst.predict(X_test, num_iteration=bst.best_iteration)
  • CV: lgb.cv(params, train_set, num_boost_round=1000, nfold=5, stratified=False, seed=42, callbacks=[lgb.early_stopping(30, verbose=False)]) → 戻り値の辞書の "rmse-mean" キーがラウンドごとの平均RMSEのリスト
  • 時間計測: t0 = time.perf_counter(); ...; elapsed = time.perf_counter() - t0
ヒント3コード骨格
import time
import lightgbm as lgb
from sklearn.model_selection import train_test_split
from sklearn.metrics import mean_squared_error

X = df[["area_m2", "age_years", "station_dist_min"]]
y = df["rent_man_yen"]
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)

train_set = lgb.Dataset(X_train, label=y_train)
valid_set = lgb.Dataset(X_test, label=y_test, reference=___)

params = {
    "objective": "regression",
    "metric": "rmse",
    "learning_rate": 0.1,
    "num_leaves": 15,
    "verbose": -1,
    "seed": 42,
}

t0 = time.perf_counter()
bst = lgb.train(
    params, train_set,
    num_boost_round=1000,
    valid_sets=[___],
    callbacks=[lgb.early_stopping(___), lgb.log_evaluation(0)],
)
lgb_time = time.perf_counter() - t0

print("best_iteration:", bst.___)

y_pred = bst.predict(X_test, num_iteration=bst.best_iteration)
lgb_rmse = mean_squared_error(y_test, y_pred, squared=___)
print("LightGBM RMSE:", lgb_rmse)

# num_leaves のグリッド探索
results = []
for nl in [7, 15, 31, 63]:
    p = {**params, "num_leaves": nl}
    cv_results = lgb.cv(
        p, train_set,
        num_boost_round=1000,
        nfold=5,
        stratified=False,
        seed=42,
        callbacks=[lgb.early_stopping(30, verbose=False)],
    )
    scores = cv_results["___"]
    results.append({"num_leaves": nl, "best_round": len(scores), "best_rmse": min(scores)})

print(pd.DataFrame(results))

模範解答

import numpy as np
import pandas as pd
import time
import lightgbm as lgb
import xgboost as xgb
from sklearn.model_selection import train_test_split
from sklearn.metrics import mean_squared_error

# --- データ準備(Day 091-092と同じ) ---
np.random.seed(42)
n = 200
area_m2 = np.random.normal(45, 15, n).clip(15, 100)
age_years = np.random.randint(0, 35, n)
station_dist_min = np.random.normal(8, 4, n).clip(1, 25)
rent_man_yen = (
    3.0 + area_m2 * 0.25 - age_years * 0.05 - station_dist_min * 0.15
    + np.random.normal(0, 1.0, n)
).clip(3.0, None)

df = pd.DataFrame({
    "area_m2": area_m2,
    "age_years": age_years,
    "station_dist_min": station_dist_min,
    "rent_man_yen": rent_man_yen,
})

# --- 1. train/testに分割(Day091と同じ random_state=42) ---
X = df[["area_m2", "age_years", "station_dist_min"]]
y = df["rent_man_yen"]
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)

# --- 2. lgb.Datasetに変換 ---
train_set = lgb.Dataset(X_train, label=y_train)
valid_set = lgb.Dataset(X_test, label=y_test, reference=train_set)

# --- 3. 学習(early stopping付き) ---
params = {
    "objective": "regression",
    "metric": "rmse",
    "learning_rate": 0.1,
    "num_leaves": 15,
    "verbose": -1,
    "seed": 42,
}

t0 = time.perf_counter()
bst = lgb.train(
    params, train_set,
    num_boost_round=1000,
    valid_sets=[valid_set],
    callbacks=[lgb.early_stopping(20), lgb.log_evaluation(0)],
)
lgb_time = time.perf_counter() - t0
print("best_iteration:", bst.best_iteration)

# --- 4. テストRMSE ---
y_pred = bst.predict(X_test, num_iteration=bst.best_iteration)
lgb_rmse = mean_squared_error(y_test, y_pred, squared=False)
print(f"LightGBM テストRMSE: {lgb_rmse:.4f} 万円")

# --- 5. XGBoostとの時間・精度比較(Day091と同パラメータ) ---
dtrain = xgb.DMatrix(X_train, label=y_train)
dtest = xgb.DMatrix(X_test, label=y_test)
xgb_params = {"objective": "reg:squarederror", "eta": 0.1, "max_depth": 4, "eval_metric": "rmse", "seed": 42}

t0 = time.perf_counter()
xgb_bst = xgb.train(
    xgb_params, dtrain,
    num_boost_round=500,
    evals=[(dtrain, "train"), (dtest, "eval")],
    early_stopping_rounds=20,
    verbose_eval=False,
)
xgb_time = time.perf_counter() - t0

xgb_pred = xgb_bst.predict(dtest, iteration_range=(0, xgb_bst.best_iteration + 1))
xgb_rmse = mean_squared_error(y_test, xgb_pred, squared=False)

print(f"\n--- XGBoost vs LightGBM(n={n})---")
print(f"XGBoost   : RMSE={xgb_rmse:.4f}, 学習時間={xgb_time*1000:.1f}ms, 木の本数={xgb_bst.best_iteration}")
print(f"LightGBM  : RMSE={lgb_rmse:.4f}, 学習時間={lgb_time*1000:.1f}ms, 木の本数={bst.best_iteration}")

# --- 6. num_leaves のグリッド探索 ---
results = []
for nl in [7, 15, 31, 63]:
    p = {**params, "num_leaves": nl}
    cv_results = lgb.cv(
        p, train_set,
        num_boost_round=1000,
        nfold=5,
        stratified=False,
        seed=42,
        callbacks=[lgb.early_stopping(30, verbose=False)],
    )
    scores = cv_results["rmse-mean"]
    results.append({"num_leaves": nl, "best_round": len(scores), "best_rmse": min(scores)})

results_df = pd.DataFrame(results)
print("\nnum_leaves グリッド探索結果:")
print(results_df)

# 出力例:
# best_iteration: 4x
# LightGBM テストRMSE: 1.0x 万円
#
# --- XGBoost vs LightGBM(n=200)---
# XGBoost   : RMSE=1.0x, 学習時間=15.x ms, 木の本数=3x
# LightGBM  : RMSE=1.0x, 学習時間=12.x ms, 木の本数=4x
#   (n=200程度ではプロセス起動・ロギングのオーバーヘッドが支配的で、
#     アルゴリズムそのものの速度差はほぼ見えない)
#
# num_leaves グリッド探索結果:
#    num_leaves  best_round  best_rmse
# 0           7          ..        1.0x
# 1          15          ..        1.0x   ← 最良付近
# 2          31          ..        1.1x   ← 悪化し始める
# 3          63          ..        1.2x   ← 過学習でさらに悪化

🪜 Step-by-Step 解説

1lgb.Dataset への変換(reference の役割)

train_set = lgb.Dataset(X_train, label=y_train)
valid_set = lgb.Dataset(X_test, label=y_test, reference=train_set)
🔑
なぜこうするか: LightGBMはヒストグラムベースの分割探索を行うため、内部で特徴量を「bin」に離散化する。検証用Datasetにreference=train_setを指定すると、訓練データと同じbin境界を使うようになり、学習時と検証時で分割の基準がズレるのを防げる。

2early stopping を callbacks で指定する

bst = lgb.train(params, train_set, num_boost_round=1000,
                 valid_sets=[valid_set],
                 callbacks=[lgb.early_stopping(20), lgb.log_evaluation(0)])
🔑
なぜこうするか: 最近のLightGBM APIではearly_stopping_roundsを直接渡す方式は非推奨になり、callbacks=[lgb.early_stopping(...)]を使う形に統一された。lgb.log_evaluation(0)はログ出力を抑制するコールバック(XGBoostのverbose_eval=Falseに相当)。

3XGBoostと同じ分割・同じ土俵で精度を比較する

X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
🔑
なぜこうするか: Day 091のXGBoostとrandom_state=42を揃えることで、全く同じテストデータに対するRMSEを直接比較できる。異なる分割で比較すると「モデルの差」なのか「たまたまテストデータの難易度差」なのか区別できない。

4学習時間を計測し「n=200では差が出ない」ことを確認する

t0 = time.perf_counter()
# ... 学習 ...
elapsed = time.perf_counter() - t0
💡
なぜこうするか: LightGBMの「高速」は主に大規模データ・大量の特徴量・高カーディナリティなカテゴリ変数で効いてくる性質。n=200のような小さいデータではPythonのオーバーヘッドが支配的で、体感できる速度差はほぼ出ない。実測して「理屈と実感のギャップ」を体験するのが目的。

5num_leaves のグリッド探索で「leaf-wiseの危うさ」を数値で見る

for nl in [7, 15, 31, 63]:
    p = {**params, "num_leaves": nl}
    cv_results = lgb.cv(p, train_set, ...)
⚠️
なぜこうするか: leaf-wise成長は「一番効く葉」を優先的に伸ばすため、num_leavesを大きくするとlevel-wise(max_depthを増やす場合)よりも急激に木が複雑になりやすい。n=200という少ないデータでは、この過学習がCVスコアの悪化としてはっきり現れる。

🧮 数学・統計の補足(文系向け)

📐
level-wise(XGBoost)は「クラス全員に同じ回数だけ追加の宿題を出す」やり方。leaf-wise(LightGBM)は「一番点数が伸びそうな生徒を見つけて、その子にだけ追加の宿題を出す」やり方。leaf-wiseの方が同じ「宿題の総量(葉の枚数)」で全体の点数をより効率よく上げられるが、特定の生徒(葉)だけを鍛えすぎると、その生徒がテストに特化した変な解き方を覚えてしまう(過学習)リスクも高くなる。

身近な例: num_leaves と max_depth の換算

num_leavesmax_depthはどちらも「複雑さの上限」だが単位が違う。完全に均等な木なら「深さ$d$ ⇔ 葉$2^d$枚」という関係が成り立つ(例: 深さ4なら葉16枚)。しかしleaf-wiseの木はいびつな形になるため、この関係は目安に過ぎない。LightGBM公式ドキュメントも「num_leaves2^(max_depth)より小さく設定するのが過学習対策として無難」と案内している。

もし数式を見たら

leaves = 2 ^ depth (均等な木の場合)

「深さが1増えるごとに、葉の枚数が2倍になる」という意味。leaf-wiseはこの「均等さ」を捨てて、効果の高い場所にリソースを集中投資する成長戦略だと理解しておく。

🏆 Kaggleでの実践的な使い方

よく使われるコンペカテゴリ: ☑ 表形式データ(Tabular) / ☐ 自然言語処理(NLP) / ☐ 画像認識(CV) / ☑ 時系列(Time Series)

数万〜数百万行、数百以上の特徴量を持つKaggleのTabularコンペでは、XGBoostよりLightGBMの方が学習が明確に高速なため、「まずLightGBMで大量の特徴量候補・パラメータを高速に試し、最終盤でXGBoost/CatBoostも加えてアンサンブルする」という使い分けが定番。また、categorical_featureパラメータで整数エンコードしたカテゴリ変数をそのまま渡せる点も、One-Hotで次元が爆発しがちな高カーディナリティ特徴量を扱う際に重宝される(詳細はDay094)。

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
num_leavesmax_depthを同じ感覚(両方6など)で設定してしまうどちらも「木の複雑さ」を制御するパラメータに見えるnum_leavesは葉の「枚数」、max_depthは「深さ」で単位が違う。目安としてnum_leaves < 2^max_depthにしないと深さの制限が実質無効化され過学習しやすい
n=200のような小さいデータでLightGBMの速度が体感できず戸惑う「LightGBM=常に速い」という単純化された理解高速化の仕組み(ヒストグラム分割・GOSS・EFB)は大規模データほど効果を発揮する。小規模データではオーバーヘッドが支配的で差が出にくい
early_stopping_roundslgb.trainの引数に直接渡してエラーになる古いバージョンのAPI例をそのまま真似してしまう現在のLightGBMではcallbacks=[lgb.early_stopping(n)]形式が標準。バージョンによってAPIが変わる点はXGBoost以上に注意が必要
検証用のlgb.Datasetreferenceを指定し忘れる訓練用と同じ書き方をそのまま流用してしまうreferenceを指定しないと訓練データとは別のbin分割で処理され、警告が出たり評価の一貫性が崩れたりする

🚀 次のステップ

  • 発展: boosting_type="dart" を試して、通常のGBDTと精度・学習時間がどう変わるか比較してみる
  • 次回予告: Day 094「LightGBM② 高度な使い方」 — DART・GOSS・カテゴリ変数のネイティブ対応など、LightGBM特有の高度な機能を学ぶ

📝 自己評価(解いた後に記入)

自分の回答・気づき・メモ: