scikit-learnの株価予測でAUC0.87|リーク3箇所の特定

AI×自動売買

テストでAUC 0.87、本番で勝率48%だった話

少し前に、トヨタ(7203)の翌日騰落を機械学習で予測しようとLightGBMを回しました。テストデータでAUC 0.87と表示されて「これは勝ったな」と一瞬思ったのですが、1ヶ月実運用して勝率48%。手数料込みでマイナスでした。

原因を突き止めるのに数日かかりました。リークは1箇所ではなく、3箇所ありました。そのうち1つは、ネット上のサンプルコードにも普通に載っている書き方です。しかも見た目には何の問題もありません。

この記事では、その3つを再現コード付きで説明します。同じように「テストでは良いのに実運用でダメ」という人は、たぶんどれかに当てはまります。

犯人1:全期間でスケーリングしていた

これは有名なやつです。標準化を分割前にやると、テスト期間の平均と分散が学習に混ざります。

import numpy as np
import pandas as pd
from sklearn.preprocessing import StandardScaler

# ❌ 全期間でfit
scaler = StandardScaler().fit(X)
X_scaled = scaler.transform(X)

# ✅ 学習データだけでfit
scaler = StandardScaler().fit(X.iloc[train_idx])
X_tr = scaler.transform(X.iloc[train_idx])
X_te = scaler.transform(X.iloc[test_idx])

木系のモデル(LightGBM、ランダムフォレスト)は標準化の影響を受けないので、これだけならAUC 0.87にはなりません。ただ、同じ発想で「全期間の分位点でビン分割」や「全期間の順位(rank)」を特徴量にすると、木系でも一気にスコアが跳ねます。自分はここもやっていました。

# ❌ 全期間の順位。未来の分布を知っていることになる
f["ret_rank"] = f["ret1"].rank(pct=True)

# ✅ 過去だけで計算する順位
f["ret_rank"] = f["ret1"].rolling(252).rank(pct=True)

犯人2:特徴量が翌日を見ていた

# ❌ centerを付けると、中心の前後を使う=未来を含む
f["ma20"] = df["Close"].rolling(20, center=True).mean()

# ❌ bfillは未来の値で埋める
f["vol"] = df["Volume"].bfill()

# ❌ 差分を取ったあとにshift(-1)が残っている
f["momentum"] = df["Close"].pct_change().shift(-1)

意図的に書く人はいませんが、試行錯誤の途中で入れたshift(-1)が残ることは普通に起こります。自分の場合、ボラティリティ特徴量を作るときにcenter=Trueを付けたままでした。

検知は簡単で、各特徴量と目的変数の相関を見るだけです。

def leak_scan(f: pd.DataFrame, target: str = "target",
              thresh: float = 0.15) -> None:
    cols = [c for c in f.columns
            if c != target and pd.api.types.is_numeric_dtype(f[c])]
    corr = f[cols].apply(lambda s: s.corr(f[target])).abs()
    corr = corr.sort_values(ascending=False)
    print(corr.round(4).head(10))
    bad = corr[corr > thresh]
    if len(bad):
        print(f"\n⚠ 相関が高すぎる特徴量: {list(bad.index)}")
        print("   翌日方向と0.15を超えて相関する変数は、まず存在しません")

翌日の方向と0.15以上相関する特徴量は、現実には存在しません。出たら喜ぶ前に疑う。これを習慣にしてから、事故が激減しました。

犯人3:複数銘柄をconcatしてTimeSeriesSplitしていた

これが本命でした。しかも、いろいろな記事で見かける書き方です。

# 一見なんの問題もなさそうなコード
frames = []
for t in ["7203.T", "6501.T", "4063.T"]:
    d = yf.download(t, period="5y", progress=False)
    f = make_features(d)
    f["ticker"] = t
    frames.append(f)

data = pd.concat(frames).sort_index()      # ← ここ

for tr, te in TimeSeriesSplit(n_splits=5).split(X):
    ...

何が起きているか。3銘柄を連結して日付でソートすると、同じ日付の行が3つ並びます。ところがTimeSeriesSplit行番号で分割するので、境界が日付の途中に落ちます。

つまり、2024年5月1日のトヨタは学習データ、同じ日の日立はテストデータという状態になります。日本の大型製造株は日々の値動きが強く相関しているので、これは「テスト日の相場が上げだったか下げだったか」を学習データで教えているのと同じです。

def show_boundary_leak(data: pd.DataFrame, n_splits: int = 5) -> None:
    idx = data.index
    for i, (tr, te) in enumerate(TimeSeriesSplit(n_splits=n_splits).split(data)):
        overlap = set(idx[tr]) & set(idx[te])
        print(f"fold{i}: 学習と検証で日付が重複 = {len(overlap)}日")
        if overlap:
            print(f"        例: {sorted(overlap)[:3]}")

show_boundary_leak(data)

重複日数が0でなければアウトです。自分の場合、各フォールドで2日ぶんが重複していました。たった2日ですが、相関の高い銘柄では十分に効きます。

さらに深刻な問題があります。重複がゼロでも、まだリークします。特徴量にret20(20日リターン)を使っていると、テスト期間の最初の20日は学習期間の価格を参照しています。目的変数も1日先を見ているので、境界をまたいだ情報が漏れます。

正しい分割:日付でグループ化して間隔を空ける

解決策は2つの組み合わせです。日付単位で分割する(グループ分割)と、学習と検証の間に空白期間を置く(パージ/エンバーゴ)

from dataclasses import dataclass

@dataclass
class Split:
    train: np.ndarray
    test: np.ndarray

def purged_date_split(dates: pd.Series, n_splits: int = 5,
                      embargo: int = 25) -> list[Split]:
    """日付単位で時系列分割し、学習と検証の間にembargo日の空白を置く。"""
    uniq = np.array(sorted(dates.unique()))
    n = len(uniq)
    fold = n // (n_splits + 1)
    out = []

    for i in range(1, n_splits + 1):
        tr_end = fold * i
        te_start = tr_end + embargo          # 空白を挟む
        te_end = min(te_start + fold, n)
        if te_start >= te_end:
            break

        tr_dates = set(uniq[:tr_end])
        te_dates = set(uniq[te_start:te_end])
        assert not (tr_dates & te_dates), "日付が重複しています"

        out.append(Split(
            train=np.where(dates.isin(tr_dates))[0],
            test=np.where(dates.isin(te_dates))[0],
        ))
    return out

embargoいちばん長い特徴量の窓に合わせます。ret20や20日ボラティリティを使うなら25日程度。ここを0にすると、境界付近の行が学習期間の情報を持ったままテストされます。

assertを入れているのが大事です。リークしない実装を書くのではなく、リークしたら止まる実装を書く。人間の注意力は当てになりません。

やり直した検証コード

import yfinance as yf
import lightgbm as lgb
from sklearn.metrics import roc_auc_score
from scipy import stats

TICKERS = ["7203.T", "6501.T", "4063.T", "6861.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["ret20"] = c.pct_change(20)
    f["vol20"] = r.rolling(20).std()
    f["vol_ratio"] = r.rolling(5).std() / r.rolling(60).std()
    f["turnover"] = v / v.rolling(20).mean()
    f["ma20_dev"] = c / c.rolling(20).mean() - 1
    f["hl_range"] = (df["High"] - df["Low"]) / c
    f["fwd"] = r.shift(-1)                    # 評価用(特徴量には使わない)
    f["target"] = (f["fwd"] > 0).astype(int)
    return f.dropna()

def load_panel(tickers: list[str], period: str = "10y") -> pd.DataFrame:
    frames = []
    for t in tickers:
        d = yf.download(t, period=period, auto_adjust=True, progress=False)
        if isinstance(d.columns, pd.MultiIndex):
            d.columns = d.columns.get_level_values(0)
        f = make_features(d)
        f["ticker"] = t
        frames.append(f.reset_index().rename(columns={"Date": "date"}))
    return pd.concat(frames, ignore_index=True).sort_values(
        ["date", "ticker"]).reset_index(drop=True)

panel = load_panel(TICKERS)
FEATS = ["ret1", "ret5", "ret20", "vol20", "vol_ratio",
         "turnover", "ma20_dev", "hl_range"]

leak_scan(panel[FEATS + ["target"]])
def run(panel: pd.DataFrame, embargo: int = 25,
        cost: float = 0.001) -> pd.DataFrame:
    X = panel[FEATS].to_numpy()
    y = panel["target"].to_numpy()
    fwd = panel["fwd"].to_numpy()

    rows = []
    for i, sp in enumerate(purged_date_split(panel["date"], 5, embargo)):
        m = lgb.LGBMClassifier(
            n_estimators=600, learning_rate=0.02,
            num_leaves=15, min_child_samples=100,
            subsample=0.8, subsample_freq=1, colsample_bytree=0.7,
            reg_lambda=5.0, random_state=42, verbosity=-1,
        )
        m.fit(X[sp.train], y[sp.train])
        p = m.predict_proba(X[sp.test])[:, 1]

        yt, ft = y[sp.test], fwd[sp.test]
        pos = (p > 0.5).astype(float)
        pnl = pos * ft - np.abs(np.diff(np.r_[0.0, pos])) * cost
        hits = int(((p > 0.5) == yt).sum())
        ci = stats.binomtest(hits, len(yt), 0.5).proportion_ci(0.95)

        rows.append({
            "fold": i,
            "n": len(yt),
            "AUC": round(roc_auc_score(yt, p), 4),
            "精度": round(hits / len(yt), 4),
            "CI下限": round(ci.low, 3),
            "ベース": round(max(yt.mean(), 1 - yt.mean()), 3),
            "戦略%": round(((1 + pnl).prod() - 1) * 100, 2),
            "B&H%": round(((1 + ft).prod() - 1) * 100, 2),
        })
    return pd.DataFrame(rows).set_index("fold")

print(run(panel))

これで測り直したところ、AUCは0.51〜0.55、精度はベースラインとほぼ同じでした。0.87はどこにもありません。

エンバーゴの効果を確かめる

「本当にそんなに変わるのか」と思ったので、条件を変えて比較しました。

def compare_splits(panel: pd.DataFrame) -> None:
    print("=== 行番号で分割(よくある書き方)===")
    X, y = panel[FEATS].to_numpy(), panel["target"].to_numpy()
    aucs = []
    for tr, te in TimeSeriesSplit(n_splits=5).split(X):
        m = lgb.LGBMClassifier(n_estimators=600, learning_rate=0.02,
                               num_leaves=15, random_state=42, verbosity=-1)
        m.fit(X[tr], y[tr])
        aucs.append(roc_auc_score(y[te], m.predict_proba(X[te])[:, 1]))
    print(f"AUC平均 = {np.mean(aucs):.4f}")

    for emb in (0, 5, 25, 60):
        r = run(panel, embargo=emb)
        print(f"日付分割 embargo={emb:2d}日 → AUC平均 = {r['AUC'].mean():.4f}")

compare_splits(panel)

行番号分割のほうが常に高いAUCを出します。そしてエンバーゴを伸ばすほどスコアは下がります。下がるのが正常です。上がったぶんは全部、境界からの漏れでした。

分割方法AUCの傾向実運用との一致
ランダム分割(KFold)0.7以上まったく一致しない
行番号でTimeSeriesSplit0.56〜0.60過大評価
日付分割・エンバーゴなし0.54前後やや過大
日付分割+エンバーゴ25日0.51〜0.55おおむね一致

AUC 0.55は「しょぼい」のか

最初は0.55という数字にがっかりしました。でも今は、正しく測って0.55なら十分だと思っています。理由は2つあります。

1つは、翌日の方向を素朴な価格特徴量だけで予測しているから。使っている情報が価格と出来高しかない以上、これ以上出るほうが不自然です。

もう1つは、AUC 0.55でも、確信度の高い一部だけを取れば使える可能性があることです。全日取引する必要はありません。

def selective(panel: pd.DataFrame, top_pct: float = 0.1,
              cost: float = 0.001) -> None:
    """確信度が上位top_pctの日だけエントリーする。"""
    X, y = panel[FEATS].to_numpy(), panel["target"].to_numpy()
    fwd = panel["fwd"].to_numpy()

    for sp in purged_date_split(panel["date"], 5, 25)[-1:]:
        m = lgb.LGBMClassifier(n_estimators=600, learning_rate=0.02,
                               num_leaves=15, min_child_samples=100,
                               random_state=42, verbosity=-1)
        m.fit(X[sp.train], y[sp.train])
        p = m.predict_proba(X[sp.test])[:, 1]
        ft = fwd[sp.test]

        thr = np.quantile(p, 1 - top_pct)
        sel = p >= thr
        print(f"全体   : n={len(ft):5d}  平均={ft.mean() * 100:+.4f}%")
        print(f"上位{top_pct:.0%}: n={sel.sum():5d}  "
              f"平均={ft[sel].mean() * 100:+.4f}%  "
              f"コスト後={(ft[sel].mean() - 2 * cost) * 100:+.4f}%")
        t, pv = stats.ttest_1samp(ft[sel], 0.0)
        print(f"         t={t:.2f}  p={pv:.3f}")

selective(panel)

上位10%の日の平均リターンが全体平均より明確に高く、かつ往復コスト0.2%を引いてもプラスなら、意味があります。ただし、自分のケースではコストを引くとほぼゼロでした。そこが現実です。

リークを二度とやらないための仕組み

気をつける、では防げません。テストにします。

def test_no_leak(panel: pd.DataFrame) -> None:
    """CIやスクリプト冒頭で毎回走らせる検査。"""
    # 1. 学習と検証で日付が重複していないか
    for sp in purged_date_split(panel["date"], 5, 25):
        d_tr = set(panel["date"].iloc[sp.train])
        d_te = set(panel["date"].iloc[sp.test])
        assert not (d_tr & d_te), "日付が重複"
        assert min(d_te) > max(d_tr), "検証期間が学習期間より前にある"

    # 2. 特徴量が目的変数と異常に相関していないか
    for c in FEATS:
        r = abs(panel[c].corr(panel["target"]))
        assert r < 0.15, f"{c} の相関が高すぎる: {r:.3f}"

    # 3. 特徴量にNaNや無限大が残っていないか
    assert np.isfinite(panel[FEATS].to_numpy()).all(), "非有限値がある"

    # 4. 目的変数をシャッフルしたらAUCが0.5付近に落ちるか
    rng = np.random.default_rng(0)
    y_shuf = rng.permutation(panel["target"].to_numpy())
    sp = purged_date_split(panel["date"], 5, 25)[-1]
    X = panel[FEATS].to_numpy()
    m = lgb.LGBMClassifier(n_estimators=300, num_leaves=15,
                           random_state=0, verbosity=-1)
    m.fit(X[sp.train], y_shuf[sp.train])
    auc = roc_auc_score(y_shuf[sp.test], m.predict_proba(X[sp.test])[:, 1])
    assert 0.45 < auc < 0.55, f"シャッフルでAUC={auc:.3f}。配管がおかしい"

    print("リーク検査: 問題なし")

test_no_leak(panel)

4番目が特に効きます。目的変数をランダムに並べ替えたのにAUCが0.6を超えるなら、検証の仕組み自体が壊れています。モデルの問題ではなく、コードの問題です。ここで引っかかったことが実際に一度ありました。

まとめ

  • AUC 0.87の正体は3種類のリーク。全期間スケーリング、未来を見る特徴量、そして分割の境界
  • 複数銘柄をconcatして行番号で分割すると、同じ日が学習と検証に分かれる
  • 日付単位で分割し、最長の特徴量窓ぶんのエンバーゴを空ける
  • 特徴量と目的変数の相関が0.15を超えたら疑う
  • ラベルをシャッフルしてAUCが0.5に戻るかを毎回テストする
  • 正しく測ったAUC 0.55は、しょぼくない。0.8が出たら壊れている

機械学習トレードで大事なのは、派手に勝つモデルより期待どおりに動く地味なモデルです。そして期待どおりかどうかは、検証の仕組みが正しくないと分かりません。

同じようにハマっている人は、まずtest_no_leakのようなテストを書いてみてください。自分はこれを書いた日に、過去のモデルが全部おかしかったことを知りました。落ち込みましたが、それ以降は実運用との乖離がほとんどなくなりました。

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