📚 背景知識(読んでから問題へ)
| 用語 | 直感的な意味 |
|---|---|
| level-wise(XGBoostのデフォルト) | 同じ深さの階層を横一列に全部伸ばしてから、次の階層に進む。バランスの取れた形の木になる |
| leaf-wise(LightGBMのデフォルト) | 「損失を最も減らせる葉」を1つ選んで、そこだけ優先的に伸ばす。形はいびつになりやすいが、同じ葉の枚数でより損失を減らせる |
| num_leaves | leaf-wise成長でのメインの複雑さコントロール。XGBoostのmax_depthに相当する役割だが「深さ」ではなく「葉の枚数」で直接制御する |
| ヒストグラムベースの分割探索 | 連続値をあらかじめ有限個のbinに離散化し、bin境界だけを分割候補にすることで探索を高速化する仕組み |
lgb.Dataset | LightGBM専用のデータ構造。XGBoostのDMatrixに相当 |
🌳 木の育て方の違い(同じ葉4枚でも形が変わる)
level-wise(XGBoost)
すべての枝を同じ深さまで揃えて伸ばす。バランスは良いが、効果の薄い枝にも同じだけ計算コストを使う
leaf-wise(LightGBM)
「一番損失を減らせる葉」だけを優先的に伸ばす。同じ葉4枚でも形はいびつ。効率は良いが過学習しやすい
num_leavesでもLightGBMの方が過学習しやすい」と言われる理由——目安として num_leaves < 2^max_depth にするのが安全。🎯 問題
Day 091-092 と同じ「賃貸物件の家賃予測」データを使います。同じ train/test 分割にすることで、XGBoostとLightGBMの結果を直接比較できるようにします。
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 |
タスク
train_test_split(test_size=0.2, random_state=42) で分割する(Day 091と全く同じ分割になる)lgb.Dataset に変換するlearning_rate=0.1, num_leaves=15 で lgb.train(early stopping 20ラウンド)を実行し、best_iteration を確認するtime.perf_counter() でXGBoostとLightGBMの学習時間を計測・比較し、n=200では差がほぼ出ない理由を考えるnum_leaves を [7, 15, 31, 63] で変えて lgb.cv を実行し、ベストな値と大きくしすぎた時に何が起きるかを確認する📊 XGBoost vs LightGBM(出力例・同一データ n=200)
乱数シードにより多少前後するが、n=200という小さいデータでは精度・速度ともに大差がつきにくいことを実測で確認する。
テストRMSE(万円・小さいほど良い)
学習時間(ms・小さいほど良い)
📉 num_leaves を大きくすると何が起きるか(出力例)
num_leavesを63まで上げると、1本の木がデータの細部(ノイズ)まで覚え込み、CVスコアがはっきり悪化する。leaf-wiseは「効きやすい分、過学習にも直結しやすい」ことが数値で確認できる。💡 ヒント
XGBoostのmax_depthは「深さ」で複雑さを制限するが、LightGBMのnum_leavesは「葉の枚数」で直接制限する。leaf-wise成長は「一番効果のある場所」を優先的に伸ばすため、同じ計算量(同じ葉の枚数)でlevel-wiseより損失を減らせるが、その分「無制限に伸ばすとあっという間に過学習する」性質も強く持つ。num_leavesを大きくしすぎた時にCVスコアがどう動くかに注目する。
- 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
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)
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)])
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)
random_state=42を揃えることで、全く同じテストデータに対するRMSEを直接比較できる。異なる分割で比較すると「モデルの差」なのか「たまたまテストデータの難易度差」なのか区別できない。4学習時間を計測し「n=200では差が出ない」ことを確認する
t0 = time.perf_counter()
# ... 学習 ...
elapsed = time.perf_counter() - t0
5num_leaves のグリッド探索で「leaf-wiseの危うさ」を数値で見る
for nl in [7, 15, 31, 63]: p = {**params, "num_leaves": nl} cv_results = lgb.cv(p, train_set, ...)
num_leavesを大きくするとlevel-wise(max_depthを増やす場合)よりも急激に木が複雑になりやすい。n=200という少ないデータでは、この過学習がCVスコアの悪化としてはっきり現れる。🧮 数学・統計の補足(文系向け)
身近な例: num_leaves と max_depth の換算
num_leavesとmax_depthはどちらも「複雑さの上限」だが単位が違う。完全に均等な木なら「深さ$d$ ⇔ 葉$2^d$枚」という関係が成り立つ(例: 深さ4なら葉16枚)。しかしleaf-wiseの木はいびつな形になるため、この関係は目安に過ぎない。LightGBM公式ドキュメントも「num_leavesは2^(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_leavesとmax_depthを同じ感覚(両方6など)で設定してしまう | どちらも「木の複雑さ」を制御するパラメータに見える | num_leavesは葉の「枚数」、max_depthは「深さ」で単位が違う。目安としてnum_leaves < 2^max_depthにしないと深さの制限が実質無効化され過学習しやすい |
| n=200のような小さいデータでLightGBMの速度が体感できず戸惑う | 「LightGBM=常に速い」という単純化された理解 | 高速化の仕組み(ヒストグラム分割・GOSS・EFB)は大規模データほど効果を発揮する。小規模データではオーバーヘッドが支配的で差が出にくい |
early_stopping_roundsをlgb.trainの引数に直接渡してエラーになる | 古いバージョンのAPI例をそのまま真似してしまう | 現在のLightGBMではcallbacks=[lgb.early_stopping(n)]形式が標準。バージョンによってAPIが変わる点はXGBoost以上に注意が必要 |
検証用のlgb.Datasetにreferenceを指定し忘れる | 訓練用と同じ書き方をそのまま流用してしまう | referenceを指定しないと訓練データとは別のbin分割で処理され、警告が出たり評価の一貫性が崩れたりする |
🚀 次のステップ
- 発展:
boosting_type="dart"を試して、通常のGBDTと精度・学習時間がどう変わるか比較してみる - 次回予告: Day 094「LightGBM② 高度な使い方」 — DART・GOSS・カテゴリ変数のネイティブ対応など、LightGBM特有の高度な機能を学ぶ
📝 自己評価(解いた後に記入)
自分の回答・気づき・メモ: