📚 背景知識(読んでから問題へ)
num_leaves)を学びました。今日は LightGBM が XGBoost に対して持つ「3つの武器」——DART・GOSS・カテゴリ変数のネイティブ対応——を実際に使います。| 用語 | 直感的な意味 |
|---|---|
| GBDT(デフォルト) | 毎ラウンド、これまでの予測誤差を新しい1本の木で埋める標準的な勾配ブースティング |
| DART(Dropout) | 学習途中で既存の木を一部ランダムに一時ドロップしてから新しい木を作る。過学習を抑えやすいがearly stopping と相性が悪い |
| GOSS(勾配ベースサンプリング) | 勾配が大きい(まだ苦手な)サンプルは全部残し、勾配が小さいサンプルは一部だけ使う高速化技法 |
| カテゴリのネイティブ対応 | 整数化 or category dtype の列を One-Hot に展開せずそのまま扱える。高カーディナリティで次元爆発を回避 |
categorical_feature | どの列をカテゴリとして扱うかを教える引数("auto" なら category dtype を自動検出) |
⚔️ 3つの武器の役割(精度 or 速度 or 表現力)
🎯 DART
Dropouts meet MART
既存の木を確率的にドロップして新しい木を作る。序盤の少数の木への過剰依存を防ぎ過学習を抑える。ただし遅く、early stopping 不可。
⚡ GOSS
Gradient-based One-Side Sampling
勾配が小さい(もう解けている)サンプルを間引く。精度をなるべく保ったまま学習を高速化。巨大データで真価を発揮。
🏷️ ネイティブ categorical
category dtype をそのまま
One-Hot 不要。カテゴリを損失最小の2グループに最適分割。高カーディナリティでも1列のまま扱える。
🎯 問題
Day 093 の家賃データにカテゴリ変数を2つ追加した拡張データ(n=600)を使います。
import numpy as np import pandas as pd np.random.seed(42) n = 600 # 高度機能の差を見るため多め 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) # --- カテゴリ変数を2つ追加 --- layout = np.random.choice(["1K", "1DK", "1LDK", "2LDK", "3LDK"], n, p=[0.30, 0.15, 0.25, 0.20, 0.10]) structure = np.random.choice(["木造", "鉄骨", "RC"], n, p=[0.45, 0.30, 0.25]) layout_effect = pd.Series(layout).map( {"1K": 0.0, "1DK": 1.0, "1LDK": 2.5, "2LDK": 4.0, "3LDK": 6.0}).values structure_effect = pd.Series(structure).map( {"木造": 0.0, "鉄骨": 0.8, "RC": 1.8}).values rent_man_yen = ( 3.0 + area_m2 * 0.20 - age_years * 0.05 - station_dist_min * 0.15 + layout_effect + structure_effect + 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, "layout": layout, "structure": structure, "rent_man_yen": rent_man_yen, })
| カラム名 | 意味 | 型 |
|---|---|---|
| area_m2 | 専有面積(平米) | float |
| age_years | 築年数 | int |
| station_dist_min | 最寄り駅からの徒歩時間(分) | float |
| layout | 間取り(1K〜3LDK) | category |
| structure | 建物構造(木造/鉄骨/RC) | category |
| rent_man_yen | 家賃(万円・目的変数) | float |
タスク
layout・structure を astype("category") に変換する(One-Hot はしない)train_test_split(test_size=0.2, random_state=42) で分割し、lgb.Dataset(..., categorical_feature="auto") でカテゴリが自動認識されるか確認するgbdt / dart / goss)を 5-fold lgb.cv で比較し、ベスト RMSE を表にするnum_boost_round を固定して回すpd.get_dummies の列数とネイティブの列数、RMSE を比べる📊 boosting_type 比較(出力例・5-fold CV, n=600)
乱数シードにより多少前後するが、この規模のデータでは3手法の RMSE は近く、DART はわずかに過学習抑制、GOSS は速度狙いという傾向を掴むのが目的。
CV ベスト RMSE(万円・小さいほど良い)
🏷️ ネイティブ categorical vs One-Hot(出力例)
特徴量の列数(少ないほどメモリ・速度で有利)
💡 ヒント
3つの boosting は役割が違う。DART は過学習を抑えて精度を狙う(遅く・early stopping が使いにくい)、GOSS は精度を保ったまま速くする方向。まず gbdt をベースラインにして、そこから DART・GOSS がどう動くかを相対的に見る。カテゴリ変数は「整数コードに変換 → LightGBM に "これはカテゴリだよ" と教える」だけで One-Hot なしに扱える。
- カテゴリ変換:
df["layout"] = df["layout"].astype("category")→categorical_feature="auto"が自動検出 - DART:
{"boosting_type": "dart", "drop_rate": 0.1}。early stopping は付けずnum_boost_roundを固定 - GOSS: 新しめの版では
{"boosting_type": "gbdt", "data_sample_strategy": "goss"}(boosting_type="goss"は非推奨) - CV:
lgb.cv(params, train_set, num_boost_round=200, nfold=5, stratified=False, seed=42)→ dict の"valid rmse-mean"(版により"rmse-mean")の最小値 - One-Hot 比較:
pd.get_dummies(X, columns=["layout","structure"]).shape[1]で列数を数える
import lightgbm as lgb from sklearn.model_selection import train_test_split from sklearn.metrics import mean_squared_error # 1. カテゴリ dtype 化 for c in ["layout", "structure"]: df[c] = df[c].astype("category") X = df[["area_m2", "age_years", "station_dist_min", "layout", "structure"]] 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, categorical_feature="auto") base = {"objective": "regression", "metric": "rmse", "learning_rate": 0.05, "num_leaves": 15, "verbose": -1, "seed": 42} configs = { "gbdt": {**base, "boosting_type": "gbdt"}, "dart": {**base, "boosting_type": "dart", "drop_rate": 0.1}, "goss": {**base, "boosting_type": "gbdt", "data_sample_strategy": "goss"}, } results = [] for name, params in configs.items(): cv = lgb.cv(params, train_set, num_boost_round=200, nfold=5, stratified=False, seed=42) key = "valid rmse-mean" if "valid rmse-mean" in cv else "rmse-mean" scores = cv[___] results.append({"boosting": name, "best_rmse": min(scores)}) print(pd.DataFrame(results)) # One-Hot との列数比較 onehot = pd.get_dummies(X, columns=["layout", "structure"]) print("native 列数:", X.shape[1], " / one-hot 列数:", onehot.shape[1])
✅ 模範解答
import numpy as np import pandas as pd import time import lightgbm as lgb from sklearn.model_selection import train_test_split from sklearn.metrics import mean_squared_error # --- データ準備(カテゴリ変数付き) --- np.random.seed(42) n = 600 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) layout = np.random.choice(["1K", "1DK", "1LDK", "2LDK", "3LDK"], n, p=[0.30, 0.15, 0.25, 0.20, 0.10]) structure = np.random.choice(["木造", "鉄骨", "RC"], n, p=[0.45, 0.30, 0.25]) layout_effect = pd.Series(layout).map( {"1K": 0.0, "1DK": 1.0, "1LDK": 2.5, "2LDK": 4.0, "3LDK": 6.0}).values structure_effect = pd.Series(structure).map( {"木造": 0.0, "鉄骨": 0.8, "RC": 1.8}).values rent_man_yen = ( 3.0 + area_m2 * 0.20 - age_years * 0.05 - station_dist_min * 0.15 + layout_effect + structure_effect + 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, "layout": layout, "structure": structure, "rent_man_yen": rent_man_yen, }) # --- 1. カテゴリ dtype に変換(One-Hot はしない) --- for c in ["layout", "structure"]: df[c] = df[c].astype("category") # --- 2. 分割 & Dataset作成(カテゴリ自動検出) --- X = df[["area_m2", "age_years", "station_dist_min", "layout", "structure"]] 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, categorical_feature="auto") base = { "objective": "regression", "metric": "rmse", "learning_rate": 0.05, "num_leaves": 15, "verbose": -1, "seed": 42, } # --- 3. 3種の boosting を CV で比較 --- configs = { "gbdt": {**base, "boosting_type": "gbdt"}, "dart": {**base, "boosting_type": "dart", "drop_rate": 0.1}, "goss": {**base, "boosting_type": "gbdt", "data_sample_strategy": "goss"}, } results = [] for name, params in configs.items(): t0 = time.perf_counter() cv = lgb.cv(params, train_set, num_boost_round=200, nfold=5, stratified=False, seed=42) elapsed = time.perf_counter() - t0 key = "valid rmse-mean" if "valid rmse-mean" in cv else "rmse-mean" scores = cv[key] results.append({ "boosting": name, "best_round": int(np.argmin(scores)) + 1, "best_rmse": round(min(scores), 4), "time_s": round(elapsed, 3), }) results_df = pd.DataFrame(results) print("--- boosting_type 比較(5-fold CV, n=600)---") print(results_df) # --- 4. DART の早期終了の注意(固定ラウンドで学習) --- dart_params = {**base, "boosting_type": "dart", "drop_rate": 0.1} dart_bst = lgb.train(dart_params, train_set, num_boost_round=200) # early_stoppingなし dart_pred = dart_bst.predict(X_test) # num_iteration指定しない(全木で予測) dart_rmse = mean_squared_error(y_test, dart_pred, squared=False) print(f"\nDART テストRMSE(200本固定): {dart_rmse:.4f} 万円") # --- 5. ネイティブ vs One-Hot の比較 --- gbdt_bst = lgb.train({**base, "boosting_type": "gbdt"}, train_set, num_boost_round=200) native_pred = gbdt_bst.predict(X_test) native_rmse = mean_squared_error(y_test, native_pred, squared=False) X_oh = pd.get_dummies(X, columns=["layout", "structure"]) Xtr_oh, Xte_oh, ytr, yte = train_test_split(X_oh, y, test_size=0.2, random_state=42) oh_set = lgb.Dataset(Xtr_oh, label=ytr) oh_bst = lgb.train({**base, "boosting_type": "gbdt"}, oh_set, num_boost_round=200) oh_pred = oh_bst.predict(Xte_oh) oh_rmse = mean_squared_error(yte, oh_pred, squared=False) print(f"\n--- ネイティブ categorical vs One-Hot ---") print(f"ネイティブ : 列数={X.shape[1]}, RMSE={native_rmse:.4f}") print(f"One-Hot : 列数={X_oh.shape[1]}, RMSE={oh_rmse:.4f}") # 出力例: # --- boosting_type 比較(5-fold CV, n=600)--- # boosting best_round best_rmse time_s # 0 gbdt 1xx 1.0x 0.xx # 1 dart 1xx 1.0x 0.xx ← 同等〜わずかに改善(過学習抑制) # 2 goss 1xx 1.0x 0.xx ← 精度ほぼ同等で高速化狙い # # DART テストRMSE(200本固定): 1.0x 万円 # # --- ネイティブ categorical vs One-Hot --- # ネイティブ : 列数=5, RMSE=1.0x # One-Hot : 列数=11, RMSE=1.0x ← 列数は増えるが精度はほぼ同じ
🪜 Step-by-Step 解説
1pandas の category dtype に変換する
for c in ["layout", "structure"]: df[c] = df[c].astype("category")
category dtype にしておけば categorical_feature="auto" が「この列はカテゴリだ」と自動認識し、内部で整数コードに変換してネイティブ処理する。LabelEncoder を自分で回す必要すらない。2One-Hot に頼らずカテゴリを扱う意味
3DART は「作った木を一部ドロップして」学習する
"boosting_type": "dart", "drop_rate": 0.1
drop_rate で一時無効化するので、特定の木への過剰依存が減り過学習を抑えやすい。ニューラルネットの Dropout と同じ発想。4DART では early stopping を使わない
dart_bst = lgb.train(dart_params, train_set, num_boost_round=200) # early_stoppingなし dart_pred = dart_bst.predict(X_test) # num_iteration を指定しない
best_iteration の一部だけで予測すると正規化が中途半端になり精度が出ない。DART は num_boost_round を固定し、全ラウンドで予測するのが基本。5GOSS は「勾配の小さいサンプルを間引いて」速くする
"boosting_type": "gbdt", "data_sample_strategy": "goss"
boosting_type="goss" は非推奨で data_sample_strategy="goss" を使う。🧮 数学・統計の補足(文系向け)
カテゴリの最適分割(数式を見たら)
目的変数の平均でカテゴリを並べ替え → 最良の切れ目を選ぶ
LightGBM は各カテゴリの「目的変数の平均」で一列に並べ替え、連続値と同じく「どこで切ると損失が最も減るか」を探す。これで 2^(k-1) - 1 通りの分け方を k 個の候補に減らして高速に最適分割を見つける(k = カテゴリ数)。
🏆 Kaggleでの実践的な使い方
よく使われるコンペカテゴリ: ☑ 表形式データ(Tabular) / ☐ 自然言語処理(NLP) / ☐ 画像認識(CV) / ☑ 時系列(Time Series)
Tabular コンペでは category dtype にした列をそのまま LightGBM に渡すのが定番の第一手。都市名・商品ID・カテゴリコードなど高カーディナリティ列を One-Hot で展開すると数千〜数万列になりメモリと学習時間が爆発するが、ネイティブなら1列のまま。DART は「CV は伸びるが学習が重い」ため、終盤にスコアを一押しする隠し玉として gbdt モデルとアンサンブルする形でよく使われる。GOSS は巨大データで学習を回し切るための高速化オプション。
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
DART に early stopping を付けて best_iteration で予測する | gbdt と同じ書き方を流用する | DART は木を落として正規化するため early stopping の前提が崩れる。num_boost_round を固定し全木で予測する |
カテゴリを文字列のまま lgb.Dataset に渡してエラー | 「ネイティブ対応」を「文字列もOK」と誤解 | 文字列は不可。category dtype にするか整数コードに変換して categorical_feature で指定する |
boosting_type="goss" と書いて警告が出る | 古い記事の書き方を真似する | 新しめの版では data_sample_strategy="goss"(boosting は gbdt のまま)が推奨 |
| 高カーディナリティ列をそのままネイティブで渡し過学習する | 「ネイティブ=万能」と思い込む | カテゴリ数が多いと木がカテゴリごとに覚え込みやすい。min_data_per_group・cat_smooth・max_cat_threshold で制御する |
| train と test でカテゴリの水準がズレて予測がおかしくなる | 分割後に別々に astype("category") する | 変換は分割前に df 全体で行い、train/test で同じカテゴリ辞書を共有する |
🚀 次のステップ
- 発展:
drop_rateを[0.05, 0.1, 0.2]で変えて DART の CV RMSE と学習時間の動きを見る。max_cat_threshold・cat_smoothでカテゴリ分割の挙動も観察 - 次回予告: Day 095「CatBoost」 — Ordered Boosting とカテゴリ処理の強さ。3大GBDT(XGBoost/LightGBM/CatBoost)の使い分けを整理する
📝 自己評価(解いた後に記入)
自分の回答・気づき・メモ: