📚 背景知識(読んでから問題へ)
| 用語 | 直感的な意味 |
|---|---|
| Target Statistics(TS) | カテゴリを「そのカテゴリに属する行の目的変数の平均」で数値化(=Target Encoding)。強力だが素朴にやると激しいリークを起こす |
| Ordered Target Statistics | データに人工的な順序を与え、各行の数値化にはその行より前の行だけを使う。将来の情報を見ないのでリークを構造的に防ぐ |
| Ordered Boosting | 同じ発想を勾配計算にも適用。各行の残差を「その行より前のデータだけで学習したモデル」で計算し、prediction shift(自分で自分を採点するバイアス)を抑える |
| Symmetric Tree(対称木) | 同じ深さのノードが全部同じ分割条件を使う左右対称の木。表現力は制限されるが正則化が強く・推論が非常に速い |
cat_features | どの列をカテゴリとして扱うかを渡す引数。文字列のまま渡してよい(LightGBMはcategory dtype/整数化が必要だった) |
🐱 CatBoost を支える3本柱
🏷️ Ordered TS
安全な Target Encoding
各行の数値化に「前の行だけ」を使い、答えを覗き見できない構造にする。高カーディナリティ列で無双。
🔀 Ordered Boosting
prediction shift 対策
残差を「前のデータだけで学習したモデル」で計算。小〜中データで効き、デフォルトで自動選択。
🌲 対称木
oblivious tree
深さごとに同じ分割を使う対称構造。強い正則化+各行をビット列に落とせるので推論が超高速。
🎯 問題
Day 094 の家賃データに高カーディナリティのカテゴリ変数 district(エリア名・30種類)を追加した拡張データ(n=800)を使います。高カーディナリティ列こそ CatBoost の Ordered TS が最も光る場面です。
import numpy as np import pandas as pd np.random.seed(42) n = 800 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]) # --- 高カーディナリティのカテゴリ変数(エリア名 30種類) --- districts = [f"area_{i:02d}" for i in range(30)] district = np.random.choice(districts, n) district_base = {d: v for d, v in zip(districts, np.random.uniform(0, 5, 30))} 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 district_effect = pd.Series(district).map(district_base).values rent_man_yen = ( 3.0 + area_m2 * 0.20 - age_years * 0.05 - station_dist_min * 0.15 + layout_effect + structure_effect + district_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, "district": district, "rent_man_yen": rent_man_yen, })
| カラム名 | 意味 | 型 | カーディナリティ |
|---|---|---|---|
| area_m2 | 専有面積(平米) | float | 連続 |
| age_years | 築年数 | int | 連続 |
| station_dist_min | 最寄り駅からの徒歩時間(分) | float | 連続 |
| layout | 間取り(1K〜3LDK) | category | 低(5種) |
| structure | 建物構造(木造/鉄骨/RC) | category | 低(3種) |
| district | エリア名 | category | 高(30種) |
| rent_man_yen | 家賃(万円・目的変数) | float | 連続 |
タスク
district を全データ平均で数値化して線形回帰の特徴量にし、train R² が不自然に高くなることを確認。件数が少ないカテゴリほどリークが強い理由を説明するlayout/structure/district を文字列のまま(category dtype も get_dummies もせずに)cat_features=[...] を指定して CatBoostRegressor を学習するboosting_type="Ordered"(小データ向け)vs "Plain"(大データ向け・高速)の CV RMSE を比較するget_feature_importance() で district の効きを確認し、LightGBM native と「高カーディナリティ列の扱いやすさ」を一言で比較する🕳️ なぜ素朴な Target Encoding は危険か(リークの構造)
全データ平均でカテゴリを数値化すると、その行自身の目的変数を使って自分を予測してしまう。特にカテゴリの件数が少ないほど「自分の寄与」が支配的になり、train だけ好スコアで test は崩壊する。
📊 3大GBDT 5-fold CV RMSE 比較(出力例・n=800)
乱数・環境で多少前後するが、高カーディナリティ列(district 30種)を含むこのデータでは CatBoost の Ordered TS が有利になりやすく、One-Hot は列数が膨れる。
CV ベスト RMSE(万円・小さいほど良い)
💡 ヒント
CatBoost の思想は一貫して「未来のデータ(自分を含む後ろの行)を見ずに、過去のデータだけで自分を数値化・予測する」。Target Encoding は本来リークの塊だが、CatBoost は「行に順番を付けて前の行だけ使う」でこれを安全にする。まずタスク1で素朴 TE の危険を train R² が不自然に高い形で体感し、そのうえで CatBoost が同じことを安全にやっていると理解するのが今日の軸。実装は文字列をそのまま渡して cat_features を指定するだけ。
- インストール:
pip install catboost(Colab は基本入っている) - リーク実演:
df.groupby("district")["rent_man_yen"].transform("mean")で全体平均エンコード → 線形回帰 → train R² が異常に高い=リーク - CatBoost基本:
from catboost import CatBoostRegressor, Pool。cat_featuresは列名リストか列インデックス。文字列カテゴリOK - 静かに学習:
CatBoostRegressor(verbose=0, random_state=42) - boosting_type:
"Ordered"vs"Plain"。RMSE はmean_squared_error(..., squared=False) - 重要度:
model.get_feature_importance(prettified=True)
import numpy as np import pandas as pd from catboost import CatBoostRegressor, Pool from sklearn.model_selection import train_test_split, KFold from sklearn.linear_model import LinearRegression from sklearn.metrics import mean_squared_error, r2_score cat_cols = ["layout", "structure", "district"] num_cols = ["area_m2", "age_years", "station_dist_min"] X = df[num_cols + cat_cols] y = df["rent_man_yen"] # --- タスク1: 素朴な Target Encoding のリーク実演 --- leaky = df.copy() leaky["district_te"] = leaky.groupby("district")["rent_man_yen"].transform("mean") # ★全データ平均=リーク lin = LinearRegression().fit(leaky[["district_te"]], y) print("リークTEのtrain R²:", r2_score(y, lin.predict(leaky[["district_te"]]))) # --- タスク2: CatBoost(文字列のまま) --- X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42) model = CatBoostRegressor(iterations=___, learning_rate=0.05, depth=6, cat_features=cat_cols, random_state=42, verbose=0) model.fit(X_train, y_train) pred = model.predict(X_test) print("CatBoost test RMSE:", mean_squared_error(y_test, pred, squared=False))
✅ 模範解答
import numpy as np import pandas as pd from catboost import CatBoostRegressor, Pool from sklearn.model_selection import train_test_split, KFold from sklearn.linear_model import LinearRegression from sklearn.metrics import mean_squared_error, r2_score # ====== データ準備(高カーディナリティ district を追加) ====== np.random.seed(42) n = 800 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]) districts = [f"area_{i:02d}" for i in range(30)] district = np.random.choice(districts, n) district_base = {d: v for d, v in zip(districts, np.random.uniform(0, 5, 30))} 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 district_effect = pd.Series(district).map(district_base).values rent_man_yen = ( 3.0 + area_m2 * 0.20 - age_years * 0.05 - station_dist_min * 0.15 + layout_effect + structure_effect + district_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, "district": district, "rent_man_yen": rent_man_yen, }) cat_cols = ["layout", "structure", "district"] num_cols = ["area_m2", "age_years", "station_dist_min"] X = df[num_cols + cat_cols] y = df["rent_man_yen"] # ====== タスク1: 素朴な Target Encoding のリークを体感 ====== leaky = df.copy() # ★全データの目的変数平均でエンコード → 自分の家賃を含めて平均している=リーク leaky["district_te"] = leaky.groupby("district")["rent_man_yen"].transform("mean") lin = LinearRegression().fit(leaky[["district_te"]], y) leak_r2 = r2_score(y, lin.predict(leaky[["district_te"]])) counts = df["district"].value_counts() print("--- タスク1: 素朴Target Encodingのリーク ---") print(f"district_te 単独の train R²: {leak_r2:.3f}") print(f"1カテゴリあたり平均件数: {counts.mean():.1f} 行") # ====== タスク2: CatBoost を「文字列のまま」学習 ====== X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42) model = CatBoostRegressor( iterations=400, learning_rate=0.05, depth=6, cat_features=cat_cols, # ← 文字列のまま渡してOK。One-Hotもcategory dtypeも不要 random_state=42, verbose=0, ) model.fit(X_train, y_train) cb_pred = model.predict(X_test) cb_rmse = mean_squared_error(y_test, cb_pred, squared=False) print(f"\nCatBoost test RMSE: {cb_rmse:.4f} 万円") # ====== タスク3: 3大GBDT の 5-fold CV RMSE 比較 ====== import xgboost as xgb import lightgbm as lgb kf = KFold(n_splits=5, shuffle=True, random_state=42) X_oh = pd.get_dummies(X, columns=cat_cols) # XGBoost: One-Hot xgb_rmses = [] for tr, va in kf.split(X_oh): m = xgb.XGBRegressor(n_estimators=400, learning_rate=0.05, max_depth=6, random_state=42, verbosity=0) m.fit(X_oh.iloc[tr], y.iloc[tr]) xgb_rmses.append(mean_squared_error(y.iloc[va], m.predict(X_oh.iloc[va]), squared=False)) X_lgb = X.copy() # LightGBM: native categorical for c in cat_cols: X_lgb[c] = X_lgb[c].astype("category") lgb_rmses = [] for tr, va in kf.split(X_lgb): m = lgb.LGBMRegressor(n_estimators=400, learning_rate=0.05, num_leaves=31, random_state=42, verbose=-1) m.fit(X_lgb.iloc[tr], y.iloc[tr], categorical_feature=cat_cols) lgb_rmses.append(mean_squared_error(y.iloc[va], m.predict(X_lgb.iloc[va]), squared=False)) cb_rmses = [] # CatBoost: Ordered TS for tr, va in kf.split(X): m = CatBoostRegressor(iterations=400, learning_rate=0.05, depth=6, cat_features=cat_cols, random_state=42, verbose=0) m.fit(X.iloc[tr], y.iloc[tr]) cb_rmses.append(mean_squared_error(y.iloc[va], m.predict(X.iloc[va]), squared=False)) compare = pd.DataFrame({ "model": ["XGBoost (One-Hot)", "LightGBM (native cat)", "CatBoost (Ordered TS)"], "cv_rmse_mean": [np.mean(xgb_rmses), np.mean(lgb_rmses), np.mean(cb_rmses)], "cv_rmse_std": [np.std(xgb_rmses), np.std(lgb_rmses), np.std(cb_rmses)], "feat_cols": [X_oh.shape[1], X_lgb.shape[1], X.shape[1]], }).round(4) print("\n--- タスク3: 3大GBDT 5-fold CV RMSE 比較 ---") print(compare) # ====== タスク4: Ordered vs Plain boosting ====== def cb_cv_rmse(boosting_type): rmses = [] for tr, va in kf.split(X): m = CatBoostRegressor(iterations=400, learning_rate=0.05, depth=6, boosting_type=boosting_type, cat_features=cat_cols, random_state=42, verbose=0) m.fit(X.iloc[tr], y.iloc[tr]) rmses.append(mean_squared_error(y.iloc[va], m.predict(X.iloc[va]), squared=False)) return np.mean(rmses) print("\n--- タスク4: Ordered vs Plain(n=800)---") print(f"Ordered: CV RMSE = {cb_cv_rmse('Ordered'):.4f} (小データで過学習しにくい)") print(f"Plain : CV RMSE = {cb_cv_rmse('Plain'):.4f} (大データ向け・高速)") # ====== タスク5: 特徴量重要度 ====== full = CatBoostRegressor(iterations=400, learning_rate=0.05, depth=6, cat_features=cat_cols, random_state=42, verbose=0) full.fit(X, y) print("\n--- タスク5: CatBoost 特徴量重要度 ---") print(full.get_feature_importance(prettified=True)) # 出力例(乱数・環境で多少前後): # district_te 単独の train R²: 0.4x 1カテゴリあたり平均件数: 26.7 行 # CatBoost test RMSE: 1.0x 万円 # --- 3大GBDT 5-fold CV RMSE 比較 --- # model cv_rmse_mean cv_rmse_std feat_cols # 0 XGBoost (One-Hot) 1.2x 0.0x 38 # 1 LightGBM (native cat) 1.1x 0.0x 6 # 2 CatBoost (Ordered TS) 1.0x 0.0x 6 ← 高カーデで有利
🪜 Step-by-Step 解説
1なぜ素朴な Target Encoding はリークするのか
leaky["district_te"] = leaky.groupby("district")["rent_man_yen"].transform("mean")
district_te はその行の家賃そのものになり、「答えを特徴量に埋め込んだ」状態になる。件数が少ないほど自分の寄与が大きく、train では高精度・test では崩壊する。2Ordered TS はこれを「順番」で防ぐ
3文字列のまま渡せる(cat_features)
model = CatBoostRegressor(cat_features=["layout", "structure", "district"], ...) model.fit(X_train, y_train) # X_train は文字列カラムを含む DataFrame
category dtype 化か整数化が必要だったが、CatBoost は文字列カラムをそのまま受け取り cat_features で指定するだけ。前処理コードが激減する。数値カラムはそのまま、カテゴリだけ列名(or 列インデックス)で伝える。4Ordered Boosting と prediction shift
CatBoostRegressor(boosting_type="Ordered") # 小データ向け(自動選択されやすい) CatBoostRegressor(boosting_type="Plain") # 大データ向け・高速
5対称木による正則化と高速推論
🧮 数学・統計の補足(文系向け)
Target Statistics の数式を見たら
x̂ₖ = ( Σ前の行 j [xⱼ=xₖ]·yⱼ + a·p ) / ( Σ前の行 j [xⱼ=xₖ] + a )
「そのカテゴリに属する(前の行の)目的変数の平均を、件数が少ないときは全体平均 p(prior)に寄せる」だけ。a は寄せ具合のスムージング係数、[xⱼ=xₖ] は「同じカテゴリなら1」の印。分母の「前の行だけ」がリークを防ぐ肝。
🏆 Kaggleでの実践的な使い方
よく使われるコンペカテゴリ: ☑ 表形式データ(Tabular) / ☐ 自然言語処理(NLP) / ☐ 画像認識(CV) / ☑ 時系列(Time Series)
CatBoost は Tabular コンペの鉄板アンサンブル要員。理由は「XGBoost/LightGBM とは中身(Ordered TS・対称木)が違い、予測の傾向がずれるので、平均するとスコアが上がりやすい」から。使い分けの目安:
- 高カーディナリティのカテゴリが多い(ユーザーID・商品ID・地域名)→ CatBoost が第一候補。手作業 TE は out-of-fold 設計を誤るとリークするが、CatBoost は内部で安全にやる
- とりあえずベースラインを堅く → デフォルトでも高精度なので初手向き
- 最終アンサンブル → LightGBM(速い・軽い)+ CatBoost(カテゴリ強い)+ XGBoost の3本平均が定番
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
| カテゴリを One-Hot / LabelEncoder してから CatBoost に渡す | 他ライブラリの癖を持ち込む | 文字列のまま cat_features で指定するのが正解。自前エンコードは Ordered TS の恩恵を捨てる |
cat_features の指定を忘れる | fit するだけで動いてしまう | 忘れるとカテゴリが数値扱いされ精度が落ちる。列名リストか列インデックスで必ず渡す |
| 自前 Target Encoding を全データ平均で作りリークさせる | 「平均で置換」の危険性を知らない | 必ず out-of-fold で作る。または CatBoost の Ordered TS に任せる |
| CatBoost は遅いから使わない | Ordered Boosting が重い印象 | 学習は重めだが推論は対称木で非常に速い。大データは自動で Plain。GPU も使える |
cat_features に float 列を混ぜてエラー | 数値カラムまで指定する | カテゴリ列だけ指定する。NaN を含むカテゴリは文字列化(例 "NA")してから渡す |
🚀 次のステップ
- 発展:
districtの一部カテゴリをわざと件数1〜2件に減らし、素朴 TE の test RMSE が崩壊する一方で CatBoost が耐えることを確認する。one_hot_max_size(この値以下のカテゴリ数なら One-Hot に切り替える閾値)の挙動も観察 - 次回予告: Day 096「Optuna 基礎」 — Study / Trial / パラメータ空間の定義。XGBoost / LightGBM / CatBoost のハイパラを自動探索する土台を作る
📝 自己評価(解いた後に記入)
自分の回答・気づき・メモ: