Day 095 — CatBoost — Ordered Boosting・Ordered Target Statistics・対称木

2026-07-15 青 / Phase 4 理論→コーディング Ordered TS / symmetric tree / cat_features

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

🔵
Day 091-092 で XGBoost、Day 093-094 で LightGBM を学びました。今日は3大GBDTの最後、CatBoost(Categorical + Boosting)。「カテゴリ変数の扱い」と「小データでの過学習しにくさ」に特化し、ハイパラをほぼ触らなくても高スコアが出ることで有名です。
用語直感的な意味
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 が最も光る場面です。

使用データ高カーディナリティ district 追加 n=800
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連続

タスク

1
【理論】素朴な Target Encoding のリークを体感:district を全データ平均で数値化して線形回帰の特徴量にし、train R² が不自然に高くなることを確認。件数が少ないカテゴリほどリークが強い理由を説明する
2
【実装】layout/structure/district文字列のまま(category dtype も get_dummies もせずにcat_features=[...] を指定して CatBoostRegressor を学習する
3
【比較】XGBoost(One-Hot)・LightGBM(native cat)・CatBoost(Ordered TS)の 5-fold CV RMSE を表にまとめる
4
【理論】boosting_type="Ordered"(小データ向け)vs "Plain"(大データ向け・高速)の CV RMSE を比較する
5
【解釈】get_feature_importance()district の効きを確認し、LightGBM native と「高カーディナリティ列の扱いやすさ」を一言で比較する

🕳️ なぜ素朴な Target Encoding は危険か(リークの構造)

全データ平均でカテゴリを数値化すると、その行自身の目的変数を使って自分を予測してしまう。特にカテゴリの件数が少ないほど「自分の寄与」が支配的になり、train だけ好スコアで test は崩壊する。

素朴 TE(全データ平均) 自分 y も平均に含む 答えが特徴量に漏れる train↑ / test↓(過学習) Ordered TS(前の行だけ) 前の行のみで平均 自分 y は絶対に見ない リークなし(汎化する)
🛑
あるカテゴリに1行しかなければ、その行の TS は「自分の家賃そのもの」になり、答えを特徴量に埋め込んだ状態になる。CatBoost はこれを「順番+prior(全体平均への寄せ)」で自動回避する。

📊 3大GBDT 5-fold CV RMSE 比較(出力例・n=800)

乱数・環境で多少前後するが、高カーディナリティ列(district 30種)を含むこのデータでは CatBoost の Ordered TS が有利になりやすく、One-Hot は列数が膨れる。

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

XGBoost(One-Hot)
約1.2x
列数=38(膨張)
LightGBM(native cat)
約1.1x
列数=6
CatBoost(Ordered TS)
約1.0x
列数=6・高カーデ有利
💡
絶対値の勝ち負けはデータ次第。重要なのは「中身(Ordered TS・対称木)が違うから予測の傾向がずれる」こと。だから3本を平均するアンサンブルでスコアが伸びる。次テーマ以降の伏線。

💡 ヒント

ヒント1方向性

CatBoost の思想は一貫して「未来のデータ(自分を含む後ろの行)を見ずに、過去のデータだけで自分を数値化・予測する」。Target Encoding は本来リークの塊だが、CatBoost は「行に順番を付けて前の行だけ使う」でこれを安全にする。まずタスク1で素朴 TE の危険を train R² が不自然に高い形で体感し、そのうえで CatBoost が同じことを安全にやっていると理解するのが今日の軸。実装は文字列をそのまま渡して cat_features を指定するだけ

ヒント2アプローチ
  • インストール: pip install catboost(Colab は基本入っている)
  • リーク実演: df.groupby("district")["rent_man_yen"].transform("mean") で全体平均エンコード → 線形回帰 → train R² が異常に高い=リーク
  • CatBoost基本: from catboost import CatBoostRegressor, Poolcat_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)
ヒント3コード骨格
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")
🛑
なぜ危険か: カテゴリを「対応する目的変数の平均」で置換するのが Target Encoding。だが平均を計算する時にその行自身の目的変数も混ぜているのが問題。あるカテゴリに1行しかなければ district_te はその行の家賃そのものになり、「答えを特徴量に埋め込んだ」状態になる。件数が少ないほど自分の寄与が大きく、train では高精度・test では崩壊する。

2Ordered TS はこれを「順番」で防ぐ

🔑
なぜ安全か: CatBoost はデータにランダムな順序を割り当て、各行の数値化に「その行より前に並ぶ行だけ」を使う。自分と後ろの行は絶対に見ないので、答えを覗くのが構造的に不可能。件数が少ないカテゴリには prior(全体平均)を混ぜて滑らかにするため、1行しかないカテゴリでも暴走しない。手作業の out-of-fold TE を CatBoost が内部で自動化してくれる。

3文字列のまま渡せる(cat_features)

model = CatBoostRegressor(cat_features=["layout", "structure", "district"], ...)
model.fit(X_train, y_train)   # X_train は文字列カラムを含む DataFrame
🔑
なぜ楽か: LightGBM(Day094)は category dtype 化か整数化が必要だったが、CatBoost は文字列カラムをそのまま受け取り cat_features で指定するだけ。前処理コードが激減する。数値カラムはそのまま、カテゴリだけ列名(or 列インデックス)で伝える。

4Ordered Boosting と prediction shift

CatBoostRegressor(boosting_type="Ordered")  # 小データ向け(自動選択されやすい)
CatBoostRegressor(boosting_type="Plain")    # 大データ向け・高速
🔀
なぜ効くか: 通常の GBDT は残差を「その行を学習に使ったモデル」で計算するため、「自分で採点して自分を直す」微妙なバイアス(prediction shift)が出る。Ordered Boosting は各行の残差を「その行より前のデータだけで学習したモデル」で計算しこれを抑える。重いので小規模は自動で Ordered、大規模は高速な Plain を選ぶ。n=800 のような小データで恩恵が出やすい。

5対称木による正則化と高速推論

🌲
なぜ堅い・速いか: CatBoost の木は「深さごとに全ノードが同じ分割条件」を使う完全対称な木。自由に枝分かれできない分過学習しにくく(強い正則化)、推論時は各サンプルを1つのビット列に落とすだけなので予測が非常に速い。「ハイパラをいじらなくても堅い」CatBoost の理由の一つ。

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

📐
Ordered TS を直感で: クラスの席順を1列に並べ、各生徒の「所属クラブの平均成績」を出すとき、自分より前の席の同じクラブの人だけで平均を取る。自分や後ろの人の成績は使わない。こうすれば「自分の答えを見て自分を予測する」ズルが構造的にできない。CatBoost はこれを何通りもの並び順でやって安定させる。
🔀
prediction shift を直感で: 模試を「自分が作った解答例で自分の答案を採点」すると無意識に甘くなる。Ordered Boosting は「自分より前に受けた人の答案だけで作った採点基準」で自分を採点するので、身内びいき(バイアス)が減る。

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 のハイパラを自動探索する土台を作る

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

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