LightGBM×LSTMで日経平均予測|正解率53%の正しい読み方

AI×自動売買

「アンサンブルで正解率53%」は何も言っていない

LightGBMとLSTMを組み合わせて日経平均の翌日方向を予測する。性質の異なるモデルを平均すれば誤差の分散が下がる、という発想は理屈として正しく、実務でも使われます。

問題は評価です。「正解率53%を達成しました」という記事は、ほぼすべて何も示せていません。理由は3つあります。

  • 日経平均が上がる日は元々52%前後ある。「毎日上がると答えるモデル」が52%を取る
  • テストデータが500日なら、正解率の95%信頼区間は±4.4ポイント。53%は50%と区別できない
  • LSTMは乱数シードを変えるだけで正解率が数ポイント動く。実行ごとのブレが「精度向上」より大きい

この記事では実装を示したうえで、その予測が本当に情報を持っているかを検証します。最後にラベルをシャッフルした対照実験を行い、同じパイプラインがデタラメなデータでも同じような数字を出すことを確認します。ここまでやって初めて、モデルの良し悪しを議論できます。

まずベースラインを決める

比較対象なしに精度を語ることはできません。最初にこれを作ります。

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

def load(symbol: str = "^N225", start: str = "2010-01-01") -> pd.DataFrame:
    df = yf.download(symbol, start=start, auto_adjust=True, progress=False)
    if isinstance(df.columns, pd.MultiIndex):
        df.columns = df.columns.get_level_values(0)
    return df.dropna()

def baseline(y: np.ndarray) -> dict:
    """常に多数派クラスを答えるモデルの成績。"""
    up_rate = float(y.mean())
    majority = max(up_rate, 1 - up_rate)
    n = len(y)
    se = np.sqrt(0.25 / n)          # 正解率の標準誤差(p=0.5のとき最大)
    return {
        "n": n,
        "上昇日の割合": round(up_rate * 100, 2),
        "常に多数派を答える正解率": round(majority * 100, 2),
        "95%信頼区間の幅(±pp)": round(1.96 * se * 100, 2),
    }

df = load()
y_all = (df["Close"].pct_change().shift(-1) > 0).astype(int).dropna().to_numpy()
print(baseline(y_all))

実行すると、上昇日の割合は52%前後、テスト期間が数百日なら信頼区間は±4ポイント程度になります。この幅を超えないかぎり、どんなモデルも「ベースラインと同じ」です。

特徴量とリーク検査

特徴量を作るときの原則は1つです。t日の終値時点で知り得る情報だけを使う。

def make_features(df: pd.DataFrame) -> pd.DataFrame:
    c, v = df["Close"], df["Volume"]
    r = c.pct_change()

    f = pd.DataFrame(index=df.index)
    f["ret1"] = r
    f["ret5"] = c.pct_change(5)
    f["ma5_dev"] = c / c.rolling(5).mean() - 1
    f["ma20_dev"] = c / c.rolling(20).mean() - 1
    f["ma60_dev"] = c / c.rolling(60).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["vol_z"] = (v / v.rolling(60).mean()).replace([np.inf, -np.inf], np.nan)
    f["gap"] = df["Open"] / c.shift(1) - 1
    f["dow"] = df.index.dayofweek

    f["target"] = (r.shift(-1) > 0).astype(int)     # 翌日の方向
    return f.dropna()

def leak_check(f: pd.DataFrame) -> None:
    """特徴量が目的変数と異常に相関していないか確認する。"""
    cols = [c for c in f.columns if c != "target"]
    corr = f[cols].apply(lambda s: s.corr(f["target"])).abs().sort_values(
        ascending=False)
    print(corr.round(4))
    if corr.iloc[0] > 0.2:
        print("⚠ 相関が高すぎます。未来情報の混入を疑ってください")

feat = make_features(df)
leak_check(feat)

相関が0.2を超える特徴量が出たら、まずリークを疑ってください。金融時系列で翌日の方向と0.2も相関する変数は、普通は存在しません。rolling()のあとにshift()を忘れた、といったミスがほぼ確実に潜んでいます。

ここで出る相関はせいぜい0.02〜0.05です。これが現実のSN比で、この程度の信号から53%を取ろうとしている、という感覚を持っておくと判断を誤りません。

ウォークフォワード検証を実装する

「必ずウォークフォワード検証を行ってください」と書いてあるのに、コードは単純な8:2分割、という記事が非常に多いです。実装します。

from dataclasses import dataclass

@dataclass
class Fold:
    train: slice
    valid: slice
    test: slice

def walk_forward(n: int, train: int = 1000, valid: int = 250,
                 test: int = 250) -> list[Fold]:
    """学習→検証→テストを時系列順にスライドさせる。"""
    folds, start = [], 0
    while start + train + valid + test <= n:
        a = start + train
        b = a + valid
        c = b + test
        folds.append(Fold(slice(start, a), slice(a, b), slice(b, c)))
        start += test          # テスト期間ぶんだけ前進
    return folds

folds = walk_forward(len(feat))
print(f"{len(folds)}フォールド")
for i, fd in enumerate(folds):
    print(f"  fold{i}: 学習 {feat.index[fd.train.start].date()}〜"
          f"{feat.index[fd.train.stop - 1].date()}  "
          f"テスト {feat.index[fd.test.start].date()}〜"
          f"{feat.index[fd.test.stop - 1].date()}")

検証(valid)期間を分けているのが重要です。早期終了の判定やアンサンブル重みの決定はここで行い、テスト期間は最後まで触りません。テスト期間を見てハイパーパラメータを決めた時点で、その成績は嘘になります。

LightGBM:早期終了を必ず入れる

import lightgbm as lgb

FEATS = [c for c in feat.columns if c != "target"]

def fit_lgbm(f: pd.DataFrame, fd: Fold, seed: int = 0):
    Xtr, ytr = f[FEATS].iloc[fd.train], f["target"].iloc[fd.train]
    Xva, yva = f[FEATS].iloc[fd.valid], f["target"].iloc[fd.valid]

    model = lgb.LGBMClassifier(
        n_estimators=2000,
        learning_rate=0.01,
        num_leaves=7,           # 小さく。データが少なくノイズが多い
        min_child_samples=50,   # 葉に十分なサンプルを要求する
        subsample=0.8, subsample_freq=1,
        colsample_bytree=0.7,
        reg_lambda=5.0,
        random_state=seed,
        verbosity=-1,
    )
    model.fit(Xtr, ytr, eval_set=[(Xva, yva)], eval_metric="auc",
              callbacks=[lgb.early_stopping(100, verbose=False)])
    return model

元記事のようなn_estimators=400, max_depth=5を早期終了なしで回すと、確実に過学習します。データ数千行・特徴量10個・SN比0.03という条件では、木は浅く葉は少なくが鉄則です。num_leaves=7は小さすぎるように見えますが、これでも十分に過学習します。

LSTM:シードを固定し、ブレを測る

TensorFlow 2.16以降はKeras 3が既定です。LSTM(32, input_shape=...)という書き方は非推奨になったので、Input層を使います。

import tensorflow as tf
from keras import Sequential
from keras.layers import Input, LSTM, Dropout, Dense
from keras.callbacks import EarlyStopping
from sklearn.preprocessing import StandardScaler

SEQ = 20

def make_sequences(f: pd.DataFrame, cols: list[str], sl: slice,
                   scaler: StandardScaler):
    """sl の各時点について、直近SEQ日の系列を作る。"""
    raw = f[cols].to_numpy()
    z = scaler.transform(raw)
    start = max(sl.start, SEQ - 1)          # 先頭は系列が足りないので捨てる
    idx = range(start, sl.stop)
    X = np.stack([z[i - SEQ + 1:i + 1] for i in idx])   # t を含める
    y = f["target"].to_numpy()[start:sl.stop]
    return X, y

def fit_lstm(f: pd.DataFrame, fd: Fold, cols: list[str], seed: int = 0):
    tf.keras.utils.set_random_seed(seed)     # 再現性のために必須

    scaler = StandardScaler().fit(f[cols].iloc[fd.train].to_numpy())
    Xtr, ytr = make_sequences(f, cols, fd.train, scaler)
    Xva, yva = make_sequences(f, cols, fd.valid, scaler)

    model = Sequential([
        Input(shape=(SEQ, len(cols))),
        LSTM(16),                 # 小さく保つ
        Dropout(0.3),
        Dense(1, activation="sigmoid"),
    ])
    model.compile(optimizer="adam", loss="binary_crossentropy",
                  metrics=["AUC"])
    model.fit(Xtr, ytr, validation_data=(Xva, yva),
              epochs=100, batch_size=64, verbose=0,
              callbacks=[EarlyStopping(monitor="val_loss", patience=10,
                                       restore_best_weights=True)])
    return model, scaler

make_sequencesz[i - SEQ + 1 : i + 1]としているのが要点です。t日を含めた直近SEQ日を使ってt+1日を予測します。元記事のようにz[i-SEQ:i]とすると、最新の1日を捨てることになります。

そして、シードを変えて何度か回してみてください。

from sklearn.metrics import roc_auc_score

SEQ_COLS = ["ret1", "ma5_dev", "vol20", "rng"]

def seed_variance(f: pd.DataFrame, fd: Fold, seeds=range(5)) -> None:
    scores = []
    for s in seeds:
        model, scaler = fit_lstm(f, fd, SEQ_COLS, seed=s)
        Xte, yte = make_sequences(f, SEQ_COLS, fd.test, scaler)
        p = model.predict(Xte, verbose=0).ravel()
        scores.append({
            "seed": s,
            "auc": round(roc_auc_score(yte, p), 4),
            "acc": round(float(((p > 0.5) == yte).mean()), 4),
        })
    t = pd.DataFrame(scores)
    print(t)
    print(f"正解率の幅: {t['acc'].min():.3f} 〜 {t['acc'].max():.3f}")

seed_variance(feat, folds[-1])

正解率が0.49〜0.55の間でばらつくはずです。シードを変えただけで6ポイント動くなら、「53%を達成」という報告に意味はありません。論文でも記事でも、シードを1つしか試していない結果は、良かった1回を見せている可能性があります。

アンサンブルの重みは検証期間で決める

def run_fold(f: pd.DataFrame, fd: Fold, seeds=(0, 1, 2)) -> pd.DataFrame:
    # --- LightGBM(シード平均)---
    p_va_g, p_te_g = [], []
    for s in seeds:
        m = fit_lgbm(f, fd, seed=s)
        p_va_g.append(m.predict_proba(f[FEATS].iloc[fd.valid])[:, 1])
        p_te_g.append(m.predict_proba(f[FEATS].iloc[fd.test])[:, 1])
    p_va_g, p_te_g = np.mean(p_va_g, axis=0), np.mean(p_te_g, axis=0)

    # --- LSTM(シード平均)---
    p_va_l, p_te_l = [], []
    for s in seeds:
        m, sc = fit_lstm(f, fd, SEQ_COLS, seed=s)
        Xva, _ = make_sequences(f, SEQ_COLS, fd.valid, sc)
        Xte, _ = make_sequences(f, SEQ_COLS, fd.test, sc)
        p_va_l.append(m.predict(Xva, verbose=0).ravel())
        p_te_l.append(m.predict(Xte, verbose=0).ravel())
    p_va_l, p_te_l = np.mean(p_va_l, axis=0), np.mean(p_te_l, axis=0)

    # --- 重みは valid だけで決める(test は絶対に見ない)---
    yva = f["target"].to_numpy()[fd.valid]
    best_w, best_auc = 0.5, -1.0
    for w in np.linspace(0, 1, 21):
        auc = roc_auc_score(yva, w * p_va_g + (1 - w) * p_va_l)
        if auc > best_auc:
            best_w, best_auc = w, auc

    return pd.DataFrame({
        "date": f.index[fd.test],
        "y": f["target"].to_numpy()[fd.test],
        "p_gbm": p_te_g,
        "p_lstm": p_te_l,
        "p_ens": best_w * p_te_g + (1 - best_w) * p_te_l,
        "w": best_w,
    }).set_index("date")

oof = pd.concat([run_fold(feat, fd) for fd in folds])
print(oof.head())

重みの探索にテストデータを使わない。元記事の「検証データで最適化するとさらに改善できます」という記述は正しいのですが、実際のコードでテスト期間の成績を見ながら重みを選ぶと、それは最適化ではなくカンニングです。

シード平均を取っているのも意図的です。単一シードのブレを均すと、報告する数字が安定します。

評価:正解率ではなく期待値を見る

def evaluate(oof: pd.DataFrame, ret_next: pd.Series,
             cost: float = 0.0005) -> None:
    y = oof["y"].to_numpy()
    r = ret_next.reindex(oof.index).to_numpy()

    print("=== 判別性能 ===")
    n = len(y)
    for col in ("p_gbm", "p_lstm", "p_ens"):
        p = oof[col].to_numpy()
        hits = int(((p > 0.5) == y).sum())
        ci = stats.binomtest(hits, n, 0.5).proportion_ci(0.95)
        print(f"{col:7s} AUC={roc_auc_score(y, p):.4f}  "
              f"正解率={hits / n:.4f} [{ci.low:.3f}, {ci.high:.3f}]")
    print(f"{'baseline':7s} 常に上昇と答える正解率={y.mean():.4f}")

    print("\n=== 確信度別の翌日リターン ===")
    p = oof["p_ens"].to_numpy()
    bucket = pd.qcut(p, 5, labels=["最弱", "弱", "中", "強", "最強"])
    tbl = pd.DataFrame({"bucket": bucket, "ret": r})
    out = tbl.groupby("bucket", observed=True)["ret"].agg(
        ["count", "mean", "std"])
    out["mean_pct"] = (out["mean"] * 100).round(4)
    out["t"] = (out["mean"] / (out["std"] / np.sqrt(out["count"]))).round(2)
    print(out[["count", "mean_pct", "t"]])
    print(f"全期間の平均リターン: {np.nanmean(r) * 100:.4f}%")

    print("\n=== コスト後の損益 ===")
    pos = (p > 0.5).astype(float)
    trades = np.abs(np.diff(np.r_[0.0, pos]))
    pnl = pos * r - trades * cost
    bh = r
    for name, s in [("モデル", pnl), ("Buy&Hold", bh)]:
        s = s[~np.isnan(s)]
        print(f"{name:9s} 累積={((1 + s).prod() - 1) * 100:7.2f}%  "
              f"Sharpe={s.mean() / s.std() * np.sqrt(252):5.2f}  "
              f"取引={int(trades.sum())}回")

ret_next = df["Close"].pct_change().shift(-1)
evaluate(oof, ret_next)

ここで見るべきは確信度別のリターンです。予測確率が高い日ほど翌日リターンが高いという単調性があれば、モデルは何かを捉えています。バケット間で差がないなら、正解率が何%でも使えません。

そしてコスト後の損益。往復0.1%のコストでも、毎日ポジションを入れ替えると年間で数十%が消えます。「正解率53%」が「取引コストを引いたら負け」になるのは普通のことです。

対照実験:ラベルをシャッフルする

最後に、いちばん大事な検証をします。目的変数をランダムに入れ替えて、同じパイプラインを回す。本来ゼロの性能しか出ないはずです。

def shuffle_test(f: pd.DataFrame, fd: Fold, trials: int = 20,
                 seed: int = 0) -> None:
    rng = np.random.default_rng(seed)
    real = fit_lgbm(f, fd)
    yte = f["target"].to_numpy()[fd.test]
    real_auc = roc_auc_score(
        yte, real.predict_proba(f[FEATS].iloc[fd.test])[:, 1])

    aucs = []
    for _ in range(trials):
        g = f.copy()
        tr = g["target"].to_numpy()[fd.train].copy()
        rng.shuffle(tr)                    # 学習ラベルだけ壊す
        g.iloc[fd.train, g.columns.get_loc("target")] = tr
        m = fit_lgbm(g, fd)
        aucs.append(roc_auc_score(
            yte, m.predict_proba(g[FEATS].iloc[fd.test])[:, 1]))

    aucs = np.array(aucs)
    p = float((aucs >= real_auc).mean())
    print(f"実データのAUC     : {real_auc:.4f}")
    print(f"シャッフル平均AUC : {aucs.mean():.4f} (±{aucs.std():.4f})")
    print(f"シャッフルが上回った割合 = {p:.3f}  "
          + ("→ 実質的に無意味" if p > 0.05 else "→ 一応の有意性あり"))

shuffle_test(feat, folds[-1])

ここでp > 0.05なら、そのモデルはノイズを学習しているだけです。自分が試した範囲では、日次方向予測でこの検定を通るのは簡単ではありません。通らなかったことは失敗ではなく、正しく測れたということです。

それでも改善するとしたら

方向性やること期待できること
予測対象を変える方向ではなくボラティリティを予測大幅に予測しやすい
時間軸を延ばす翌日ではなく5〜20日先ノイズ比が下がる
外部データを足すVIX、ドル円、SOX、金利情報源が増える
全部取引しない確信度上位20%の日だけコストが激減する
銘柄を横断する個別株のクロスセクション予測サンプル数が数百倍

実務的に効果が大きいのはボラティリティ予測への切り替えクロスセクション化です。方向は予測できなくても、ボラティリティは自己相関が強く、はっきり予測できます。それだけでポジションサイズの制御に使えます。

また、日経平均1本では学習データが数千行しかありません。東証プライムの数百銘柄を横断すれば、サンプル数は一気に数十万行になります。SN比が低いなら、サンプル数で殴るのが正攻法です。

まとめ

  • 正解率はベースライン(上昇日の割合)と信頼区間を併記しないと意味がない
  • LSTMはシードで数ポイント動く。1回の結果を報告しない
  • ウォークフォワードで学習・検証・テストを分け、重みは検証期間で決める
  • 正解率より確信度別リターンコスト後の損益を見る
  • ラベルシャッフルの対照実験を通らないモデルは、ノイズを学習している
  • 方向予測が難しいなら、ボラティリティ予測やクロスセクションに切り替える

アンサンブルは魔法ではありません。ただ、正しく測る枠組みを一度作っておけば、次にどのアイデアを試しても「効いたかどうか」を即座に判定できます。その枠組みのほうが、個々のモデルより長く価値を持ちます。

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