時系列データの交差検証:なぜTimeSeriesSplitを使うべきなのか
はじめに
需要予測や売上予測のモデルを作り、交差検証(Cross-Validation)で良いスコアが出たのに、本番に載せると精度が出ない。時系列データでよくある失敗で、原因はモデルではなく検証のやり方にあることが少なくありません。通常のK-分割交差検証(K-Fold Cross-Validation)はデータをランダムに分割するため、未来のデータで学習して過去を予測する状況が生まれ、評価が実際より良く見えてしまいます。この記事では、時系列に適した交差検証であるTimeSeriesSplitを取り上げ、K-Foldとの違いを理論と実験の両面から確認します。
対象読者:
- 機械学習エンジニア、データサイエンティスト
- 時系列データの分析に携わる方
- モデルの評価方法について深く理解したい方
記事のポイント:
- 時系列データに通常のK-分割交差検証を使うと何が起きるか
- TimeSeriesSplitの分割の考え方
- scikit-learnでの実装と、K-Foldとの評価値の比較
時系列データが通常の分割と相性が悪い理由
時系列データには、一般のデータセットにない性質があります。ある時点の値は過去の値の影響を受け(時間的依存、temporal dependency)、並び順そのものに意味があるため入れ替えられません。平均や分散といった統計的性質が時間とともに変わる非定常性(non-stationarity)を持つこともあります。
この性質を無視して通常のK-分割交差検証を適用すると、未来のデータで学習して過去を予測するデータリーケージ(data leakage)が起きます。実運用では過去のデータしか使えないので、リーケージを含んだ評価は本番の性能を表さず、モデルの実力を過大に見積もることになります。
交差検証手法の比較
通常のK-分割交差検証
通常のK-分割交差検証は、データをランダムにK個のフォールド(部分集合)に分割し、そのうち1つを検証データ、残りを訓練データとしてモデルを評価します。 時系列データでは、このランダムな分割が問題になります。
時系列データは時間的な依存関係があるため、上記のように条件付き確率と周辺確率が等しくなりません。 ランダムに分割すると、モデルは未来のデータを使って過去を予測できてしまい、性能を過大評価する結果になります。
TimeSeriesSplit
TimeSeriesSplitは、データを時間順に分割し、常に過去のデータで学習して未来を検証する交差検証です。未来のデータが訓練側に混じらないため、リーケージが起きません。データは次のように分割されます。
訓練データは常に検証データよりも前の時点のデータで構成されます。過去の情報だけを使って未来を予測するという、実際の運用と同じ状況を再現できます。
分割のされ方と評価値を実際に比べる
まず、K-FoldとTimeSeriesSplitでデータがどう分割されるかを可視化します。紫が学習用データ、黄色が検証用データです。

上のK-Foldでは学習用と検証用が時間軸の全体に散らばり、検証区間の前後どちらのデータでも学習しています。下のTimeSeriesSplitでは、どの分割でも検証区間より前のデータだけで学習しており、時間の順序が守られています。
次に、両者で予測性能を測って比べます。

平均二乗誤差(MSE)で比べると、K-Foldの方が誤差が小さく出ます。未来のデータを学習に使えるためで、この差の分だけ評価が楽観的になっています。実運用で期待できる性能に近いのはTimeSeriesSplitの値の方です。
scikit-learnでの書き方
scikit-learnを使えば、TimeSeriesSplitは数行で書けます。
from sklearn.model_selection import TimeSeriesSplit
# 5分割の場合
tscv = TimeSeriesSplit(n_splits=5)
# 交差検証の実行
for train_index, test_index in tscv.split(X):
X_train, X_test = X[train_index], X[test_index]
y_train, y_test = y[train_index], y[test_index]
# モデルの学習と評価
まとめ
時系列データの交差検証では、TimeSeriesSplitがデータの時間的な順序を保ち、未来のデータが訓練側に混じるのを防ぎます。K-Foldのように性能を過大評価することがなく、実運用に近い形で予測性能を測れます。
金融データ、需要予測、センサーデータのように時間的な依存が強いデータでは、TimeSeriesSplitを選ぶのが基本です。手元の検証コードでKFoldをTimeSeriesSplitに差し替えて測り直せば、これまでの評価がどれだけ楽観的だったかを自分のデータで確認できます。
コード
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import japanize_matplotlib
from sklearn.model_selection import KFold, TimeSeriesSplit
from sklearn.linear_model import LinearRegression
from sklearn.metrics import mean_squared_error
# シード固定
np.random.seed(42)
def generate_time_series_data(n_samples=100):
"""時系列データの生成"""
t = np.linspace(0, 10, n_samples)
# トレンド + 季節性 + ノイズ
trend = 0.5 * t
seasonal = 2 * np.sin(2 * np.pi * t)
noise = np.random.normal(0, 0.5, n_samples)
y = trend + seasonal + noise
return t, y
def create_features(t, y, lookback=3):
"""特徴量とターゲットの作成"""
X, Y = [], []
for i in range(lookback, len(y)):
X.append(y[i-lookback:i])
Y.append(y[i])
return np.array(X), np.array(Y)
def plot_cv_indices(cv, X, ax, n_splits, lw=10):
"""交差検証の分割を可視化"""
for i, (train_idx, val_idx) in enumerate(cv.split(X)):
train = np.zeros(len(X))
train[train_idx] = 1
val = np.zeros(len(X))
val[val_idx] = 2
used_indices = np.concatenate([train_idx, val_idx])
train_plot = train[used_indices]
val_plot = val[used_indices]
ax.scatter(used_indices, [i + .5] * len(used_indices),
c=train_plot, marker='_', lw=lw, label='Training Set')
ax.scatter(used_indices, [i + .5] * len(used_indices),
c=val_plot, marker='_', lw=lw, label='Validation Set')
ax.set_xlabel('Sample Index')
ax.set_ylabel('CV Iteration')
ax.set_title('Data Split Comparison')
ax.set_yticks(np.arange(n_splits) + .5)
ax.set_yticklabels([f'Split {i+1}' for i in range(n_splits)])
# データ生成
t, y = generate_time_series_data()
X, Y = create_features(t, y)
# 交差検証の設定
n_splits = 5
kf = KFold(n_splits=n_splits, shuffle=True, random_state=42)
tscv = TimeSeriesSplit(n_splits=n_splits)
# 可視化
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(15, 10))
# K-Fold
plot_cv_indices(kf, X, ax1, n_splits)
ax1.set_title('K-Fold Cross Validation')
# TimeSeriesSplit
plot_cv_indices(tscv, X, ax2, n_splits)
ax2.set_title('Time Series Split')
plt.tight_layout()
plt.savefig('cv_comparison.png')
plt.close()
# 予測性能の比較
def evaluate_cv(cv, X, y):
mse_scores = []
for train_idx, val_idx in cv.split(X):
X_train, X_val = X[train_idx], X[val_idx]
y_train, y_val = y[train_idx], y[val_idx]
model = LinearRegression()
model.fit(X_train, y_train)
y_pred = model.predict(X_val)
mse = mean_squared_error(y_val, y_pred)
mse_scores.append(mse)
return np.mean(mse_scores)
kf_score = evaluate_cv(kf, X, Y)
ts_score = evaluate_cv(tscv, X, Y)
# 結果の可視化
results = pd.DataFrame({
'Method': ['K-Fold', 'TimeSeriesSplit'],
'MSE': [kf_score, ts_score]
})
plt.figure(figsize=(10, 6))
plt.bar(results['Method'], results['MSE'])
plt.title('Prediction Error Comparison')
plt.ylabel('Mean Squared Error')
plt.savefig('error_comparison.png')
plt.close()