通常の K-Fold クロスバリデーション(K-Fold cross validation)は、時系列データではそのまま使えません。本記事では、なぜ時系列で K-Fold が問題になるかを整理し、TimeSeriesSplit と Walk-Forward(ウォークフォワード)による分割を Python で実装します。パラメータ選定の正攻法として押さえておきたい内容です。

目次

  1. なぜ通常の K-Fold が使えないか
  2. 単純な訓練/検証分割
  3. TimeSeriesSplit
  4. Walk-Forward(ローリング窓)
  5. 適用例: SMA 窓幅のグリッドサーチ
  6. ネストした CV(参考)
  7. sklearn cross_val_score との連携
  8. チェックリスト

なぜ通常の K-Fold が使えないか

K-Fold は、データを K 個のブロックにランダムに分割し、各 fold で「他のブロックで学習し、残り 1 ブロックで検証」を繰り返します。

時系列で K-Fold をそのまま使うと、次の問題が起きます。

問題内容
ルックアヘッドバイアス検証ブロックの「後」のデータで学習することになる
データの非独立性隣接日のリターンには弱い相関がある。ランダム分割で訓練と検証が同じ局面のサンプルを共有
評価が楽観的「過去 → 未来」を予測する難しさが反映されない

時系列の評価は 「過去で学習し、未来で検証する」 構造を必ず守ります。

単純な訓練/検証分割

最初は、時系列順に 1 回だけ分割する形が出発点です。

import numpy as np
import pandas as pd
rng = np.random.default_rng(seed=43)
n_days = 1500
ret = rng.normal(loc=0.0003, scale=0.012, size=n_days)
price = 1500 * np.exp(np.cumsum(ret))
df = pd.DataFrame({
"C": price,
"ret": np.r_[np.nan, np.diff(np.log(price))],
}, index=pd.date_range("2020-01-02", periods=n_days, freq="B"))
split_idx = int(len(df) * 0.7)
train = df.iloc[:split_idx]
test = df.iloc[split_idx:]
print(f"train: {train.index.min().date()}{train.index.max().date()} ({len(train)} 行)")
print(f"test: {test.index.min().date()}{test.index.max().date()} ({len(test)} 行)")

訓練 70% / 検証 30% は典型的な比率です。最低限「未来データを訓練に混ぜていない」を守れます。一方で、検証区間が 1 つしか無いため、たまたまその期間が有利だった可能性は残ります。

TimeSeriesSplit

sklearn の TimeSeriesSplit は、時系列順を守ったまま複数 fold を作る分割器です。

基本の使い方

from sklearn.model_selection import TimeSeriesSplit
tscv = TimeSeriesSplit(n_splits=5)
for fold, (tr_idx, va_idx) in enumerate(tscv.split(df)):
tr_period = (df.index[tr_idx[0]].date(), df.index[tr_idx[-1]].date())
va_period = (df.index[va_idx[0]].date(), df.index[va_idx[-1]].date())
print(f"fold {fold}: train {tr_period} -> valid {va_period}")

各 fold で、訓練区間が「これまでのすべて」、検証区間が「次のブロック」になります。fold が進むにつれ訓練データが大きくなる expanding window(拡張窓)方式です。

gap でリーク防止

訓練終了日と検証開始日の間に ギャップ(gap) を置く設定があります。当日の特徴量に翌日のラベルがにじむケースを防ぐのに有効です。

tscv_gap = TimeSeriesSplit(n_splits=5, gap=20, max_train_size=500)
for fold, (tr_idx, va_idx) in enumerate(tscv_gap.split(df)):
print(f"fold {fold}: train [{df.index[tr_idx[0]].date()} .. {df.index[tr_idx[-1]].date()}] "
f"valid [{df.index[va_idx[0]].date()} .. {df.index[va_idx[-1]].date()}]")

max_train_size を指定すると、訓練データの最大長を制限できます(古すぎるデータを切り捨てる、ローリング窓の発想)。

Walk-Forward(ローリング窓)

固定長の訓練窓を時間とともにスライドさせる方式です。市場のレジーム変化に追従しやすく、長期で訓練データが古くなる影響を受けにくいのが利点です。

def walk_forward_splits(
n: int,
train_size: int,
test_size: int,
step: int | None = None,
):
"""ローリング窓で (train_idx, test_idx) を生成する。"""
step = step or test_size
start = 0
while start + train_size + test_size <= n:
tr = np.arange(start, start + train_size)
te = np.arange(start + train_size, start + train_size + test_size)
yield tr, te
start += step
for fold, (tr_idx, va_idx) in enumerate(walk_forward_splits(len(df), train_size=400, test_size=80)):
print(f"fold {fold}: train [{df.index[tr_idx[0]].date()} .. {df.index[tr_idx[-1]].date()}] "
f"valid [{df.index[va_idx[0]].date()} .. {df.index[va_idx[-1]].date()}]")

train_sizetest_size の比は、扱うシグナルの寿命と検証の安定性のトレードオフで決めます。短い検証窓は不安定、長い検証窓はレジーム変化を平均化してしまいます。

適用例: SMA 窓幅のグリッドサーチ

時系列クロスバリデーションを使って、25/75 / 50/200 / 5/30 の 3 つの SMA 設定を比較してみます。検証指標は年率シャープレシオです。

from itertools import product
TRADING_DAYS = 252
COST = 0.001
def evaluate_sma_pair(
df_local: pd.DataFrame,
short_w: int,
long_w: int,
cost: float = COST,
) -> float:
"""SMA クロス戦略の年率シャープを返す。"""
sma_s = df_local["C"].rolling(short_w).mean()
sma_l = df_local["C"].rolling(long_w).mean()
signal = (sma_s > sma_l).astype(int)
position = signal.shift(1)
daily_ret = df_local["C"].pct_change()
pos_change = position.diff().abs().fillna(position)
strat_ret = (position * daily_ret - pos_change * cost).dropna()
if strat_ret.std(ddof=0) == 0:
return float("nan")
return (strat_ret.mean() / strat_ret.std(ddof=0)) * np.sqrt(TRADING_DAYS)
candidates = [(5, 30), (25, 75), (50, 200)]
results: list[dict] = []
for fold, (tr_idx, va_idx) in enumerate(walk_forward_splits(len(df), train_size=500, test_size=120)):
train_df = df.iloc[tr_idx]
valid_df = df.iloc[va_idx]
# 訓練区間で最良パラメータを選ぶ
best = max(candidates, key=lambda p: evaluate_sma_pair(train_df, *p) or -np.inf)
# 検証区間で評価
va_score = evaluate_sma_pair(valid_df, *best)
results.append({"fold": fold, "best_pair": best, "valid_sharpe": va_score})
print(pd.DataFrame(results))

ポイントは次の 2 点です。

  • パラメータ選定は訓練区間だけで 行う
  • 検証区間のスコアの平均 を採用する。1 つの fold だけで判断しない

検証区間の年率シャープが fold 間で大きくバラつく場合、「過去にたまたま勝てた」だけの可能性があります。

ネストした CV(参考)

「パラメータ選定」と「最終モデルの汎化性能評価」を同時に行いたい場合は、ネストした CV(nested CV)が原則です。

  • 外側ループ: 最終評価(年率シャープなど)
  • 内側ループ: パラメータ選定

外側 5 fold × 内側 5 fold だと、計算量は 25 倍になります。実務では小さい候補集合に絞ったり、Walk-Forward で代替したりする工夫が必要です。

sklearn cross_val_score との連携

sklearn の cross_val_scoreTimeSeriesSplit を渡すと、時系列順を守った CV が回せます。リーク防止のため、前処理は Pipeline に含めておくのが安全です。

from sklearn.linear_model import LogisticRegression
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import Pipeline
from sklearn.model_selection import cross_val_score
# 例: 過去 5 日のリターン平均で「翌日の上昇」を当てる
df["feat"] = df["ret"].rolling(5).mean().shift(1)
df["label"] = (df["ret"].shift(-1) > 0).astype(int)
X = df[["feat"]].dropna()
y = df.loc[X.index, "label"]
pipe = Pipeline([
("scaler", StandardScaler()),
("clf", LogisticRegression()),
])
scores = cross_val_score(pipe, X.values, y.values, cv=TimeSeriesSplit(n_splits=5), scoring="accuracy")
print("scores:", scores)
print("mean: ", scores.mean())

平均値だけでなく、fold 間の ばらつき を必ず確認します。標準偏差が大きい設定は、本番運用での実績が読みにくい設定です。

チェックリスト

時系列 CV の実装後に確認したい項目です。

[ ] 検証区間が常に訓練区間より「未来」になっている
[ ] gap を入れる必要があるラベルか確認した(N 日先のリターン予測など)
[ ] 前処理(標準化・補完)は Pipeline でカプセル化されている
[ ] パラメータ選定は訓練区間内で完結している
[ ] 検証区間が 1 つではなく、複数 fold の平均で評価している
[ ] fold 間のばらつき(標準偏差)も併記している

注意点

  • データ量と fold 数のバランス: fold が多すぎると検証区間が短く、評価が不安定になります
  • レジーム変化: 古いデータを訓練に含め続ける expanding window は、レジーム変化に追従しにくい一面があります
  • 検証区間の独立性: gap を 0 にすると、当日の特徴量と翌日のラベルが事実上同じ情報を共有する場合があり、リークになります
  • 本記事のコードは検証用で、実発注 API は呼びません。実取引に流用する場合は、注文金額の上限・誤発注防止のチェックを別途実装します

生成AI へのプロンプト例

TimeSeriesSplit と Pipeline を組み合わせて、複数モデルの比較表を作りたい場合の例です。

入力:
- X: pd.DataFrame, 説明変数(全て float)
- y: pd.Series, 目的変数(0/1)
- models: dict[str, sklearn estimator]
次の処理を行う関数 compare_time_series_cv(X, y, models, n_splits=5, gap=10) を
書いてください。
仕様:
- TimeSeriesSplit(n_splits=n_splits, gap=gap) で時系列順 CV
- 各モデルを Pipeline([("scaler", StandardScaler()), ("model", model)]) でラップ
- accuracy / roc_auc を fold ごとに計算し、平均と標準偏差を集計
- 戻り値: pd.DataFrame(列: model, acc_mean, acc_std, auc_mean, auc_std)
要件:
- pandas 2.2 / sklearn 1.4 系
- docstring を日本語で書く
- 標準偏差は ddof=0

まとめ

  • 通常の K-Fold は時系列でリーク・楽観評価を生むため使わない
  • 単純な「訓練/検証分割」が出発点。期間に依存するため fold を増やすと安定する
  • TimeSeriesSplit で expanding window、自前関数で Walk-Forward(ローリング窓)が組める
  • パラメータ選定は 訓練区間だけで 行い、検証区間で平均評価する
  • 前処理は Pipeline に入れて、fit/transform の分離を自動化する