Day 094 — LightGBM② 高度な使い方 — DART・GOSS・カテゴリ変数のネイティブ対応

2026-07-14 青 / Phase 4 コーディング dart / goss / categorical

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

🔵
Day 093 で LightGBM の基本(leaf-wise 成長・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)を使います。

使用データカテゴリ変数付き 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

タスク

1
layoutstructureastype("category") に変換する(One-Hot はしない
2
train_test_split(test_size=0.2, random_state=42) で分割し、lgb.Dataset(..., categorical_feature="auto") でカテゴリが自動認識されるか確認する
3
3種の boostinggbdt / dart / goss)を 5-fold lgb.cv で比較し、ベスト RMSE を表にする
4
DART の早期終了の注意を確認する:early stopping を付けず num_boost_round を固定して回す
5
ネイティブ categorical vs One-Hot を比較する:pd.get_dummies の列数とネイティブの列数、RMSE を比べる

📊 boosting_type 比較(出力例・5-fold CV, n=600)

乱数シードにより多少前後するが、この規模のデータでは3手法の RMSE は近く、DART はわずかに過学習抑制、GOSS は速度狙いという傾向を掴むのが目的。

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

gbdt(ベースライン)
約1.0x
標準
dart(Dropout)
約1.0x
同等〜わずかに改善
goss(サンプリング)
約1.0x
精度ほぼ同・高速化狙い
💡
DART の真価は過学習しやすい大きめモデルで出る。GOSS の高速化は数十万行以上で効いてくる。小〜中規模データでは精度差が小さくても、「どう動くか」を先に体で覚えておくのが狙い。

🏷️ ネイティブ categorical vs One-Hot(出力例)

特徴量の列数(少ないほどメモリ・速度で有利)

ネイティブ(category)
5 列
layout/structure=各1列
One-Hot(get_dummies)
11 列
5+3カテゴリで展開
⚠️
今回は5+3カテゴリなので11列で済むが、郵便番号・商品IDのような高カーディナリティ列では One-Hot が数千列に膨れ、メモリと学習時間が爆発する。ネイティブなら列数は1のまま——これが LightGBM の大きな実務的アドバンテージ。RMSE 自体はこの規模ではほぼ同等。

💡 ヒント

ヒント1方向性

3つの boosting は役割が違う。DART は過学習を抑えて精度を狙う(遅く・early stopping が使いにくい)、GOSS は精度を保ったまま速くする方向。まず gbdt をベースラインにして、そこから DART・GOSS がどう動くかを相対的に見る。カテゴリ変数は「整数コードに変換 → LightGBM に "これはカテゴリだよ" と教える」だけで One-Hot なしに扱える。

ヒント2アプローチ
  • カテゴリ変換: 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] で列数を数える
ヒント3コード骨格
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")
🔑
なぜこうするか: LightGBM は文字列のままではカテゴリを扱えない。だが category dtype にしておけば categorical_feature="auto" が「この列はカテゴリだ」と自動認識し、内部で整数コードに変換してネイティブ処理する。LabelEncoder を自分で回す必要すらない。

2One-Hot に頼らずカテゴリを扱う意味

🔑
LightGBM はカテゴリ専用の分割(多数のカテゴリを損失最小の2グループに振り分ける)を使う。One-Hot は「1K か否か」の1軸しか作れないが、ネイティブなら「{1K, 1DK} vs {1LDK, 2LDK, 3LDK}」のようなまとまった分割を1回で作れる。高カーディナリティ(郵便番号1000種など)では One-Hot が1000列に膨れる一方、ネイティブは1列のまま。

3DART は「作った木を一部ドロップして」学習する

"boosting_type": "dart", "drop_rate": 0.1
🔑
なぜこうするか: 通常の GBDT は「後半の木が序盤の少数の木に強く依存」しがち。DART は新しい木を作る前に既存の木を確率 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 を指定しない
🛑
なぜこうするか: DART は木を落として残りを正規化し直すため、「途中で評価が最良=そこで止めれば良い」という early stopping の前提が崩れる。best_iteration の一部だけで予測すると正規化が中途半端になり精度が出ない。DART は num_boost_round を固定し、全ラウンドで予測するのが基本。

5GOSS は「勾配の小さいサンプルを間引いて」速くする

"boosting_type": "gbdt", "data_sample_strategy": "goss"
なぜこうするか: すでに上手く予測できているサンプル(勾配が小さい)は次の木への情報量が少ないので一部だけ使う。勾配が大きい(まだ苦手な)サンプルは全部残すため重要情報を失いにくく、精度を保ったまま高速化できる。新しめの版では boosting_type="goss" は非推奨で data_sample_strategy="goss" を使う。

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

📐
DART を直感で: いつも同じ数人のエース(序盤の強い木)に頼りきると、その子が休んだ日に総崩れになる。DART は「たまにエースを1日休ませて、他のメンバーだけで組み立てる練習」をさせるようなもの。特定メンバーへの依存が減り、チーム全体(モデル)が頑丈になる。
GOSS を直感で: テスト対策で「もう完璧に解ける問題」を全部やり直すのは時間の無駄。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_groupcat_smoothmax_cat_threshold で制御する
train と test でカテゴリの水準がズレて予測がおかしくなる分割後に別々に astype("category") する変換は分割前に df 全体で行い、train/test で同じカテゴリ辞書を共有する

🚀 次のステップ

  • 発展: drop_rate[0.05, 0.1, 0.2] で変えて DART の CV RMSE と学習時間の動きを見る。max_cat_thresholdcat_smooth でカテゴリ分割の挙動も観察
  • 次回予告: Day 095「CatBoost」 — Ordered Boosting とカテゴリ処理の強さ。3大GBDT(XGBoost/LightGBM/CatBoost)の使い分けを整理する

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

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