Facebook Prophetでドル円(USD/JPY)を予測してみた|精度と限界をPythonで検証

Python実装・コード

ドル円が161円台まで急騰したニュースを見て、僕はふと思ってしまいました。「これって、チャートのトレンドから事前にある程度は予測できたんじゃないか」と。

完全に後出しの発想なのは自覚しています。それでも本当にそうなのか確かめたくて、時系列予測ライブラリのProphetを使って検証してみることにしました。

結論を先に書くと、まったく追いつけませんでした。ただ、その「外れ方」を丁寧に見ていくと、為替や株価の予測が難しい理由が構造的に理解できたのが収穫でした。この記事はその記録です。

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

Prophetはどういう道具なのか

Prophetは、時系列データを3つの要素に分解して予測するライブラリです。統計の専門知識がなくても扱えるよう設計されており、数行で予測が出せます。

予測値 = トレンド + 季節性 + 祝日効果 + 誤差

  トレンド : 長期的な上昇・下降の傾向
  季節性   : 週次・年次などの周期的なパターン
  祝日効果 : 特定の日付による影響

この構造を見た時点で、為替との相性の悪さが予想できます。Prophetが得意なのは「周期的なパターンがあるデータ」ですが、為替に明確な周期があるとは考えにくいからです。

Prophetが得意Prophetが苦手
Webサイトのアクセス数株価・為替
店舗の売上(曜日・季節性あり)ニュースで急変するもの
電力需要ランダムウォークに近いもの
周期がはっきりしたデータ周期性が乏しいデータ

それでも試す価値はあると考えました。「どう外れるか」を知ることは、「当たる方法」を探すのと同じくらい有益だからです。

Prophetが人気を集めたのは、設定をほとんど触らなくても、それなりの結果が出るためです。従来の時系列モデルは、定常性の確認やパラメータの調整に専門知識が要りました。その敷居を大きく下げたのがProphetです。

ただし、この手軽さには裏返しの危うさもあります。データの性質を確認しないまま予測が出せてしまうので、「出た数字がどこまで信用できるか」を判断しないまま使いがちなのです。

今回の検証は、まさにその点を確かめる作業でもありました。数字が出ること自体は、当たっていることを意味しません。

📘 外部参考Prophet 公式ドキュメントGitHubリポジトリ

実装して予測を出す

pip install prophet yfinance pandas numpy matplotlib

Prophetは列名が決まっているため、データを整形してから渡します。

import yfinance as yf
import numpy as np
import pandas as pd
from prophet import Prophet


def prepare(symbol="JPY=X", period="3y"):
    """Prophetが要求する形式(ds, y)に整える"""
    px = yf.download(symbol, period=period, auto_adjust=True,
                     progress=False)["Close"].dropna()

    if isinstance(px, pd.DataFrame):
        px = px.iloc[:, 0]

    df = pd.DataFrame({
        "ds": px.index.tz_localize(None) if px.index.tz else px.index,
        "y": px.values,
    })
    return df.reset_index(drop=True)


df = prepare()
print(f"期間: {df['ds'].iloc[0]:%Y-%m-%d} 〜 {df['ds'].iloc[-1]:%Y-%m-%d}")
print(f"件数: {len(df):,}")
print(df.tail(3).to_string(index=False))

列名はds(日付)と y(値)で固定です。ここを間違えるとエラーになります。またタイムゾーン付きのインデックスも受け付けないので、外しておきます。

def forecast(df, days=30, **kwargs):
    """Prophetで予測する"""
    model = Prophet(
        daily_seasonality=False,      # 日足なので日次の季節性は不要
        weekly_seasonality=True,
        yearly_seasonality=True,
        interval_width=0.80,          # 予測区間の幅
        **kwargs,
    )
    model.fit(df)

    future = model.make_future_dataframe(periods=days)
    return model, model.predict(future)


model, fcst = forecast(df, days=30)

cols = ["ds", "yhat", "yhat_lower", "yhat_upper"]
print(fcst[cols].tail(5).round(2).to_string(index=False))

yhat が予測値、yhat_loweryhat_upper が予測区間です。この区間の広さが、実は最も重要な情報だと後で分かります。

急騰局面での結果を確かめる

本題です。急騰の直前までのデータで学習させて、その後を予測できたかを検証します。

def backtest_forecast(df, cutoff_ratio=0.85, horizon=30):
    """途中で区切って、その先を予測できたか確かめる"""
    cut = int(len(df) * cutoff_ratio)
    train, actual = df.iloc[:cut], df.iloc[cut:cut + horizon]

    if len(actual) < 5:
        raise ValueError("検証データが不足しています")

    model, fcst = forecast(train, days=horizon)

    # 予測と実測を突き合わせる
    merged = actual.merge(
        fcst[["ds", "yhat", "yhat_lower", "yhat_upper"]], on="ds", how="left"
    ).dropna()

    merged["誤差"] = merged["y"] - merged["yhat"]
    merged["誤差率%"] = merged["誤差"] / merged["y"] * 100
    merged["区間内"] = ((merged["y"] >= merged["yhat_lower"]) &
                        (merged["y"] <= merged["yhat_upper"]))

    return merged, model


result, m = backtest_forecast(df)

print(f"検証期間     : {result['ds'].iloc[0]:%m/%d} 〜 {result['ds'].iloc[-1]:%m/%d}")
print(f"平均絶対誤差 : {float(result['誤差'].abs().mean()):>6.3f} 円")
print(f"平均誤差率   : {float(result['誤差率%'].abs().mean()):>6.3f} %")
print(f"最大誤差     : {float(result['誤差'].abs().max()):>6.3f} 円")
print(f"予測区間内   : {float(result['区間内'].mean()) * 100:>5.1f} %")

急騰を含む期間で試すと、誤差が数円単位に膨らみます。ドル円で3円のズレは、レバレッジをかけていれば致命的な水準です。

そして予測区間内に収まった割合を見てください。設定した80%を大きく下回ることが多いはずです。「8割の確率でこの範囲」という前提自体が崩れているということです。

なぜ追いつけないのか

Prophetの予測は、「過去のトレンドがそのまま続く」という前提で作られます。急騰は、そのトレンドが不連続に変わる現象です。構造的に予測できません。

# Prophetがどんなトレンドを想定していたかを確認する
components = m.predict(m.history)
trend_slope = np.polyfit(range(len(components)), components["trend"], 1)[0]

print(f"学習期間の想定トレンド: 1日あたり {trend_slope:+.4f} 円")
print(f"30日後の想定変化      : {trend_slope * 30:+.3f} 円")
print(f"実際に起きた変化      : "
      f"{float(result['y'].iloc[-1] - result['y'].iloc[0]):+.3f} 円")

想定していた変化と実際の変化を比べると、桁が違うことが分かります。Prophetは「これまでのペース」を延長しているだけなので、ペースが変わる場面には対応できません。

そして為替が急騰する理由は、金融政策の変更、要人発言、経済指標のサプライズ——いずれも価格データの中には存在しない情報です。チャートだけを見て予測できるはずがない、というのが冷静に考えた結論でした。

「どれくらい外れるか」を測る

1回の検証では「たまたま」の可能性があるので、期間をずらしながら何度も検証します。

def rolling_backtest(df, horizon=30, step=30, min_train=400):
    """複数の時点で予測精度を測る"""
    rows = []
    start = min_train

    while start + horizon <= len(df):
        train = df.iloc[:start]
        actual = df.iloc[start:start + horizon]

        try:
            _, fcst = forecast(train, days=horizon)
            merged = actual.merge(
                fcst[["ds", "yhat", "yhat_lower", "yhat_upper"]],
                on="ds", how="left").dropna()

            if len(merged) >= 5:
                err = (merged["y"] - merged["yhat"]).abs()
                inside = ((merged["y"] >= merged["yhat_lower"]) &
                          (merged["y"] <= merged["yhat_upper"])).mean()

                rows.append({
                    "予測開始": merged["ds"].iloc[0].date(),
                    "平均誤差": round(float(err.mean()), 3),
                    "最大誤差": round(float(err.max()), 3),
                    "区間内%": round(float(inside) * 100, 1),
                })
        except Exception as e:
            print(f"  スキップ: {e}")

        start += step

    return pd.DataFrame(rows)


bt = rolling_backtest(df)
print(bt.to_string(index=False))
print(f"\n平均誤差の平均 : {bt['平均誤差'].mean():.3f} 円")
print(f"区間内の平均   : {bt['区間内%'].mean():.1f} %(設定は80%)")

結果を見ると、穏やかな期間では誤差1円程度に収まる一方、急変を含む期間では数円まで跳ね上がるという傾向が出ます。

ここが重要な点です。「平均的にはそこそこ当たるが、当たってほしい局面でだけ大きく外れる」——リスク管理の観点からは、これが最も困る性質です。

「動かない予測」との比較

予測精度を評価するとき、比較対象を用意するのが鉄則です。ここでは「今日の値がそのまま続く」という最も単純な予測と比べます。

def compare_with_naive(df, horizon=30, cutoff_ratio=0.85):
    """Prophetと『前日の値をそのまま使う』予測を比較する"""
    cut = int(len(df) * cutoff_ratio)
    train, actual = df.iloc[:cut], df.iloc[cut:cut + horizon]

    _, fcst = forecast(train, days=horizon)
    merged = actual.merge(fcst[["ds", "yhat"]], on="ds").dropna()

    last_value = float(train["y"].iloc[-1])

    prophet_err = float((merged["y"] - merged["yhat"]).abs().mean())
    naive_err = float((merged["y"] - last_value).abs().mean())

    print(f"Prophet の平均誤差 : {prophet_err:>6.3f} 円")
    print(f"『動かない』予測   : {naive_err:>6.3f} 円")
    print(f"改善率             : "
          f"{(1 - prophet_err / naive_err) * 100:>6.1f} %")

    if prophet_err >= naive_err:
        print("\n→ Prophetは『何もしない予測』にすら勝てていません")


compare_with_naive(df)

この比較が本記事で一番シビアな結果でした。多くの期間で、Prophetは「前日の値をそのまま使う」予測に勝てません

考えてみれば当然です。為替がランダムウォークに近いなら、「明日も今日と同じ」が最も合理的な予測になります。トレンドを外挿するProphetは、むしろ余計な予測を加えているぶん精度を落としているわけです。

ランダムウォークの検証については自己相関とランダムウォーク仮説をPythonで確かめてみたで詳しく扱っています。

それでも使い道はある

ここまで否定的に書いてきましたが、使い方を変えれば有用だと感じた部分もありました。

予測区間をリスクの目安として使う

予測値そのものは当てになりませんが、予測区間の広さは「これくらいは動きうる」という目安になります。

def risk_range(df, days=30):
    """予測区間から、想定される変動幅を読み取る"""
    model, fcst = forecast(df, days=days)
    future = fcst.tail(days)
    current = float(df["y"].iloc[-1])

    lower = float(future["yhat_lower"].iloc[-1])
    upper = float(future["yhat_upper"].iloc[-1])

    print(f"現在値          : {current:>8.2f} 円")
    print(f"{days}日後の想定範囲: {lower:>8.2f} 〜 {upper:.2f} 円")
    print(f"想定される変動幅: {upper - lower:>8.2f} 円 "
          f"({(upper - lower) / current * 100:.1f}%)")
    print(f"下方リスク      : {(lower / current - 1) * 100:>7.1f} %")

    return lower, upper


risk_range(df)

「1か月後に何円になるか」は当てられませんが、「1か月で±5円くらいは動きうる」という感覚は得られます。これはポジションサイズを決める際の材料になります。

ただしこの区間も過小評価されている点は忘れないでください。実測では区間内に収まる割合が設定を下回るので、1.5倍程度の余裕を見るのが安全です。

トレンドと季節性を分解して見る

予測ではなく過去データの構造分析としては、素直に有用です。

import matplotlib.pyplot as plt

model, fcst = forecast(df, days=1)

fig = model.plot_components(fcst)
fig.savefig("prophet_components.png", dpi=110, bbox_inches="tight")

# 曜日ごとの傾向を数値でも確認する
weekly = fcst[["ds", "weekly"]].copy()
weekly["曜日"] = weekly["ds"].dt.dayofweek
names = ["月", "火", "水", "木", "金", "土", "日"]

summary = weekly.groupby("曜日")["weekly"].mean()
for i, v in summary.items():
    if i < 5:
        print(f"{names[i]}曜: {v:+.4f}")

ただし、ここで出てくる曜日効果も統計的に有意とは限りません。「月曜は上がりやすい」といった結果が出ても、それが偶然かどうかは別途検定が必要です。この点はp値を知ってバックテストの見方が変わった話で扱っています。

変化点の検出は面白い

Prophetにはトレンドが変わった時点を自動検出する機能があります。これは予測ではなく、過去の振り返りとして有用です。

# トレンドの転換点を抽出する
deltas = model.params["delta"].mean(axis=0)
changepoints = pd.DataFrame({
    "日付": model.changepoints,
    "変化量": deltas,
})

significant = changepoints[changepoints["変化量"].abs() > 0.01]
print("トレンドが大きく変わった時点")
for _, row in significant.iterrows():
    direction = "上向き" if row["変化量"] > 0 else "下向き"
    print(f"  {row['日付']:%Y-%m-%d}: {direction} ({row['変化量']:+.4f})")

検出された日付を実際のニュースと照合すると、金融政策の変更や重要指標の発表と一致することがよくあります。「あのとき何が起きていたか」を振り返る道具としては優秀です。

検証して学んだこと

予測モデルは必ずベースラインと比べる

これが最大の学びでした。「誤差1円」と言われても、それが良いのか悪いのかは単体では判断できません。「何もしない予測」と比べて初めて意味が出ます。

この視点を持つと、世の中の「AI予測」の見方も変わります。精度95%と言われても、ベースラインが94%なら価値はほとんどありません。

当たらない理由を理解するのが重要

Prophetが外れたのは、ライブラリが劣っているからではありません。為替の急変を引き起こす情報が、価格データの中に存在しないからです。

これを理解すると、「もっと高度なモデルを使えば当たるのでは」という発想が的外れだと分かります。入力に情報がないなら、どんなモデルでも取り出せません。

予測から「備え」へ発想を変える

検証後、僕は「予測する」ことをやめました。代わりに「どこまで動きうるかに備える」という発想に切り替えています。

予測する発想備える発想
問いいくらになるかどこまで動きうるか
使う指標予測値ボラティリティ・予測区間
外れたとき損失に直結想定の範囲内
実現可能性低い高い

方向は読めなくても、変動の大きさはある程度予測できる——これは自己相関の検証でも確認されている性質です。だとすれば、力を注ぐべきはリスク管理のほうだという結論になります。

実際、この発想の転換で運用は変わりました。以前は「次にどう動くか」を考えるのに時間を使っていましたが、いまは「想定の倍動いても耐えられるか」を確認するようになっています。

予測が当たれば儲かりますが、外れたときに退場しては続きません。当てられないことを前提に組み立てたほうが、結果的に長く生き残れる——今回の検証で得た一番の実感です。

まとめ

Prophetでドル円を予測し、その限界を確かめた記録をまとめました。

  • Prophetはトレンド+季節性で予測する。周期性のあるデータが得意
  • 急騰局面では誤差が数円に膨らむ。予測区間の的中率も設定を下回る
  • 多くの期間で「前日の値をそのまま使う」予測に勝てない
  • 外れる理由は、急変の原因が価格データに含まれていないから
  • 予測値ではなく予測区間をリスクの目安として使うなら有用
  • 変化点の検出は、過去を振り返る道具として面白い
  • 予測モデルは必ずベースラインと比較する。単体の精度に意味はない

「後から見れば予測できたはず」という感覚は、結果を知っているから生まれる錯覚でした。実際に事前のデータだけで試してみると、見事に何も見えていませんでした。

ただ、この検証には確かな価値がありました。「予測できない」と自分の手で確認できたおかげで、予測に時間を使うのをやめられたからです。その時間をリスク管理に回すほうが、はるかに実りがあります。

もし予測モデルを試すなら、必ず「何もしない予測」と比べてください。それに勝てないモデルは、どれだけ高度でも使う意味がありません。

本記事の compare_with_naive() は10行程度の関数です。この10行を通すかどうかで、無駄な期待に何か月も費やすかが決まります。新しいモデルを試すときは、まずこれを走らせることをおすすめします。

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