📚 背景知識(読んでから問題へ)
🎯
Day 076 — Phase 3 継続: EDA第8弾は Target Encoding の深掘り。Day 075 では4手法を概観したが、今日は Target Encoding に絞って「ナイーブ実装→リーク発生→OOF で解決→Smoothing で安定化」の完全な流れを実装する。
Target Encoding とは?
カテゴリ変数の各値を、そのカテゴリに属するサンプルの目的変数の平均値で置き換える手法です。
House Prices の Neighborhood(住宅地区名、25種類)で考えると:
| Neighborhood | 件数 | SalePrice 平均 | エンコード後の値 |
|---|---|---|---|
| NridgHt | 225件 | $316,000 | 316,000 |
| StoneBr | 25件 | $310,000 | 310,000 |
| NAmes | 225件 | $145,000 | 145,000 |
| MeadowV | 37件 | $98,000 | 98,000 |
| Landmrk | 1件 | $175,000 | → 危険!過学習 |
なぜ Target Encoding が強力か?
- 次元爆発なし: One-Hot の 25 列 → 1 列
- 意味のある数値: 「NridgHt は高い地区」という情報が数値に含まれる
- 高カーディナリティに強い: 100 種類あっても 1 列のまま
- GBDT との相性◎: LightGBM + Target Encoding は Kaggle の定番組み合わせ
⚖️ Target Encoding 3手法の比較
| 手法 | リーク | 少数カテゴリ | 実装難易度 | 推奨 |
|---|---|---|---|---|
| ナイーブ TE 全データで平均 |
あり (必ず) | 過学習 | 簡単 | 本番NG |
| OOF TE fold ごとに計算 |
なし | やや不安定 | 普通 | 最低限OK |
| OOF TE + Smoothing グローバル平均で引き寄せ |
なし | 安定 | 普通 | 推奨 |
⚠️ データリーク発生の仕組み
💥
ナイーブ実装の問題:
groupby().transform('mean') は自分自身のサンプルを含む平均を計算する。特にレア(1件のみ)なカテゴリでは 100% リークになる。Landmrk(1件のみ)でのリーク例
🔄 OOF (Out-of-Fold) 方式の図解
5-Fold の場合、各 fold ごとに「その fold を除いた残り 4 fold の平均」を使ってエンコードする。
Fold 1 の処理
Fold 1
検証
検証
Fold 2
学習
学習
Fold 3
学習
学習
Fold 4
学習
学習
Fold 5
学習
学習
Fold2〜5 の平均 → Fold 1 に適用
Fold 2 の処理
Fold 1
学習
学習
Fold 2
検証
検証
Fold 3
学習
学習
Fold 4
学習
学習
Fold 5
学習
学習
Fold1,3,4,5 の平均 → Fold 2 に適用
✓
各サンプルは「自分自身が含まれない」平均でエンコードされる → リークゼロ
📊 Smoothing 効果の視覚化
スムージング係数 k=10 の場合、出現数 n によってグローバル平均への引き寄せ度が変わる。
グローバル平均 = $163,000 | 計算式: (n × cat_mean + k × global) / (n + k)
カテゴリ別: 生の平均 vs Smoothing後 (k=10)
Landmrk
n=1
→ smoothed: $164k
グローバル平均に 91% 引き寄せ
StoneBr
n=25
→ smoothed: $294k
71% は cat_mean、29% はグローバル平均
MeadowV
n=37
→ smoothed: $101k
79% は cat_mean、21% はグローバル平均
NAmes
n=225
→ smoothed: $145k
96% は cat_mean(ほぼ変化なし)
NridgHt
n=225
→ smoothed: $315k
96% は cat_mean(ほぼ変化なし)
n(出現数)とグローバル平均への引き寄せ率 (k=10)
📈 RMSE 比較チャート(3 手法)
LinearRegression の 5-Fold CV RMSE。低いほど良い。ナイーブ TE は本来よりも低く(楽観的に)見える点に注意。
⚠️
ナイーブ TE の RMSE が低く見えるのは 嘘の数字。実際に提出すると Public LB スコアが大幅に悪化する典型的なリークパターン。
🗂️ データスキーマ(House Prices — Neighborhood 列)
| 列名 | 型 | 説明 | ユニーク数 | 例 |
|---|---|---|---|---|
Neighborhood | object (str) | 住宅地区名(カテゴリ変数) | 25 | NridgHt, NAmes, OldTown... |
SalePrice | int64 | 販売価格(目的変数) | - | 163,000 (平均) |
Neighborhood_te_naive | float64 | ナイーブ TE 後 | 25 | 地区別の全体平均 |
Neighborhood_te_oof | float64 | OOF TE 後 | - | fold ごとに異なる |
Neighborhood_te_smooth | float64 | Smooth OOF TE 後 | - | k=10 でスムージング |
🎯 問題
House Prices データセット(train.csv)の Neighborhood 変数(25 種類)を題材に、Target Encoding の 3 段階実装に取り組む。
タスク 1: ナイーブ実装(リーク版)
groupby().transform('mean')で全データからエンコードする関数を書く- なぜリークが起こるかをコメントで説明する
- 5-Fold CV で LinearRegression の RMSE を計算する
タスク 2: OOF Target Encoding(正しい実装)
- 5-Fold CV を使った OOF 方式の Target Encoding 関数
target_encode_oof()を実装する - テスト時は train 全体の平均を使う(実装コメントで説明)
- 同じ LinearRegression で RMSE を比較する
タスク 3: Smoothing 付き Target Encoding
- スムージング係数
k=10のtarget_encode_oof_smooth()を実装する Landmrk(1件)でエンコード値がどう変わるかを出力する- 3 手法の RMSE を並べて比較する
💡 ヒント
ヒント 1 — 方向性
OOF Target Encoding は「自分自身のサンプルを除いた fold のデータで平均を計算する」という考え方。
sklearn.model_selection.KFold で fold を作り、各 fold の train 部分で平均を計算、val 部分に適用する。
val_fold['Neighborhood'].map(cat_means) で対応するカテゴリの平均値を割り当てられる。
ヒント 2 — アプローチ
- OOF TE:
KFold(n_splits=5)→ ループ内でtrain_fold.groupby(col)[target].mean()→val_fold[col].map(cat_means).fillna(global_mean) - Smoothing:
stats = groupby().agg(['mean','count'])→stats['smoothed'] = (count * mean + k * global) / (count + k) - 未知カテゴリ(テストにしかない)は
.fillna(global_mean)で必ず補完
ヒント 3 — コード骨格
def target_encode_oof_smooth(df, col, target, n_splits=5, k=10): kf = KFold(n_splits=n_splits, shuffle=True, random_state=42) te_col = np.zeros(len(df)) global_mean = df[target].mean() for train_idx, val_idx in kf.split(df): train_fold = df.iloc[train_idx] val_fold = df.iloc[val_idx] stats = train_fold.groupby(col)[target].agg(['mean', 'count']) stats['smoothed'] = ( stats['count'] * stats['mean'] + k * global_mean ) / (stats['count'] + k) te_col[val_idx] = val_fold[col].map( stats['smoothed'] ).fillna(global_mean).values return te_col
✅ 模範解答
import pandas as pd import numpy as np from sklearn.model_selection import KFold, cross_val_score from sklearn.linear_model import LinearRegression # ── サンプルデータ生成(Neighborhood 25種類を模擬)────────── np.random.seed(42) neighborhoods = ['NridgHt', 'Crawfor', 'Mitchel', 'Somerst', 'NWAmes', 'OldTown', 'BrkSide', 'Sawyer', 'NAmes', 'SawyerW', 'IDOTRR', 'MeadowV', 'Edwards', 'Timber', 'Gilbert', 'StoneBr', 'ClearCr', 'NPkVill', 'Blmngtn', 'BrDale', 'SWISU', 'Blueste', 'Greens', 'GrnHill', 'Landmrk'] base_prices = { 'NridgHt': 316000, 'Crawfor': 210000, 'Mitchel': 156000, 'Somerst': 225000, 'NWAmes': 182000, 'OldTown': 128000, 'BrkSide': 124000, 'Sawyer': 136000, 'NAmes': 145000, 'SawyerW': 187000, 'IDOTRR': 100000, 'MeadowV': 98000, 'Edwards': 121000, 'Timber': 242000, 'Gilbert': 192000, 'StoneBr': 310000, 'ClearCr': 213000, 'NPkVill': 142000, 'Blmngtn': 194000, 'BrDale': 104000, 'SWISU': 141000, 'Blueste': 138000, 'Greens': 195000, 'GrnHill': 280000, 'Landmrk': 175000 } counts = [225,60,49,86,73,113,58,74,225,59, 37,37,100,38,79,25,28,9,17,16, 25,10,9,2,1] rows = [] for nbr, cnt, base in zip(neighborhoods, counts, [base_prices[n] for n in neighborhoods]): prices = np.random.normal(base, base * 0.15, cnt) prices = np.clip(prices, 50000, 700000) rows.extend([(nbr, p) for p in prices]) df = pd.DataFrame(rows, columns=['Neighborhood', 'SalePrice']) df = df.sample(frac=1, random_state=42).reset_index(drop=True) global_mean = df['SalePrice'].mean() # ══ タスク 1: ナイーブ TE(リーク版)══ def naive_target_encode(df, col, target): # NG: 自分自身を含む全データで平均を計算 → リーク return df.groupby(col)[target].transform('mean') df['te_naive'] = naive_target_encode(df, 'Neighborhood', 'SalePrice') kf = KFold(n_splits=5, shuffle=True, random_state=42) model = LinearRegression() rmse_naive = -cross_val_score( model, df[['te_naive']], df['SalePrice'], cv=kf, scoring='neg_root_mean_squared_error' ).mean() print(f"ナイーブ TE RMSE: {rmse_naive:,.0f} ← 楽観的すぎる(リーク)") # ══ タスク 2: OOF TE(リークなし)══ def target_encode_oof(df, col, target, n_splits=5): kf = KFold(n_splits=n_splits, shuffle=True, random_state=42) te_col = np.zeros(len(df)) global_mean = df[target].mean() for train_idx, val_idx in kf.split(df): cat_means = df.iloc[train_idx].groupby(col)[target].mean() te_col[val_idx] = df.iloc[val_idx][col].map(cat_means).\ fillna(global_mean).values return te_col df['te_oof'] = target_encode_oof(df, 'Neighborhood', 'SalePrice') rmse_oof = -cross_val_score( model, df[['te_oof']], df['SalePrice'], cv=kf, scoring='neg_root_mean_squared_error' ).mean() print(f"OOF TE RMSE: {rmse_oof:,.0f} ← 現実的な評価") # ══ タスク 3: Smoothing 付き OOF TE ══ def target_encode_oof_smooth(df, col, target, n_splits=5, k=10): kf = KFold(n_splits=n_splits, shuffle=True, random_state=42) te_col = np.zeros(len(df)) global_mean = df[target].mean() for train_idx, val_idx in kf.split(df): train_fold = df.iloc[train_idx] stats = train_fold.groupby(col)[target].agg(['mean', 'count']) stats['smoothed'] = ( stats['count'] * stats['mean'] + k * global_mean ) / (stats['count'] + k) te_col[val_idx] = df.iloc[val_idx][col].\ map(stats['smoothed']).fillna(global_mean).values return te_col df['te_smooth'] = target_encode_oof_smooth(df, 'Neighborhood', 'SalePrice', k=10) rmse_smooth = -cross_val_score( model, df[['te_smooth']], df['SalePrice'], cv=kf, scoring='neg_root_mean_squared_error' ).mean() print(f"OOF TE + Smoothing RMSE: {rmse_smooth:,.0f}") # Landmrk の変化を確認 lnd_mask = df['Neighborhood'] == 'Landmrk' print(f"\nLandmrk(1件)エンコード値比較:") print(f" ナイーブ: {df.loc[lnd_mask,'te_naive'].values[0]:,.0f}") print(f" OOF : {df.loc[lnd_mask,'te_oof'].values[0]:,.0f}") print(f" Smooth : {df.loc[lnd_mask,'te_smooth'].values[0]:,.0f}") print(f" グローバル平均: {global_mean:,.0f}")
🪜 Step-by-Step 解説
1
ナイーブ実装でリークを理解する
レア(1件のみ)なカテゴリでは TE 値 = そのサンプルの SalePrice そのもの = 100% リーク。
CV スコアが実際より大幅に良く見える原因になる。
groupby().transform('mean') は自分自身を含む全データで平均を計算する。レア(1件のみ)なカテゴリでは TE 値 = そのサンプルの SalePrice そのもの = 100% リーク。
CV スコアが実際より大幅に良く見える原因になる。
↓
2
KFold で fold を作り OOF 計算する
各イテレーションで
自分自身が val_idx に入っているため、train_idx に含まれない = リークゼロ。
KFold(n_splits=5, shuffle=True, random_state=42) で 5 分割。各イテレーションで
train_idx データのみで groupby().mean() を計算。val_idx のカテゴリに .map() で対応する平均値を割り当てる。自分自身が val_idx に入っているため、train_idx に含まれない = リークゼロ。
↓
3
Smoothing で少数カテゴリを安定化
k=10 なら n=1 の場合、91% グローバル平均に引き寄せられる。
Landmrk(1件)のエンコード値が $175k → $164k(グローバル $163k に近づく)に変わる。
stats = groupby().agg(['mean', 'count']) でカテゴリ別の平均・件数を取得。smoothed = (count * cat_mean + k * global_mean) / (count + k) を適用。k=10 なら n=1 の場合、91% グローバル平均に引き寄せられる。
Landmrk(1件)のエンコード値が $175k → $164k(グローバル $163k に近づく)に変わる。
↓
4
未知カテゴリの補完
テストデータに train に存在しないカテゴリが含まれる場合、
これを怠ると LinearRegression が例外を投げ、GBDT でも予測が NaN になる。
テストデータに train に存在しないカテゴリが含まれる場合、
.map() の結果が NaN になる。.fillna(global_mean) で補完することで NaN がモデルに伝播するのを防ぐ。これを怠ると LinearRegression が例外を投げ、GBDT でも予測が NaN になる。
📐 数学・統計の補足(文系向け)
Smoothing の加重平均を直感的に理解する
(n × cat_mean + k × global_mean) / (n + k) は「投票の多数決」と同じ構造です。
| n(出現数) | k=10 時の引き寄せ率 | 直感 |
|---|---|---|
| n=1 | 91% | ほぼグローバル平均になる(情報が足りない) |
| n=10 | 50% | cat と global の 50/50 混合 |
| n=100 | 9% | ほぼ cat_mean を信頼できる |
| n=1000 | 1% | グローバル平均の影響ほぼゼロ |
💡
k の選び方: k が大きいほど「慎重(グローバル寄り)」、小さいほど「積極的(カテゴリ平均寄り)」。Optuna で k をチューニングすると CV スコアが改善することが多い。実務では k=10〜50 を試すのが一般的。
🏆 Kaggle での実践的な使い方
category_encoders ライブラリ(実戦向け)
# pip install category_encoders from category_encoders import TargetEncoder encoder = TargetEncoder(cols=['Neighborhood'], smoothing=10) X_train_encoded = encoder.fit_transform(X_train, y_train) X_test_encoded = encoder.transform(X_test) # OOF + Smoothing を自動でやってくれる
LightGBM との組み合わせ(定番パターン)
# ① OOF TE で特徴量を作成 df['nbr_te'] = target_encode_oof_smooth(df, 'Neighborhood', 'SalePrice', k=10) # ② 元カテゴリも LabelEncoding で残す df['nbr_le'] = df['Neighborhood'].factorize()[0] # ③ 両方を feature として投入 features = ['nbr_te', 'nbr_le', '...'] # LightGBM が両方の情報を活用できる
時系列コンペでの注意点
⚠️
時系列データでは 未来のデータが訓練に含まれてはいけない。KFold でランダム分割すると「未来データで過去を説明する」リークが起こる。
TimeSeriesSplit を使うこと。❌ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
transform('mean') でOK |
pandas の操作として自然に見える | 自分自身を含むため必ずリーク。OOF が必須 |
| スムージング不要と思う | 精度に影響しないと考える | 1〜5 件のカテゴリで大きく効く。k=10〜50 を試す |
| テスト時も OOF を使う | 学習時と同じにしたい | テスト時は train 全体の統計を使う(OOF は評価用) |
fillna() を忘れる |
実装が面倒 | 未知カテゴリで NaN が伝播し、予測が全て NaN になる |
| k を固定する | デフォルト値で済ませたい | Optuna で k をチューニングすると CV 改善できることが多い |
🚀 次のステップ
- 発展 (Day 077): データリークとは何か — Target Encoding 以外のリークパターン(時系列・グループ漏れ)
- 発展:
category_encoders.LeaveOneOutEncoder— 1 件ずつ除いた平均(最もリスクが低い実装) - 発展: Smoothing 係数 k を Optuna でチューニングし、CV スコアへの影響を確認する
- 関連: GroupKFold で時系列データの OOF TE を正しく実装する