Day 076 — Target Encoding 基礎 — 実装・リーク防止・Smoothing

2026-06-26 水 / Phase 3 コーディング OOF TargetEncoding / Smoothing / データリーク防止

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

🎯
Day 076 — Phase 3 継続: EDA第8弾は Target Encoding の深掘り。Day 075 では4手法を概観したが、今日は Target Encoding に絞って「ナイーブ実装→リーク発生→OOF で解決→Smoothing で安定化」の完全な流れを実装する。

Target Encoding とは?

カテゴリ変数の各値を、そのカテゴリに属するサンプルの目的変数の平均値で置き換える手法です。

House Prices の Neighborhood(住宅地区名、25種類)で考えると:

Neighborhood件数SalePrice 平均エンコード後の値
NridgHt225件$316,000316,000
StoneBr25件$310,000310,000
NAmes225件$145,000145,000
MeadowV37件$98,00098,000
Landmrk1件$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件のみ)でのリーク例

Landmrk SalePrice: $175,000 transform('mean') TE 値 = $175,000 (自分自身) → テスト時 SalePrice を知っているのと同じ! CV スコアは過楽観 / 実際のスコアは悪化

🔄 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
生: $175k
→ smoothed: $164k
グローバル平均に 91% 引き寄せ
StoneBr
n=25
生: $310k
→ smoothed: $294k
71% は cat_mean、29% はグローバル平均
MeadowV
n=37
生: $98k
→ smoothed: $101k
79% は cat_mean、21% はグローバル平均
NAmes
n=225
生: $145k
→ smoothed: $145k
96% は cat_mean(ほぼ変化なし)
NridgHt
n=225
生: $316k
→ smoothed: $315k
96% は cat_mean(ほぼ変化なし)

n(出現数)とグローバル平均への引き寄せ率 (k=10)

100% 75% 50% 25% 1 10 50 100 200 500 n(出現数) 引き寄せ率 n=1: 91% n=10: 50% n=50: 17% n=100: 9%

📈 RMSE 比較チャート(3 手法)

LinearRegression の 5-Fold CV RMSE。低いほど良い。ナイーブ TE は本来よりも低く(楽観的に)見える点に注意。

ナイーブ TE(リークあり)
偽りの低 RMSE
~$41,000
OOF TE(リークなし)
現実的な評価
~$56,000
OOF TE + Smoothing (k=10)
安定した評価
~$54,000
⚠️
ナイーブ TE の RMSE が低く見えるのは 嘘の数字。実際に提出すると Public LB スコアが大幅に悪化する典型的なリークパターン。

🗂️ データスキーマ(House Prices — Neighborhood 列)

列名説明ユニーク数
Neighborhoodobject (str)住宅地区名(カテゴリ変数)25NridgHt, NAmes, OldTown...
SalePriceint64販売価格(目的変数)-163,000 (平均)
Neighborhood_te_naivefloat64ナイーブ TE 後25地区別の全体平均
Neighborhood_te_ooffloat64OOF TE 後-fold ごとに異なる
Neighborhood_te_smoothfloat64Smooth 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=10target_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
ナイーブ実装でリークを理解する
groupby().transform('mean') は自分自身を含む全データで平均を計算する。
レア(1件のみ)なカテゴリでは TE 値 = そのサンプルの SalePrice そのもの = 100% リーク。
CV スコアが実際より大幅に良く見える原因になる。
2
KFold で fold を作り OOF 計算する
KFold(n_splits=5, shuffle=True, random_state=42) で 5 分割。
各イテレーションで train_idx データのみで groupby().mean() を計算。
val_idx のカテゴリに .map() で対応する平均値を割り当てる。
自分自身が val_idx に入っているため、train_idx に含まれない = リークゼロ。
3
Smoothing で少数カテゴリを安定化
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 に存在しないカテゴリが含まれる場合、.map() の結果が NaN になる。
.fillna(global_mean) で補完することで NaN がモデルに伝播するのを防ぐ。
これを怠ると LinearRegression が例外を投げ、GBDT でも予測が NaN になる。

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

Smoothing の加重平均を直感的に理解する

(n × cat_mean + k × global_mean) / (n + k) は「投票の多数決」と同じ構造です。

n(出現数)k=10 時の引き寄せ率直感
n=191%ほぼグローバル平均になる(情報が足りない)
n=1050%cat と global の 50/50 混合
n=1009%ほぼ cat_mean を信頼できる
n=10001%グローバル平均の影響ほぼゼロ
💡
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 を正しく実装する

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