AIの株価予測精度58%は凄いのか|正しい検証方法

AI×自動売買

先週、自作のランダムフォレストで製造業株の翌日騰落を予測するモデルを回してみたら、テスト精度が58%と出ました。思わず「イケるやん」と一人でガッツポーズしたのですが、冷静になると、コイントスが50%なんだから58%が凄いのか普通なのか、まったく判断がつきません。

それで調べ直した結果、2つの点で自分は完全に勘違いしていました。

1つは、58%という数字そのものが統計的にほぼ無意味だったこと。もう1つは、自分が書いた期待値の計算式が根本的に間違っていたことです。しかもこの間違い、ネット上の解説記事でもよく見かけます。

まず「58%」を統計的に検定する

精度を評価するには、比較対象が2つ必要です。ベースライン(常に多数派を答えるモデル)信頼区間です。

import numpy as np
import pandas as pd
import yfinance as yf
from scipy import stats

def load(ticker: str, period: str = "10y") -> pd.DataFrame:
    df = yf.download(ticker, period=period, auto_adjust=True, progress=False)
    if isinstance(df.columns, pd.MultiIndex):
        df.columns = df.columns.get_level_values(0)
    return df.dropna()

def judge_accuracy(hits: int, n: int, base_rate: float) -> None:
    acc = hits / n
    ci = stats.binomtest(hits, n, 0.5).proportion_ci(0.95)
    p_vs_base = stats.binomtest(hits, n, base_rate,
                                alternative="greater").pvalue
    print(f"精度       : {acc:.3f}  ({hits}/{n})")
    print(f"95%信頼区間: [{ci.low:.3f}, {ci.high:.3f}]")
    print(f"ベースライン: {base_rate:.3f}(常に多数派を答えた場合)")
    print(f"ベースライン超えのp値: {p_vs_base:.3f}  "
          + ("→ 有意" if p_vs_base < 0.05 else "→ 偶然の範囲"))

df = load("6954.T")            # ファナック
y = (df["Close"].pct_change().shift(-1) > 0).astype(int).dropna()
base = max(y.mean(), 1 - y.mean())

judge_accuracy(hits=87, n=150, base_rate=base)    # 58% × 150日

これを実行して固まりました。150日のテストで58%なら、95%信頼区間は約[0.50, 0.66]です。下限がぎりぎり50%。つまり「コイントスと変わらない可能性を否定できない」という意味です。

さらに、日本株は上昇日が52%前後あります。「毎日上がる」と答えるだけのモデルが52%を取るので、実質的な上積みは6ポイントぶんしかありません。

テスト日数95%信頼区間の幅58%は有意か
100日±9.8ppまったく言えない
250日±6.2pp言えない
500日±4.4ppぎりぎり
2,000日±2.2pp議論できる

数年ぶんのデータでは、精度の差を検出する力がそもそも足りません。これは自分のモデルが悪いという話ではなく、測定器の分解能の問題です。

自分が書いた期待値の計算は間違っていた

ここが今回いちばんの発見でした。当初こう書いていました。

# ❌ 間違い
ret = df["return"].shift(-1).iloc[test_idx]
win_rate = acc
avg_win = ret[ret > 0].mean()          # テスト期間の「上昇日」の平均
avg_loss = abs(ret[ret < 0].mean())    # テスト期間の「下落日」の平均
expectancy = win_rate * avg_win - (1 - win_rate) * avg_loss

avg_winが「モデルが当たったときの利益」になっていません。単にテスト期間の上昇日の平均リターンを取っているだけです。モデルの予測とまったく関係がない値です。

この式は、モデルの精度がどうであれ、上昇日の平均値と下落日の平均値だけで決まります。ランダムに予測するモデルでも、精度を代入すれば同じ「期待値」が出ます。何も測っていません。

正しくは、モデルの予測に従って建玉したときの実際の損益を計算します。

# ✅ 正しい
def trade_stats(pred: np.ndarray, fwd_ret: np.ndarray,
                cost: float = 0.001) -> dict:
    """pred=1で買い、pred=0でノーポジ。fwd_ret は翌日リターン。"""
    pos = pred.astype(float)
    pnl = pos * fwd_ret - np.abs(np.diff(np.r_[0.0, pos])) * cost

    taken = fwd_ret[pos == 1]           # 実際にエントリーした日だけ
    wins = taken[taken > 0]
    losses = taken[taken <= 0]
    if len(taken) == 0:
        return {"trades": 0}

    return {
        "エントリー日数": len(taken),
        "勝率%": round(len(wins) / len(taken) * 100, 1),
        "平均利益%": round(wins.mean() * 100, 3) if len(wins) else 0.0,
        "平均損失%": round(abs(losses.mean()) * 100, 3) if len(losses) else 0.0,
        "ペイオフレシオ": round(
            wins.mean() / abs(losses.mean()), 2) if len(losses) else np.inf,
        "1回あたり期待値%": round(taken.mean() * 100, 4),
        "コスト後累積%": round(((1 + pnl).prod() - 1) * 100, 2),
        "全日保有の累積%": round(((1 + fwd_ret).prod() - 1) * 100, 2),
    }

ポイントはtaken = fwd_ret[pos == 1]です。モデルが「上がる」と言った日のリターンだけを集めます。勝率も平均利益も、ここから計算しなければ意味がありません。

そして全日保有の累積%を必ず併記します。モデルの成績がBuy&Holdに負けているなら、そのモデルは害です。手数料と手間をかけて、持ちっぱなしより悪い結果を出していることになります。

精度が高くても負けることがある

これも見落としていました。精度と収益は別物です。小さな上げを何度も当てて、大きな下げを1回外せば、精度が高くても資産は減ります。

def accuracy_vs_pnl_demo(seed: int = 0) -> None:
    """精度70%なのに負けるモデルを人工的に作る。"""
    rng = np.random.default_rng(seed)
    n = 1000
    # 7割は小さな上げ(+0.3%)、3割は大きな下げ(-1.0%)
    is_up = rng.random(n) < 0.7
    ret = np.where(is_up, 0.003, -0.010)

    pred = np.ones(n, dtype=int)        # 常に「上がる」と予測
    acc = float((pred == is_up.astype(int)).mean())
    total = (1 + ret).prod() - 1
    print(f"精度 = {acc:.1%}  累積リターン = {total * 100:.1f}%")

accuracy_vs_pnl_demo()

精度70%で累積が大きくマイナスになります。極端な例ですが、実際のモデルでも似たことが起きます。決算やショック時の大きな変動は予測しにくく、平時の小さな動きは当てやすい。精度を最大化するように学習させると、自然とこの形に寄っていきます。

対策は、学習時からリターンの大きさで重み付けすることです。

from sklearn.ensemble import RandomForestClassifier

# 変動が大きい日ほど「当てる価値が高い」ので重みを増やす
sample_weight = np.abs(fwd_ret_train) / np.abs(fwd_ret_train).mean()
model = RandomForestClassifier(n_estimators=300, max_depth=4,
                               min_samples_leaf=50, random_state=42)
model.fit(X_train, y_train, sample_weight=sample_weight)

ちゃんとした検証コード

from sklearn.model_selection import TimeSeriesSplit
from sklearn.metrics import roc_auc_score

def make_features(df: pd.DataFrame) -> pd.DataFrame:
    c = df["Close"]
    r = c.pct_change()
    f = pd.DataFrame(index=df.index)
    f["ret1"] = r
    f["ret5"] = c.pct_change(5)
    f["ma20_dev"] = c / c.rolling(20).mean() - 1
    f["vol20"] = r.rolling(20).std()
    f["vol_ratio"] = r.rolling(5).std() / r.rolling(60).std()
    f["rng"] = (df["High"] - df["Low"]) / c
    f["fwd"] = r.shift(-1)                       # 翌日リターン(評価用)
    f["target"] = (f["fwd"] > 0).astype(int)
    return f.dropna()

def evaluate(ticker: str, n_splits: int = 5, cost: float = 0.001) -> None:
    f = make_features(load(ticker))
    cols = ["ret1", "ret5", "ma20_dev", "vol20", "vol_ratio", "rng"]
    X, y, fwd = f[cols].to_numpy(), f["target"].to_numpy(), f["fwd"].to_numpy()

    rows = []
    for i, (tr, te) in enumerate(TimeSeriesSplit(n_splits=n_splits).split(X)):
        w = np.abs(fwd[tr]) / np.abs(fwd[tr]).mean()
        m = RandomForestClassifier(n_estimators=300, max_depth=4,
                                   min_samples_leaf=50, random_state=42)
        m.fit(X[tr], y[tr], sample_weight=w)

        proba = m.predict_proba(X[te])[:, 1]
        pred = (proba > 0.5).astype(int)
        base = max(y[te].mean(), 1 - y[te].mean())

        st = trade_stats(pred, fwd[te], cost)
        rows.append({
            "fold": i,
            "n": len(te),
            "精度": round(float((pred == y[te]).mean()), 3),
            "ベース": round(float(base), 3),
            "AUC": round(roc_auc_score(y[te], proba), 3),
            "コスト後%": st.get("コスト後累積%", 0),
            "B&H%": st.get("全日保有の累積%", 0),
        })

    t = pd.DataFrame(rows).set_index("fold")
    print(t)
    print(f"\n精度の平均={t['精度'].mean():.3f}  "
          f"幅={t['精度'].min():.3f}〜{t['精度'].max():.3f}")
    print(f"ベースライン平均={t['ベース'].mean():.3f}")
    print(f"モデルがB&Hに勝ったフォールド: "
          f"{int((t['コスト後%'] > t['B&H%']).sum())}/{len(t)}")

evaluate("6954.T")

フォールドごとに並べるのが大事です。自分の場合、精度が0.48〜0.58の間でばらついていました。「58%」は5つのうち一番良かったフォールドの数字で、平均は52%。ベースラインとほぼ同じでした。

AUCも併記しています。AUCが0.5に近いなら、閾値をどう動かしても勝てません。精度は閾値次第で変わりますが、AUCは順位付けの能力そのものを測るので、こちらのほうが正直な指標です。

50%台でも勝てるのは本当。ただし条件がある

「的中率が50%台でもリスクリワード次第でプラスにできる」というのは正しいです。ただし、これには前提があります。ペイオフレシオが独立に設計できることです。

翌日リターンをそのまま取る戦略では、勝ちも負けも市場が決めます。自分で選べません。ペイオフを作るには、損切りと利確を非対称に置く必要があります。

def exit_rule_test(df: pd.DataFrame, entries: pd.Series,
                   tp: float = 0.03, sl: float = 0.015,
                   max_hold: int = 10, cost: float = 0.001) -> dict:
    """エントリー後、利確tp・損切りsl・期限max_holdのどれかで手仕舞う。"""
    o, h, l, c = df["Open"], df["High"], df["Low"], df["Close"]
    idx = np.where(entries.to_numpy())[0]
    results = []

    for i in idx:
        if i + 1 >= len(df):
            continue
        entry = float(o.iloc[i + 1])            # 翌日寄付で建てる
        up, dn = entry * (1 + tp), entry * (1 - sl)
        for j in range(i + 1, min(i + 1 + max_hold, len(df))):
            if float(l.iloc[j]) <= dn:          # 保守的に損切り優先で判定
                results.append(-sl - 2 * cost); break
            if float(h.iloc[j]) >= up:
                results.append(tp - 2 * cost); break
        else:
            j = min(i + max_hold, len(df) - 1)
            results.append(float(c.iloc[j]) / entry - 1 - 2 * cost)

    r = np.array(results)
    wins, losses = r[r > 0], r[r <= 0]
    return {
        "トレード数": len(r),
        "勝率%": round(len(wins) / len(r) * 100, 1),
        "ペイオフ": round(wins.mean() / abs(losses.mean()), 2),
        "期待値%": round(r.mean() * 100, 3),
        "累積%": round(((1 + r).prod() - 1) * 100, 1),
    }

同じ日に高値と安値の両方が閾値に触れた場合、損切り側を優先して判定しています。日足では順序が分からないので、悲観的に見積もるのが正しい姿勢です。ここを利確優先にすると、成績が実際より良く出ます。

ケリー基準を使うなら半分にする

ポジションサイズの話とセットで考えるのは正しい方向です。ケリー基準(Kelly Criterion)で最適な賭け率を計算できます。

def kelly(win_rate: float, payoff: float, fraction: float = 0.5) -> float:
    """f* = (b*p - q) / b。fraction でハーフケリー等に縮小。"""
    p, q, b = win_rate, 1 - win_rate, payoff
    f = (b * p - q) / b
    return max(0.0, f * fraction)

for wr, po in [(0.52, 1.0), (0.55, 1.2), (0.58, 1.5), (0.60, 2.0)]:
    full = kelly(wr, po, 1.0)
    half = kelly(wr, po, 0.5)
    print(f"勝率{wr:.0%} ペイオフ{po}: フルケリー={full:.1%}  "
          f"ハーフ={half:.1%}")

計算するとフルケリーが20%を超えることもありますが、これをそのまま使ってはいけません。理由は2つあります。

  • 勝率とペイオフの推定誤差。勝率58%が実は52%だった場合、フルケリーは即座に破産水準の賭け率になる
  • フルケリーは長期成長率を最大化するが、途中のドローダウンが50%を超える。精神的に耐えられない

実務ではハーフケリー以下、多くの場合クォーターケリーが使われます。成長率の損失はわずか(ハーフケリーで最大成長率の75%)なのに、ドローダウンは半分になるので、割に合う選択です。

「99%的中」を見分けるチェック項目

確認すること怪しいサイン
ベースラインの記載上昇日の割合が書かれていない
テスト期間の長さ数ヶ月しかない
期間の選び方都合の良い時期だけ切り取っている
取引コスト手数料・スリッページの記載がない
Buy&Holdとの比較比較していない
取引回数数十回以下
指標の種類精度だけでAUCも損益もない
再現性コードやシードが非公開

「99%の精度」を謳うツールは、そんなモデルが実在するなら開発者が自分で運用しているはずで、他人に売る理由がありません。ただ、悪意がなくてもリークで99%が出ることは普通にあります。目的変数を特徴量に混ぜてしまう、正規化を全期間で行う、といったミスです。自分のモデルでも起こりうる話なので、他人事ではありません。

まとめ

  • 精度はベースラインと信頼区間を併記しないと評価できない。150日で58%は有意ではない
  • 期待値はモデルがエントリーした日のリターンから計算する。上昇日の平均ではない
  • 精度が高くても大きな下げを1回外せば負ける。リターンの大きさで重み付けする
  • フォールドごとのばらつきを見る。最良の1つを報告しない
  • 常にBuy&Holdと比較する。負けているなら手間の分だけ損
  • ケリー基準はハーフ以下で使う。推定誤差が破産を招く

58%に一喜一憂していた自分に一番言いたいのは、「その数字は測れていない」ということでした。凄いのか普通なのかを判断する前に、判断できるだけの測定をしていなかった。

正しく測る枠組みを作ったら、自分のモデルはベースラインと同程度だと分かりました。がっかりはしましたが、これで次に何を試すべきかがはっきりしました。地道ですが、こっちのほうが結局は早い気がしています。

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