論文一覧に戻る 📚 用語解説(ジャストインタイム型データサイエンス教育)
Nested クロスバリデーション
Nested Cross-Validation (Nested CV)
機械学習の「ハイパーパラメータ調整 × 性能評価」を同時に行うと、性能が過大評価される問題を解決する技法。
外側 CV で「未知データへの汎化性能」を測り、 内側 CV で「α や C の最適値」を選ぶ。 5×3-fold Nested CV のような表記でよく出てくる。
機械学習 モデル選択 汎化性能 バイアス補正

🔖 キーワード索引

日本語名・英語名・別名・略語・混同しやすい語をまとめる。 同義の表記は論文や Kaggle notebook で頻繁に登場するので、 検索時の手がかりとして全て押さえておく。

アンカーリンクで本ページ内をジャンプ: 📍 文脈💡 30秒結論🎨 直感📐 数式🔬 読み解き🧮 実値計算🐍 Python⚠️ 落とし穴🌐 派生🔗 関連📚 教材🗺 概念マップ

💡 30秒で分かる結論

🍰 まずはやさしく

二重の枠組みで評価する方法です。

正しい性能を測るために使います。

部活の練習と本番を分ける感覚です。

この手法の結論を短くまとめます。

📍 あなたが今見ているもの

🍰 まずはやさしく

専門的な書き方のルールです。

分析の結果を正しく伝えるために使います。

スマホのアプリの評価画面のようなものです。

実際の表記例と意味を解説します。

機械学習の論文や Kaggle notebook で、 こんな表記を見たことはないですか:

Outer 5-fold × Inner 3-fold nested CV で評価
Ridge α: tuned via inner CV ∈ {0.01, 0.1, 1, 10}
Test RMSE = 245.3 (SD across 5 outer folds: ±18.7)

これらは Nested Cross-Validation(入れ子型クロスバリデーション) の表現。 「普通の CV で性能を出して、 同じデータでハイパーパラメータも調整した」というナイーブな手順は、 性能を楽観的に見積もる致命的バイアス を含みます。 Nested CV は「モデル選択用の CV」と「性能評価用の CV」を入れ子にすることで、 このバイアスを除去する標準的な方法論です。

🎨 直感で掴む — 「同じ問題集で勉強して同じ問題集でテストする」問題

🍰 まずはやさしく

テストのカンニングを防ぐ仕組みです。

本当の実力を知るために使います。

同じ問題集で勉強して試験を受ける例です。

なぜこの方法が必要か直感的に説明します。

🎯 ナイーブ CV のバイアス

普通の交差検証(CV)でモデル選択と性能評価を同時に行うと、 こんなことが起きます:

  1. K=5 fold に分割
  2. 各 α ∈ {0.01, 0.1, 1, 10} で 5 fold CV の平均 RMSE を計算
  3. 最も RMSE が小さい α を「最適 α」として選ぶ
  4. その最適 α での 5 fold CV 平均 RMSE を「最終性能」として報告

⚠️ 問題:手順 4 の「最終性能」は同じ fold 構造での CV 平均なので、 すでに「この fold でうまくいく α」を選んだ後の評価です。 つまりテストデータが間接的に α 選択に使われた。 結果、 報告される RMSE は実環境(真に未知のデータ)よりも小さく(楽観的に)出る。

🎓 喩え:模試と本番試験

大学受験で、 「直前模試の問題」と「本番試験の問題」が同じだったら、 模試で「この勉強法が一番点数が出る」と判明した方法は、 本番でも当然点数が出る。 でもそれは「本当に頭が良いから」ではなく、 「答えを知っていたから」です。

Nested CV の発想:

📊 nested CV vs naive CV の数字感

典型的な経験則:

シナリオnaive CV の RMSENested CV の RMSE差(バイアス)
ハイパラ 4 候補、 n=2000.850.92+0.07(楽観バイアス)
ハイパラ 20 候補、 n=2000.780.95+0.17(さらに楽観)
ハイパラ 100 候補、 n=2000.720.99+0.27(致命的)
ハイパラ 100 候補、 n=10,0000.850.86+0.01(無視できる)

つまり (1) ハイパラ候補が多いほど、 (2) サンプルが少ないほど、 naive CV のバイアスは大きくなる。 Kaggle で「CV では当たったのに、 LB(leaderboard)でガッカリ」というのは多くの場合これが原因。

🎨 概念図で押さえる

Nested CV は 「外側 CV で性能評価、 内側 CV でハイパラ選択」を分離する二重ループ構造。 以下 3 つの図で「二重ループ」「選択バイアス回避」「単純 CV との誤差差」を視覚化する。

図 1: 二重ループの構造

外側 CV: 5-fold (性能評価用) Outer Fold 1 Outer Fold 2 Outer Fold 3 Outer Fold 4 Outer 5 内側 CV: 5-fold (ハイパラ選択) In1 In2 In3 In4 → best_C, best_gamma 決定 外側ループは合計 5 回 内側ループは外側ごと 5 回 合計 5 × 5 = 25 回 fit

外側でモデル評価、 内側でハイパラ調整。 計算コストは単純 CV の k 倍だが、 選択バイアスを排除できる。

図 2: 選択バイアスの発生メカニズム

❌ 単純 GridSearchCV ① 同じ CV データでハイパラ探索 ② best_score_ を報告 → そのスコアは「最大化された値」 → 楽観的バイアス (0.05-0.1 過大) 例: best_score_ = 0.064 真の汎化性能 ≈ -0.05 差 = 0.116 が選択バイアス ✅ Nested CV ① 内側でハイパラ選択 ② 外側で性能評価 (独立) → 「未使用データ」での評価 → ほぼ偏りなし 例: nested_score = -0.052 真の汎化性能 ≈ -0.05 差 ≈ 0 (誤差範囲内)

→ 単純 CV は 「同じデータでハイパラ選んで同じデータで評価」するため楽観的。 Nested CV は内側と外側を分離するため公正な評価ができる。

図 3: ハイパラ空間と内側 CV の役割

log C accuracy 内側 CV の最良点 C=1.0 候補 候補 内側 CV の役割 「外側に何も見せず」 最良 C を 1 つ返すだけ

内側 CV はハイパラを 1 つ返すブラックボックス。 外側はそれを受け取って独立データで評価するだけなので、 ハイパラ探索の楽観的バイアスから守られる。

🎨 補足図で押さえる(サンプルサイズ・正則化・残差の視点)

サンプル分割と評価
図 4: Nested CV の核心は「内側 CV でハイパラ選択、 外側 CV で性能評価」の二段構造。 標本サイズと検証分割の関係を示す。
ハイパラ探索と過学習
図 5: 単一の CV でハイパラを選んで同じデータで評価すると楽観的バイアスが入る。 Nested CV はその過学習を防ぐ。
汎化誤差の評価
図 6: 外側 CV の各 fold で計算された残差を集計することで汎化誤差の不偏推定が得られる。

🎮 触って納得 — 外側/内側フォールドと「楽観バイアス」実験室

この対話ウィジェットは3つの体験を提供する。 (A) 外側フォールドと内側フォールドの入れ子分割を色分けで可視化し、 「外側テストフォールドは内側のハイパラ選択に一切使われない」ことを体感する。 (B) 信号が全く無い架空データ(シード固定・明記)で、 「非ネスト CV(同じデータで選択と評価)」の楽観バイアスと「ネスト CV」の不偏推定をブラウザ内でその場計算し数値対比する。 (C) 計算コスト(外 K × 内 K 倍)を可視化する。 スライダはすべて即座に再計算に反映される。

(A) 入れ子分割の可視化 — 色で「隔離」を体感

外側の帯をクリック/タップすると「現在の外側テストフォールド」を切り替えできる。 その帯は赤(評価専用・内側に非公開)になり、 下段の内側 CV には決して現れない。 内側の帯をクリックすると内側検証フォールドが動く。

(B) 楽観バイアス実験 — 非ネスト vs ネスト(信号ゼロの架空データ)

架空データは y と特徴量が完全に無相関(信号ゼロ)なので、 真の汎化精度は必ず 50%(偶然)。 各候補は1特徴量の最近傍セントロイド分類器(決定株)。 非ネストは |Λ| 個の候補の CV 精度の最大値を報告(=勝者の呪い)、 ネストは外側で独立評価。 上記反復回数だけ架空データを作り平均する。

(C) 計算コスト — 外 K × 内 K 倍

※ ブラウザ内 JavaScript で mulberry32 決定的乱数を用いその場計算。 同じシード・同じスライダなら誰の環境でも同一値。 デフォルト(n=80, |Λ|=20, Kouter=5, Kinner=4, 反復100, seed=42)で 非ネスト ≈ 62.5%・ネスト ≈ 51.2%・真値 50%(node 検証済)。

📐 数式 — Nested CV の定式化

🍰 まずはやさしく

計算の手順をまとめた設計図です。

正確な処理の流れを決めるために使います。

買い物リストを細かく分ける感覚です。

データの分け方を詳しく説明します。

データセット $\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)} = \mathcal{D} \setminus \mathcal{D}^{\text{outer}}_k, \quad \mathcal{D}_{\text{test}}^{(k)} = \mathcal{D}^{\text{outer}}_k$$

内側ループ(外側の各 fold k 内で)

$\mathcal{D}_{\text{train}}^{(k)}$ を $K_i$ 個に分割し、 各 $\lambda \in \Lambda$ について:

$$\hat{R}_{\text{inner}}^{(k)}(\lambda) = \frac{1}{K_i}\sum_{j=1}^{K_i} L\left(\mathcal{A}_\lambda(\mathcal{D}_{\text{train}}^{(k)} \setminus \mathcal{D}_{\text{inner}, j}^{(k)}),\; \mathcal{D}_{\text{inner}, j}^{(k)}\right)$$
内側 CV の平均損失。 これでハイパラ $\lambda$ を比較。

最適ハイパラを選択:

$$\hat{\lambda}^{(k)} = \underset{\lambda \in \Lambda}{\arg\min}\;\hat{R}_{\text{inner}}^{(k)}(\lambda)$$

外側ループでの性能評価

$\hat{\lambda}^{(k)}$ を使って外側の訓練データ全体で再学習し、 外側テストで評価:

$$\hat{R}_{\text{outer}}^{(k)} = L\left(\mathcal{A}_{\hat{\lambda}^{(k)}}(\mathcal{D}_{\text{train}}^{(k)}),\; \mathcal{D}_{\text{test}}^{(k)}\right)$$

最終的に外側 fold での平均と標準偏差を報告:

$$\bar{R}_{\text{nested}} = \frac{1}{K_o}\sum_{k=1}^{K_o} \hat{R}_{\text{outer}}^{(k)}, \quad \widehat{\mathrm{SD}} = \sqrt{\frac{1}{K_o - 1}\sum_{k=1}^{K_o} \left(\hat{R}_{\text{outer}}^{(k)} - \bar{R}_{\text{nested}}\right)^2}$$
これが「真の汎化性能」の不偏推定とその不確実性。

計算コスト

$$\text{学習回数} = K_o \times K_i \times |\Lambda| + K_o$$
例:5×3 nested CV、 ハイパラ 4 候補なら 5×3×4 + 5 = 65 回の学習。

📐 アルゴリズム手順 — 二重ループの詳細

Nested CV は二重ループ構造を取ります。 外側ループで $K_{\text{outer}}$ 個の fold を作り、 各 fold で「テストデータを完全に隔離」。 内側ループで残りの訓練データを $K_{\text{inner}}$ 個に分け、 ハイパーパラメータを選択。 選ばれたハイパーパラメータで訓練データ全体で再学習し、 隔離されたテストデータで評価。 これを $K_{\text{outer}}$ 回繰り返した平均が「不偏」な汎化性能推定値となります。

  1. Step 1:データ $D$ を外側 $K_{\text{outer}}$ 分割 → $D_1^{\text{out}}, \ldots, D_{K_{\text{outer}}}^{\text{out}}$
  2. Step 2:各外側 fold $k$ について:訓練データ $D \setminus D_k^{\text{out}}$ を取り出す
  3. Step 3:訓練データを内側 $K_{\text{inner}}$ 分割 → 内側 CV でハイパーパラメータ $\theta_k^\star$ を選択
  4. Step 4:訓練データ全体で $\theta_k^\star$ を用いてモデルを学習し、 $D_k^{\text{out}}$ で性能を測る
  5. Step 5:外側 $K_{\text{outer}}$ 個の性能スコアを平均し SE を計算
  6. Step 6:最終モデルは全データで再学習(ただし $\theta_k^\star$ は fold 毎に異なるので、 「合意」を取るか全データで再選択)

重要なのは、 外側 fold のテストデータ $D_k^{\text{out}}$ は内側 CV のハイパーパラメータ選択に一切使われないこと。 これにより「データを見てから選んだ」というバイアスが排除されます。

🎯 $K_{\text{outer}}$ と $K_{\text{inner}}$ の選び方

$K_{\text{outer}}$$K_{\text{inner}}$計算量倍率バイアス分散推奨場面
5525×デフォルト・$n \ge 100$
10550×極低小標本 $n < 100$
1010100×極低計算予算潤沢時
$n$ (LOO)5$5n$×極低極高$n \le 50$ のとき
33高速プロトタイプ用
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$ 倍に縮みます。

🔬 数式を「言葉」で読み解く

$\mathcal{D}^{\text{outer}}_k$(外側 fold)
性能評価用に取っておくデータ」。 K_o = 5 なら全データの 1/5。 この fold は外側ループの 1 回の反復で絶対に 訓練やハイパラ選択に触れない。
$\mathcal{D}_{\text{train}}^{(k)}$(外側訓練)
外側 fold k 以外の全データ」。 これを内側 CV にかけてハイパラを決定 → 全体で再学習。
$\hat{R}_{\text{inner}}^{(k)}(\lambda)$(内側 CV 損失)
外側 fold k を使わず、 ハイパラ λ がどれくらい良いか」を内側 K_i fold CV で評価した値。 ハイパラ選択のみに使う。
$\hat{\lambda}^{(k)}$(外側 fold k での最適ハイパラ)
外側 fold ごとに、 異なる最適ハイパラが選ばれてもよい」。 これが「単一の最適ハイパラを報告できないか?」という疑問への答え(→ 落とし穴セクションで解説)。
$\hat{R}_{\text{outer}}^{(k)}$(外側性能)
外側 fold k で、 真に未知データを見たときの損失」。 これが不偏推定の本体。 内側ループは「外側の訓練データだけ」で完結しているので、 外側テストへの漏洩は理論上ゼロ。
$\widehat{\mathrm{SD}}$(fold 間の標準偏差)
fold によって性能がどれくらいブレるか」。 K_o = 5 なら 5 つの値の SD を計算。 「実環境で何度もデプロイしたら性能がどれくらい変動するか」の代理指標。

💡 重要な性質:Nested CV は 「最終的にデプロイするモデル」を生成する手続きではないこと。 あくまで「ハイパラチューニング + 学習のパイプライン全体の性能を測る」プロトコル。 デプロイには別途、 全データで内側 CV を再実行して最適 λ を決め、 全データで学習する。

🔬 数式を言葉で読み解く(補講: ネスト CV の実装ハンズオン)

ネスト交差検証は「ハイパーパラメータ選択」と「汎化性能推定」を分離するための二重ループ構造である。 ここでは SSDSE-B-2026 を用いて、 内側ループでチューニング、 外側ループで純粋な汎化誤差を測定する流れを 4 つの Python ブロックで具体化する。

▶ ブロック 1: SSDSE-B-2026 の読込と特徴量の準備

このコードでやること: SSDSE-B-2026(2023 年・47 都道府県)を読み込み、 実在コード列(A1101=総人口/A1303=65歳以上人口/A4101=出生数/B4101=年平均気温)を特徴量、 L3221=消費支出(二人以上世帯)を目的変数とするデータセットを準備する。 ネスト CV を試す土台になる。

📥 入力データ (抜粋):

SSDSE-B-2026, Code, Prefecture, A1101, A1303, A4101, B4101, L3221, ...(2023 年 47 行) R01000, 北海道, A1101=5092000, A1303=1681000, A4101=24430, B4101=11.0, L3221=296888 R13000, 東京都, A1101=14086000, A1303=3205000, A4101=86348, B4101=17.6, L3221=341320
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
import pandas as pd
from sklearn.preprocessing import StandardScaler

# SSDSE-B-2026 を読込(cp932・2 行目の日本語ヘッダを skiprows=[1] で除外)
df = pd.read_csv('data/raw/SSDSE-B-2026.csv', encoding='cp932', skiprows=[1])
df = df[df['SSDSE-B-2026'] == 2023].copy()   # 最新年(47 都道府県)

feat = ['A1101', 'A1303', 'A4101', 'B4101']  # 総人口/65歳以上人口/出生数/年平均気温
y = df['L3221'].values                        # 目的変数: 消費支出(二人以上世帯)
X = df[feat].fillna(df[feat].median()).values
X = StandardScaler().fit_transform(X)

print(f'X shape: {X.shape}, y shape: {y.shape}')
print(f'目的変数 y (L3221 消費支出) range: {y.min()} - {y.max()}')

📤 実行例:

X shape: (47, 4), y shape: (47,) 目的変数 y (L3221 消費支出) range: 223423 - 344092

💬 47 県 × 4 特徴量の小規模データ。 サンプル数が少ないため通常の Hold-out では汎化評価が不安定で、 ネスト CV の出番である。

▶ ブロック 2: 外側ループ + 内側 GridSearchCV (Ridge)

このコードでやること: 外側 5-fold、 内側 3-fold でネスト CV を実行。 内側で Ridge の α をチューニングし、 外側で各 fold の RMSE を集計する。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
import numpy as np
from sklearn.linear_model import Ridge
from sklearn.model_selection import KFold, GridSearchCV, cross_val_score

inner = KFold(n_splits=3, shuffle=True, random_state=0)
outer = KFold(n_splits=5, shuffle=True, random_state=0)

param_grid = {'alpha': [0.01, 0.1, 1, 10, 100]}
gs = GridSearchCV(Ridge(), param_grid, cv=inner,
                  scoring='neg_root_mean_squared_error')
scores = cross_val_score(gs, X, y, cv=outer,
                         scoring='neg_root_mean_squared_error')
print(f'外側 RMSE 各 fold: {(-scores).round(1)}')
print(f'外側 RMSE 平均 ± SD: {-scores.mean():.1f} ± {scores.std():.1f}')

📤 実行例:

外側 RMSE 各 fold: [18784.1 24580.5 20973.6 32305.4 20055.4] 外側 RMSE 平均 ± SD: 23339.8 ± 4879.8

💬 消費支出(円)の 5 fold 平均 RMSE 約 23,300 円。 各 fold で α が個別に最適化されているため、 単純な GridSearchCV より楽観的でない正直な汎化指標。

▶ ブロック 3: 通常の CV と比較し「楽観バイアス」を可視化

このコードでやること: 同じデータで非ネスト CV (GridSearchCV のみ) を回し、 ネスト CV の結果と比較。 楽観バイアスがどの程度生じるかを定量化する。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
from sklearn.model_selection import GridSearchCV

gs_flat = GridSearchCV(Ridge(), param_grid, cv=outer,
                       scoring='neg_root_mean_squared_error')
gs_flat.fit(X, y)
flat_rmse = -gs_flat.best_score_

nested_rmse = -scores.mean()
print(f'非ネスト CV RMSE (best score): {flat_rmse:.1f}')
print(f'ネスト CV RMSE 平均: {nested_rmse:.1f}')
print(f'楽観バイアス (差分): {nested_rmse - flat_rmse:.1f}')

📤 実行例:

非ネスト CV RMSE (best score): 22431.2 ネスト CV RMSE 平均: 23339.8 楽観バイアス (差分): 908.7

💬 非ネスト CV はデータをチューニングと評価の双方に使うため、 RMSE が約 900 円分楽観的に(小さく)出る。 ネスト CV は計算コストが 5×3=15 倍になるが、 「本当の汎化誤差」を正直に出してくれる。

⚠️ 環境依存の注記(実測・再現条件):上の RMSE 数値は Python 3・scikit-learn 1.9・NumPy 2.3、 seed 固定(内側 random_state=0/外側 random_state=0)、 ブロック 1 で特徴量を標準化した X を用いた当環境の実測値です(外側 fold RMSE = [18784.1, 24580.5, 20973.6, 32305.4, 20055.4]、 平均 23339.8 ± 4879.8、 非ネスト 22431.2、 楽観バイアス +908.7、 fold 別 best α = [10, 10, 100, 100, 0.01])。 重要:この楽観バイアスは標準化を行った場合に現れます。 標準化せず生の特徴量(総人口など桁の大きい列)のまま同じ α グリッドを回すと、 内側 CV は常に最大の α=100 を選び(正則化が実質効かないため)、 fold 別 α は [100, 100, 100, 100, 100] で固定、 非ネスト=ネスト=22634.5 円となりバイアスはほぼ 0 に潰れます。 「弱い探索(α 5 候補)× 小標本(n=47)× α 選択が飽和する条件」では楽観バイアスは小さく、 逆に標準化して α 選択が実際に fold ごとに動く条件でこそ楽観バイアスが顕在化する、 というのが当環境の実測から読み取れる教訓です。

▶ ブロック 4: fold ごとの選択 α を取り出して安定性を診る

このコードでやること: 外側 fold ごとに内側で選ばれた α を確認。 fold をまたいで α が大きくぶれるなら「不安定なチューニング」のサインで、 特徴量選定の再考が必要。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
from sklearn.model_selection import KFold

selected_alphas = []
for tr, te in outer.split(X):
    gs2 = GridSearchCV(Ridge(), param_grid, cv=inner,
                       scoring='neg_root_mean_squared_error')
    gs2.fit(X[tr], y[tr])
    selected_alphas.append(gs2.best_params_['alpha'])

print(f'外側 fold ごとの best α: {selected_alphas}')
print('α の分布が一致 → チューニング安定、 ばらつく → 不安定')

📤 実行例:

外側 fold ごとの best α: [10, 10, 100, 100, 0.01] α の分布が一致 → チューニング安定、 ばらつく → 不安定

💬 5 fold 中 fold ごとに α が 10・10・100・100・0.01 と 4 桁も揺れており、 サンプル数 47 に対してハイパラ選択が不安定なサイン。 これは「naive CV の 1 個の best α」だけを見ていては決して気づけない情報で、 特徴量の見直しや反復 Nested CV を検討すべき合図。 交差検証ハイパーパラメータ調整 と組み合わせる際に確認したい指標。

🧮 実値で計算してみる — SSDSE-B 消費支出予測タスク

タスク設定

SSDSE-B-2026 の 47 都道府県データ(2023 年)から、 実在コード列(A1101=総人口、 A1303=65歳以上人口、 A4101=出生数、 B4101=年平均気温) を特徴量にして、 L3221=消費支出(二人以上世帯)を予測するリッジ回帰モデルを構築。 Ridge の正則化強度 α を Nested CV でチューニング。

STEP 1:データ構造とサイズ

STEP 2:nested CV の設定

STEP 3:計算結果(実データ)

【SSDSE-B 47県(2023)・nested CV の結果】
$$\bar{\text{RMSE}}_{\text{nested}} = 23339.8 \text{ 円} \pm 4879.8 \text{(fold 間 SD)}$$
naive 5-fold CV だと 22431.2 円 → 約 900 円(約 4%)の楽観バイアスがあった。
外側 fold選ばれた α外側 RMSE(円)
11018784.1
21024580.5
310020973.6
410032305.4
50.0120055.4
平均23339.8 ± 4879.8

解釈

📊 Cawley & Talbot (2010) の実証 — なぜ Nested CV が必要か

機械学習における 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% 程度性能を楽観的に評価することを実証しました。

論文の核心メッセージ

  1. モデル選択は、 それ自体が「学習」である。 ハイパラを CV で最適化する手順は、 そのハイパラ空間に対するメタ学習。 学習であるからには過学習が起こりうる
  2. 同じデータで「メタ学習」と「最終評価」を行うと、 評価がバイアスを受ける。 これは Vapnik の VC 理論や Bonferroni 補正と同じ「多重比較問題」の現れ
  3. 解決策は Nested CV か、 別個の test set。 メタ学習プロセス全体を外側ループで評価することで、 評価セットへの情報漏洩を断つ

論文の図表が示すもの

Cawley & Talbot は、 SVM のハイパラ(C と γ)を様々な手法で選択した結果を比較。 ナイーブ CV では「データが少ない場合に特に楽観バイアスが大きい」「ハイパラ候補が多いとバイアスが大きい」という 2 つの傾向を実証。 一方 Nested CV ではバイアスが事実上消失。

業界への影響

この論文以降、 機械学習の主要会議(NeurIPS, ICML)では、 ハイパラ調整を含む手法を提案する論文には Nested CV による評価 が事実上必須となりました。 Kaggle や DrivenData などのコンペでも、 「validation set はモデル選択に使う、 final leaderboard は完全分離した test set」というプロトコルが定着しています。

🎯 K_outer と K_inner はいくつにすべきか

Nested CV の唯一の自由パラメータは、 外側 fold 数 K_o と内側 fold 数 K_i です。 経験則と理論的な目安を整理:

外側 K_o の選び方

データサイズ n推奨 K_o理由
n < 50LOOCV(K = n)1 fold ≈ 1 サンプル、 fold 間の分散は大きいが、 訓練データが最大化される
50 ≤ n < 50010-fold慣習的なベストプラクティス。 fold 間の分散と訓練データ量のバランス
500 ≤ n < 10,0005-fold or 10-fold計算量と精度のバランス。 5-fold の方が高速
n ≥ 10,000Holdout(train/val/test)でも十分Nested CV は計算量で割に合わない

内側 K_i の選び方

計算コストの目安

SVM のように学習に O(n²) 〜 O(n³) かかるアルゴリズムでは:

🧮 数式に値を入れて手で計算する: ネスト CV のフィット数

合成データで外 K=5、 内 K'=3、 グリッド 10 候補の総フィット数を計算する。

Step 1: 構造

外ループ: 5 fold (各 train+val) 内ループ: 3 fold × 10 グリッド = 30 フィット 外ループ最終モデル: 1 フィット (best param で full train)

Step 2: 合計

内ループ合計 = 5 × 30 = 150 外ループ最終 = 5 全フィット数 = 150 + 5 = 155

🐍 Python で再現

1
2
3
4
5
6
7
8
9
outer = 5
inner = 3
grid = 10
inner_total = outer * inner * grid
outer_final = outer
total = inner_total + outer_final
print(f"内ループ: {inner_total}")
print(f"外ループ最終: {outer_final}")
print(f"合計: {total}")

📤 実行結果

内ループ: 150 外ループ最終: 5 合計: 155

💬 手計算 (Step 2) 155 と Python 出力が完全一致。

🐍 Python 実装 — sklearn での Nested CV

方法 A:シンプルな手動実装(教育用)

📥 入力例(SSDSE-B-2026 の 2023 年・47 都道府県から 3 行) 都道府県 A1101(総人口) A1303(65歳以上人口) A4101(出生数) B4101(年平均気温) L3221(消費支出(二人以上の世帯)) 北海道 5,092,000 1,681,000 24,430 11.0 296,888 東京都 14,086,000 3,205,000 86,348 17.6 341,320 沖縄県 1,468,000 350,000 12,549 23.8 251,222 …(全 47 行)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
# 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

# データ読込(cp932・日本語ヘッダ行を skiprows=[1] で除外)
df = pd.read_csv('data/raw/SSDSE-B-2026.csv', encoding='cp932', skiprows=[1])
df = df[df['SSDSE-B-2026'] == 2023].copy()   # 最新年(47 都道府県)

# 特徴量: 実在コード列(総人口/65歳以上人口/出生数/年平均気温)
feature_cols = ['A1101', 'A1303', 'A4101', 'B4101']
X = df[feature_cols].fillna(df[feature_cols].median()).values.astype(float)
y = df['L3221'].values.astype(float)         # 目的変数: 消費支出(二人以上世帯)
print(f'サンプル数: {len(y)}, 特徴量数: {X.shape[1]}')

# Nested CV
alpha_grid = [0.01, 0.1, 1, 10, 100]
outer_cv = KFold(n_splits=5, shuffle=True, random_state=0)
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=0)
    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}')
📤 実行例(実測) サンプル数: 47, 特徴量数: 4 Fold 1: α=10, outer RMSE=18721.11 Fold 2: α=10, outer RMSE=24578.56 Fold 3: α=100, outer RMSE=21023.19 Fold 4: α=100, outer RMSE=32081.96 Fold 5: α=0.01, outer RMSE=19905.63 Nested CV RMSE: 23262.09 ± 5394.96 選ばれた α 群: [10, 10, 100, 100, 0.01]

方法 B:sklearn の GridSearchCV + cross_val_score(推奨)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
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 RMSE: 21917.41 ± 7686.36

方法 C:deployment 用に「全データで再学習」

1
2
3
4
5
6
# 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_ がデプロイ用モデル
📤 実行例(実測) 最終モデル α: {'ridge__alpha': 0.1}

方法 D:層化 / 時系列対応の nested CV

📥 入力例(SSDSE-B-2026 の 2023 年・47 都道府県から 3 行) 都道府県 A1101(総人口) A1303(65歳以上人口) A4101(出生数) L3221(消費支出(二人以上の世帯)) 北海道 5,092,000 1,681,000 24,430 296,888 東京都 14,086,000 3,205,000 86,348 341,320 沖縄県 1,468,000 350,000 12,549 251,222 …(全 47 行)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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
# ── この抜粋で使うデータを用意します ──
import pandas as pd

df = pd.read_csv('data/raw/SSDSE-B-2026.csv', encoding='cp932', skiprows=[1])
df = df[df['SSDSE-B-2026'] == 2023].copy()   # 最新年(47 都道府県)
X = df[['A1101', 'A1303', 'A4101']].to_numpy(dtype=float)
y = df['L3221'].to_numpy(dtype=float)

# 列名は 'pref' ではなく 'Prefecture'
groups = df['Prefecture'].values  # 都道府県名
outer_cv = GroupKFold(n_splits=5)
for tr, te in outer_cv.split(X, y, groups=groups):
    # 同じ都道府県は train/test に分かれない(リーク防止)
    pass

方法 E:MLxtend ・ Optuna との組合せ

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# Optuna でベイズ最適化を内側に組み込む例
# X, y, groups, outer_cv は「方法 D」で用意したものをそのまま使う
import numpy as np
import optuna
from sklearn.linear_model import Ridge
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import mean_squared_error
from sklearn.model_selection import cross_val_score

optuna.logging.set_verbosity(optuna.logging.WARNING)

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, chosen = [], []
# GroupKFold は groups を渡さないと分割できない(渡さないと ValueError)。
# 方法 D で作った「都道府県」の groups をそのまま外側の分割に使う。
for tr_idx, te_idx in outer_cv.split(X, y, groups=groups):
    study = optuna.create_study(direction='minimize',
                                sampler=optuna.samplers.TPESampler(seed=42))
    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']
    chosen.append(round(best_alpha, 3))
    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('外側 fold ごとの best alpha:', chosen)
print(f'Bayes-Nested CV: {np.mean(outer_rmses):.2f} ± {np.std(outer_rmses, ddof=1):.2f}')

📤 実行結果:

外側 fold ごとの best alpha: [0.009, 0.017, 8.29, 0.024, 0.017] Bayes-Nested CV: 22872.82 ± 4287.21

💬 結果の読み方: 内側をグリッド探索からベイズ最適化 (TPE, 20 試行) に替えても、 外側 RMSE は 22872.82 ± 4287.21 で、 グリッド版の 23339.8 ± 4879.8 とほぼ同じ。 入れ子交差検証が測っているのは「探索アルゴリズムを含む手続き全体」の汎化性能なので、 探索が賢くなっても外側の推定値が勝手に良くなるわけではない、 という点が重要。 ここで外側 RMSE だけが下がったなら、 それは性能向上ではなく外側テストへの漏れを疑うべき合図になる。 なお α は fold ごとに 0.009〜8.29 とばらついており、 このデータでは正則化の強さが あまり効いていない(対数スケールで動かしてもスコアが平坦)ことを示している。

🐍 Python 実装 — sklearn での完全ワークフロー

sklearn の `GridSearchCV` を `cross_val_score` で包むのが標準パターン。 内側 CV は GridSearch 内で自動的に走り、 外側は cross_val_score が制御します。

🎯 解説: SSDSE-B-2026 の都道府県データで Ridge の alpha を Nested CV で選び、 不偏な汎化性能を推定する標準ワークフロー。 内側 5-fold で 25 個のグリッドを探索し、 外側 5-fold で評価する。
📥 入力例: data/raw/SSDSE-B-2026.csv(cp932・skiprows=[1]・2023 年 47 行) X = df[['A1101','A1303','A4101','B4101']] # 総人口/65歳以上/出生数/気温 y = df['L3221'] # 目的変数: 消費支出(二人以上世帯)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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) 実データ読み込み(cp932・日本語ヘッダ行を skiprows=[1] で除外)
df = pd.read_csv('data/raw/SSDSE-B-2026.csv', encoding='cp932', skiprows=[1])
df = df[df['SSDSE-B-2026'] == 2023].copy()          # 最新年(47 都道府県)
cols = ['A1101', 'A1303', 'A4101', 'B4101']         # 総人口/65歳以上/出生数/気温
X = df[cols].fillna(df[cols].median()).values
y = df['L3221'].values.astype(float)                # 目的変数: 消費支出(二人以上世帯)

# 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}')
📤 出力例: Nested CV R²: -0.052 ± 0.202 外側 5 fold の平均と標準偏差で、 真の汎化性能を不偏推定。
💬 ポイント: 単純 GridSearchCV の best_score_ (0.064) より Nested CV (-0.052) のほうが小さい。 この差約 0.116 が「選択バイアス」の正体。 消費支出は 4 指標だけでは説明力がほぼ無く(R²≈0)、 naive CV はそれを 0.064 と楽観視してしまうが、 Nested CV は「真の説明力はゼロ」という現実を暴く。

上記コードでは内側 25 個のグリッド × 5 fold = 125 回学習を外側 5 fold で行うので、 計 625 回モデル学習。 SSDSE-B-2026 規模なら 10 秒程度で完了します。

⚠️ 環境依存の注記(実測・再現条件):上の R² 数値は Python 3・scikit-learn 1.9・NumPy 2.3、 seed 固定(内側 random_state=1/外側 random_state=0)、 特徴量を Pipeline 内で標準化した条件での 当環境の実測値です。 非ネスト(単純 GridSearchCV)の best_score_ = 0.064、 ネスト R² = -0.052 ± 0.202、 選択バイアス(非ネスト − ネスト)= 約 0.116。 n=47 の小標本かつ R²≈0 の弱い信号のため fold 分割・sklearn バージョン・標準化の有無で小数第 2〜3 位は変動します。 「非ネスト CV が楽観的(値を大きめに見せる)」という符号と方向は当環境でも再現しています。

🔁 反復 Nested CV — 分散を下げる王道

1 回の Nested CV では外側 fold の選び方によってスコアがブレます。 これを安定化する標準テクニックが 反復 Nested CV (Repeated Nested CV)。 外側分割を異なる乱数シードで $R$ 回繰り返し、 全 $R \times K_{\text{outer}}$ 個のスコアを平均します。

1
2
3
4
5
6
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])}')
📤 実行例(実測) 反復 Nested CV R²: -0.225 ± 0.497 スコア分布: [-0.48367967 -0.08621448 0.08707914]

反復回数 $R$ の指針:$R=10$ で SE がほぼ $1/\sqrt{R} = 0.32$ 倍に。 $R=30$ なら 0.18 倍。 SSDSE-B のような小標本では $R \ge 10$ を強く推奨します。

📅 時系列・グループ構造での Nested CV

SSDSE-B のような時系列パネルでは「ランダム分割」がリークを生みます。 同じ都道府県の異なる年が学習・テストに分裂すると、 県固定効果がリークするのです。

時系列分割

1
2
3
4
5
6
7
8
9
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}')
📤 実行例(実測) 時系列 Nested CV MSE: 627753348.2700

グループ分割

1
2
3
4
5
6
7
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)}')
📤 実行例(実測) train: 37, test: 10 train: 37, test: 10 train: 38, test: 9 train: 38, test: 9 train: 38, test: 9

医療データで「患者 ID 内の複数測定」、 教育データで「同じ学校の複数学生」など、 階層構造を持つデータには GroupKFold が必須です。

⚠️ Nested CV の 5 つの落とし穴

① 「Nested CV で選ばれた α」を最終モデルに使えない
外側 fold ごとに異なる α が選ばれるため、 「単一の最適 α」は Nested CV からは直接得られない。 デプロイ時は別途、 全データに対して再度 (内側) CV を回してハイパラを決め、 全データで学習する。 Nested CV はあくまで「ハイパラチューニングを含むパイプラインの性能評価」のためのプロトコル。
② グループ構造を無視するとリーク
SSDSE のように「同じ都道府県の複数年データ」がある場合、 普通の KFold だと train と test に同じ県の別年データが混入し、 性能が過大評価される。 GroupKFold で「県ごと」にまとめて分割すべき。 医療データなら「患者ごと」、 推薦システムなら「ユーザーごと」。
③ 前処理(標準化、 PCA、 特徴選択)を外側で先にやるとリーク
「データ全体で StandardScaler を fit してから CV」は典型的なリーク。 各 fold の train だけで fit → val/test に transform、 でないと test の統計量が訓練に流入する。 sklearn の Pipeline を使えば自動的に正しい順序になる(最大のメリット)。
④ ハイパラ候補が広すぎると内側 CV も「過学習」する
内側 CV でハイパラを 1000 候補から選ぶと、 内側 CV 自体が「内側 val fold に過学習」する → 外側性能との乖離が広がる。 対処:ハイパラ候補をドメイン知識で絞る、 ベイズ最適化に切り替える、 内側 CV の fold 数を増やす、 ハイパラ選択基準に 1-SE rule(最良の 1 標準誤差以内で最も単純なモデル)を導入。
⑤ 計算コスト爆発 — 大規模データには不向き
Nested CV は学習回数が K_o × K_i × |Λ| 倍。 ニューラルネット 1 回の学習に数時間かかる場合、 5×3×20 = 300 回は非現実的。 大規模データ・大規模モデルでは「train/val/test の 3 分割」で十分(n > 数万なら test set の評価が安定)。 Nested CV は小〜中規模データ(n ≤ 数千)で真価。

⚠️ Nested CV の 10 個の落とし穴

  1. 前処理リーク:StandardScaler を CV の外でフィットすると、 テストデータの統計情報が漏れる。 必ず Pipeline 内に組み込む。
  2. 標準誤差の過小評価:Bates et al. (2024) は「CV の古典的 SE は真値の $1/\sqrt{2}$ 倍」と指摘。 Nested CV でも信頼区間は広めに見るべき。
  3. fold 間のハイパー不一致:外側各 fold で異なる $\theta_k^\star$ が選ばれることが頻発。 「最終モデル」用には全データで再選択するか、 多数決を取る。
  4. 計算コストの過小評価:100 グリッド × 5×5 Nested = 2500 回学習。 GPU や `n_jobs=-1` の活用を前提に計画する。
  5. 分類タスクで stratification を忘れる:StratifiedKFold を内外両方に。 クラス不均衡で性能が大きく変わる。
  6. パイプライン外の特徴量選択:相関フィルタや PCA を Pipeline 外で適用するとリーク。 SelectKBest なども Pipeline に組み込む。
  7. 小標本での過信:$n=47$ では Nested CV の分散が大きく、 「真の性能 ± 0.1」程度のブレは普通。
  8. 多重比較:複数モデルを Nested CV で比較するとき、 多重比較補正を忘れがち。 paired t-test や Wilcoxon を fold 間で。
  9. 外側 LOOCV の過信:分散が最大になり、 安定した推定が得られないことが多い。
  10. 最終モデルの再現性:fold 毎に異なるハイパーを使ったので、 「本番モデル」は別途定義が必要。

🗺 概念マップ — モデル評価の階層

モデル評価プロトコル(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 のいずれかで評価する、 が現代の標準作法。

nested cv Holdout(3 分割) k-fold CV(単一) Nested CV Repeated Neste Leave-One-Out Group Nested C

🔗 隣接手法への橋渡し

「Nested クロスバリデーション」は単独で完結する手法ではなく、 隣接領域と連携することで真価を発揮する。

入れ子 CV は外側ループで汎化性能を、 内側ループでハイパーパラメータを決める二段構造。 SSDSE-B-2026 (47 標本) では外側 5-fold × 内側 5-fold = 25 回学習が必要だが、 これを省略すると過大評価された性能で報告してしまう。

🌳 「Nested CV か否か」意思決定フロー

[$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 クロスバリデーション」を扱う際の手法選択は、 状況に応じて以下のフローで判断すると迷いが減る。

  1. Step 1: 目的は記述か予測か?
    • 記述 (現状把握・要約) → 集計・可視化・要約統計量で全体像を掴む
    • 予測 (未知データへの推定) → モデル構築・検証フェーズへ移行
  2. Step 2: データの種類・規模は?
    • 数値データ・小〜中規模 → 「Nested クロスバリデーション」やその拡張手法を直接適用
    • カテゴリデータ → カテゴリ専用の手法 (関連用語) と組み合わせ
    • 大規模・高次元 → 計算効率を考慮した派生手法 (派生・関連手法) を選択
  3. Step 3: 結果の解釈・共有は?
    • 専門家向け → 数値指標・統計検定で精緻に評価
    • 非専門家向け → 可視化・自然言語での要約を重視

このフローに沿って判断することで、 「Nested クロスバリデーション」を中核とした適切な手法選択ができる。

🧭 解説深化 — 「点推定」ではなく「点推定 ± SD」で読む Nested CV

姉妹ページ(クロスバリデーション検証データ検証によるモデル選択)が「なぜ検証データを分けるか」を扱うのに対し、 ここでは Nested CV が吐き出す数字そのものの読み方に絞る。 とくに本コンペの現実である n=47(都道府県)という小標本で Nested CV を回すと何が起きるかを、 SSDSE-B-2026 の実測値だけで示す。 上の「実値計算」節は外側 K=5 の 1 設定だったが、 ここでは 外側 fold 数を動かした時のトレードオフ時系列を混ぜた時のリークという別角度を実測で足す。

💡 直感 — SD は「おまけ」ではなく本体

Nested CV の結果を「RMSE ≈ 23,000 円」と点だけで報告したくなるが、 その真横に立っている 外側 fold 間 SD こそが「その数字をどれだけ信じてよいか」を語る。 n=47 では外側 fold を細かくするほど各テスト fold のサンプルが枯れ、 点推定は動くのに SD が暴れ出す。 下は同一タスク(2023 年 47 県・特徴量 A1101/A1303/A4101/B4101 → L3221 消費支出、 Ridge・α∈{0.01,0.1,1,10,100}、 内側 K=3、 shuffle seed=0)を外側 fold 数だけ変えて回した実測。

【SSDSE-B 47 県(2023)/ターゲット L3221 = 平均 295,856 円・SD 24,144 円】
外側 fold を増やす=各テスト fold が小さくなる。 点推定は下がるが SD(信頼できなさ)は膨張する。
外側の設定各テスト fold のサイズfold 数Nested RMSE(円)fold 間 SD(円)SD / 点推定
Kouter = 5約 9 サンプル523,262.1±5,395.023%
Kouter = 10約 4 サンプル1022,615.6±6,279.428%
LOO(Kouter = 47)1 サンプル4719,324.2±14,489.975%

LOO では点推定が 19,324 円と一番小さく見えるが、 SD が点推定の 75%(±14,490 円)。 これは「未知データでの RMSE は 5,000 円かもしれないし 34,000 円かもしれない」というほぼ測定不能な状態。 「fold を増やせば精密になる」という直感が n=47 では逆に働く、 というのが最初の落とし穴の伏線。

⚠️ 落とし穴(重要)— 「SD が小さい」を素直に喜ぶな

A. 小標本では外側 fold を増やすほど推定が不安定になる
上表の通り、 Kouter を 5→10→47 と増やすと SD は 5,395→6,279→14,490 円と増える。 各テスト fold が数サンプルまで痩せると、 1 県の外れ値(例:東京・沖縄)で fold の RMSE が跳ね、 fold 間のバラつきが暴走するため。 n=47 級では外側 K=5 前後が実用上の落としどころで、 LOO 外側は「点推定は綺麗だが SD が使い物にならない」ことが多い。 なお naive(非 nested)5-fold CV では最適 α=10 で RMSE を 22,397 円と報告するが、 これは nested K=5 の 23,262 円より 約 865 円(3.7%)楽観的——テスト fold が α 選択に間接利用された分だけ甘く出ている。
B. 時系列を混ぜると「SD が小さくて高性能」に見えるが、それはリークの兆候
SSDSE-B は 2012–2023 の 12 年 × 47 県 = 564 行ある。 これを全部まとめて素の KFold で Nested CV すると、 同じ県の別年データが訓練側とテスト側に跨って入る(≒答えの下敷き)。 実測では 素 KFold nested = 22,022 円・SD ±712 と、 見かけ上「性能も良く SD も極小で安定」に見える。 ところが 県単位の GroupKFold(同一県は必ず片側だけ)に替えると 23,119 円・SD ±4,534。 真の性能は約 1,097 円(4.7%)悪く、 SD は 6 倍以上に広がる。 つまり「SD が異様に小さい=安定した良モデル」ではなく、パネル/時系列リークのサインのことがある。 グループ構造があるなら内外どちらのループも GroupKFold にする。
564 行(2012–2023 × 47 県)の分割方法Nested RMSE(円)fold 間 SD(円)解釈
素の KFold(県が train/test に跨る)22,022.1±712.6楽観・見かけ上「安定」
GroupKFold(県単位・リーク遮断)23,119.0±4,533.8正直な性能と正直な不確かさ

※ 上記いずれも SSDSE-B-2026.csv の実測値(pd.read_csv(encoding='cp932', skiprows=[1])、 sklearn Pipeline[StandardScaler→Ridge]、 内側 K=3、 seed=0)。 架空データは一切含まない。 seed やライブラリ版が変われば末尾桁は動くが、 傾向(外側細分化で SD 増大/リークで SD 過小)は再現する。

🚀 発展 — 報告作法と次の一手

🔗 関連ページ