訓練・検証・テスト分割とk分割交差検証の正しい使い方

「モデルの精度は95%です」——この数字はどれだけ信頼できるでしょうか。もしこの精度が訓練データで測定されたものならば、全く信頼できません。モデルが訓練データを「丸暗記」しているだけかもしれないからです。

機械学習における最も基本的かつ重要なルールは、モデルの評価は訓練に使っていないデータで行わなければならないということです。しかし、未知のデータでの性能をどうやって推定するのでしょうか。

この問いへの答えが、訓練・検証・テスト分割交差検証です。データを適切に分割し、モデルの訓練・選択・評価を正しく行うことは、信頼性の高い機械学習システムを構築する上で欠かせません。

正しいデータ分割を理解すると、以下のような判断ができるようになります。

  • モデル選択: ハイパーパラメータのチューニングをどのデータで行うべきか
  • 汎化性能の推定: テスト精度の「楽観的」な見積もりを避ける方法
  • 小規模データ: データが少ないときに交差検証でデータ効率を上げる方法
  • データリーク防止: 特徴量工学やスケーリングの正しいタイミング

本記事の内容

  • 訓練・検証・テスト分割の役割と理由
  • k分割交差検証の理論と統計的性質
  • 層化分割・グループ分割・時系列分割
  • ネストされた交差検証
  • Pythonでの実装と可視化

前提知識

この記事を読む前に、以下の記事を読んでおくと理解が深まります。

なぜデータを分割するのか

訓練誤差の楽観性

モデルの訓練に使ったデータでそのモデルを評価すると、訓練誤差は真の汎化誤差よりも楽観的(低い)になります。これは統計学では古くから知られた事実です。

モデルのパラメータ数を $d$、データ数を $n$ とすると、訓練誤差と汎化誤差の関係は近似的に

$$ E[\text{Test Error}] \approx E[\text{Training Error}] + \frac{2d\sigma^2}{n} $$

この差 $2d\sigma^2/n$ は楽観性(optimism)と呼ばれ、モデルの複雑さ $d$ に比例し、データ数 $n$ に反比例します。AIC(赤池情報量規準)はまさにこの楽観性を推定するための指標です。

つまり、訓練誤差はモデルが複雑なほど・データが少ないほど、真の性能を大きく過大評価します。信頼できる性能評価には、モデルが「見たことのない」データが必要です。

三つの分割の役割

データセット 役割 いつ使うか
訓練データ モデルのパラメータを学習 毎エポックの訓練
検証データ ハイパーパラメータの選択、モデル比較 訓練中の早期停止、HPチューニング
テストデータ 最終的な汎化性能の評価 全てのモデル選択が終わった後に一度だけ

なぜ検証とテストを分ける必要があるのでしょうか。検証データでハイパーパラメータを選択する過程で、間接的に検証データの情報がモデルに漏洩します。検証データ上で最高の性能を達成するハイパーパラメータを選んでいるのですから、検証データへの「過学習」が起きるのです。テストデータはこの汚染を受けていない、最後の砦です。

import numpy as np
import matplotlib.pyplot as plt
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import PolynomialFeatures
from sklearn.linear_model import LinearRegression
from sklearn.metrics import mean_squared_error

np.random.seed(42)

# データ生成
n = 200
X = np.random.uniform(0, 1, n).reshape(-1, 1)
y = np.sin(2 * np.pi * X.ravel()) + np.random.normal(0, 0.3, n)

# 3分割
X_train, X_temp, y_train, y_temp = train_test_split(X, y, test_size=0.4, random_state=42)
X_val, X_test, y_val, y_test = train_test_split(X_temp, y_temp, test_size=0.5, random_state=42)

degrees = list(range(1, 16))
train_errors = []
val_errors = []
test_errors = []

for d in degrees:
    poly = PolynomialFeatures(d)
    X_train_poly = poly.fit_transform(X_train)
    X_val_poly = poly.transform(X_val)
    X_test_poly = poly.transform(X_test)

    model = LinearRegression().fit(X_train_poly, y_train)

    train_errors.append(mean_squared_error(y_train, model.predict(X_train_poly)))
    val_errors.append(mean_squared_error(y_val, model.predict(X_val_poly)))
    test_errors.append(mean_squared_error(y_test, model.predict(X_test_poly)))

fig, axes = plt.subplots(1, 2, figsize=(14, 5.5))

# (a) 訓練・検証・テスト誤差
ax = axes[0]
ax.plot(degrees, train_errors, "b-o", linewidth=2, markersize=5, label="Training error")
ax.plot(degrees, val_errors, "r-s", linewidth=2, markersize=5, label="Validation error")
ax.plot(degrees, test_errors, "g-^", linewidth=2, markersize=5, label="Test error")

best_d = degrees[np.argmin(val_errors)]
ax.axvline(best_d, color="purple", linestyle="--", linewidth=2,
           label=f"Best degree (by val) = {best_d}")

ax.set_xlabel("Polynomial Degree", fontsize=12)
ax.set_ylabel("MSE", fontsize=12)
ax.set_title("Train / Validation / Test Error", fontsize=13)
ax.legend(fontsize=10)
ax.grid(True, alpha=0.3)
ax.set_xticks(degrees)
ax.set_ylim(0, 1.0)

# (b) データ分割の図示
ax = axes[1]
total = len(X)
train_n = len(X_train)
val_n = len(X_val)
test_n = len(X_test)

ax.barh(0, train_n, color="steelblue", alpha=0.8, label=f"Train ({train_n})")
ax.barh(0, val_n, left=train_n, color="coral", alpha=0.8, label=f"Validation ({val_n})")
ax.barh(0, test_n, left=train_n+val_n, color="lightgreen", alpha=0.8,
        label=f"Test ({test_n})")

ax.set_xlim(0, total)
ax.set_ylim(-1, 1)
ax.set_xlabel("Number of samples", fontsize=12)
ax.set_title("Data Split (60% / 20% / 20%)", fontsize=13)
ax.legend(fontsize=11)
ax.set_yticks([])
ax.grid(True, alpha=0.3, axis="x")

# 矢印でワークフローを追加
ax.annotate("1. Train model", xy=(train_n/2, -0.4), fontsize=10, ha="center", color="steelblue")
ax.annotate("2. Select HP", xy=(train_n + val_n/2, -0.4), fontsize=10, ha="center", color="coral")
ax.annotate("3. Final eval", xy=(train_n + val_n + test_n/2, -0.4), fontsize=10, ha="center",
            color="green")

plt.tight_layout()
plt.savefig("train_val_test.png", dpi=150, bbox_inches="tight")
plt.show()

このグラフから、三つの分割の役割が明確に読み取れます。

  1. 左図(誤差曲線): 訓練誤差(青)は次数の増加とともに単調に減少し、15次多項式ではほぼ0です。検証誤差(赤)は次数3-5で最小値をとり、それ以降は過学習により増加します。紫の破線が検証誤差を最小化する最適な次数を示しています。テスト誤差(緑)は検証誤差と近い傾向を示していますが、若干異なる値をとります

  2. 右図(分割の図示): データの60%を訓練、20%を検証、20%をテストに分割しています。訓練でモデルを学習し、検証でハイパーパラメータ(多項式の次数)を選び、テストで最終的な性能を評価する3ステップのワークフローを示しています

k分割交差検証

動機

訓練・検証分割には重要な弱点があります。

  1. データの無駄: 検証に使ったデータは訓練に使えない
  2. 分割への依存: どのデータが検証に入るかでスコアが変わる
  3. 小規模データ: データが少ないときに分割すると訓練データがさらに減る

k分割交差検証(k-fold cross-validation)はこれらの問題を緩和する方法です。

アルゴリズム

  1. データを $k$ 個のほぼ等しいサイズのフォールド(fold)に分割する
  2. $i = 1, \ldots, k$ について: – 第 $i$ フォールドを検証データ、残り $k-1$ フォールドを訓練データとする – 訓練データでモデルを学習し、検証データで評価する
  3. $k$ 個の評価スコアの平均をCV(交差検証)スコアとする

$$ \begin{equation} \text{CV}_k = \frac{1}{k}\sum_{i=1}^k L(\hat{f}^{(-i)}, \mathcal{D}_i) \end{equation} $$

$\hat{f}^{(-i)}$ は第 $i$ フォールドを除いて訓練したモデル、$\mathcal{D}_i$ は第 $i$ フォールドのデータです。

バイアスとバリアンスのトレードオフ

k分割交差検証にはその推定値にバイアスとバリアンスがあり、$k$ の選択がこのトレードオフを制御します。

$k$ が小さい(例: $k=2$): 各フォールドの訓練データ数が全体の50%なので、訓練データ不足によりモデルが過小学習し、CVスコアは真の汎化誤差を過大評価します(悲観的バイアス)。ただし、訓練データの重複が少ないためフォールド間の相関が低く、分散は小さくなります。

$k$ が大きい(例: $k = n$、Leave-One-Out): 訓練データ数がほぼ $n$ なので、バイアスは非常に小さくなります。しかし、$n$ 個のモデルがそれぞれほぼ同じデータで訓練されるため、予測が高度に相関し、CVスコアの分散が大きくなります。計算コストも $n$ 倍になります。

$k = 5$ または $k = 10$ が経験的に良いバランスとされており、実務では $k = 5$ が最も広く使われています。

import numpy as np
import matplotlib.pyplot as plt
from sklearn.model_selection import KFold, cross_val_score
from sklearn.preprocessing import PolynomialFeatures
from sklearn.linear_model import LinearRegression
from sklearn.pipeline import make_pipeline

np.random.seed(42)

n = 100
X = np.random.uniform(0, 1, n).reshape(-1, 1)
y = np.sin(2 * np.pi * X.ravel()) + np.random.normal(0, 0.3, n)

fig, axes = plt.subplots(1, 3, figsize=(16, 5))

# (a) k分割の可視化(k=5)
ax = axes[0]
kf = KFold(n_splits=5, shuffle=True, random_state=42)
colors = plt.cm.Set3(np.linspace(0, 1, 5))

for i, (train_idx, val_idx) in enumerate(kf.split(X)):
    ax.scatter(np.zeros(len(train_idx)) + i, train_idx, color="steelblue", s=5, alpha=0.5)
    ax.scatter(np.zeros(len(val_idx)) + i, val_idx, color="coral", s=15, alpha=0.8)

ax.set_xlabel("Fold", fontsize=12)
ax.set_ylabel("Sample index", fontsize=12)
ax.set_title("5-Fold Cross-Validation Split", fontsize=13)
ax.set_xticks(range(5))
ax.set_xticklabels([f"Fold {i+1}" for i in range(5)])
ax.grid(True, alpha=0.3)

# 凡例
from matplotlib.lines import Line2D
legend_elements = [Line2D([0],[0], marker="o", color="w", markerfacecolor="steelblue",
                          markersize=8, label="Training"),
                   Line2D([0],[0], marker="o", color="w", markerfacecolor="coral",
                          markersize=8, label="Validation")]
ax.legend(handles=legend_elements, fontsize=10)

# (b) kの値によるCVスコアの変化
ax = axes[1]
k_values = [2, 3, 5, 10, 20, 50, n]  # LOO = n
degree = 4
model = make_pipeline(PolynomialFeatures(degree), LinearRegression())

cv_means = []
cv_stds = []
for k in k_values:
    if k == n:
        from sklearn.model_selection import LeaveOneOut
        scores = cross_val_score(model, X, y, cv=LeaveOneOut(),
                                 scoring="neg_mean_squared_error")
    else:
        scores = cross_val_score(model, X, y, cv=k,
                                 scoring="neg_mean_squared_error")
    cv_means.append(-scores.mean())
    cv_stds.append(scores.std())

k_labels = [str(k) if k != n else "LOO" for k in k_values]
ax.errorbar(range(len(k_values)), cv_means, yerr=cv_stds, fmt="bo-",
            linewidth=2, markersize=8, capsize=5, label="CV score $\\pm$ std")
ax.set_xticks(range(len(k_values)))
ax.set_xticklabels(k_labels)
ax.set_xlabel("$k$ (number of folds)", fontsize=12)
ax.set_ylabel("CV Score (MSE)", fontsize=12)
ax.set_title("Effect of $k$ on CV Score", fontsize=13)
ax.legend(fontsize=10)
ax.grid(True, alpha=0.3)

# (c) CVによるモデル選択
ax = axes[2]
degrees = list(range(1, 16))
cv_mean_scores = []
cv_std_scores = []

for d in degrees:
    model = make_pipeline(PolynomialFeatures(d), LinearRegression())
    scores = cross_val_score(model, X, y, cv=5, scoring="neg_mean_squared_error")
    cv_mean_scores.append(-scores.mean())
    cv_std_scores.append(scores.std())

cv_mean_scores = np.array(cv_mean_scores)
cv_std_scores = np.array(cv_std_scores)

ax.plot(degrees, cv_mean_scores, "b-o", linewidth=2, markersize=6, label="CV mean")
ax.fill_between(degrees, cv_mean_scores - cv_std_scores,
                cv_mean_scores + cv_std_scores, color="blue", alpha=0.1)

best_cv_d = degrees[np.argmin(cv_mean_scores)]
ax.axvline(best_cv_d, color="red", linestyle="--", linewidth=2,
           label=f"Best degree = {best_cv_d}")

# 1SE rule
best_score = cv_mean_scores[np.argmin(cv_mean_scores)]
best_se = cv_std_scores[np.argmin(cv_mean_scores)]
threshold = best_score + best_se
candidates = [d for d, s in zip(degrees, cv_mean_scores) if s <= threshold]
one_se_d = min(candidates)
ax.axvline(one_se_d, color="green", linestyle=":", linewidth=2,
           label=f"1-SE rule = {one_se_d}")

ax.set_xlabel("Polynomial Degree", fontsize=12)
ax.set_ylabel("CV Score (MSE)", fontsize=12)
ax.set_title("Model Selection via 5-Fold CV", fontsize=13)
ax.legend(fontsize=9)
ax.grid(True, alpha=0.3)
ax.set_xticks(degrees)
ax.set_ylim(0, 1.0)

plt.tight_layout()
plt.savefig("cross_validation.png", dpi=150, bbox_inches="tight")
plt.show()

このグラフから、交差検証の仕組みと活用法が読み取れます。

  1. 左図(分割の可視化): 5つのフォールドそれぞれで、赤い点が検証データ、青い点が訓練データを示しています。各サンプルは5回の試行で必ず1回だけ検証に使われ、残り4回は訓練に使われます。全てのデータが検証に使われるため、データの無駄がありません

  2. 中央図(kの値の影響): $k$ が大きくなるとCVスコアの平均はわずかに変化しますが、標準偏差(エラーバー)にも変化があります。LOO(Leave-One-Out、$k = n$)ではバイアスは最小ですが、$k = 5$ や $k = 10$ も十分に良い推定を与えていることがわかります

  3. 右図(モデル選択): 5分割CVスコアにより次数4が最適と判定されています(赤の破線)。青い帯はCVスコアの標準偏差を示しており、1標準誤差ルール(1-SE rule)を適用すると、CVスコアが最小値から1SE以内にある最も単純なモデル(緑の点線)が選ばれます。1-SEルールはより保守的なモデル選択を行い、過学習のリスクを下げます

層化分割

分類問題での注意点

クラス不均衡のあるデータ(例: 陽性1%、陰性99%)では、ランダムに分割するとあるフォールドに陽性サンプルが全く含まれないことがあります。

層化分割(stratified split)は、各フォールドのクラス比率を全体と同じに保つ分割方法です。

import numpy as np
import matplotlib.pyplot as plt
from sklearn.model_selection import KFold, StratifiedKFold

np.random.seed(42)

# 不均衡データの生成
n = 100
y = np.zeros(n)
y[:10] = 1  # 10%が陽性

fig, axes = plt.subplots(1, 2, figsize=(14, 5.5))

# (a) 通常のKFold
ax = axes[0]
kf = KFold(n_splits=5, shuffle=True, random_state=42)
class_ratios = []
for i, (train_idx, val_idx) in enumerate(kf.split(np.zeros(n), y)):
    ratio = y[val_idx].mean()
    class_ratios.append(ratio)
    ax.bar(i, ratio, color="coral" if abs(ratio - 0.1) > 0.05 else "steelblue",
           alpha=0.8, edgecolor="gray")

ax.axhline(0.1, color="red", linestyle="--", linewidth=2, label="True ratio (10%)")
ax.set_xlabel("Fold", fontsize=12)
ax.set_ylabel("Positive class ratio", fontsize=12)
ax.set_title("Regular KFold (class ratio varies)", fontsize=13)
ax.legend(fontsize=10)
ax.grid(True, alpha=0.3, axis="y")
ax.set_xticks(range(5))
ax.set_ylim(0, 0.3)

# (b) StratifiedKFold
ax = axes[1]
skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
class_ratios_strat = []
for i, (train_idx, val_idx) in enumerate(skf.split(np.zeros(n), y)):
    ratio = y[val_idx].mean()
    class_ratios_strat.append(ratio)
    ax.bar(i, ratio, color="steelblue", alpha=0.8, edgecolor="gray")

ax.axhline(0.1, color="red", linestyle="--", linewidth=2, label="True ratio (10%)")
ax.set_xlabel("Fold", fontsize=12)
ax.set_ylabel("Positive class ratio", fontsize=12)
ax.set_title("Stratified KFold (class ratio preserved)", fontsize=13)
ax.legend(fontsize=10)
ax.grid(True, alpha=0.3, axis="y")
ax.set_xticks(range(5))
ax.set_ylim(0, 0.3)

plt.tight_layout()
plt.savefig("stratified_kfold.png", dpi=150, bbox_inches="tight")
plt.show()

このグラフから、層化分割の効果が明確に読み取れます。

  1. 左図(通常のKFold): フォールドごとの陽性クラス比率がばらついており、10%の真の比率から大きく乖離しているフォールド(赤い棒)があります。あるフォールドでは陽性が多く、別のフォールドでは少ない——このばらつきがCVスコアの分散を増大させます

  2. 右図(StratifiedKFold): 全てのフォールドで陽性クラスの比率がほぼ10%に保たれています。これにより、各フォールドでの評価条件が均一化され、CVスコアの分散が低減します

分類問題では常に StratifiedKFold を使うことが推奨されます。

時系列データの分割

時系列データでは、未来のデータが過去の予測に漏洩するのを防ぐため、時間方向の分割が必要です。ランダムなシャッフルは厳禁です。

import numpy as np
import matplotlib.pyplot as plt
from sklearn.model_selection import TimeSeriesSplit

n = 100
X = np.arange(n).reshape(-1, 1)

fig, ax = plt.subplots(figsize=(12, 5))

tscv = TimeSeriesSplit(n_splits=5)
for i, (train_idx, test_idx) in enumerate(tscv.split(X)):
    ax.scatter(train_idx, np.full_like(train_idx, i), color="steelblue", s=15, alpha=0.6)
    ax.scatter(test_idx, np.full_like(test_idx, i), color="coral", s=25, alpha=0.8)

ax.set_xlabel("Time index", fontsize=12)
ax.set_ylabel("Fold", fontsize=12)
ax.set_title("Time Series Cross-Validation", fontsize=13)
ax.set_yticks(range(5))
ax.set_yticklabels([f"Fold {i+1}" for i in range(5)])
ax.grid(True, alpha=0.3)

from matplotlib.lines import Line2D
legend_elements = [Line2D([0],[0], marker="o", color="w", markerfacecolor="steelblue",
                          markersize=8, label="Training"),
                   Line2D([0],[0], marker="o", color="w", markerfacecolor="coral",
                          markersize=8, label="Test")]
ax.legend(handles=legend_elements, fontsize=11)

plt.tight_layout()
plt.savefig("time_series_cv.png", dpi=150, bbox_inches="tight")
plt.show()

このグラフから、時系列交差検証の構造が読み取れます。各フォールドで訓練データ(青)が常に検証データ(赤)より前の時点にあることが保証されています。フォールドが進むにつれて訓練データが増加し、より多くの過去データを使って将来を予測する現実的な状況を再現しています。通常のKFoldのようなランダムシャッフルとは異なり、未来のデータが訓練に混入する時間的なデータリークを完全に防ぎます。

ネストされた交差検証

ハイパーパラメータ選択と性能評価の分離

交差検証でハイパーパラメータを選択し、同じ交差検証スコアで性能を評価すると、スコアは楽観的になります。これを防ぐためにネストされた交差検証(nested CV)を使います。

  • 外側のCV: 汎化性能の推定
  • 内側のCV: ハイパーパラメータの選択
import numpy as np
import matplotlib.pyplot as plt
from sklearn.model_selection import cross_val_score, KFold, GridSearchCV
from sklearn.linear_model import Ridge

np.random.seed(42)

n = 200
d = 20
X = np.random.randn(n, d)
w_true = np.zeros(d)
w_true[:5] = np.array([3, -2, 1.5, -1, 0.8])
y = X @ w_true + np.random.randn(n) * 2

# 非ネスト交差検証(楽観的)
alphas = np.logspace(-3, 3, 20)
best_scores_non_nested = []
for alpha in alphas:
    model = Ridge(alpha=alpha)
    scores = cross_val_score(model, X, y, cv=5, scoring="neg_mean_squared_error")
    best_scores_non_nested.append(-scores.mean())

best_alpha_non_nested = alphas[np.argmin(best_scores_non_nested)]
best_score_non_nested = min(best_scores_non_nested)

# ネスト交差検証
outer_cv = KFold(n_splits=5, shuffle=True, random_state=42)
inner_cv = KFold(n_splits=5, shuffle=True, random_state=42)

nested_scores = []
non_nested_scores = []

for train_idx, test_idx in 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でベストなalphaを選択
    grid = GridSearchCV(Ridge(), {"alpha": alphas}, cv=inner_cv,
                        scoring="neg_mean_squared_error")
    grid.fit(X_train, y_train)

    # 外側のテストデータで評価
    nested_score = np.mean((grid.predict(X_test) - y_test)**2)
    nested_scores.append(nested_score)

    # 非ネスト(内側CVスコアをそのまま報告)
    non_nested_scores.append(-grid.best_score_)

fig, ax = plt.subplots(figsize=(10, 6))

x_pos = np.arange(5)
width = 0.35

ax.bar(x_pos - width/2, non_nested_scores, width, color="coral", alpha=0.8,
       label=f"Non-nested (inner CV) mean={np.mean(non_nested_scores):.2f}")
ax.bar(x_pos + width/2, nested_scores, width, color="steelblue", alpha=0.8,
       label=f"Nested (outer test) mean={np.mean(nested_scores):.2f}")

ax.set_xlabel("Outer Fold", fontsize=12)
ax.set_ylabel("MSE", fontsize=12)
ax.set_title("Nested vs Non-Nested Cross-Validation", fontsize=14)
ax.legend(fontsize=10)
ax.grid(True, alpha=0.3, axis="y")
ax.set_xticks(x_pos)
ax.set_xticklabels([f"Fold {i+1}" for i in range(5)])

plt.tight_layout()
plt.savefig("nested_cv.png", dpi=150, bbox_inches="tight")
plt.show()

print(f"Non-nested CV score (optimistic): {np.mean(non_nested_scores):.4f}")
print(f"Nested CV score (unbiased):       {np.mean(nested_scores):.4f}")

このグラフから、ネストされた交差検証の重要性が確認できます。非ネスト(赤、内側CVスコア)の方がネスト(青、外側テストスコア)よりも一貫して低い(楽観的な)MSEを報告しています。この差が「ハイパーパラメータ選択による楽観性」であり、最終的な性能評価にはネストされたCVスコアを使うべきであることを示しています。

まとめ

本記事では、データ分割と交差検証の理論と実践について解説しました。

  • 訓練・検証・テストの三つの分割はそれぞれ異なる役割を持ち、テストデータは最後に一度だけ使う
  • k分割交差検証はデータを効率的に活用しながら汎化誤差を推定する手法であり、$k = 5$ または $k = 10$ が標準
  • 層化分割は分類問題でクラス比率を保持し、CVスコアの分散を低減する
  • 時系列分割は未来の情報が過去の予測に漏洩するのを防ぐ
  • ネストされたCVはハイパーパラメータ選択と性能評価を分離し、楽観的な推定を避ける

次のステップとして、以下の記事も参考にしてください。