「モデルの精度は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()
このグラフから、三つの分割の役割が明確に読み取れます。
-
左図(誤差曲線): 訓練誤差(青)は次数の増加とともに単調に減少し、15次多項式ではほぼ0です。検証誤差(赤)は次数3-5で最小値をとり、それ以降は過学習により増加します。紫の破線が検証誤差を最小化する最適な次数を示しています。テスト誤差(緑)は検証誤差と近い傾向を示していますが、若干異なる値をとります
-
右図(分割の図示): データの60%を訓練、20%を検証、20%をテストに分割しています。訓練でモデルを学習し、検証でハイパーパラメータ(多項式の次数)を選び、テストで最終的な性能を評価する3ステップのワークフローを示しています
k分割交差検証
動機
訓練・検証分割には重要な弱点があります。
- データの無駄: 検証に使ったデータは訓練に使えない
- 分割への依存: どのデータが検証に入るかでスコアが変わる
- 小規模データ: データが少ないときに分割すると訓練データがさらに減る
k分割交差検証(k-fold cross-validation)はこれらの問題を緩和する方法です。
アルゴリズム
- データを $k$ 個のほぼ等しいサイズのフォールド(fold)に分割する
- $i = 1, \ldots, k$ について: – 第 $i$ フォールドを検証データ、残り $k-1$ フォールドを訓練データとする – 訓練データでモデルを学習し、検証データで評価する
- $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()
このグラフから、交差検証の仕組みと活用法が読み取れます。
-
左図(分割の可視化): 5つのフォールドそれぞれで、赤い点が検証データ、青い点が訓練データを示しています。各サンプルは5回の試行で必ず1回だけ検証に使われ、残り4回は訓練に使われます。全てのデータが検証に使われるため、データの無駄がありません
-
中央図(kの値の影響): $k$ が大きくなるとCVスコアの平均はわずかに変化しますが、標準偏差(エラーバー)にも変化があります。LOO(Leave-One-Out、$k = n$)ではバイアスは最小ですが、$k = 5$ や $k = 10$ も十分に良い推定を与えていることがわかります
-
右図(モデル選択): 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()
このグラフから、層化分割の効果が明確に読み取れます。
-
左図(通常のKFold): フォールドごとの陽性クラス比率がばらついており、10%の真の比率から大きく乖離しているフォールド(赤い棒)があります。あるフォールドでは陽性が多く、別のフォールドでは少ない——このばらつきがCVスコアの分散を増大させます
-
右図(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はハイパーパラメータ選択と性能評価を分離し、楽観的な推定を避ける
次のステップとして、以下の記事も参考にしてください。
- 評価指標の全体像 — CVスコアの選び方
- バイアス-バリアンストレードオフ — 過学習の理論的背景
- 特徴量スケーリングの理論 — CV内での前処理の注意点