0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

特徴量が、気づけば全部ゼロだった——タイムゾーンの静かな罠

0
Posted at

この記事は 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回分の内容は、追って一冊にまとめる予定です。

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?