交差検証(Cross-Validation)の種類と使い分けを解説

交差検証(Cross-Validation)は、機械学習モデルの汎化性能を評価するための標準的な手法です。限られたデータを最大限活用し、過学習を検出してモデルの信頼性を評価します。

本記事では、様々な交差検証手法の理論と数学的背景、そしてPythonでの実装を解説します。

本記事の内容

  • 交差検証の基本概念と必要性
  • K-Fold交差検証の理論
  • 層化K-Fold交差検証
  • Leave-One-Out交差検証
  • 時系列データ向けの交差検証
  • Pythonでの実装と比較

交差検証とは

基本概念

交差検証は、データセット $\mathcal{D} = \{(x_i, y_i)\}_{i=1}^N$ を複数のフォールドに分割し、一部を訓練、残りを検証に使用するプロセスを繰り返す手法です。

ホールドアウト法の問題点

単純なホールドアウト(例:80%訓練、20%テスト)には以下の問題があります:

  1. テストデータの選び方で評価が変動
  2. データの一部が学習に使われない
  3. 小規模データセットでは信頼性が低い

期待汎化誤差の推定

交差検証の目標は、期待汎化誤差を推定することです:

$$ \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$ について:

  1. $\mathcal{D}_k$ を検証セット、$\mathcal{D} \setminus \mathcal{D}_k$ を訓練セットとする
  2. モデル $f^{(-k)}$ を訓練セットで学習
  3. 検証セットで誤差を計算

$$ \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が良い出発点となります。

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