交差検証(Cross-Validation)は、機械学習モデルの汎化性能を評価するための標準的な手法です。限られたデータを最大限活用し、過学習を検出してモデルの信頼性を評価します。
本記事では、様々な交差検証手法の理論と数学的背景、そしてPythonでの実装を解説します。
本記事の内容
- 交差検証の基本概念と必要性
- K-Fold交差検証の理論
- 層化K-Fold交差検証
- Leave-One-Out交差検証
- 時系列データ向けの交差検証
- Pythonでの実装と比較
交差検証とは
基本概念
交差検証は、データセット $\mathcal{D} = \{(x_i, y_i)\}_{i=1}^N$ を複数のフォールドに分割し、一部を訓練、残りを検証に使用するプロセスを繰り返す手法です。
ホールドアウト法の問題点
単純なホールドアウト(例:80%訓練、20%テスト)には以下の問題があります:
- テストデータの選び方で評価が変動
- データの一部が学習に使われない
- 小規模データセットでは信頼性が低い
期待汎化誤差の推定
交差検証の目標は、期待汎化誤差を推定することです:
$$ \text{Err} = \mathbb{E}_{(x, y) \sim P}[\ell(f(x), y)] $$
ここで、$P$ はデータの真の分布、$\ell$ は損失関数、$f$ は学習されたモデルです。
K-Fold交差検証
理論
K-Fold交差検証では、データを $K$ 個の互いに素な部分集合(フォールド)に分割します:
$$ \mathcal{D} = \mathcal{D}_1 \cup \mathcal{D}_2 \cup \cdots \cup \mathcal{D}_K, \quad \mathcal{D}_i \cap \mathcal{D}_j = \emptyset \text{ for } i \neq j $$
各フォールド $k$ について:
- $\mathcal{D}_k$ を検証セット、$\mathcal{D} \setminus \mathcal{D}_k$ を訓練セットとする
- モデル $f^{(-k)}$ を訓練セットで学習
- 検証セットで誤差を計算
$$ \text{CV}_K = \frac{1}{K} \sum_{k=1}^{K} \frac{1}{|\mathcal{D}_k|} \sum_{(x, y) \in \mathcal{D}_k} \ell(f^{(-k)}(x), y) $$
バイアス-バリアンスのトレードオフ
$K$ の選択にはトレードオフがあります:
| $K$ | バイアス | バリアンス | 計算コスト |
|---|---|---|---|
| 小さい(例:2) | 高い(訓練データが少ない) | 低い | 低い |
| 大きい(例:N) | 低い | 高い(フォールド間の相関) | 高い |
一般的には $K = 5$ または $K = 10$ が推奨されます。
数学的な分析
K-Fold CVの推定量の性質を分析します。
期待値(ほぼ不偏):
$$ \mathbb{E}[\text{CV}_K] \approx \text{Err}_{(N-N/K)} $$
ここで、$\text{Err}_{(N-N/K)}$ は訓練サンプル数 $N – N/K$ での期待誤差です。
分散:
$$ \text{Var}(\text{CV}_K) = \frac{\sigma^2}{K} + \frac{K-1}{K} \rho \sigma^2 $$
ここで、$\sigma^2$ は各フォールドの誤差分散、$\rho$ はフォールド間の相関です。
層化K-Fold交差検証
理論
分類問題において、各フォールドでクラス分布を保持する手法です。
クラス $c$ の割合を $\pi_c = N_c / N$ とすると、各フォールド $\mathcal{D}_k$ で:
$$ \frac{|\{(x, y) \in \mathcal{D}_k : y = c\}|}{|\mathcal{D}_k|} \approx \pi_c $$
必要性
クラス不均衡データでは、ランダム分割により一部のフォールドで少数クラスが欠落する可能性があります。層化抽出によりこの問題を回避します。
Leave-One-Out交差検証(LOOCV)
理論
$K = N$(サンプル数)の極端なケースです。
$$ \text{LOOCV} = \frac{1}{N} \sum_{i=1}^{N} \ell(f^{(-i)}(x_i), y_i) $$
特徴: – バイアスが最小($N-1$ サンプルで学習) – 計算コストが高い($N$ 回の学習) – 分散が高い(訓練セット間の重複が大きい)
線形回帰での効率的計算
線形回帰では、LOOCV を 1 回の学習で計算できます:
$$ \text{LOOCV} = \frac{1}{N} \sum_{i=1}^{N} \left( \frac{y_i – \hat{y}_i}{1 – h_{ii}} \right)^2 $$
ここで、$h_{ii}$ はハット行列 $H = X(X^\top X)^{-1}X^\top$ の対角成分です。
時系列データ向けの交差検証
問題点
時系列データでは、未来のデータで過去を予測するデータリークが発生します。標準的なK-Fold CVは時間構造を無視するため不適切です。
Time Series Split
訓練データは常に検証データより前の時点のみを使用します。
フォールド $k$ について: – 訓練:$\{1, 2, \ldots, t_k\}$ – 検証:$\{t_k + 1, \ldots, t_{k+1}\}$
拡張ウィンドウ vs スライディングウィンドウ
| 手法 | 訓練データ | 特徴 |
|---|---|---|
| 拡張ウィンドウ | $\{1, \ldots, t_k\}$ | 過去全データを使用 |
| スライディングウィンドウ | $\{t_k – w, \ldots, t_k\}$ | 固定長の最近データ |
Pythonでの実装
K-Fold交差検証
import numpy as np
import matplotlib.pyplot as plt
from sklearn.model_selection import KFold, StratifiedKFold, LeaveOneOut, TimeSeriesSplit
from sklearn.linear_model import LogisticRegression, Ridge
from sklearn.datasets import make_classification, make_regression
from sklearn.metrics import accuracy_score, mean_squared_error
# K-Fold CVの実装(スクラッチ)
class KFoldCV:
"""K-Fold交差検証のスクラッチ実装"""
def __init__(self, n_splits=5, shuffle=True, random_state=None):
self.n_splits = n_splits
self.shuffle = shuffle
self.random_state = random_state
def split(self, X):
"""インデックスを分割"""
n_samples = len(X)
indices = np.arange(n_samples)
if self.shuffle:
rng = np.random.RandomState(self.random_state)
rng.shuffle(indices)
fold_sizes = np.full(self.n_splits, n_samples // self.n_splits)
fold_sizes[:n_samples % self.n_splits] += 1
current = 0
for fold_size in fold_sizes:
start, stop = current, current + fold_size
val_indices = indices[start:stop]
train_indices = np.concatenate([indices[:start], indices[stop:]])
yield train_indices, val_indices
current = stop
def cross_val_score(self, model, X, y, scoring='accuracy'):
"""交差検証スコアを計算"""
scores = []
for train_idx, val_idx in self.split(X):
X_train, X_val = X[train_idx], X[val_idx]
y_train, y_val = y[train_idx], y[val_idx]
model.fit(X_train, y_train)
y_pred = model.predict(X_val)
if scoring == 'accuracy':
score = accuracy_score(y_val, y_pred)
elif scoring == 'mse':
score = -mean_squared_error(y_val, y_pred) # 負にして大きいほど良い
else:
raise ValueError(f"Unknown scoring: {scoring}")
scores.append(score)
return np.array(scores)
# 使用例:分類問題
np.random.seed(42)
X, y = make_classification(n_samples=200, n_features=10, n_informative=5,
n_redundant=2, random_state=42)
# スクラッチ実装
kfold = KFoldCV(n_splits=5, shuffle=True, random_state=42)
model = LogisticRegression(max_iter=1000)
scores = kfold.cross_val_score(model, X, y, scoring='accuracy')
print("K-Fold CV (スクラッチ実装)")
print(f"各フォールドのスコア: {scores}")
print(f"平均スコア: {scores.mean():.4f} (+/- {scores.std() * 2:.4f})")
層化K-Fold交差検証
from sklearn.model_selection import cross_val_score
# 不均衡データの作成
X_imb, y_imb = make_classification(n_samples=200, n_features=10,
weights=[0.9, 0.1], # 90%:10%の不均衡
random_state=42)
print(f"\nクラス分布: {np.bincount(y_imb)}")
# 通常のK-FoldとStratified K-Foldの比較
model = LogisticRegression(max_iter=1000)
# 通常のK-Fold
kfold = KFold(n_splits=5, shuffle=True, random_state=42)
scores_kfold = cross_val_score(model, X_imb, y_imb, cv=kfold)
# 層化K-Fold
skfold = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
scores_skfold = cross_val_score(model, X_imb, y_imb, cv=skfold)
print("\n通常のK-Fold:")
print(f" 各フォールドのスコア: {scores_kfold}")
print(f" 平均: {scores_kfold.mean():.4f}, 標準偏差: {scores_kfold.std():.4f}")
print("\n層化K-Fold:")
print(f" 各フォールドのスコア: {scores_skfold}")
print(f" 平均: {scores_skfold.mean():.4f}, 標準偏差: {scores_skfold.std():.4f}")
# 各フォールドのクラス分布を確認
print("\n各フォールドのクラス分布(層化K-Fold):")
for i, (train_idx, val_idx) in enumerate(skfold.split(X_imb, y_imb)):
train_dist = np.bincount(y_imb[train_idx])
val_dist = np.bincount(y_imb[val_idx])
print(f" Fold {i+1}: Train {train_dist}, Val {val_dist}")
Leave-One-Out交差検証
from sklearn.linear_model import Ridge
# 小規模データでLOOCV
X_small, y_small = make_regression(n_samples=50, n_features=5, noise=10, random_state=42)
# LOOCV
loo = LeaveOneOut()
model = Ridge(alpha=1.0)
# 効率的なLOOCV計算(線形回帰の場合)
def efficient_loocv_ridge(X, y, alpha=1.0):
"""ハット行列を使った効率的なLOOCV計算"""
n = len(X)
# リッジ回帰のハット行列
# H = X(X'X + αI)^{-1}X'
XtX = X.T @ X
XtX_inv = np.linalg.inv(XtX + alpha * np.eye(X.shape[1]))
H = X @ XtX_inv @ X.T
# 予測値
beta = XtX_inv @ X.T @ y
y_pred = X @ beta
# LOOCV誤差
residuals = y - y_pred
h_diag = np.diag(H)
loocv_errors = (residuals / (1 - h_diag)) ** 2
return np.mean(loocv_errors)
# 比較
# sklearn版
scores_loo = cross_val_score(model, X_small, y_small, cv=loo,
scoring='neg_mean_squared_error')
mse_loo = -scores_loo.mean()
# 効率的計算版
mse_efficient = efficient_loocv_ridge(X_small, y_small, alpha=1.0)
print("\nLOOCV比較:")
print(f"sklearn LOOCV MSE: {mse_loo:.4f}")
print(f"効率的計算 MSE: {mse_efficient:.4f}")
時系列交差検証
# 時系列データの生成
np.random.seed(42)
n_samples = 100
time = np.arange(n_samples)
X_ts = np.column_stack([
np.sin(2 * np.pi * time / 20),
np.cos(2 * np.pi * time / 20),
time / n_samples
])
y_ts = 0.5 * X_ts[:, 0] + 0.3 * X_ts[:, 1] + 0.1 * time / n_samples + np.random.randn(n_samples) * 0.1
# Time Series Split
tscv = TimeSeriesSplit(n_splits=5)
print("\nTime Series Split:")
for i, (train_idx, val_idx) in enumerate(tscv.split(X_ts)):
print(f" Fold {i+1}: Train [{train_idx[0]}-{train_idx[-1]}], "
f"Val [{val_idx[0]}-{val_idx[-1]}]")
# 可視化
fig, axes = plt.subplots(2, 3, figsize=(15, 8))
axes = axes.flatten()
for i, (train_idx, val_idx) in enumerate(tscv.split(X_ts)):
ax = axes[i]
ax.scatter(time[train_idx], y_ts[train_idx], c='blue', alpha=0.5, label='Train')
ax.scatter(time[val_idx], y_ts[val_idx], c='red', alpha=0.5, label='Validation')
ax.set_xlabel('Time')
ax.set_ylabel('y')
ax.set_title(f'Fold {i+1}')
ax.legend()
ax.grid(True, alpha=0.3)
axes[5].axis('off')
plt.tight_layout()
plt.savefig('timeseries_cv.png', dpi=150, bbox_inches='tight')
plt.show()
# 時系列CVでのスコア
model = Ridge(alpha=1.0)
scores_ts = cross_val_score(model, X_ts, y_ts, cv=tscv,
scoring='neg_mean_squared_error')
print(f"\nTime Series CV MSE: {-scores_ts.mean():.4f} (+/- {scores_ts.std():.4f})")
Kの選択による影響の可視化
import matplotlib.pyplot as plt
import numpy as np
from sklearn.model_selection import cross_val_score, KFold
from sklearn.linear_model import LogisticRegression
from sklearn.datasets import make_classification
# データ生成
X, y = make_classification(n_samples=200, n_features=20, n_informative=10,
random_state=42)
# 異なるKでの交差検証
K_values = [2, 3, 5, 7, 10, 15, 20, 50]
means = []
stds = []
times = []
import time
for K in K_values:
kfold = KFold(n_splits=K, shuffle=True, random_state=42)
model = LogisticRegression(max_iter=1000)
start = time.time()
scores = cross_val_score(model, X, y, cv=kfold)
elapsed = time.time() - start
means.append(scores.mean())
stds.append(scores.std())
times.append(elapsed)
# 可視化
fig, axes = plt.subplots(1, 3, figsize=(15, 4))
# 平均スコア
axes[0].errorbar(K_values, means, yerr=stds, marker='o', capsize=5)
axes[0].set_xlabel('K (Number of Folds)')
axes[0].set_ylabel('Mean CV Score')
axes[0].set_title('CV Score vs K')
axes[0].grid(True, alpha=0.3)
# 標準偏差
axes[1].plot(K_values, stds, 'o-')
axes[1].set_xlabel('K (Number of Folds)')
axes[1].set_ylabel('Standard Deviation')
axes[1].set_title('Score Variance vs K')
axes[1].grid(True, alpha=0.3)
# 計算時間
axes[2].plot(K_values, times, 'o-')
axes[2].set_xlabel('K (Number of Folds)')
axes[2].set_ylabel('Computation Time (s)')
axes[2].set_title('Computation Time vs K')
axes[2].grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig('cv_k_comparison.png', dpi=150, bbox_inches='tight')
plt.show()
ネステッド交差検証
from sklearn.model_selection import GridSearchCV, cross_val_score
from sklearn.svm import SVC
# ネステッド交差検証:ハイパーパラメータ選択とモデル評価を同時に
X, y = make_classification(n_samples=200, n_features=20, random_state=42)
# 外側ループ:モデル評価
outer_cv = KFold(n_splits=5, shuffle=True, random_state=42)
# 内側ループ:ハイパーパラメータ選択
inner_cv = KFold(n_splits=3, shuffle=True, random_state=42)
# パラメータグリッド
param_grid = {'C': [0.1, 1, 10], 'gamma': [0.01, 0.1, 1]}
# ネステッドCV
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:最適パラメータを探索
clf = GridSearchCV(SVC(), param_grid, cv=inner_cv)
clf.fit(X_train, y_train)
# 外側:テストセットで評価
score = clf.score(X_test, y_test)
nested_scores.append(score)
print(f"Best params: {clf.best_params_}, Test score: {score:.4f}")
print(f"\nNested CV Score: {np.mean(nested_scores):.4f} (+/- {np.std(nested_scores):.4f})")
# 比較:非ネステッドCV(楽観的バイアスあり)
clf_simple = GridSearchCV(SVC(), param_grid, cv=5)
clf_simple.fit(X, y)
print(f"Non-nested CV Score: {clf_simple.best_score_:.4f}")
まとめ
本記事では、交差検証(Cross-Validation)について解説しました。
- K-Fold CV: データをK個に分割し、各フォールドを順番に検証に使用
- 層化K-Fold CV: クラス分布を各フォールドで保持(不均衡データに有効)
- LOOCV: 1サンプルずつを検証に使用(小規模データ向け)
- 時系列CV: 時間順序を尊重した分割(データリーク防止)
- ネステッドCV: ハイパーパラメータ選択とモデル評価を分離
適切な交差検証手法の選択は、データの特性(サイズ、クラス分布、時間依存性)に依存します。一般的には、$K=5$ または $K=10$ の層化K-Foldが良い出発点となります。
次のステップとして、以下の記事も参考にしてください。