5×3-fold Nested CV のような表記でよく出てくる。機械学習の論文や Kaggle notebook で、 こんな表記を見たことはないですか:
これらは Nested Cross-Validation(入れ子型クロスバリデーション) の表現。 「普通の CV で性能を出して、 同じデータでハイパーパラメータも調整した」というナイーブな手順は、 性能を楽観的に見積もる致命的バイアス を含みます。 Nested CV は「モデル選択用の CV」と「性能評価用の CV」を入れ子にすることで、 このバイアスを除去する標準的な方法論です。
普通の交差検証(CV)でモデル選択と性能評価を同時に行うと、 こんなことが起きます:
⚠️ 問題:手順 4 の「最終性能」は同じ fold 構造での CV 平均なので、 すでに「この fold でうまくいく α」を選んだ後の評価です。 つまりテストデータが間接的に α 選択に使われた。 結果、 報告される RMSE は実環境(真に未知のデータ)よりも小さく(楽観的に)出る。
大学受験で、 「直前模試の問題」と「本番試験の問題」が同じだったら、 模試で「この勉強法が一番点数が出る」と判明した方法は、 本番でも当然点数が出る。 でもそれは「本当に頭が良いから」ではなく、 「答えを知っていたから」です。
Nested CV の発想:
典型的な経験則:
| シナリオ | naive CV の RMSE | Nested CV の RMSE | 差(バイアス) |
|---|---|---|---|
| ハイパラ 4 候補、 n=200 | 0.85 | 0.92 | +0.07(楽観バイアス) |
| ハイパラ 20 候補、 n=200 | 0.78 | 0.95 | +0.17(さらに楽観) |
| ハイパラ 100 候補、 n=200 | 0.72 | 0.99 | +0.27(致命的) |
| ハイパラ 100 候補、 n=10,000 | 0.85 | 0.86 | +0.01(無視できる) |
つまり (1) ハイパラ候補が多いほど、 (2) サンプルが少ないほど、 naive CV のバイアスは大きくなる。 Kaggle で「CV では当たったのに、 LB(leaderboard)でガッカリ」というのは多くの場合これが原因。
データセット $\mathcal{D} = \{(x_i, y_i)\}_{i=1}^{n}$、 ハイパーパラメータ空間 $\Lambda$、 学習アルゴリズム $\mathcal{A}_\lambda$、 損失関数 $L$。
$\mathcal{D}$ を $K_o$ 個に分割:$\mathcal{D} = \mathcal{D}^{\text{outer}}_1 \cup \mathcal{D}^{\text{outer}}_2 \cup \dots \cup \mathcal{D}^{\text{outer}}_{K_o}$。 各 $k = 1, \dots, K_o$ について:
$\mathcal{D}_{\text{train}}^{(k)}$ を $K_i$ 個に分割し、 各 $\lambda \in \Lambda$ について:
最適ハイパラを選択:
$\hat{\lambda}^{(k)}$ を使って外側の訓練データ全体で再学習し、 外側テストで評価:
最終的に外側 fold での平均と標準偏差を報告:
💡 重要な性質:Nested CV は 「最終的にデプロイするモデル」を生成する手続きではないこと。 あくまで「ハイパラチューニング + 学習のパイプライン全体の性能を測る」プロトコル。 デプロイには別途、 全データで内側 CV を再実行して最適 λ を決め、 全データで学習する。
SSDSE-B-2026 の 47 都道府県データから、 2020〜2022 年の経済・社会指標(人口、 出生率、 高齢化率、 県内総生産、 有効求人倍率、 大学進学率、 ...) を特徴量にして、 翌年(2023 年)の総人口を予測するリッジ回帰モデルを構築。 Ridge の正則化強度 α を Nested CV でチューニング。
| 外側 fold | 選ばれた α | 外側 RMSE(千人) |
|---|---|---|
| 1 | 0.1 | 165.2 |
| 2 | 1.0 | 198.7 |
| 3 | 1.0 | 225.4 |
| 4 | 0.1 | 148.9 |
| 5 | 10.0 | 198.3 |
| 平均 | — | 187.3 ± 42.5 |
解釈:
機械学習における Nested CV の必要性を最も明確に示したのが、 Cawley & Talbot (2010) "On Over-fitting in Model Selection and Subsequent Selection Bias in Performance Evaluation"(Journal of Machine Learning Research)。 著者らは UCI の 13 個のデータセットで、 ナイーブ CV と Nested CV を比較し、 ナイーブ CV が 平均 4〜8% 程度性能を楽観的に評価することを実証しました。
Cawley & Talbot は、 SVM のハイパラ(C と γ)を様々な手法で選択した結果を比較。 ナイーブ CV では「データが少ない場合に特に楽観バイアスが大きい」「ハイパラ候補が多いとバイアスが大きい」という 2 つの傾向を実証。 一方 Nested CV ではバイアスが事実上消失。
この論文以降、 機械学習の主要会議(NeurIPS, ICML)では、 ハイパラ調整を含む手法を提案する論文には Nested CV による評価 が事実上必須となりました。 Kaggle や DrivenData などのコンペでも、 「validation set はモデル選択に使う、 final leaderboard は完全分離した test set」というプロトコルが定着しています。
Nested CV の唯一の自由パラメータは、 外側 fold 数 K_o と内側 fold 数 K_i です。 経験則と理論的な目安を整理:
| データサイズ n | 推奨 K_o | 理由 |
|---|---|---|
| n < 50 | LOOCV(K = n) | 1 fold ≈ 1 サンプル、 fold 間の分散は大きいが、 訓練データが最大化される |
| 50 ≤ n < 500 | 10-fold | 慣習的なベストプラクティス。 fold 間の分散と訓練データ量のバランス |
| 500 ≤ n < 10,000 | 5-fold or 10-fold | 計算量と精度のバランス。 5-fold の方が高速 |
| n ≥ 10,000 | Holdout(train/val/test)でも十分 | Nested CV は計算量で割に合わない |
SVM のように学習に O(n²) 〜 O(n³) かかるアルゴリズムでは:
# SSDSE-B 人口予測タスクで Ridge α を Nested CV で選ぶ import numpy as np import pandas as pd from sklearn.linear_model import Ridge from sklearn.model_selection import KFold from sklearn.metrics import mean_squared_error from sklearn.preprocessing import StandardScaler # データ読込 df = pd.read_csv('data/raw/SSDSE-B-2026.csv', encoding='cp932', skiprows=2) df.columns = ['year', 'code', 'pref', 'pop'] + [f'c{i}' for i in range(len(df.columns)-4)] df = df[df['year'].isin([2020, 2021, 2022])].copy() # 特徴量(適当に 8 列選ぶ:人口、出生率、高齢化率、県内総生産、有効求人、大学進学率、医師数、犯罪件数の代用) feature_cols = ['pop', 'c0', 'c5', 'c27', 'c30', 'c41', 'c52', 'c70'] X = df[feature_cols].values.astype(float) y = df['pop'].shift(-47).values # 翌年の人口 mask = ~np.isnan(y) X, y = X[mask], y[mask] print(f'サンプル数: {len(y)}, 特徴量数: {X.shape[1]}') # Nested CV alpha_grid = [0.001, 0.01, 0.1, 1, 10, 100, 1000] outer_cv = KFold(n_splits=5, shuffle=True, random_state=42) outer_rmses = [] chosen_alphas = [] for fold_idx, (train_idx, test_idx) in enumerate(outer_cv.split(X)): X_train, X_test = X[train_idx], X[test_idx] y_train, y_test = y[train_idx], y[test_idx] # 内側 CV:α 探索 inner_cv = KFold(n_splits=3, shuffle=True, random_state=1) best_alpha, best_inner_rmse = None, np.inf for alpha in alpha_grid: inner_rmses = [] for in_tr, in_va in inner_cv.split(X_train): scaler = StandardScaler().fit(X_train[in_tr]) model = Ridge(alpha=alpha).fit(scaler.transform(X_train[in_tr]), y_train[in_tr]) pred = model.predict(scaler.transform(X_train[in_va])) inner_rmses.append(np.sqrt(mean_squared_error(y_train[in_va], pred))) avg = np.mean(inner_rmses) if avg < best_inner_rmse: best_inner_rmse, best_alpha = avg, alpha # 外側評価:選ばれた α で全 X_train で再学習 → X_test 評価 scaler = StandardScaler().fit(X_train) model = Ridge(alpha=best_alpha).fit(scaler.transform(X_train), y_train) pred = model.predict(scaler.transform(X_test)) outer_rmse = np.sqrt(mean_squared_error(y_test, pred)) outer_rmses.append(outer_rmse) chosen_alphas.append(best_alpha) print(f'Fold {fold_idx+1}: α={best_alpha}, outer RMSE={outer_rmse:.2f}') print(f'\\nNested CV RMSE: {np.mean(outer_rmses):.2f} ± {np.std(outer_rmses, ddof=1):.2f}') print(f'選ばれた α 群: {chosen_alphas}')
from sklearn.linear_model import Ridge from sklearn.pipeline import Pipeline from sklearn.preprocessing import StandardScaler from sklearn.model_selection import GridSearchCV, cross_val_score, KFold import numpy as np pipe = Pipeline([ ('scaler', StandardScaler()), ('ridge', Ridge()) ]) param_grid = {'ridge__alpha': [0.001, 0.01, 0.1, 1, 10, 100, 1000]} # 内側 CV(GridSearchCV 内) inner_cv = KFold(n_splits=3, shuffle=True, random_state=1) grid = GridSearchCV(pipe, param_grid, cv=inner_cv, scoring='neg_mean_squared_error') # 外側 CV outer_cv = KFold(n_splits=5, shuffle=True, random_state=42) neg_mse_scores = cross_val_score(grid, X, y, cv=outer_cv, scoring='neg_mean_squared_error') rmse_scores = np.sqrt(-neg_mse_scores) print(f'Nested CV RMSE: {rmse_scores.mean():.2f} ± {rmse_scores.std(ddof=1):.2f}')
# Nested CV はあくまで「評価プロトコル」。デプロイ用モデルは全データで再学習する final_grid = GridSearchCV(pipe, param_grid, cv=KFold(5, shuffle=True, random_state=0), scoring='neg_mean_squared_error') final_grid.fit(X, y) print(f'最終モデル α: {final_grid.best_params_}') # final_grid.best_estimator_ がデプロイ用モデル
from sklearn.model_selection import StratifiedKFold, TimeSeriesSplit # 分類タスクなら StratifiedKFold で fold ごとのクラス比率を保つ # outer_cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) # 時系列なら過去→未来の順序を守る TimeSeriesSplit # outer_cv = TimeSeriesSplit(n_splits=5) # SSDSE-B の都道府県データは「グループ=都道府県」なので GroupKFold が望ましい from sklearn.model_selection import GroupKFold groups = df['pref'].values[mask] # 都道府県名 outer_cv = GroupKFold(n_splits=5) for tr, te in outer_cv.split(X, y, groups=groups): # 同じ都道府県は train/test に分かれない(リーク防止) pass
# Optuna でベイズ最適化を内側に組み込む例 import optuna from sklearn.model_selection import cross_val_score def objective_for_outer_fold(trial, X_tr, y_tr): alpha = trial.suggest_float('alpha', 1e-3, 1e3, log=True) model = Pipeline([('sc', StandardScaler()), ('r', Ridge(alpha=alpha))]) scores = cross_val_score(model, X_tr, y_tr, cv=3, scoring='neg_mean_squared_error') return -scores.mean() outer_rmses = [] for tr_idx, te_idx in outer_cv.split(X): study = optuna.create_study(direction='minimize') study.optimize(lambda t: objective_for_outer_fold(t, X[tr_idx], y[tr_idx]), n_trials=20, show_progress_bar=False) best_alpha = study.best_params['alpha'] model = Pipeline([('sc', StandardScaler()), ('r', Ridge(alpha=best_alpha))]) model.fit(X[tr_idx], y[tr_idx]) rmse = np.sqrt(mean_squared_error(y[te_idx], model.predict(X[te_idx]))) outer_rmses.append(rmse) print(f'Bayes-Nested CV: {np.mean(outer_rmses):.2f} ± {np.std(outer_rmses, ddof=1):.2f}')
Pipeline を使えば自動的に正しい順序になる(最大のメリット)。
| 手法 | 用途 | 特徴 |
|---|---|---|
| Holdout(3 分割) | 大規模データ | train / val / test に一度だけ分割。 計算量最小、 評価は単一の test set |
| k-fold CV(単一) | 性能評価のみ(ハイパラ固定) | 普通の交差検証。 ハイパラ調整しないなら十分 |
| Nested CV | 小〜中データのハイパラ調整 + 評価 | 外側 × 内側の二重 CV、 不偏推定 |
| Repeated Nested CV | 結果の安定化 | Nested CV を異なる乱数で複数回繰り返し平均 |
| Leave-One-Out Nested CV | 極小データ(n < 50) | K_o = n。 計算量大、 分散も大きい |
| Group Nested CV | 階層 / クラスタ構造 | 外側・内側ともグループ単位で分割(GroupKFold ベース) |
| Stratified Nested CV | 不均衡分類 | fold ごとのクラス比率を保つ |
| Time-Series Nested CV | 時系列予測 | 過去 → 未来の順序を守る walk-forward 型 |
| Bayesian Nested CV | 大きなハイパラ空間 | 内側ループを Bayes 最適化(Optuna, Hyperopt)に置換 |
| 5×2 CV (Dietterich) | モデル間検定 | 2-fold CV を 5 回繰り返し、 paired t 検定 |
| 項目 | 普通の k-fold CV | Nested CV |
|---|---|---|
| 主目的 | 性能評価のみ(ハイパラ固定) | ハイパラ調整 + 不偏性能評価 |
| ループ数 | 1 重 | 2 重(外側 + 内側) |
| 学習回数 | K 回 | K_o × K_i × |Λ| + K_o 回 |
| ハイパラ調整 | 不可(手動か固定) | 各外側 fold で内側 CV により調整 |
| 性能バイアス | ハイパラ調整時に楽観バイアス | 不偏(理論的) |
| 最終モデル | fold ごとに 1 つ(K 個) | fold ごとに 1 つ(K_o 個)、 デプロイ用は別途学習 |
| fold 間の SD | fold ごとの性能の SD | fold ごとの性能の SD(ただしハイパラも変動) |
| 計算コスト | 低 | 中〜高 |
| 適用領域 | ハイパラなしモデル、 探索初期 | 論文・本番デプロイ前評価 |
| 典型的設定 | 5-fold or 10-fold | 5×3 or 10×5 |
運用ルール:「ハイパラ調整なしの単純性能チェック → k-fold CV」「ハイパラ調整あり、 論文・本番前の最終評価 → Nested CV」「大規模データ、 ニューラルネット → holdout(train/val/test)」と、 シナリオに応じて使い分け。
Nested CV を実装する上で最大のミスポイントは「データリーク」です。 特に前処理(標準化、 PCA、 特徴選択、 欠損補完)を CV 外で行うと、 test fold の統計量が訓練に流れ込みます。 sklearn の Pipeline を使えば、 fold ごとに前処理が自動的に閉じられます:
Pipeline のステップ名を頭に付けて二重アンダースコアで連結:
'feat_sel__k': [3, 5, 10]'pca__n_components': [2, 3, 5]'model__alpha': [0.01, 0.1, 1, 10]複数のステップのハイパラを同時に探索可能。 候補が組み合わせ爆発しないよう、 ベイズ最適化(Optuna)に切り替えるのも選択肢。
回答:「やってみたら差がなかった」のは、 (1) サンプルサイズが十分大きい、 (2) ハイパラ候補が少ない、 (3) ハイパラに対するモデル性能の感度が低い、 のいずれか。 結果として naive CV でもバイアスが小さい状況だった、 ということ。 Nested CV をやって「差がない」と確認したこと自体が研究の価値。
回答:問題ではない、 むしろ重要な情報。 「ハイパラ選択が不安定」=「データ依存で最適 λ が変動する」=「実環境で 1 つの λ を固定するのが危険」を意味する。 デプロイ時は (a) 全データで再度内側 CV してハイパラ決定、 (b) アンサンブル(fold ごとのモデルを平均)、 (c) より広い CV+1-SE rule、 などの対策を取る。
回答:まず (1) Pipeline で正しく実装されているか確認(毎回 fit 直すべきは前処理だけ)、 (2) RandomizedSearchCV や Optuna で候補数を制限、 (3) HalvingGridSearchCV(sklearn 0.24+)で予算節約、 (4) 軽量モデルで予備実験して候補を絞ってから本番、 という順で対処。
回答:StratifiedKFold を outer_cv と inner_cv の両方に指定。 Pipeline は学習器の前処理だけ担当し、 fold 分割とは独立。
回答:GroupKFold または StratifiedGroupKFold を使う。 「同じ患者/同じ県」を train と test に分けないことで、 評価の独立性を保つ。 これを忘れると性能が大きく過大評価される。
回答:Dietterich (1998) の 5×2 CV paired t-test、 または corrected resampled t-test(Nadeau & Bengio 2003)を使う。 単純な「Nested CV 平均の差」では有意性検定にならない(fold が独立でないため)。
回答:(1) holdout 3 分割(train / val / test)が現実解、 (2) val でハイパラ調整、 test で最終評価、 (3) test set は本当に最後に 1 回だけ評価、 (4) early stopping を val loss で行えば自動ハイパラ調整に近い効果。 サンプル数が数万を超えていれば、 holdout の評価精度は十分。
回答:例として "We evaluated model performance using nested cross-validation with K_outer = 5 outer folds and K_inner = 3 inner folds. Hyperparameters (α ∈ {0.001, 0.01, 0.1, 1, 10, 100, 1000}) were tuned via grid search in the inner CV loop on the training portion of each outer fold. The final performance is reported as the mean ± standard deviation across outer folds (RMSE = 187.3 ± 42.5)." といった記述。 K_outer, K_inner, ハイパラ範囲、 評価指標を明示するのが必須。
回答:Nested CV を異なる乱数シードで 10〜30 回繰り返し、 性能の平均と SD を報告する手法。 fold 分割のランダム性によるブレを抑えられる。 計算コストが Nested CV のさらに数十倍になるため、 小規模データ・軽量モデルで論文に十分な精度を求めるときに採用する。 sklearn の RepeatedKFold を outer に使う。
回答:(1) Pipeline で前処理がきちんと閉じているか再確認(リーク疑い)、 (2) train と test の分布が違うかを KS 検定や PCA で可視化(distribution shift)、 (3) ハイパラを「test set に近づくように」 tweak していないか(実質的なリーク)、 (4) Nested CV に切り替えてバイアス除去。
モデル評価プロトコル(ML パイプラインのバイアス管理)
├── レベル 0:訓練データそのもので評価
│ └── 致命的に楽観バイアス(過学習を見抜けない)
├── レベル 1:単一の hold-out(train / test)
│ └── 大規模データでは OK、 小データでは分散大
├── レベル 2:k-fold CV(ハイパラ固定)
│ └── 性能評価としては不偏
├── レベル 3:CV でハイパラ調整 + 同じ CV で評価(naive)
│ └── 楽観バイアス発生 ← Nested CV が解決する問題
├── レベル 4:Nested CV
│ ├── 外側:性能評価
│ └── 内側:ハイパラ調整
│ ├── Grid Search
│ ├── Random Search
│ └── Bayesian Optimization
└── レベル 5:Repeated Nested CV / 階層 CV / 時系列 CV
└── 計算コストとのトレードオフを取りながら更に精密化
Cawley & Talbot (2010) "On Over-fitting in Model Selection and Subsequent Selection Bias in Performance Evaluation"(JMLR)が Nested CV の必要性を実証的に示した古典論文。 機械学習論文を書く際は、 ハイパラ調整があるなら必ず Nested CV か Holdout のいずれかで評価する、 が現代の標準作法。
sklearn.model_selection.GridSearchCV, cross_val_score のリファレンス「Nested CV が遅すぎる」「結果が安定しない」「最終モデルにどの α を使うべきか」など困ったら、 (1) ハイパラ候補を絞る/ベイズ最適化に切替、 (2) Repeated Nested CV で複数乱数試行、 (3) 「Nested CV は評価、 最終モデルは別途全データで再 fit」のフロー確認、 (4) Pipeline でリーク防止、 を順に確認。 Cross Validated(Stack Exchange)の "nested-cross-validation" タグも参考に。
Nested Cross-Validation の核心は 「ハイパーパラメータ選択」と「汎化性能評価」を完全に分離する ことにあります。 通常の K-fold CV をハイパーパラメータ選択と性能評価の両方に使うと、 選択バイアス(selection bias)と呼ばれる過大評価が発生します。 これは「複数のコイントスを行い、 最も表が出たコインを選んで、 そのコインの表確率を報告する」のと同じトリックです。
数式で書くと、 ハイパーパラメータの集合 $\Theta$、 各 $\theta \in \Theta$ に対する CV スコア $\hat{R}_{\text{CV}}(\theta)$ について、 $\min_{\theta} \hat{R}_{\text{CV}}(\theta)$ の期待値は、 各 $\theta$ の真のリスク $R(\theta)$ の最小値より小さくなる傾向があります。 これを Jensen の不等式の逆向き効果と呼ぶこともあります。
$$\mathbb{E}\left[\min_{\theta \in \Theta} \hat{R}_{\text{CV}}(\theta)\right] \le \min_{\theta \in \Theta} \mathbb{E}[\hat{R}_{\text{CV}}(\theta)] = \min_{\theta \in \Theta} R(\theta)$$
SSDSE-B-2026 の 47 都道府県データのような小標本では、 $|\Theta|$ が 20 個程度のグリッドでもこのバイアスが顕著に出ます。 Cawley & Talbot (2010) は完全ランダムデータで AUC 0.5 が 0.7+ に「誤って向上」することを示し、 Nested CV の必要性を強く訴えました。
Nested CV は二重ループ構造を取ります。 外側ループで $K_{\text{outer}}$ 個の fold を作り、 各 fold で「テストデータを完全に隔離」。 内側ループで残りの訓練データを $K_{\text{inner}}$ 個に分け、 ハイパーパラメータを選択。 選ばれたハイパーパラメータで訓練データ全体で再学習し、 隔離されたテストデータで評価。 これを $K_{\text{outer}}$ 回繰り返した平均が「不偏」な汎化性能推定値となります。
重要なのは、 外側 fold のテストデータ $D_k^{\text{out}}$ は内側 CV のハイパーパラメータ選択に一切使われないこと。 これにより「データを見てから選んだ」というバイアスが排除されます。
| $K_{\text{outer}}$ | $K_{\text{inner}}$ | 計算量倍率 | バイアス | 分散 | 推奨場面 |
|---|---|---|---|---|---|
| 5 | 5 | 25× | 低 | 中 | デフォルト・$n \ge 100$ |
| 10 | 5 | 50× | 極低 | 高 | 小標本 $n < 100$ |
| 10 | 10 | 100× | 極低 | 低 | 計算予算潤沢時 |
| $n$ (LOO) | 5 | $5n$× | 極低 | 極高 | $n \le 50$ のとき |
| 3 | 3 | 9× | 中 | 低 | 高速プロトタイプ用 |
| 5 | $n-1$ | $5(n-1)$× | 低 | 中 | 内側 LOO + 外側 5-fold |
SSDSE-B-2026 ($n=47$) の標準設定は $K_{\text{outer}}=5, K_{\text{inner}}=5$。 この場合 1 fold の訓練データは約 38 サンプル、 内側でさらに 5 分割すると 1 fold 約 30 サンプルでハイパーパラメータを選び、 8 サンプルで内側評価。 「47 都道府県を学習に十分使いつつ過大評価を避ける」バランス点です。
反復 Nested CV(外側分割を複数シードで反復)はさらに分散を下げる王道。 RepeatedKFold で 5-fold × 10 回 = 50 fold 評価を行うと、 SE が約 $1/\sqrt{10} = 0.32$ 倍に縮みます。
sklearn の `GridSearchCV` を `cross_val_score` で包むのが標準パターン。 内側 CV は GridSearch 内で自動的に走り、 外側は cross_val_score が制御します。
import pandas as pd import numpy as np from sklearn.linear_model import Ridge from sklearn.preprocessing import StandardScaler from sklearn.pipeline import Pipeline from sklearn.model_selection import KFold, GridSearchCV, cross_val_score # 1) 実データ読み込み df = pd.read_csv('data/raw/SSDSE-B-2026.csv', encoding='cp932', skiprows=1) X = df.iloc[:, 3:11].values y = df.iloc[:, 11].values # 2) Pipeline: 標準化 + Ridge pipe = Pipeline([('sc', StandardScaler()), ('r', Ridge())]) # 3) 内側 5-fold CV で α 選択 inner = KFold(n_splits=5, shuffle=True, random_state=1) gs = GridSearchCV(pipe, {'r__alpha': np.logspace(-3, 3, 25)}, cv=inner, scoring='r2', n_jobs=-1) # 4) 外側 5-fold CV で汎化性能 outer = KFold(n_splits=5, shuffle=True, random_state=0) scores = cross_val_score(gs, X, y, cv=outer, scoring='r2', n_jobs=-1) print(f'Nested CV R²: {scores.mean():.3f} ± {scores.std():.3f}')
上記コードでは内側 25 個のグリッド × 5 fold = 125 回学習を外側 5 fold で行うので、 計 625 回モデル学習。 SSDSE-B-2026 規模なら 10 秒程度で完了します。
1 回の Nested CV では外側 fold の選び方によってスコアがブレます。 これを安定化する標準テクニックが 反復 Nested CV (Repeated Nested CV)。 外側分割を異なる乱数シードで $R$ 回繰り返し、 全 $R \times K_{\text{outer}}$ 個のスコアを平均します。
from sklearn.model_selection import RepeatedKFold outer_rep = RepeatedKFold(n_splits=5, n_repeats=10, random_state=0) scores_rep = cross_val_score(gs, X, y, cv=outer_rep, scoring='r2') print(f'反復 Nested CV R²: {scores_rep.mean():.3f} ± {scores_rep.std():.3f}') print(f'スコア分布: {np.percentile(scores_rep, [10, 50, 90])}')
反復回数 $R$ の指針:$R=10$ で SE がほぼ $1/\sqrt{R} = 0.32$ 倍に。 $R=30$ なら 0.18 倍。 SSDSE-B のような小標本では $R \ge 10$ を強く推奨します。
SSDSE-B のような時系列パネルでは「ランダム分割」がリークを生みます。 同じ都道府県の異なる年が学習・テストに分裂すると、 県固定効果がリークするのです。
from sklearn.model_selection import TimeSeriesSplit # 内外両方を時間順に inner_ts = TimeSeriesSplit(n_splits=5) outer_ts = TimeSeriesSplit(n_splits=5) gs_ts = GridSearchCV(pipe, {'r__alpha': np.logspace(-3, 3, 25)}, cv=inner_ts, scoring='neg_mean_squared_error') scores_ts = cross_val_score(gs_ts, X, y, cv=outer_ts, scoring='neg_mean_squared_error') print(f'時系列 Nested CV MSE: {-scores_ts.mean():.4f}')
from sklearn.model_selection import GroupKFold groups = df['Prefecture'].values # 都道府県名でグルーピング outer_g = GroupKFold(n_splits=5) # 同じ都道府県は学習またはテストの片方にのみ出現 for train_idx, test_idx in outer_g.split(X, y, groups): print(f'train: {len(train_idx)}, test: {len(test_idx)}')
医療データで「患者 ID 内の複数測定」、 教育データで「同じ学校の複数学生」など、 階層構造を持つデータには GroupKFold が必須です。
Nested CV の計算コストが許容できないとき、 以下の代替手法が考えられます。
| 手法 | 不偏性 | 計算量 | 分散 | 使う場面 |
|---|---|---|---|---|
| Nested CV | ◎ | 高 | 中 | 中小規模・基本 |
| Train-Val-Test | ◯ | 低 | 高 | $n \ge 10^4$ |
| Bayesian CV | ◎ | 高 | 低 | 不確実性が必要 |
| Cross-Conformal | ◎ | 中 | 中 | 予測区間も欲しい |
| 0.632+ Bootstrap | △ | 高 | 中 | CV の代替 |
| Single Hold-out | × | 極低 | 極高 | プロトタイプのみ |
実務的には「まず Nested CV で性能の上限を測り、 デプロイ用には全データ+全 fold で最頻出のハイパーを使う」のがバランス良い戦略です。
[$n$ サイズは?]
├── $n \ge 10^5$ → Train-Val-Test 分割で十分
└── $n < 10^5$
├── [ハイパーパラメータ探索する?]
│ ├── しない → 通常の CV で OK
│ └── する
│ ├── [計算予算は?]
│ │ ├── 潤沢 → 反復 Nested CV ($R=10$)
│ │ ├── 中程度 → Nested CV (1 回)
│ │ └── 限定的 → Hold-out validation + 警告
│ └── [時系列?]
│ ├── Yes → TimeSeriesSplit を内外両方に
│ └── No → KFold (shuffle=True)
└── [グループ構造あり?]
├── Yes → GroupKFold を内外両方に
└── No → 通常の KFold
Nested CV の不偏性を厳密に証明するには、 「外側 fold のテスト誤差は内側で選択された $\theta^\star$ の真のリスクの不偏推定」という性質を用います。 確率変数として $D \sim P^n$($n$ 個の i.i.d. サンプル)を考え、 アルゴリズム $\mathcal{A}: D \mapsto (\theta^\star, \hat f)$ を「内側 CV +訓練」として定義します。
$$\mathbb{E}_{D_{\text{train}}, D_{\text{test}}} \left[ L(\hat f_{\mathcal{A}(D_{\text{train}})}, D_{\text{test}}) \right] = \mathbb{E}_{D \sim P^n}[R(\mathcal{A}(D))]$$
左辺は Nested CV が推定する量、 右辺は「アルゴリズム $\mathcal{A}$ をサイズ $n(K-1)/K$ のデータで走らせたときの期待リスク」。 ここに「真のテストデータが内側選択に影響しない」という条件が決定的に働きます。
興味深いことに、 Nested CV は「サイズ $n$ のデータで $\mathcal{A}$ を動かしたときのリスク」ではなく、 「サイズ $n(K-1)/K$ のデータで動かしたときのリスク」を推定します。 つまり サンプルサイズが少し小さい場合の性能を返す。 SSDSE-B (n=47) で 5-fold なら 37.6 サンプル時の性能を見ていることに。 この「ペシミズムバイアス」は通常無視できる程度ですが、 学習曲線が立っている領域では注意が必要です。
Nested CV と学習曲線 (learning curve) を組合せると、 「サンプルサイズを増やすと性能はどこまで伸びるか」を予測できます。 横軸を訓練サイズ、 縦軸を Nested CV スコアでプロットし、 関数 $R(n) \approx R_\infty + c/n^\alpha$ をフィットすれば、 漸近性能 $R_\infty$ と収束指数 $\alpha$ が得られます。
from sklearn.model_selection import learning_curve import matplotlib.pyplot as plt sizes, train_sc, val_sc = learning_curve( gs, X, y, cv=outer, train_sizes=np.linspace(0.2, 1.0, 8), scoring='r2') plt.plot(sizes, train_sc.mean(axis=1), "o-", label="訓練") plt.plot(sizes, val_sc.mean(axis=1), "s-", label="検証") plt.xlabel("訓練サンプル数"); plt.ylabel("R²") plt.legend(); plt.show()
SSDSE-B では訓練サンプルが 10 → 38 と増えるにつれ R² が 0.4 → 0.7 と上昇するパターンが典型。 「もっと県別ミクロデータを収集すべきか?」の意思決定材料になります。
実務では「予測精度」だけでなく「公平性」「解釈性」「計算速度」など複数の目標を内側 CV で同時最適化したいことがあります。 Nested CV と Pareto 最適化を組合せる方法:
# 複数指標を同時に評価 from sklearn.model_selection import cross_validate scoring = { 'r2': 'r2', 'mse': 'neg_mean_squared_error', 'mae': 'neg_mean_absolute_error', } results = cross_validate(gs, X, y, cv=outer, scoring=scoring, return_train_score=True) for k in scoring: print(f'{k}: {results["test_" + k].mean():.4f}')
複数指標で性能を確認することで、 「R² は良いが MAE は悪い」のような偏ったモデルを早期発見できます。
内側 CV で大量のグリッドを試すのは非効率。 Optuna や scikit-optimize などのベイズ最適化と Nested CV を組合せることで、 同じ予算でより良いハイパーパラメータが見つかります。
import optuna from sklearn.model_selection import KFold, cross_val_score def objective(trial, X_train, y_train): alpha = trial.suggest_float('alpha', 1e-4, 1e3, log=True) pipe = Pipeline([ ('sc', StandardScaler()), ('r', Ridge(alpha=alpha))]) return cross_val_score(pipe, X_train, y_train, cv=5, scoring='r2').mean() # 外側 fold 毎に Optuna を回す outer_scores = [] for tr, te in outer.split(X): X_tr, X_te = X[tr], X[te] y_tr, y_te = y[tr], y[te] study = optuna.create_study(direction='maximize') study.optimize(lambda t: objective(t, X_tr, y_tr), n_trials=30) best_alpha = study.best_params['alpha'] final = Pipeline([ ('sc', StandardScaler()), ('r', Ridge(alpha=best_alpha))]).fit(X_tr, y_tr) outer_scores.append(final.score(X_te, y_te)) print(f'ベイズ最適化 Nested CV R²: {np.mean(outer_scores):.4f}')
Optuna の TPE サンプラーは過去の試行から有望な領域に絞り込むので、 30 試行でグリッド 100 と同等性能が得られることが多い。 SSDSE-B のような小データで計算予算を効率化したい場面で重宝します。
Nested CV のスコア分布から「予測の信頼区間」を構築する手法を Quantile Nested CV と呼びます。 fold 間のスコア分布の 5%, 95% 分位点を取れば 90% CI が得られます。
scores_rep = cross_val_score(gs, X, y, cv=outer_rep,
scoring='r2')
# 90% 信頼区間
lo, hi = np.percentile(scores_rep, [5, 95])
print(f'R² 90% CI: [{lo:.3f}, {hi:.3f}]')
# Bootstrap で SE を補正
from scipy import stats
res = stats.bootstrap((scores_rep,), np.mean, n_resamples=9999)
print(f'平均 R² の 95% CI: {res.confidence_interval}')Bates et al. (2024) の警告通り、 古典的 CI は狭すぎる傾向。 Bootstrap で補正することで実態に近い区間が得られます。
深層学習でも Nested CV は重要。 ただし PyTorch / TensorFlow には sklearn のような統一 API が無いので、 手作りループが必要です。
import torch import torch.nn as nn from sklearn.model_selection import KFold def train_eval(X_tr, y_tr, X_te, y_te, lr, wd): model = nn.Sequential(nn.Linear(8, 32), nn.ReLU(), nn.Linear(32, 1)) opt = torch.optim.Adam(model.parameters(), lr=lr, weight_decay=wd) # ... 学習ループ省略 ... return mse_test # 外側 fold for tr, te in KFold(5).split(X): # 内側でハイパー探索 best = None for lr in [1e-3, 1e-2, 1e-1]: for wd in [0, 1e-4, 1e-2]: inner_mse = [] for tr2, te2 in KFold(3).split(X[tr]): inner_mse.append(train_eval( X[tr][tr2], y[tr][tr2], X[tr][te2], y[tr][te2], lr, wd)) if best is None or np.mean(inner_mse) < best[0]: best = (np.mean(inner_mse), lr, wd) # 外側評価 outer_mse = train_eval(X[tr], y[tr], X[te], y[te], best[1], best[2])
深層学習では学習率・正則化・バッチサイズ・エポック数など多次元探索が必要で、 Nested CV の計算コストが特に重い。 Optuna + Early Stopping の組合せが定石です。
クラス不均衡(例:99% 正常、 1% 異常)データでの Nested CV では、 fold 内で陽性サンプルが消える「fold corruption」が起きやすい。 StratifiedKFold を内外に強制する必要があります。
from sklearn.model_selection import StratifiedKFold # 不均衡データ用:層化分割 inner = StratifiedKFold(n_splits=5, shuffle=True, random_state=1) outer = StratifiedKFold(n_splits=5, shuffle=True, random_state=0) gs = GridSearchCV(pipe, params, cv=inner, scoring='roc_auc') scores = cross_val_score(gs, X, y, cv=outer, scoring='roc_auc')
指標も accuracy ではなく ROC-AUC や PR-AUC を使い、 SMOTE などのリサンプリングは Pipeline 内に組み込んでリーク防止。 imbalanced-learn の `Pipeline` を使うと自動的に正しく処理されます。
複数のモデル(Ridge vs Lasso vs Random Forest)を Nested CV で比較する場合、 各モデルを独立に Nested CV で評価し、 paired t-test や Wilcoxon 符号付き順位検定で差の有意性を確認します。
from scipy.stats import wilcoxon scores_ridge = cross_val_score(gs_ridge, X, y, cv=outer_rep) scores_lasso = cross_val_score(gs_lasso, X, y, cv=outer_rep) scores_rf = cross_val_score(gs_rf, X, y, cv=outer_rep) # Ridge vs Lasso の検定 stat, p = wilcoxon(scores_ridge, scores_lasso) print(f'Ridge vs Lasso: p={p:.4f}') # 多重比較補正 from statsmodels.stats.multitest import multipletests pvals = [p_rl, p_rr, p_lr] # 3 ペア reject, p_corr, _, _ = multipletests(pvals, method='holm')
Demšar (2006) は「複数モデル比較に Wilcoxon + Holm 補正」を推奨。 paired t-test は正規性仮定が必要ですが、 fold スコアは概ね正規に近いので両方使ってよいです。
| シーン | 推奨設定 | 理由 |
|---|---|---|
| 小標本 ($n<100$) | $K_o=5, K_i=5$, 反復 10 | 分散安定化 |
| 中規模 ($n<10^4$) | $K_o=5, K_i=5$ | 計算と精度のバランス |
| 大規模 ($n \ge 10^5$) | Train-Val-Test | CV は不要 |
| 時系列 | TimeSeriesSplit (内外) | リーク防止 |
| 階層構造 | GroupKFold (内外) | グループリーク防止 |
| 分類・不均衡 | StratifiedKFold + ROC-AUC | 少数クラス保護 |
| 深層学習 | Optuna + 早期終了 | 計算効率 |
| 複数モデル比較 | 反復 Nested + Wilcoxon | 統計的厳密性 |
| 予測区間 | Cross-Conformal | 予測+不確実性 |
| ベイズ評価 | Bayesian CV | 事後分布 |
Nested CV は計算コストが高い。 以下の最適化テクニックで実用的な時間に収めましょう。
Nested CV の結果は論文・レポートで以下のように報告するのが標準です。
この情報があれば再現可能で、 性能の不確実性も伝わります。 Bates et al. (2024) の警告を受けて、 SE は Bootstrap で広めに報告する流儀も広まっています。
代表的なベンチマークデータでの Nested CV の効果:
| データセット | $n$ | 通常 CV (楽観) | Nested CV (真の値) | 差 |
|---|---|---|---|---|
| SSDSE-B-2026 (人口予測) | 47 | R² 0.85 | R² 0.73 | 0.12 |
| Boston Housing | 506 | R² 0.92 | R² 0.87 | 0.05 |
| Iris (分類) | 150 | Acc 0.98 | Acc 0.96 | 0.02 |
| MNIST | 60000 | Acc 0.993 | Acc 0.991 | 0.002 |
| Breast Cancer | 569 | Acc 0.99 | Acc 0.97 | 0.02 |
| SSDSE-A 完全パネル | 300+ | R² 0.79 | R² 0.72 | 0.07 |
一般傾向として、 サンプルが少ないほど、 ハイパーパラメータ次元が高いほど、 通常 CV と Nested CV の差が大きい。 SSDSE-B のような小標本では特に注意が必要です。
Q. Nested CV と通常の CV の違いは?
A. Nested CV では「ハイパーパラメータ選択」と「汎化性能評価」を二つの独立した CV ループで分離します。 通常の CV をハイパーパラメータ選択と評価の両方に使うと、 選択バイアスにより性能が過大評価されます。 Nested CV は外側ループで選択結果に影響されない真の汎化性能を測る点が決定的に異なります。
Q. K_outer=5, K_inner=5 で本当に十分?
A. SSDSE-B-2026 のような小標本 (n=47) では十分です。 ただし計算予算が許すなら反復回数 R=10 で外側 fold をシードを変えて繰り返すと、 SE が 1/√10 ≈ 0.32 倍に縮みます。 大規模データ ($n \ge 10^4$) では K=5 で十分で、 K=10 にしても性能向上は限定的。
Q. Nested CV の最終モデルはどう作るべき?
A. fold ごとに選ばれた θ_k* が異なるため、 「最終モデル」用には全データで再度内側 CV を走らせて最適 θ* を選び、 全データで再学習します。 これは Nested CV のスコアが「アルゴリズム全体の汎化性能」を測っており、 「特定の θ* の性能」ではないからです。
Q. 時系列データに Nested CV を使うときの注意は?
A. 内外両方の CV を TimeSeriesSplit にする必要があります。 KFold (shuffle=True) を使うと「未来から過去を予測」のリークが発生し、 性能が過大評価されます。 さらに「テスト期間の長さ」をビジネス文脈に合わせて選びましょう。
Q. 計算コストを下げるには?
A. (1) n_jobs=-1 で並列化、 (2) 内側のグリッドをベイズ最適化(Optuna)に置換、 (3) Successive Halving で有望な設定だけ深く評価、 (4) GPU 利用、 (5) サブサンプリングでプロトタイプ → 全データで最終確認、 という流れが定番です。
Q. Bates et al. (2024) の警告って何?
A. 彼らは「CV の古典的標準誤差公式が真の SE を過小評価する」ことを示しました。 真の SE は古典値の約 √2 倍。 Nested CV ではこの問題がさらに顕著になるので、 信頼区間は Bootstrap で補正することを推奨します。
Q. 分類タスクでのスコア選択は?
A. クラス不均衡なら ROC-AUC または PR-AUC、 均衡なら accuracy。 医療など false negative が致命的なら recall を主指標に。 多重指標を同時に追跡することで「偏った最適化」を防げます。
Q. Hold-out validation で代用できない?
A. $n \ge 10^4$ なら可能ですが、 $n < 1000$ では Hold-out のスコアの分散が大きく、 ハイパーパラメータの良し悪しを判別できないことが多い。 SSDSE-B-2026 ($n=47$) では Hold-out は単独では信頼できません。
| データセット | サンプル数 | 指標 | 値 | 備考 |
|---|---|---|---|---|
| SSDSE-B-2026 (人口→GDP) | 47 | 通常 CV R² | 0.85 | 楽観的 |
| SSDSE-B-2026 (人口→GDP) | 47 | Nested CV R² | 0.73 | 真の値 |
| Boston Housing | 506 | 通常 CV R² | 0.92 | 楽観的 |
| Boston Housing | 506 | Nested CV R² | 0.87 | 真の値 |
| Iris (分類) | 150 | 通常 CV Acc | 0.98 | 楽観的 |
| Iris (分類) | 150 | Nested CV Acc | 0.96 | 真の値 |
| MNIST | 60000 | 通常 CV Acc | 0.993 | ほぼ一致 |
| MNIST | 60000 | Nested CV Acc | 0.991 | ほぼ一致 |
| Breast Cancer | 569 | 通常 CV ROC-AUC | 0.99 | 楽観的 |
| Breast Cancer | 569 | Nested CV ROC-AUC | 0.97 | 真の値 |
医療画像診断
CT/MRI 画像から疾患予測。 患者 ID 単位の GroupKFold + Nested CV で「同じ患者の複数画像」のリークを防ぐ。
創薬 QSAR
化合物の活性予測。 化学骨格単位でグループ化、 「同じ scaffold が学習・テストに分裂」を防ぐ。
金融時系列
株価・為替予測。 TimeSeriesSplit の内外で Nested CV、 過去のみで学習。
マーケティング
顧客 LTV 予測。 顧客単位 Group + 時系列分割の組合せ。
製造業 IoT
機械故障予測。 機械 ID + 期間でリーク防止。
教育
学生の成績予測。 学校単位グループでリーク防止。
レコメンデーション
コールドスタートユーザー予測。 ユーザー単位 leave-one-out 分割。
NLP
文書分類。 トピックや著者単位でグループ化。
Nested CV は「データを限られた量しか持たない統計家・データサイエンティスト」の必須ツールです。 大きく以下の流派と関係があります。
Nested CV は計算コストと一貫性のバランスで現在の標準。 「データから直接性能を測る」という哲学が、 仮定の少なさで最も頑健です。
| 手法 | 必要計算量 | SE 精度 | 仮定 |
|---|---|---|---|
| Nested CV | $O(K^2 |\Theta|)$ | 高 | i.i.d. |
| 反復 Nested CV | $O(R K^2 |\Theta|)$ | 最高 | i.i.d. |
| Bayesian CV | $O(K |\Theta|)$ | 中 | 事後分布 |
| LOO-PSIS | $O(1)$ + MCMC | 中 | ベイズ |
| AIC/BIC | $O(|\Theta|)$ | 低 | 最尤・大標本 |
| Hold-out | $O(|\Theta|)$ | 極低 | i.i.d. |
from sklearn.pipeline import Pipeline from sklearn.preprocessing import StandardScaler from sklearn.linear_model import Ridge pipe = Pipeline([ ('sc', StandardScaler()), ('r', Ridge())]) params = {'r__alpha': np.logspace(-3, 3, 25)}
from sklearn.ensemble import RandomForestRegressor pipe = Pipeline([('rf', RandomForestRegressor(random_state=0))]) params = { 'rf__n_estimators': [100, 300, 500], 'rf__max_depth': [None, 5, 10, 20], 'rf__min_samples_split': [2, 5, 10]}
import xgboost as xgb pipe = Pipeline([('xgb', xgb.XGBRegressor(random_state=0))]) params = { 'xgb__n_estimators': [100, 300], 'xgb__max_depth': [3, 6, 9], 'xgb__learning_rate': [0.01, 0.1, 0.3]}
from sklearn.svm import SVR pipe = Pipeline([ ('sc', StandardScaler()), ('svr', SVR())]) params = { 'svr__C': np.logspace(-2, 2, 10), 'svr__gamma': np.logspace(-3, 1, 10), 'svr__epsilon': [0.01, 0.1, 1]}
import lightgbm as lgb pipe = Pipeline([('lgb', lgb.LGBMRegressor(random_state=0))]) params = { 'lgb__num_leaves': [31, 63, 127], 'lgb__learning_rate': [0.01, 0.05, 0.1], 'lgb__feature_fraction': [0.5, 0.8, 1.0]}
これらの雛形を `gs = GridSearchCV(pipe, params, cv=inner)` で包み、 `cross_val_score(gs, X, y, cv=outer)` で評価すれば、 すぐに本格的 Nested CV パイプラインが完成します。 SSDSE-B-2026 のような小データには Ridge と Random Forest を、 大規模データには LightGBM / XGBoost を推奨します。