📚 背景知識(読んでから問題へ)
kを、そのkで作った特徴量自身のCVスコアで評価している」ことの問題を指摘しました。今日はこの問題を正面から扱います。| 用語 | 直感的な意味 |
|---|---|
| 外側CV(Outer CV) | 最終的な汎化性能を測るための分割。ここで作られたテストFoldはチューニング過程で一切参照しない |
| 内側CV(Inner CV) | 外側の訓練データの中だけで行う、ハイパーパラメータ探索用の分割 |
| 選択バイアス(optimism bias) | 同じデータでチューニングと評価を両方行うと、報告スコアが実際の汎化性能より楽観的になる現象 |
| 入れ子構造(nested) | 外側Foldの内部で、さらに独立した内側Foldを回す二重構造。計算コストは「外側分割数×内側での学習回数」倍に増える |
🧩 NestedCVの3つの原則
🔒 外側テストは内側CVから見えない
チューニングと評価の完全分離
外側テストFoldの行番号は、内側CVの探索処理のスコープに一切入らない。構造的にリークが起こり得ない。
🪆 Foldごとに独立した内側CV
ベストkはFoldごとに変わってよい
外側5Foldそれぞれで、外側訓練データの中だけを使って独立に内側CVを回し、ベストなkを選び直す。
⚖️ 実務では簡略版で運用
原則は守り、規模は妥協する
フルNestedCVは高コスト。train/valid/testの単純な3分割でも「分離の原則」さえ守れば実務上は十分なことが多い。
🎯 問題
Day 076・Day 100と全く同じ合成データ(House Prices風、Neighborhood 25種類、Landmrkは出現数1件)を使います。今日はDay 100の「Optunaで探したkのCVスコアをそのまま報告する」やり方(素朴な1段階CV)と、NestedCVで測った公正な性能推定値を直接比較します。
import numpy as np import pandas as pd from sklearn.model_selection import KFold from sklearn.linear_model import LinearRegression from sklearn.metrics import mean_squared_error 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・Day100と同一 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 |
タスク
smoothed_loo_teを再利用し、「訓練統計だけでまだ見ていないholdoutにSmoothed TEを適用する」fit_apply_smoothed_te(train_df, holdout_df, col, target, k)を実装する[1,3,10,30,100,300,1000]に対し、外側5-Fold・内側3-FoldのNestedCVを実装し、5つのRMSEの平均を「NestedCVスコア」とする🪆 外側CV × 内側CVの入れ子構造
外側テストFold(赤)は、内側CV(緑の内側の箱)の処理に一切登場しない。この「見えない」状態を守ることが、NestedCVが公正である理由のすべて。
📊 NestedCVスコア vs 素朴な1段階CV(イメージ)
実際の数値は乱数・データに依存するため、ここでは相対的な傾向のイメージを示す。素朴な1段階CVはRMSEが小さく(良く)見えるが、これは選択バイアスによる楽観であり信用できない。
外側Foldごとに選ばれるベストkの例(イメージ): [30, 100, 30, 10, 100] — ばらつきが大きいほど、そのハイパーパラメータの選択自体が不安定であることを示唆する。
💡 ヒント
Day 100までは「1段階」のCVしかなかった。Optunaがkを探すのも、最終スコアを報告するのも同じKFoldの同じ5分割だった。今日はこの「1段階」を「外側」と「内側」の2段階に分ける。外側Foldの訓練データを、さらに内側でCVすると考えればよい。「外側テストデータには、内側CVのどのステップからも絶対に触れさせない」という制約さえ守れば、あとはDay 076・Day 100で書いたコードの組み合わせ。
fit_apply_smoothed_te(train_df, holdout_df, col, target, k):train_df.groupby(col)[target].agg(['mean','count'])で統計を作り、(count*mean + k*global_mean)/(count+k)をholdout_df[col].map(...)で貼り付ける(未出現カテゴリはglobal_meanで埋める)- 外側訓練データ自身の特徴量にはDay 100の
smoothed_loo_te(Leave-One-Out)を使う。自分自身の値を使わないようにするため - 内側CV: 外側訓練データを
KFold(n_splits=3)でさらに分割し、各k候補の3Fold平均RMSEを比較して最小のkを選ぶ - 外側CV: 内側CVで選んだkで外側訓練データ全体を学習 → 外側テストデータには
fit_apply_smoothed_teでエンコード+予測 → RMSE - 素朴な1段階CVは、外側CVと同じ
KFold分割を使うが、内側CVを挟まずFold内で選んだkのスコアをそのまま性能とする点が違う
def smoothed_loo_te(y, group_sum, group_count, global_mean, k): return (group_sum - y + k * global_mean) / (group_count - 1 + k) def fit_apply_smoothed_te(train_df, holdout_df, col, target, k): stats = train_df.groupby(col)[target].agg(['mean', 'count']) global_mean = train_df[target].mean() smoothed_map = (stats['count'] * stats['___'] + k * global_mean) / (stats['count'] + ___) return holdout_df[col].map(smoothed_map).fillna(___).values def inner_cv_best_k(train_df, col, target, k_candidates, n_inner_splits=3, random_state=0): inner_kf = KFold(n_splits=___, shuffle=True, random_state=random_state) mean_scores = {} for k in k_candidates: fold_rmses = [] for tr_idx, val_idx in inner_kf.split(train_df): # tr_foldはLOO、val_foldはfit_apply_smoothed_teでエンコード ... mean_scores[k] = np.mean(fold_rmses) best_k = min(mean_scores, key=mean_scores.get) return best_k, mean_scores outer_kf = KFold(n_splits=5, shuffle=True, random_state=1) outer_scores, outer_best_ks = [], [] for outer_tr_idx, outer_te_idx in outer_kf.split(df): outer_train = df.iloc[outer_tr_idx].reset_index(drop=True) outer_test = df.iloc[outer_te_idx].reset_index(drop=True) best_k, _ = inner_cv_best_k(outer_train, 'Neighborhood', 'SalePrice', ___) outer_best_ks.append(best_k) # best_kで外側訓練データ全体を学習し、外側テストデータで評価 ...
✅ 模範解答
import numpy as np import pandas as pd from sklearn.model_selection import KFold from sklearn.linear_model import LinearRegression from sklearn.metrics import mean_squared_error # ====== データ準備(Day076・Day100と完全同一) ====== np.random.seed(42) # neighborhoods / base_prices / counts はDay076・Day100と同一(省略) 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) K_CANDIDATES = [1, 3, 10, 30, 100, 300, 1000] # ====== タスク2: 訓練統計だけでholdoutをエンコードする関数 ====== def smoothed_loo_te(y, group_sum, group_count, global_mean, k): """外側/内側 訓練データ“自身”の特徴量を作るときに使う(Day100と同じ関数)""" return (group_sum - y + k * global_mean) / (group_count - 1 + k) def fit_apply_smoothed_te(train_df, holdout_df, col, target, k): """train_dfの統計だけを使い、holdout_dfにSmoothed TEを適用する。holdoutの情報は一切使わない""" stats = train_df.groupby(col)[target].agg(['mean', 'count']) global_mean = train_df[target].mean() smoothed_map = (stats['count'] * stats['mean'] + k * global_mean) / (stats['count'] + k) return holdout_df[col].map(smoothed_map).fillna(global_mean).values def rmse_for_k(train_df, holdout_df, col, target, k): """train_dfでLOOエンコード→LinearRegression学習→holdout_dfで評価しRMSEを返す共通処理""" tr_group_sum = train_df.groupby(col)[target].transform('sum') tr_group_count = train_df.groupby(col)[target].transform('count') tr_global_mean = train_df[target].mean() tr_te = smoothed_loo_te(train_df[target], tr_group_sum, tr_group_count, tr_global_mean, k) ho_te = fit_apply_smoothed_te(train_df, holdout_df, col, target, k) model = LinearRegression().fit(tr_te.values.reshape(-1, 1), train_df[target].values) pred = model.predict(ho_te.reshape(-1, 1)) return np.sqrt(mean_squared_error(holdout_df[target].values, pred)) # ====== タスク3: NestedCV(外側5-Fold × 内側3-Fold) ====== def inner_cv_best_k(train_df, col, target, k_candidates, n_inner_splits=3, random_state=0): inner_kf = KFold(n_splits=n_inner_splits, shuffle=True, random_state=random_state) mean_scores = {k: [] for k in k_candidates} for tr_idx, val_idx in inner_kf.split(train_df): inner_train = train_df.iloc[tr_idx].reset_index(drop=True) inner_val = train_df.iloc[val_idx].reset_index(drop=True) for k in k_candidates: mean_scores[k].append(rmse_for_k(inner_train, inner_val, col, target, k)) mean_scores = {k: np.mean(v) for k, v in mean_scores.items()} best_k = min(mean_scores, key=mean_scores.get) return best_k, mean_scores outer_kf = KFold(n_splits=5, shuffle=True, random_state=1) outer_scores, outer_best_ks = [], [] for outer_tr_idx, outer_te_idx in outer_kf.split(df): outer_train = df.iloc[outer_tr_idx].reset_index(drop=True) outer_test = df.iloc[outer_te_idx].reset_index(drop=True) # 内側CV: 外側テストデータには一切触れず、外側訓練データの中だけでkを選ぶ best_k, _ = inner_cv_best_k(outer_train, 'Neighborhood', 'SalePrice', K_CANDIDATES) outer_best_ks.append(best_k) # 選ばれたkで外側訓練データ全体を学習し、初めて外側テストデータで評価する rmse = rmse_for_k(outer_train, outer_test, 'Neighborhood', 'SalePrice', best_k) outer_scores.append(rmse) nested_cv_score = np.mean(outer_scores) print("--- タスク3: NestedCV ---") print(f"外側Foldごとの選択k : {outer_best_ks}") print(f"NestedCVスコア(平均): {nested_cv_score:,.0f}") # ====== タスク4: 素朴な1段階CV(内側CVを挟まず、そのままベストkのスコアを報告) ====== naive_kf = KFold(n_splits=5, shuffle=True, random_state=1) # 外側CVと同じ分割方法(比較を公平にするため) naive_scores = {k: [] for k in K_CANDIDATES} for tr_idx, te_idx in naive_kf.split(df): tr_fold = df.iloc[tr_idx].reset_index(drop=True) te_fold = df.iloc[te_idx].reset_index(drop=True) for k in K_CANDIDATES: naive_scores[k].append(rmse_for_k(tr_fold, te_fold, 'Neighborhood', 'SalePrice', k)) naive_mean_scores = {k: np.mean(v) for k, v in naive_scores.items()} naive_best_k = min(naive_mean_scores, key=naive_mean_scores.get) naive_best_rmse = naive_mean_scores[naive_best_k] # ←これを「性能」として報告するのが素朴なやり方 print("\n--- タスク4: 素朴な1段階CV ---") print(f"素朴な1段階CVのベストスコア: {naive_best_rmse:,.0f}") print(f"NestedCVスコア : {nested_cv_score:,.0f}") gap = nested_cv_score - naive_best_rmse print(f"ギャップ(楽観バイアスの目安): {gap:,.0f}")
出力イメージ(乱数依存のため相対的な傾向として見る):
--- タスク3: NestedCV --- 外側Foldごとの選択k : [30, 100, 30, 10, 100] NestedCVスコア(平均): 1x,xxx --- タスク4: 素朴な1段階CV --- 素朴な1段階CVのベストスコア: 1x,xxx NestedCVスコア : 1x,xxx ギャップ(楽観バイアスの目安): xxx ← NestedCVの方がRMSEが大きい=素朴な方法は楽観的だった
n_trialsを絞る、③複数シードでCVスコアの分散を確認する、といった妥協が使われる。それでも「チューニングに使ったデータと最終評価に使うデータを分ける」というNestedCVの原則そのものは必ず守るべき。🪜 Step-by-Step 解説
1「訓練統計だけでholdoutを塗る」関数を独立させる
def fit_apply_smoothed_te(train_df, holdout_df, col, target, k): stats = train_df.groupby(col)[target].agg(['mean', 'count']) global_mean = train_df[target].mean() smoothed_map = (stats['count'] * stats['mean'] + k * global_mean) / (stats['count'] + k) return holdout_df[col].map(smoothed_map).fillna(global_mean).values
2訓練データ自身のエンコードにはLOOを使う
tr_te = smoothed_loo_te(train_df[target], tr_group_sum, tr_group_count, tr_global_mean, k)
fit_apply_smoothed_teはholdout用。訓練データ自身にこれと同じ単純平均を使うと、各行が自分自身のyを含む統計でエンコードされ、ナイーブ実装のリークがモデル学習側に紛れ込む。訓練データ自身にはDay 100のLOO(自分だけを除く)を使うことで、外側・内側どちらの階層でも一貫してリークのない特徴量を作れる。3内側CVで「外側テストを一切見ずに」kを選ぶ
def inner_cv_best_k(train_df, col, target, k_candidates, n_inner_splits=3, random_state=0): inner_kf = KFold(n_splits=n_inner_splits, shuffle=True, random_state=random_state) ...
train_dfは、外側CVで既にテストFoldを除外した後の「外側訓練データ」だけ。関数の中でこのデータをさらに3分割し、その中だけでk候補を比較する。外側テストFoldの行番号はこの関数のスコープに一切入らないため、「外側テストを見てチューニングしてしまう」ミスが構造的に起こり得ない。4外側ループで「選ぶ→学習する→初めて見る」を1セットにする
for outer_tr_idx, outer_te_idx in outer_kf.split(df): outer_train = df.iloc[outer_tr_idx].reset_index(drop=True) outer_test = df.iloc[outer_te_idx].reset_index(drop=True) best_k, _ = inner_cv_best_k(outer_train, 'Neighborhood', 'SalePrice', K_CANDIDATES) rmse = rmse_for_k(outer_train, outer_test, 'Neighborhood', 'SalePrice', best_k)
outer_testを切り分ける、②outer_trainの中だけでbest_kを決める、③そのbest_kでouter_train全体を学習し、④最後に一度だけouter_testを予測に使う。この順番を守る限り、outer_testは「モデルが一度も調整の材料にしていない初見データ」であり続ける。5素朴な1段階CVと並べて、ギャップを可視化する
gap = nested_cv_score - naive_best_rmse
🧮 数学・統計の補足(文系向け)
数式を見たら
NestedCV Score = (1/K) Σᵢ RMSE( f_{k*ᵢ}(Trainᵢ), Testᵢ )
「i番目の外側Foldでは、その訓練データTrainᵢの中だけで選んだベストなk*ᵢを使ってモデルfを作り、一度も使っていないテストデータTestᵢで誤差を測る。それをK個のFold分平均する」という意味。k*ᵢの添字がi(Foldごとに違ってよい)になっている点が、単純な「1つのkを全体で使う」CVとの違い。
🏆 Kaggleでの実践的な使い方
よく使われるコンペカテゴリ: ☑ 表形式データ(Tabular) / ☐ 自然言語処理(NLP) / ☐ 画像認識(CV) / ☐ 時系列(Time Series)
- 上位解法の「CV戦略」セクション: Kaggleの上位解法解説記事では必ず「どうCVを組んだか」が説明される。強いチームほど「ハイパーパラメータ探索用のCV」と「モデル選択・最終アンサンブル評価用のCV」を意識的に分けている記述が見られる
cross_val_score+ 手動の外側ループ: sklearnには専用のNestedCV関数はないが、内側CVのobjective関数の中でcross_val_scoreを呼び出し、外側は自前のforループで回すのが定番の書き方(今日のコードもこのパターン)- Optunaでの実務的な妥協: フルNestedCVは重すぎるため、実務では「train/validで探索、held-out testで1回だけ最終確認」という簡略版がよく使われる。これは「内側CV1本・外側Fold1本」に相当するNestedCVの最小構成と見ることができる
- 時系列コンペでの重要性: 時系列データ(Day 099のRolling/Lag特徴量が絡むコンペ)では、外側・内側のいずれも「未来のデータで訓練しない」時系列分割(
TimeSeriesSplit)にする必要があり、通常のKFold以上にリーク対策が絡み合う
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
| Optunaで探索したCVスコアがそのままモデルの実力だと思い込む | 「CVで評価している」時点で安全だと錯覚する | 探索に使ったCVスコアには選択バイアスが乗っている。真に公正な評価にはチューニングに使っていないデータでの再評価が必要 |
| 内側CVと外側CVで同じ乱数シード・同じ分割を使ってしまう | 「同じKFold設定を使い回せば楽」と考える | 外側テストFoldの行が内側CVの学習・検証に混ざると、外側テストが「初見データ」でなくなりNestedCVの意味がなくなる |
| NestedCVスコアが素朴な1段階CVより悪く見えると「実装が間違っている」と思う | 数字が下がる(誤差が増える)とバグを疑いたくなる | NestedCVスコアの方が悪く見えるのはむしろ正常。素朴な方法が本来より楽観的だったことが可視化されただけ |
| 常にフルNestedCVを使うべきだと考える | 「公正な方法が常に正解」という思い込み | 計算コストが非常に高いため、実務では計算コストと公正さのバランスを取った簡略版を使うのが一般的 |
| k候補を1つの外側Foldでしか選ばず、他のFoldにも使い回す | 内側CVを1回だけ回せば十分だと考える | 外側Foldごとにデータの中身が変わるため、ベストなkもFoldごとに変わりうる。各外側Foldで独立に内側CVを回すのがNestedCVの定義 |
🚀 次のステップ
- 発展: 内側CVの中身をグリッドサーチではなくOptuna(
n_trials=20程度)に置き換え、より広いk候補・計算コストの増加を体感してみる - 次回予告: Day 102「アンサンブル①(Averaging)」 — 複数モデルの予測を組み合わせるSimple Average・Rank Average・重み最適化。今日まで1つのモデルの中でのチューニングを厳密化してきましたが、次は「複数のモデルをどう組み合わせるか」という新しいテーマに進みます
📝 自己評価(解いた後に記入)
自分の回答・気づき・メモ: