Day 100 — 高度なTargetEncoding — LeaveOneOut・Smoothing係数最適化

2026-07-21 青 / Phase 4 コーディング LeaveOneOut / Smoothing / Optuna

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

🔵
Day 076 では Target Encoding の基礎として、①ナイーブ実装(リークあり)、②5-FoldのOOF(Out-Of-Fold)、③出現数が少ないカテゴリをグローバル平均に引き寄せるSmoothing(k=10固定)を学びました。今日はこの2つをさらに発展させます。
💡
K-Fold OOFは「自分の属する班(数十〜数百件)」を除外しますが、Leave-One-Out(LOO)は「自分自身1件だけ」を除外し、行ごとに個別のエンコード値を計算します。さらにSmoothingの係数kを経験則で決め打ちせず、Optunaで自動探索します。
用語直感的な意味
LOO Target Encoding各行を、そのカテゴリの「自分以外全員」の平均でエンコードする手法。K-Fold OOFよりさらに細かい単位でリークを防ぐ
ベクトル化されたLOO計算transform('sum')/transform('count')で行ループなしに(group_sum - y) / (group_count - 1)を一括計算
Smoothed LOOの統合公式(group_sum - y + k×global_mean) / (group_count - 1 + k)。出現数1件のカテゴリでは自動的にglobal_meanに収束
kのハイパーパラメータ探索Optunaのtrial.suggest_float("k", ..., log=True)でCVのRMSEが最小になるkを探す

🧩 今日の2つの発展ポイント

除外の単位を細かく

🎯 K-Fold OOF → LOO

自分の班を除く → 自分1件だけ除く

行ごとに個別の値になり、より精密な「自分以外の平均」が得られる。ループなしでベクトル化できる。

経験則 → 自動探索

🔍 k=10固定 → Optunaで最適化

Day096-097のOptunaを再利用

CVのRMSEを最小化するSmoothing係数kを、モデルのハイパーパラメータと同じ要領で探す。

数式だけで解決

🛡️ 統合公式の美しさ

出現数1件でも特別扱い不要

LOO+Smoothingを1本の式にまとめると、出現数1件のカテゴリが自動的にglobal_meanへ収束する。

🎯 問題

Day 076 と全く同じ合成データ(House Prices風、Neighborhood 25種類・出現数が極端に偏る)を使います。同じデータに対して、より高度なLOO + SmoothingのTargetEncodingを実装し、Day 076の手法と直接比較します。

使用データDay076と完全同一 Neighborhood 25種(Landmrk n=1)
import numpy as np
import pandas as pd

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(各地区の真のベース価格)・counts(出現数、Landmrkは1件)はDay076と同一

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)
カラム名意味
Neighborhood住宅地区名(25種類、Landmrkは出現数1件)category
SalePrice住宅価格(円)float

タスク

1
【理論】LOOがK-Fold OOFより行ごとに個別化されている理由と、出現数が極端に少ないカテゴリでノイズが大きくなる理由を説明する
2
【実装】transform('sum'/'count')smoothed_loo_te(y, group_sum, group_count, global_mean, k)を実装し、k=0k=10Landmrkの挙動を確認する
3
【実装】Optunaでkを1〜300(対数スケール)で探索し、5-Fold CV RMSEを最小化する最良のkを見つける
4
【実装/分析】Day076の2手法と今日の2手法、計4手法のCV RMSEを同一設定で比較する
5
【戦略】K-Fold OOFとLOOの使い分け、および同一データでのkチューニング+評価の問題点を説明する

⚠️ なぜ出現数1件のカテゴリが自動的に global_mean になるのか

smoothed_loo_i = (group_sum - y_i + k×global_mean) / (group_count - 1 + k) に、出現数1件(group_count=1)を代入すると、特別な分岐を書かなくても式が自然に収束する。

Landmrk(出現数1件)に統合公式を当てはめると… smoothed_loo = (group_sum − y + k·global_mean) / (group_count − 1 + k) Landmrkは group_sum = y(自分しかいない) / group_count = 1 ✗ k=0 のとき… (y − y + 0) / (0 + 0) = 0 / 0 = NaN(自分以外の情報が0件で計算不能) ✓ k=10 のとき… (0 + 10·global_mean) / (0 + 10) = global_mean(自動的に全体平均へ収束) 「情報がないカテゴリは全体平均で代用する」がif文なしで実現される

📊 4手法のRMSE比較(イメージ)

実際の数値は乱数・データに依存するため、ここでは相対的な傾向のイメージを示す。ナイーブTEはリークにより見かけ上小さく出るが信用できない点に注意。

① Naive TE(Day076)
見かけ上は小さい
⚠ リークで楽観的
② OOF K-Fold + Smooth(k=10)
Day076の正しい基準値
信頼できる評価
③ LOO + Smooth(k=10固定)
行ごとに精密
信頼できる評価
④ LOO + Smooth(Optuna最適化k)
kを自動調整
最も信頼できる中で最良

💡 ヒント

ヒント1方向性

Day 076のOOF実装は「KFoldで5分割し、for文で1Foldずつ計算」というループが必要だった。しかしLOOは「自分1件だけを除く」操作なので、実はKFoldのforループよりシンプルにベクトル化できる。「そのカテゴリの合計」から「自分の値」を引き算するだけで済むからだ。Optunaの使い方はDay 096-097と同じ「objective関数を書いてstudy.optimize()」の型を踏襲する。

ヒント2アプローチ
  • group_sum = df.groupby('Neighborhood')['SalePrice'].transform('sum')でカテゴリごとの合計を全行に展開
  • group_count = ...transform('count')でカテゴリごとの件数を全行に展開
  • LOO(スムージングなし): (group_sum - y) / (group_count - 1)
  • 統合公式: (group_sum - y + k * global_mean) / (group_count - 1 + k)
  • 出現数1件(Landmrk)はgroup_count - 1 = 0になるため、k=0だと分母が0(NaN)、k>0だと式がk*global_mean / k = global_meanに自動収束する
  • Optuna: trial.suggest_float("k", 1, 300, log=True)→ そのkで特徴量を作りcross_val_scoreを返す
ヒント3コード骨格
group_sum = df.groupby('Neighborhood')['SalePrice'].transform('sum')
group_count = df.groupby('Neighborhood')['SalePrice'].transform('count')
global_mean = df['SalePrice'].mean()

def smoothed_loo_te(y, group_sum, group_count, global_mean, k):
    return (group_sum - y + k * ___) / (group_count - ___ + k)

df['te_loo_k0']  = smoothed_loo_te(df['SalePrice'], group_sum, group_count, global_mean, k=___)
df['te_loo_k10'] = smoothed_loo_te(df['SalePrice'], group_sum, group_count, global_mean, k=___)

# タスク3: Optunaでkを探索
import optuna

def objective(trial):
    k = trial.suggest_float("k", ___, ___, log=True)
    te = smoothed_loo_te(df['SalePrice'], group_sum, group_count, global_mean, k)
    X = te.values.reshape(-1, 1)
    y = df['SalePrice'].values
    kf = KFold(n_splits=5, shuffle=True, random_state=42)
    scores = cross_val_score(LinearRegression(), X, y, cv=kf, scoring=___)
    return -scores.mean()

study = optuna.create_study(direction=___)
study.optimize(objective, n_trials=30)

模範解答

import numpy as np
import pandas as pd
import optuna
from sklearn.model_selection import KFold, cross_val_score
from sklearn.linear_model import LinearRegression

# ====== データ準備(Day076と完全同一) ======
np.random.seed(42)
# neighborhoods / base_prices / counts はDay076と同一(省略)

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()
group_sum = df.groupby('Neighborhood')['SalePrice'].transform('sum')
group_count = df.groupby('Neighborhood')['SalePrice'].transform('count')

# ====== タスク2: Smoothed LOO Target Encoding(ベクトル化・ループなし) ======
def smoothed_loo_te(y, group_sum, group_count, global_mean, k):
    """smoothed_loo_i = (group_sum_i - y_i + k*global_mean) / (group_count_i - 1 + k)"""
    return (group_sum - y + k * global_mean) / (group_count - 1 + k)

df['te_loo_k0']  = smoothed_loo_te(df['SalePrice'], group_sum, group_count, global_mean, k=0)
df['te_loo_k10'] = smoothed_loo_te(df['SalePrice'], group_sum, group_count, global_mean, k=10)

print("--- タスク2: Landmrk(出現数1件)でのLOOの挙動 ---")
print(df[df['Neighborhood'] == 'Landmrk'][
    ['Neighborhood', 'SalePrice', 'te_loo_k0', 'te_loo_k10']
])
print(f"te_loo_k0 のNaN件数: {df['te_loo_k0'].isna().sum()} 件")

# ====== タスク3: Optunaでスムージング係数kを最適化 ======
def objective(trial):
    k = trial.suggest_float("k", 1, 300, log=True)
    te = smoothed_loo_te(df['SalePrice'], group_sum, group_count, global_mean, k)
    X = te.values.reshape(-1, 1)
    y = df['SalePrice'].values
    kf = KFold(n_splits=5, shuffle=True, random_state=42)
    scores = cross_val_score(
        LinearRegression(), X, y, cv=kf,
        scoring='neg_root_mean_squared_error'
    )
    return -scores.mean()

optuna.logging.set_verbosity(optuna.logging.WARNING)
study = optuna.create_study(direction='minimize')
study.optimize(objective, n_trials=30)

best_k = study.best_params['k']
best_rmse = study.best_value
print(f"\nbest_k: {best_k:.1f} / best_rmse: {best_rmse:,.0f}")

df['te_loo_best'] = smoothed_loo_te(df['SalePrice'], group_sum, group_count, global_mean, best_k)

# ====== タスク4: Day076の2手法 + 今日の2手法、計4手法を比較 ======
kf = KFold(n_splits=5, shuffle=True, random_state=42)
model = LinearRegression()
y = df['SalePrice'].values

def cv_rmse(feature_col):
    X = df[[feature_col]].values
    scores = cross_val_score(model, X, y, cv=kf, scoring='neg_root_mean_squared_error')
    return -scores.mean()

df['te_naive'] = df.groupby('Neighborhood')['SalePrice'].transform('mean')

def target_encode_oof_smooth(frame, col, target, n_splits=5, k=10, random_state=42):
    kf_inner = KFold(n_splits=n_splits, shuffle=True, random_state=random_state)
    te_col = np.zeros(len(frame))
    gm = frame[target].mean()
    for train_idx, val_idx in kf_inner.split(frame):
        train_fold = frame.iloc[train_idx]
        val_fold = frame.iloc[val_idx]
        stats = train_fold.groupby(col)[target].agg(['mean', 'count'])
        stats['smoothed'] = (stats['count'] * stats['mean'] + k * gm) / (stats['count'] + k)
        te_col[val_idx] = val_fold[col].map(stats['smoothed']).fillna(gm).values
    return te_col

df['te_oof_smooth10'] = target_encode_oof_smooth(df, 'Neighborhood', 'SalePrice', k=10)

results = {
    "① Naive TE(Day076・参考値)":                       cv_rmse('te_naive'),
    "② OOF K-Fold TE + Smoothing k=10(Day076)":       cv_rmse('te_oof_smooth10'),
    "③ LOO + Smoothing k=10 固定(今日)":                cv_rmse('te_loo_k10'),
    f"④ LOO + Smoothing k={best_k:.0f} Optuna最適化(今日)": cv_rmse('te_loo_best'),
}

print("\n--- タスク4: 4手法のRMSE比較 ---")
for name, rmse in results.items():
    bar = '█' * int(rmse / 3000)
    print(f"  {name:48s}: {rmse:>10,.0f}  {bar}")
🧠
理論問題(タスク1): K-Fold OOFは「5つのFoldのうち自分が属するFold全体(多数のサンプル)」を除外するため、同じFoldの行は全員同じエンコード値を受け取る。LOOは「自分自身のたった1件」だけを除外するため、行ごとにより精密な値を持つ。ただし出現数が非常に少ないカテゴリ(例: 2件)では、count-1=1となり「もう1件の値ほぼそのもの」になってしまうため、非常にノイズが大きくなる。だからこそLOOはSmoothingとほぼ必須のセットになる。
🧠
戦略問題(タスク5): K-Fold OOFはFoldごとに1回集計すればよく計算コストが低いため高カーディナリティのデータで扱いやすい。LOOはベクトル化すれば軽量だが、外れ値1件の影響を直接受けやすい性質がある。また「Optunaで見つけた最良のkを、そのkで作った特徴量自身のCVスコアで評価している」ことには問題がある。ハイパーパラメータの選択と性能評価を同じCV分割で行うと、報告されるスコアが本来の汎化性能よりわずかに楽観的になりうる。これを厳密に解消するのが次回学ぶNestedCVである。

🪜 Step-by-Step 解説

1group_sum/group_countを先に作っておく

group_sum = df.groupby('Neighborhood')['SalePrice'].transform('sum')
group_count = df.groupby('Neighborhood')['SalePrice'].transform('count')
🔑
なぜこうするか: transformは「カテゴリごとの集計値を全行に展開して」返す。これを1度計算しておけば、行ごとにforループを回さずともgroup_sum - yのようなベクトル演算だけで「自分以外の合計」が求まる。Day 076のOOF実装がKFoldで5回ループしていたのに対し、今日のLOOはループが一切不要。

2LOOの生の式を組み立てる

loo_i = (group_sum - y) / (group_count - 1)
🧪
なぜこうするか: 「カテゴリ全体の合計」から「自分自身の値」を引けば「自分以外の合計」になり、それを「自分以外の件数(count-1)」で割れば「自分以外全員の平均」が得られる。

3Smoothingを1つの式に統合する

(group_sum - y + k * global_mean) / (group_count - 1 + k)
🔑
なぜこうするか: Day 076のSmoothing公式(n×cat_mean + k×global_mean)/(n+k)n=group_count-1cat_mean=loo_iを代入すると、分子の(count-1)×loo_iはちょうどgroup_sum - yと等しくなるため、シンプルな1本の式に落とし込める。

4k=0と出現数1件のカテゴリの相性を確認する

⚖️
なぜこうするか: Landmrkは出現数1件なのでgroup_count-1=0k=0だと分母が0+0=0になり、分子もgroup_sum-y=0になるため0/0NaNが発生する。k>0にすると分母が0+k=k、分子が0+k×global_mean=k×global_meanになり、式全体が自動的にglobal_meanに一致する。「情報がないカテゴリは全体平均で代用する」という振る舞いが、特別な分岐なしで数式のまま実現される

5Optunaでkを探索する

study = optuna.create_study(direction='minimize')
study.optimize(objective, n_trials=30)
🧪
なぜこうするか: Day 076ではk=10を経験則で決め打ちしていたが、本来「どれだけ全体平均に引き寄せるべきか」はデータの分布によって変わる。log=Trueを指定するのは、k=1と10の差(10倍)とk=100と1000の差(10倍)を同じ重みで探索したいため。線形スケールだと大きい値ばかり細かく調べてしまう。

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

📐
LOOを直感で: クラス全員のテストの平均点を出したいが「自分の点数」は参考にしたくないとき、「クラス全体の合計点から自分の点数を引いて、自分以外の人数で割る」——これがLOOの発想。K-Fold OOFが「自分の班(数十人)を除いて他の班の平均を見る」のに対し、LOOは「自分1人だけを除いて残り全員の平均を見る」ので、より細かく情報量の多い比較ができる。
📏
Smoothingとの組み合わせを直感で: クラスに自分しかいない(出現数1件)場合、「自分以外の平均」はそもそも計算できない。そこで「学年全体の平均」(global_mean)を代わりに使う保険をかけるのがSmoothing。今日の統合公式は、この2つを「自分以外の人数(n-1)」と「保険の重みk」の比率で自動的にブレンドしてくれる。

数式を見たら

smoothed_loo_i = (Σ_{j∈c, j≠i} y_j + k·ȳ) / ((n_c − 1) + k)

「自分(i)を除いたカテゴリcの合計」に「保険としてのグローバル平均×k」を足し、「自分を除いた件数+k」で割る、という意味。n_c=1(自分しかいない)のとき、分子はk·ȳ、分母はkとなり、式全体がȳ(グローバル平均)に一致する。

🏆 Kaggleでの実践的な使い方

よく使われるコンペカテゴリ: ☑ 表形式データ(Tabular) / ☐ 自然言語処理(NLP) / ☐ 画像認識(CV) / ☐ 時系列(Time Series)

  • category_encoders.LeaveOneOutEncoder: 今日実装したロジックをそのまま使えるライブラリ実装。sigmaパラメータで学習データにさらに小さなランダムノイズを加え、出現数の少ないカテゴリでの過学習をさらに抑えられる
  • 与信・不正検知コンペ(Home Credit, American Express Default Prediction, IEEE-CIS Fraud Detection): 顧客ID・カード種別・端末IDなど高カーディナリティな変数が大量にあるコンペでは、K-Fold OOFとLOOの両方を試しCVスコアで採用を決めるのが定石
  • Optunaでのkチューニング: LightGBM/XGBoostのハイパーパラメータと同様に、Target Encodingのk自体もOptunaの探索対象に含める上位解法が多い

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
LOOなら完全にリークがないと思い込む「自分を除いている」から安全に見えるglobal_meanはデータ全体(自分自身のyを含む)から計算されるため、ごくわずかな情報漏れが残る。件数が多ければ影響は無視できるほど小さい
k=0(スムージングなし)のLOOをそのまま使う「自分以外の平均」だけで十分だと考える出現数が少ないカテゴリでNaNや生の値に近い極端な値になる。LOOはSmoothingとセットが基本
LOOは1件ずつ計算するから重いと思い込む名前の響きから逐次処理を連想するtransform('sum'/'count')でベクトル化でき、KFoldのforループより高速なことも多い
Optunaで探した最良のkのCVスコアをそのまま「本当の実力」とする探索と評価を同じデータで行う問題に気づいていない探索に使ったCVスコアはやや楽観的になりがち。真に公正な評価にはNestedCV(探索用と評価用のCVの分離)が必要
Smoothingのkを全カテゴリで同じ値にすればよいと考えるシンプルに1つの値で済ませたい実務ではまず1つのkで十分なことが多いが、カテゴリ群の性質が大きく異なる場合はカテゴリ群ごとに異なるkを検討する余地もある

🚀 次のステップ

  • 発展: category_encoders.LeaveOneOutEncoder(sigma=0.05)を使い、ノイズ付加ありとなしでCVスコアがどう変わるか比較してみる
  • 次回予告: Day 101「NestedCV」 — チューニングとCV評価の分離。今日タスク5で触れた「Optunaで探したkを同じデータで評価してしまう」問題を、外側CVと内側CVを分離することで解消する手法に進みます

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

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