バックテストの過学習を防ぐウォークフォワード分析をPythonで実装する方法【日本株対応】

基礎知識・戦略

「バックテストで年利30%が出たのに、実運用に移した2週間で損失」——これは僕の実話です。原因を突き詰めると、ルックアヘッドバイアスではありませんでした。犯人はパラメータの過学習でした。

移動平均の期間を5日から50日まで総当たりで試し、いちばん成績の良かった「23日」を採用していたのです。当時は最適化したつもりでいましたが、実際にやっていたのは過去のノイズに合わせ込む作業でした。

この記事では、それを防ぐウォークフォワード分析をPythonで実装します。日本株のデータで実際に検証し、「最適化した数字」と「実際に使える数字」がどれだけ違うかを確認していきます。

※本記事のコードや情報は執筆時点の仕様に基づいています。投資は自己責任であり、必ずデモ環境や少額資金でテストした上で運用してください。

最適化が「嘘」を生む仕組み

まず、なぜ最適化が危険なのかを整理します。問題は最適化そのものではなく、最適化した同じデータで成績を評価することにあります。

試験に例えると分かりやすくなります。答えを見ながら解いたテストで満点を取っても、実力の証明にはなりません。バックテストの最適化は、まさにこれをやっています。

やり方試験に例えると結果の意味
全期間で最適化して全期間で評価答えを見ながら解く無意味
前半で最適化して後半で評価過去問で勉強して本番1回だけの試験
ウォークフォワード模試を何度も受ける実力が分かる

2番目でも一応の検証にはなりますが、1回きりの結果なので運の要素が残ります。後半がたまたま得意な相場だった、ということもあり得ます。

もう少し具体的に、なぜ最適化が嘘を生むのかを考えてみます。パラメータを25通り試したとき、そのうち最も成績の良かったものを選んでいるわけですが、その1位には必ず「たまたま運が良かった分」が上乗せされています。

25人でくじを引いて、いちばん当たりが多かった人を「くじ運の才能がある」と評価するようなものです。翌週も同じ人が勝つとは限りません。試す数が多いほど、1位の数字は実力から乖離していきます。

この「試す数が増えるほど偶然の当たりが混ざる」という問題は、統計的には多重検定として知られています。詳しくはp値を知ってバックテストの見方が変わった話で扱っていますが、要するに「たくさん試すこと自体が結果を汚す」ということです。

📘 外部参考Walk forward optimization(Wikipedia)Overfitting(Investopedia)

ウォークフォワード分析の考え方

やることはシンプルです。期間をずらしながら「学習→検証」を何度も繰り返すだけです。

【期間の使い方】

  ①  [====学習====][検証]
  ②        [====学習====][検証]
  ③              [====学習====][検証]
  ④                    [====学習====][検証]
                                        → 時間の流れ

  学習期間: パラメータを最適化する(この成績は捨てる)
  検証期間: 決めたパラメータをそのまま使う(この成績だけを集める)

重要なのは、検証期間の成績だけをつなぎ合わせて評価する点です。学習期間の成績は、どれだけ良くても一切見ません。カンニングした答案だからです。

この方法の優れているところは、「相場が変わったらパラメータを見直す」という実際の運用そのものを再現していることです。1回だけ最適化して10年放置する人はいません。定期的に見直すのが普通であり、その運用込みで検証できるわけです。

ウォークフォワードから得られる情報を整理しておきます。単に「使えるかどうか」だけでなく、複数の観点で戦略を評価できるのが利点です。

  • 検証期の通算成績: 実際に運用したらどうなっていたか。いちばん重要な数字
  • 学習期からの劣化率: 最適化がどれだけ幻を生んでいたか
  • プラスだった期間の割合: 一時期の大勝ちに依存していないか
  • 採用パラメータの安定性: 毎回違う値なら、それはノイズを見ている

特に最後の観点は、他の検証手法では得られにくい情報です。「そもそもこの戦略にとって最適なパラメータは存在するのか」という、より根本的な問いに答えてくれます。

Pythonで実装する

pip install yfinance pandas numpy matplotlib

まず、検証対象となる戦略とその評価関数を用意します。ここでは移動平均クロスを使います。

import yfinance as yf
import numpy as np
import pandas as pd

COST = 0.001   # 往復0.1%を想定


def ma_cross_signal(price, short, long):
    """移動平均クロスのシグナル(1=買い持ち, 0=ノーポジ)"""
    ma_s = price.rolling(short).mean()
    ma_l = price.rolling(long).mean()
    return (ma_s > ma_l).astype(int)


def evaluate(price, signal, cost=COST):
    """シグナルからコスト込みの成績を計算する"""
    ret = price.pct_change()
    pos = signal.shift(1).fillna(0)          # 必ず1本ずらす

    turnover = pos.diff().abs().fillna(0)
    pnl = (pos * ret - turnover * cost).fillna(0)

    if pnl.std() == 0 or len(pnl) < 2:
        return {"sharpe": 0.0, "total": 0.0, "pnl": pnl}

    return {
        "sharpe": float(pnl.mean() / pnl.std() * np.sqrt(252)),
        "total": float((1 + pnl).prod() - 1),
        "pnl": pnl,
    }

signal.shift(1)evaluate() の中に入れているのがポイントです。ずらし忘れが構造的に起きないようにしています。この点はルックアヘッドバイアスをPythonで排除する方法で詳しく扱っています。

学習期間で最適パラメータを探す

import itertools

SHORT_RANGE = [5, 10, 15, 20, 25]
LONG_RANGE = [40, 60, 75, 100, 120]


def optimize(price, metric="sharpe"):
    """学習期間の中で最も成績の良いパラメータを探す"""
    best, best_score = None, -np.inf

    for s, l in itertools.product(SHORT_RANGE, LONG_RANGE):
        if s >= l:
            continue
        sig = ma_cross_signal(price, s, l)
        score = evaluate(price, sig)[metric]
        if score > best_score:
            best_score, best = score, (s, l)

    return best, best_score

ウォークフォワードのループを回す

def walk_forward(price, train_days=500, test_days=125, step=None):
    """学習と検証を前進させながら繰り返す

    train_days : 学習期間(約2年)
    test_days  : 検証期間(約半年)
    """
    step = step or test_days
    results, oos_pnl = [], []

    start = 0
    while start + train_days + test_days <= len(price):
        train = price.iloc[start:start + train_days]
        test = price.iloc[start + train_days - 120:
                          start + train_days + test_days]
        # ↑ 移動平均の計算に助走が要るので、少し重ねて切り出す

        params, in_sample = optimize(train)
        if params is None:
            start += step
            continue

        s, l = params
        sig = ma_cross_signal(test, s, l)
        out = evaluate(test, sig)

        # 助走部分を除いた検証期間だけを取り出す
        pnl_test = out["pnl"].iloc[120:]
        oos_pnl.append(pnl_test)

        results.append({
            "検証開始": test.index[120].date(),
            "検証終了": test.index[-1].date(),
            "採用短期": s,
            "採用長期": l,
            "学習期シャープ": round(in_sample, 2),
            "検証期シャープ": round(
                float(pnl_test.mean() / pnl_test.std() * np.sqrt(252))
                if pnl_test.std() else 0, 2),
        })
        start += step

    return pd.DataFrame(results), pd.concat(oos_pnl) if oos_pnl else pd.Series()


price = yf.download("^N225", period="10y", auto_adjust=True,
                    progress=False)["Close"].dropna()
table, oos = walk_forward(price)
print(table.to_string(index=False))

実行して「学習期シャープ」と「検証期シャープ」の列を見比べてください。ここに、この記事でいちばん見てほしい現実が現れます。

学習期と検証期の落差を見る

print(f"学習期の平均シャープ : {table['学習期シャープ'].mean():>6.2f}")
print(f"検証期の平均シャープ : {table['検証期シャープ'].mean():>6.2f}")
print(f"劣化率               : "
      f"{(1 - table['検証期シャープ'].mean() / table['学習期シャープ'].mean()) * 100:>5.1f} %")

wins = int((table["検証期シャープ"] > 0).sum())
print(f"\n検証期がプラスだった回数: {wins} / {len(table)} 回")

# 全期間の成績
if len(oos):
    print(f"\n--- アウトオブサンプル通算 ---")
    print(f"  累積リターン : {float((1 + oos).prod() - 1) * 100:>7.1f} %")
    print(f"  シャープ     : "
          f"{float(oos.mean() / oos.std() * np.sqrt(252)):>7.2f}")
    eq = (1 + oos).cumprod()
    print(f"  最大DD       : "
          f"{float((eq / eq.cummax() - 1).min()) * 100:>7.1f} %")

# 比較: 全期間で最適化した場合(カンニング版)
best_params, best_score = optimize(price)
print(f"\n--- 全期間で最適化した場合(参考) ---")
print(f"  最適パラメータ : {best_params}")
print(f"  シャープ       : {best_score:>7.2f}  ← これは実現できない数字

典型的には、学習期のシャープが1.5前後なのに、検証期は0.3〜0.6程度まで落ちます。劣化率でいえば6割以上です。

そして最後の「全期間で最適化した場合」の数字が、僕がかつて信じていた「年利30%」の正体です。その数字は、どんなに頑張っても実現できません。未来を知っていなければ選べないパラメータだからです。

採用パラメータが安定しているかを見る

もう1つ、重要な観察ポイントがあります。期間ごとに選ばれるパラメータがバラバラかどうかです。

print("採用された短期パラメータの分布:")
print(table["採用短期"].value_counts().sort_index().to_string())
print("\n採用された長期パラメータの分布:")
print(table["採用長期"].value_counts().sort_index().to_string())

# 毎回違うパラメータが選ばれるなら、それは最適化できていない証拠
unique_ratio = len(table.groupby(["採用短期", "採用長期"])) / len(table)
print(f"\nパラメータの多様性: {unique_ratio:.2f}")
if unique_ratio > 0.7:
    print("⚠ 期間ごとに違うパラメータが選ばれています")
    print("   → ノイズに反応しているだけの可能性が高いです")

これは意外と知られていない見方です。本当に有効なパラメータなら、期間が変わってもだいたい同じ値が選ばれるはずです。毎回まったく違う値が最適になるなら、それはその期間のノイズに合わせているだけということになります。

パラメータの「地形」を確認する

もう一歩踏み込んだ検査を紹介します。最適値の周辺でも成績が安定しているかを見る方法です。

def parameter_landscape(price):
    """パラメータの組み合わせごとの成績を表にする"""
    rows = []
    for s in SHORT_RANGE:
        row = {"短期": s}
        for l in LONG_RANGE:
            if s >= l:
                row[f"長期{l}"] = np.nan
                continue
            sig = ma_cross_signal(price, s, l)
            row[f"長期{l}"] = round(evaluate(price, sig)["sharpe"], 2)
        rows.append(row)
    return pd.DataFrame(rows).set_index("短期")


landscape = parameter_landscape(price)
print(landscape.to_string())

flat = landscape.values.ravel()
flat = flat[~np.isnan(flat)]
print(f"\n最良 {flat.max():.2f} / 平均 {flat.mean():.2f} / 最悪 {flat.min():.2f}")
print(f"最良と平均の差: {flat.max() - flat.mean():.2f}")

この表の読み方が肝心です。1マスだけ突出して良い数字なら、それは偶然と考えてください。

地形見え方判断
なだらかな高台近い値も同じくらい良い信頼できる
鋭い尖り1マスだけ突出カーブフィッティング
全体的に平坦どれも似た成績パラメータの影響が小さい
まだら良し悪しが不規則ノイズを見ている

僕が採用していた「23日」も、まさに鋭い尖りでした。22日や24日にすると成績が大きく落ちる——この時点で気づくべきだったのです。1日ずらしただけで崩れる優位性が、未来に通用するはずがありません。

期間の設定をどう決めるか

学習期間と検証期間の長さは、結果に大きく影響します。実際に振ってみると、その敏感さが分かります。

configs = [
    (250, 60),    # 学習1年・検証3か月
    (500, 125),   # 学習2年・検証半年
    (750, 250),   # 学習3年・検証1年
    (1000, 250),  # 学習4年・検証1年
]

print(f"{'学習':>6s} {'検証':>6s} {'回数':>5s} {'検証シャープ':>13s} {'勝ち回数':>9s}")
for train, test in configs:
    t, o = walk_forward(price, train_days=train, test_days=test)
    if len(t) == 0:
        continue
    sharpe = float(o.mean() / o.std() * np.sqrt(252)) if len(o) else 0
    wins = int((t["検証期シャープ"] > 0).sum())
    print(f"{train:>6d} {test:>6d} {len(t):>5d} {sharpe:>13.2f} "
          f"{wins:>5d}/{len(t):<3d}")

結果を見ながら決めることになりますが、目安は次のとおりです。

  • 学習期間: 検証期間の3〜4倍。短すぎるとノイズを拾い、長すぎると環境変化に追随できない
  • 検証期間: 実際にパラメータを見直す間隔に合わせる。四半期ごとなら3か月
  • 反復回数: 最低でも5回以上。1〜2回では偶然と区別できない

ここで1つ注意があります。「検証期の成績が良くなる設定」を探し始めた瞬間、それもまた過学習です。期間設定は、成績を見る前に運用実態から決めてください。四半期ごとに見直すつもりなら検証期間は3か月、それだけの話です。

結果をどう判断するか

ウォークフォワードを回したあと、実運用に進んでよいかの判断基準をまとめます。

def judge(table, oos):
    """ウォークフォワードの結果から実運用の可否を判断する"""
    if len(table) == 0 or len(oos) == 0:
        return print("データ不足です")

    sharpe = float(oos.mean() / oos.std() * np.sqrt(252))
    win_periods = float((table["検証期シャープ"] > 0).mean())
    decay = 1 - table["検証期シャープ"].mean() / table["学習期シャープ"].mean()
    diversity = len(table.groupby(["採用短期", "採用長期"])) / len(table)

    print(f"検証期通算シャープ : {sharpe:>6.2f}")
    print(f"プラスだった期間   : {win_periods:>6.1%}")
    print(f"学習期からの劣化   : {decay:>6.1%}")
    print(f"パラメータ多様性   : {diversity:>6.2f}")

    issues = []
    if sharpe < 0.5:
        issues.append("検証期のシャープが低すぎます")
    if win_periods < 0.6:
        issues.append("プラスの期間が6割に届きません")
    if decay > 0.7:
        issues.append("学習期からの劣化が大きすぎます")
    if diversity > 0.7:
        issues.append("パラメータが安定していません")

    print()
    if issues:
        print("✗ 実運用は見送りを推奨します")
        for i in issues:
            print(f"   - {i}")
    else:
        print("✓ 少額から試す価値はありそうです")


judge(table, oos)

正直に言うと、この基準を通る戦略はそう多くありません。単純な移動平均クロスは、まず通らないと思ってよいでしょう。

ただ、それは悪いことではありません。通らなかった戦略に資金を入れずに済んだわけですから。僕の場合、この検証を導入してから「実運用に上げる戦略」が激減しましたが、そのぶん想定外の損失もなくなりました。

成績評価の指標そのものについてはシャープレシオを使って投資戦略を評価する方法もあわせてご覧ください。

よくあるつまずき

検証期間の成績を見てから設定を変える

最も多い失敗です。「検証期の成績が悪いから、学習期間を延ばしてみよう」——この時点で、検証期間はもうアウトオブサンプルではありません。結果を見て調整した以上、それは学習データの一部です。

対処法は、最初から一部の期間を完全に封印しておくことです。全期間の直近2割は触らず、すべての検討が終わってから最後に1度だけ使います。

# 最後の2割を封印してから検証を始める
split = int(len(price) * 0.8)
develop = price.iloc[:split]      # ここで自由に試す
holdout = price.iloc[split:]      # 最後まで触らない

print(f"開発用   : {develop.index[0].date()} 〜 {develop.index[-1].date()}")
print(f"封印期間 : {holdout.index[0].date()} 〜 {holdout.index[-1].date()}")
print("※ holdout は全ての検討が終わるまで一度も使わないこと")

移動平均の助走期間を忘れる

75日移動平均を使うなら、検証期間の頭から計算するにはその前の75日分のデータが必要です。これを忘れると、検証期間の最初のほうがNaNになって取引が発生しません。

本記事のコードで train_days - 120 から切り出しているのはこのためです。助走部分は成績の集計から除くことも忘れないでください。

コストを入れずに検証する

ウォークフォワードは期間ごとにパラメータが変わるため、売買回数が増えがちです。コストを入れないと、実態よりかなり良い数字が出ます。

コストの見積もり方は手数料・スリッページをPythonで組み込む方法にまとめています。少なくとも往復0.1%程度は必ず入れて検証してください。

まとめ

過学習を防ぐウォークフォワード分析の実装と読み方をまとめました。

  • 問題は最適化ではなく最適化したデータで評価すること
  • 期間をずらしながら学習と検証を繰り返し、検証期の成績だけを集める
  • 典型的には学習期シャープ1.5に対し、検証期は0.3〜0.6まで落ちる
  • 期間ごとに違うパラメータが選ばれるならノイズを見ている証拠
  • パラメータの地形を見て、1マスだけ突出しているなら採用しない
  • 期間設定は運用実態から決める。成績を見て調整したら、それも過学習
  • 直近2割は封印し、すべての検討が終わってから1度だけ使う

この検証を覚えてから、僕のバックテスト結果は軒並み地味になりました。年利30%は消え、良くて年利数%です。しかしその数字なら実運用でも再現できる——そこが決定的に違います。

もう1つ、考え方が変わった点があります。「最適なパラメータを見つける」という発想自体をやめたことです。地形がなだらかなら、その平らな部分の真ん中あたりを取ればよく、1位である必要はありません。むしろ1位を狙うほど、偶然の当たりを掴む確率が上がります

手元に「最適化済みのパラメータ」を持っているなら、ぜひウォークフォワードにかけてみてください。がっかりする可能性は高いですが、実弾を入れてからがっかりするよりはるかに安上がりです。

タイトルとURLをコピーしました