📚 背景知識(読んでから問題へ)
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の手法と直接比較します。
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 |
タスク
transform('sum'/'count')でsmoothed_loo_te(y, group_sum, group_count, global_mean, k)を実装し、k=0とk=10でLandmrkの挙動を確認するkを1〜300(対数スケール)で探索し、5-Fold CV RMSEを最小化する最良のkを見つける⚠️ なぜ出現数1件のカテゴリが自動的に global_mean になるのか
smoothed_loo_i = (group_sum - y_i + k×global_mean) / (group_count - 1 + k) に、出現数1件(group_count=1)を代入すると、特別な分岐を書かなくても式が自然に収束する。
📊 4手法のRMSE比較(イメージ)
実際の数値は乱数・データに依存するため、ここでは相対的な傾向のイメージを示す。ナイーブTEはリークにより見かけ上小さく出るが信用できない点に注意。
💡 ヒント
Day 076のOOF実装は「KFoldで5分割し、for文で1Foldずつ計算」というループが必要だった。しかしLOOは「自分1件だけを除く」操作なので、実はKFoldのforループよりシンプルにベクトル化できる。「そのカテゴリの合計」から「自分の値」を引き算するだけで済むからだ。Optunaの使い方はDay 096-097と同じ「objective関数を書いてstudy.optimize()」の型を踏襲する。
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を返す
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}")
count-1=1となり「もう1件の値ほぼそのもの」になってしまうため、非常にノイズが大きくなる。だからこそLOOはSmoothingとほぼ必須のセットになる。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)
(n×cat_mean + k×global_mean)/(n+k)にn=group_count-1、cat_mean=loo_iを代入すると、分子の(count-1)×loo_iはちょうどgroup_sum - yと等しくなるため、シンプルな1本の式に落とし込める。4k=0と出現数1件のカテゴリの相性を確認する
group_count-1=0。k=0だと分母が0+0=0になり、分子もgroup_sum-y=0になるため0/0のNaNが発生する。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)
log=Trueを指定するのは、k=1と10の差(10倍)とk=100と1000の差(10倍)を同じ重みで探索したいため。線形スケールだと大きい値ばかり細かく調べてしまう。🧮 数学・統計の補足(文系向け)
数式を見たら
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を分離することで解消する手法に進みます
📝 自己評価(解いた後に記入)
自分の回答・気づき・メモ: