この記事は Zenn に投稿したものの再掲です。初出: 特徴量が、気づけば全部ゼロだった——タイムゾーンの静かな罠
系列トレードの検証で踏む罠を扱う連載の第5回(最終回)です。第1回(未来参照バグ)・第2回(特徴量リーク)・第3回(ライブ/BT乖離)・第4回(過学習)の続きです。
ここまでの4回は、どれも大きな概念の話でした。未来参照、情報漏れ、ライブ乖離、過学習。最終回は、少し毛色が違います。壮大な理論ではなく、しょうもなくて、静かで、それでいて何日も溶かすタイプのバグ。タイムスタンプとタイムゾーンの話です。
このシリーズを貫くテーマは「検証は、エラーを出さずに嘘をつく」でした。タイムスタンプのバグは、その最も純粋な形です。例外は飛ばない。処理は通る。ただ、数字が静かに間違っている。
私が溶かした実例から始めます。
代表例1:別系列を混ぜたら、特徴量が全部ゼロになっていた
日中システムでは、対象銘柄だけでなく、指数や先物を特徴量に混ぜることがよくあります。「日経が上げているときは…」といった文脈を与えるためです。
私はこれをやって、ある日気づきました。先物由来の特徴量が、学習期間を通してすべてゼロだったのです。モデルはその特徴量を、ずっと「無」として扱っていた。
原因はタイムゾーンでした。銘柄データのインデックスは日本時間(tz-aware)、先物データのインデックスはUTC(あるいはtz-naive)。この2つを揃えずに結合すると、時刻が対応せず、ほぼ全行がNaNになります。
# 銘柄は JST-aware、先物は UTC(または naive)。気づかず結合すると…
merged = stock.join(futures_feature) # 時刻が対応せず、ほぼ全行 NaN
merged = merged.fillna(0) # ← NaN を 0 で埋めた瞬間、先物特徴が全ゼロ化
reindexでも同じことが起きます。
# tz が食い違うと、reindex はほぼ全行 NaN を返す
futures_feature = futures_feature.reindex(stock.index) # naive vs aware でマッチせず
そして、最後のfillna(0)がとどめです。NaNを0で埋めるのは一見無害な処理ですが、tz不整合で全行NaNになった列を、全ゼロの列に変えてしまう。エラーは出ません。モデルは「常に0の特徴量」を淡々と学習し、当然そこからは何も学べない。あるいは、もっと悪いことに、ゼロという定数を何かの手がかりとして誤って覚えます。
「なぜか先物の情報が効いていない」という曖昧な症状としてしか現れないので、原因にたどり着くまで時間がかかりました。
代表例2:バーの時刻は「始まり」か「終わり」か
もう一つ、地味に効くのがバーの時刻規約です。
「9:00のバー」と言ったとき、それは何を指すでしょうか。9:00に始まって9:00〜9:01を含むバーか、それとも8:59〜9:00を集計して9:00で終わるバーか。データソースによって規約が違います。
問題は、規約の違うソースを混ぜたときです。ソースAが「バーの開始時刻」でラベルし、ソースBが「バーの終了時刻」でラベルしていると、同じ「9:00」でも中身が1本ズレます。
# ソースA:9:00ラベル = 9:00-9:01を集計(開始時刻ラベル)
# ソースB:9:00ラベル = 8:59-9:00を集計(終了時刻ラベル)
# この2つを時刻で結合すると、実データが1本分ズレて対応する
この1本のズレは、2つの顔を持ちます。特徴量どうしのズレなら精度を静かに削り、もし「終了時刻ラベル」のバーを「その時刻に確定済み」と誤認すれば、それは第1回で扱った未来参照になります。実はこのシリーズの各回で「タイムスタンプを揃えろ」と繰り返してきたのは、この規約のズレが、あらゆる罠の隠れた入口だからです。
補足:海外データを混ぜるなら夏時間に注意
日本市場には夏時間(DST)がないので、国内データだけなら時差は一定です。でも米国の指数や先物を混ぜる場合は話が別で、JSTと米国市場時刻の差は、年に2回変わります。「時差は14時間で固定」と決め打つと、切り替え時期に1時間ズレます。海外系列を触るなら、ここは必ず意識してください。
検出する
タイムスタンプのバグは静かなので、こちらから能動的にアサーションで捕まえるのが基本です。
# tz-naive が紛れていないか
assert stock.index.tz is not None, "tz-naive index が紛れている"
# 結合後に欠損が多すぎないか(tz不整合の典型症状)
matched = futures_feature.reindex(stock.index).notna().mean()
assert matched > 0.9, f"結合後の欠損が多すぎる(tz不整合の疑い): matched={matched:.2f}"
# 特徴量が定数化(全ゼロ化)していないか
assert df["futures_feat"].std() > 0, "特徴量が定数になっている"
もう一つ効くのが、生データを重ねてプロットして目視すること。銘柄と先物の価格を同じ時間軸に描いて、山と谷がだいたい合っているか。ズレていれば一目で分かります。数字のパイプラインだけ見ていると気づけないズレも、図にすると露見します。
予防:取り込みの境界で正規化する
対策の要は、データが入ってくる境界で、タイムゾーンを一度だけ正規化することです。
def to_utc(s):
# 生データのTZが不明なら、想定TZを明示的に付与してからUTCへ
if s.index.tz is None:
s = s.tz_localize("Asia/Tokyo") # 生の想定TZを明示する
return s.tz_convert("UTC")
原則はこうです。
- 内部は単一のTZ(UTC推奨)で統一する。 表示のときだけローカルに変換する
- tz-naive と tz-aware を混ぜない。 取り込み時にすべてawareに寄せる
- バーの時刻規約を1か所に固定する。 「うちは開始時刻ラベルで統一」と決め、全ソースをそこに合わせる
- 結合の直後にアサーションを置く。 「揃っているはず」を、コードに検証させる
要は、「あとで揃える」のではなく「入り口で揃えて、二度と疑わない」設計にすることです。
まとめ
- タイムスタンプのバグは、このシリーズの「エラーを出さずに嘘をつく」を最も純粋に体現する
- tz不整合で結合すると全行NaN→
fillna(0)で特徴量が静かに全ゼロ化する - バーの時刻規約(開始/終了)の食い違いは、1本ズレを生み、時に未来参照になる
- 海外データを混ぜるなら、DSTで時差が年2回変わることに注意
- 検出はアサーションと目視プロット、予防は取り込み境界での単一TZ正規化
連載を終えて
全5回で、検証が嘘をつくパターンを潰してきました。時間の漏れ(第1回)、情報の漏れ(第2回)、ライブ乖離(第3回)、過学習(第4回)、そしてタイムスタンプ(今回)。
通底しているのは、ひとつの姿勢です。良い成績を、素直に喜ばない。 バックテストが微笑みかけてきたら、まずそれが嘘でないかを確かめる。この疑り深さこそが、自作システムを本番で生き残らせる唯一の武器だと、私は思っています。
長い連載にお付き合いいただき、ありがとうございました。
質問や「自分はこのタイムスタンプで溶かした」という話があればコメントで。5回分の内容は、追って一冊にまとめる予定です。