データリークの恐ろしさ
競馬AI、つまり機械学習を用いた競馬予想は、通常の機械学習プロジェクトと同様にデータリークに細心の注意を払う必要があります。(むしろ、通常より落とし穴が多いかもしれません)
本来は利用できない未来のデータが少しでも入力特徴量に混じる(データリーク)と、機械学習モデルはたちまち予言者並みの力を発揮し、予言の上位5頭をボックス買いするだけで一生稼ぎ続けることができるようになります。
ただし、稼げるのはバックテスト(過去データを用いた非リアルタイム予測によるテスト)の世界の中だけという重大な欠陥があります。
この事実に気づかず、勝てるモデルと信じてお金を落とし続けることもあり得るのです。
つまり、データリークは絶対起こしてはいけません。
バックテストとフォワードテストの一致確認の必要性
とは言え、データリークする落とし穴はたくさんあるので、引っかかってしまうのは仕方ないとしましょう。
代わりに、データリークを必ず見つけて修正できるようにする必要があります。
その際に、武器になるのが「バックテストとフォワードテストの一致確認」です。
過去データを用いたテスト(=バックテスト)に対して、予測時点(現在)のデータを用いたリアルタイム予測によるテストがフォワードテストです。
予測時点(例えば出走10分前)のデータには、未来(発走後)に分かるデータが100%含まれないので、フォワードテストではデータリークが発生することはなく、モデルの真のパフォーマンスを測ることができます。
ただし、リアルタイムに予測をしなければならないので、中央競馬であれば、毎週末にリアルタイム予測を行い、ある程度の期間蓄積してようやく評価が可能になる点が欠点です。
もし仮に、バックテストとフォワードテストの結果が完全に一致することが確認できれば、バックテスト時にデータリークしていないと言えます。
そうすると、過去のデータを用いたテストでもモデルの真のパフォーマンスを測ることができるということになります。
毎週末の予測を蓄積せずとも、長い期間でのテストができるのは嬉しいです。
バックテストとフォワードテストの一致をどこまで確認するか
バックテストとフォワードテストの一致確認と言っても、いろいろなレイヤーがあると思っています。
特徴量のレイヤー、予測値のレイヤー、買い目のレイヤー、収支のレイヤー等々。
正直かなりめんどくさいですが、今挙げた全てのレイヤーで一致確認をする必要があると考えています。
特に、特徴量の一致確認(理想は完全一致)は、この記事の主題であるデータリークを見つけるのに非常に大事です。
特徴量レイヤーの一致が確認できたら、他のレイヤーは難しくないはずです。
データリークのアンチパターン
これ以降は、バックテストとフォワードテストにおける特徴量が一致しなくなる、誤ったデータ処理方法をアンチパターンとして紹介します。
特徴量は、あるレースのある馬に関する情報であり、その馬のそのレースでの勝率や走破タイムを予測するのに利用されるとします。モデルは、ツリー系、NN系問わないです。
レース結果
予測対象レースにおける以下のような特徴量は、発走後に初めて分かる未来のデータですので、予測に使用してはいけません。
- 着順
- 走破タイム
- ラップタイム
- 当該レースにおける脚質
- 出遅れ
- 不利
- 位置取り
確定オッズ・確定人気
確定オッズ・確定人気は発走後に決定されるため、予測時点では知りえません。
また、予測時点が発走10分前であれば、発走9分前以降のオッズ・人気も使用できません。
ついでに、発走11分前以前の値を使用するのもよろしくないです。なるべく10分前ちょうどの値を使いましょう。
天気・馬場状態
天気・馬場状態にも確定値があります。
予測時点が発走10分前であれば、発走9分前以降に発表された天気・馬場状態は使用できません。
JRA-VAN データラボ等で取れる天気・馬場状態は、当然発走時点の値のため、厳密には使用できないということになります。
ただし、天気・馬場状態は10分くらいでは大きく変わらないことも多く、その場合予測結果に与える影響が少ないのも事実であり、かつJRA-VAN データラボのデータを使えた方が楽なので、そのまま使ってしまうのも全然ありだと思います。
その場合、天気が変わりやすい日や馬場状態が重から稍重や良に移行するような日の予測は、データリークの可能性が高いことに注意してください。
ちなみに、JRA-VAN データラボの天気は、蓄積系データと速報系データが一致しない気がしてます。特定の日しか確認してませんが、速報系データでは1日通して全てのレースで晴れだったのに、後日蓄積系データを見ると晴れと曇りが混ざっているみたいなことがありました。
競走中止
競走中止は発走後に分かるため、競走を中止した馬を予測時点で無視してはいけません。
何らかの特徴量の同レース内での平均を取って新たな特徴量とするような場合、競走中止馬を無視して計算してしまうと、計算結果がずれます。
また、競走中止馬を無視して予測勝率を求めると、レースが始まる前から競走中止馬の勝率を0%と見積もることになり、他の馬の予測勝率が不当に高くなります。
競走除外・出走頭数
競走除外は発走前に分かりますが、それが予測時点以降に明らかになった場合は、予測時点で無視してはいけません。
例えば、予測時点が発走10分前で、発走9分前以降に競走除外が発表された馬は無視してはいけません。
また、競走除外が発生することで出走頭数が減りますが、競走除外馬を無視してはいけない場合に、出走頭数(登録頭数-競走除外馬の数)の値を使用すると計算がずれます。
例えば、何らかの特徴量を出走頭数で割って新たな特徴量とするような処理でずれます。
この場合は、登録頭数を使いましょう。
One-Hot Encoding
この節の話は、詳しい検証をしたわけではなく、感覚的に影響がなさそうでは?という気もするので…話半分で読んでください。
One-Hot Encodingはお馴染みのカテゴリ変数の数値化を行う前処理手法です。
N種の値を持つ1列のカテゴリ変数をN列のbool変数で表すことで数値化します。
ここで、N列のうち何番目の列が何のカテゴリ値を表すか、学習から予測まで固定である必要があります。
もし、2019年末までのデータで学習し、2020年以降のデータでテストする場合、例えば小林脩斗騎手はデビューしてませんから、小林脩斗騎手を表す列は存在しないはずです。
つまり、学習データの範囲で存在するカテゴリ値のみでbool列を生成し、その他のカテゴリ値はその他を表す列で表現するか列を設けない(全てのbool列が0)ようにする必要があります。
とは言いますが、確証がなくあまり自信はないです。
また、Label Encodingも同様の話が成り立ちます。
Target Encoding
Target Encodingもカテゴリ変数の数値化を行う前処理手法です。
カテゴリ値毎に目的変数(Target)の統計量を計算することで数値化します。
例えば、カテゴリ変数である騎手名と目的変数である3着以内結果(3着以内: 1, それ以外: 0)の特徴量があったとして、騎手Aの3着以内結果の平均(統計量)を取ることで、騎手Aの強さを表す(数値化する)ことができます。
ただし、特徴量の作成に目的変数を使用するため、気を付けないとすぐにデータリークします。
以降では、polarsを用いたTarget Encoding作成コードを少しずつ改善しながらデータリークを修正していこうと思います。
全レコードの平均
2005年から2022年までの以下のようなデータを用意します。
df.head()
raceId | yearMonthDay | horseName | place |
---|---|---|---|
str | str | str | i8 |
"200548010101" | "20050101" | "トーホウサンシュウ" | 0 |
"200548010101" | "20050101" | "ヒミコウインク" | 0 |
"200548010101" | "20050101" | "アバンテ" | 0 |
"200548010101" | "20050101" | "マル" | 1 |
"200548010101" | "20050101" | "ルードオーレ" | 1 |
以下のコードで各馬の全レースの place
の平均(=3着以内率)が求まります。
試しに、ヴェラアズールだけ抜き出してみましょう。
df = df.with_columns(
pl.col('place').mean().over('horseName').alias('mean')
)
df.filter(pl.col('horseName')=='ヴェラアズール')
raceId | yearMonthDay | horseName | place | mean |
---|---|---|---|---|
str | str | str | i8 | f64 |
"202009010705" | "20200320" | "ヴェラアズール" | 1 | 0.652174 |
"202009020502" | "20200411" | "ヴェラアズール" | 1 | 0.652174 |
"202008031202" | "20200531" | "ヴェラアズール" | 1 | 0.652174 |
"202009030303" | "20200613" | "ヴェラアズール" | 1 | 0.652174 |
"202009030802" | "20200628" | "ヴェラアズール" | 1 | 0.652174 |
"202009050708" | "20201123" | "ヴェラアズール" | 0 | 0.652174 |
"202009060508" | "20201219" | "ヴェラアズール" | 1 | 0.652174 |
"202107010307" | "20210110" | "ヴェラアズール" | 1 | 0.652174 |
"202107020312" | "20210320" | "ヴェラアズール" | 0 | 0.652174 |
"202104010111" | "20210410" | "ヴェラアズール" | 0 | 0.652174 |
"202105030409" | "20210613" | "ヴェラアズール" | 1 | 0.652174 |
"202109030412" | "20210627" | "ヴェラアズール" | 0 | 0.652174 |
"202105040709" | "20211030" | "ヴェラアズール" | 0 | 0.652174 |
"202109050708" | "20211127" | "ヴェラアズール" | 0 | 0.652174 |
"202109060509" | "20211218" | "ヴェラアズール" | 1 | 0.652174 |
"202207010309" | "20220109" | "ヴェラアズール" | 0 | 0.652174 |
"202209011109" | "20220319" | "ヴェラアズール" | 1 | 0.652174 |
"202206030812" | "20220417" | "ヴェラアズール" | 1 | 0.652174 |
"202205020710" | "20220514" | "ヴェラアズール" | 1 | 0.652174 |
"202205030311" | "20220611" | "ヴェラアズール" | 1 | 0.652174 |
"202209040311" | "20221010" | "ヴェラアズール" | 1 | 0.652174 |
"202205050812" | "20221127" | "ヴェラアズール" | 1 | 0.652174 |
"202206050811" | "20221225" | "ヴェラアズール" | 0 | 0.652174 |
20221225
時点の ヴェラアズール
の3着以内率は確かに 0.652174
です。
しかしそれ以外の日は、その日より未来の日の place
情報を含んだ平均になってしまっているのでデータリークしています。
これを避けるには、全レコードの平均を計算するのではなく、未来の日を計算に入れないようにローリング平均を使って計算しましょう。
ローリング平均
それでは、polarsの rolling_mean
を使ってみます。
df = df.with_columns(
pl.col('place').rolling_mean(window_size=10**8, min_periods=1).over('horseName').alias('mean')
)
df.filter(pl.col('horseName')=='ヴェラアズール')
raceId | yearMonthDay | horseName | place | mean |
---|---|---|---|---|
str | str | str | i8 | f64 |
"202009010705" | "20200320" | "ヴェラアズール" | 1 | 1.0 |
"202009020502" | "20200411" | "ヴェラアズール" | 1 | 1.0 |
"202008031202" | "20200531" | "ヴェラアズール" | 1 | 1.0 |
"202009030303" | "20200613" | "ヴェラアズール" | 1 | 1.0 |
"202009030802" | "20200628" | "ヴェラアズール" | 1 | 1.0 |
"202009050708" | "20201123" | "ヴェラアズール" | 0 | 0.833333 |
"202009060508" | "20201219" | "ヴェラアズール" | 1 | 0.857143 |
"202107010307" | "20210110" | "ヴェラアズール" | 1 | 0.875 |
"202107020312" | "20210320" | "ヴェラアズール" | 0 | 0.777778 |
"202104010111" | "20210410" | "ヴェラアズール" | 0 | 0.7 |
"202105030409" | "20210613" | "ヴェラアズール" | 1 | 0.727273 |
"202109030412" | "20210627" | "ヴェラアズール" | 0 | 0.666667 |
"202105040709" | "20211030" | "ヴェラアズール" | 0 | 0.615385 |
"202109050708" | "20211127" | "ヴェラアズール" | 0 | 0.571429 |
"202109060509" | "20211218" | "ヴェラアズール" | 1 | 0.6 |
"202207010309" | "20220109" | "ヴェラアズール" | 0 | 0.5625 |
"202209011109" | "20220319" | "ヴェラアズール" | 1 | 0.588235 |
"202206030812" | "20220417" | "ヴェラアズール" | 1 | 0.611111 |
"202205020710" | "20220514" | "ヴェラアズール" | 1 | 0.631579 |
"202205030311" | "20220611" | "ヴェラアズール" | 1 | 0.65 |
"202209040311" | "20221010" | "ヴェラアズール" | 1 | 0.666667 |
"202205050812" | "20221127" | "ヴェラアズール" | 1 | 0.681818 |
"202206050811" | "20221225" | "ヴェラアズール" | 0 | 0.652174 |
例えば、 20201123
の mean
は 20201123
以前の平均となり、未来の日の情報は含みません。
しかし、このままだとその日の結果が含まれてしまいます。20201123
の予想をしようとしているのに、その日の結果(ヴェラアズールは3着以外)を含んだ平均になっています。
ローリング平均+シフト
polarsの shift
を使うことで解決します。
df = df.with_columns(
pl.col('place').rolling_mean(window_size=10**8, min_periods=1).shift(1).over('horseName').alias('mean')
)
df.filter(pl.col('horseName')=='ヴェラアズール')
raceId | yearMonthDay | horseName | place | mean |
---|---|---|---|---|
str | str | str | i8 | f64 |
"202009010705" | "20200320" | "ヴェラアズール" | 1 | null |
"202009020502" | "20200411" | "ヴェラアズール" | 1 | 1.0 |
"202008031202" | "20200531" | "ヴェラアズール" | 1 | 1.0 |
"202009030303" | "20200613" | "ヴェラアズール" | 1 | 1.0 |
"202009030802" | "20200628" | "ヴェラアズール" | 1 | 1.0 |
"202009050708" | "20201123" | "ヴェラアズール" | 0 | 1.0 |
"202009060508" | "20201219" | "ヴェラアズール" | 1 | 0.833333 |
"202107010307" | "20210110" | "ヴェラアズール" | 1 | 0.857143 |
"202107020312" | "20210320" | "ヴェラアズール" | 0 | 0.875 |
"202104010111" | "20210410" | "ヴェラアズール" | 0 | 0.777778 |
"202105030409" | "20210613" | "ヴェラアズール" | 1 | 0.7 |
"202109030412" | "20210627" | "ヴェラアズール" | 0 | 0.727273 |
"202105040709" | "20211030" | "ヴェラアズール" | 0 | 0.666667 |
"202109050708" | "20211127" | "ヴェラアズール" | 0 | 0.615385 |
"202109060509" | "20211218" | "ヴェラアズール" | 1 | 0.571429 |
"202207010309" | "20220109" | "ヴェラアズール" | 0 | 0.6 |
"202209011109" | "20220319" | "ヴェラアズール" | 1 | 0.5625 |
"202206030812" | "20220417" | "ヴェラアズール" | 1 | 0.588235 |
"202205020710" | "20220514" | "ヴェラアズール" | 1 | 0.611111 |
"202205030311" | "20220611" | "ヴェラアズール" | 1 | 0.631579 |
"202209040311" | "20221010" | "ヴェラアズール" | 1 | 0.65 |
"202205050812" | "20221127" | "ヴェラアズール" | 1 | 0.666667 |
"202206050811" | "20221225" | "ヴェラアズール" | 0 | 0.681818 |
このように、 rolling_mean
した結果を1行ずつシフトした結果になります。
これにより、当日と未来の情報を除いた、過去の情報のみから計算された平均になります。
ローリング平均+シフト+同一レースのレース結果を除く
馬や騎手など、同一レースに同じ値が存在しえないカテゴリ変数を扱う場合は、これまでの処理で問題ありません。
しかし、調教師や種牡馬など、同一レースに同じ値が複数存在しうるカテゴリ変数において、ローリング平均+シフトする際は要注意です。
例えば、2022年のアルゼンチン共和国杯では、種牡馬が同じ キングカメハメハ
である ユーキャンスマイル
と ヒートオンビート
が出走しています。
ここで、以下のように種牡馬についてローリング平均+シフトしてみます。
df = df.with_columns(
pl.col('place').rolling_mean(window_size=10**8, min_periods=1).shift(1).over('breedName_F').alias('mean')
)
df.filter(
(pl.col('breedName_F')=='キングカメハメハ') & \
(pl.col('raceId')=='202205050211')
)
raceId | yearMonthDay | horseNumber | horseName | breedName_F | place | mean |
---|---|---|---|---|---|---|
str | str | i8 | str | str | i8 | f64 |
"202205050211" | "20221106" | 3 | "ユーキャンスマイル" | "キングカメハメハ" | 0 | 0.315442 |
"202205050211" | "20221106" | 16 | "ヒートオンビート" | "キングカメハメハ" | 1 | 0.31543 |
ユーキャンスマイル
と ヒートオンビート
の mean
が異なる結果になりました。
これは、このレースにおける ユーキャンスマイル
の place
情報が ヒートオンビート
の mean
に含まれてしまったからです。
このレースにおける ユーキャンスマイル
の place
は、レース前に知りえるはずがないのでデータリークしていることになります。
以下のようにして解決します。
tmp_df = df
tmp_df = tmp_df.with_columns(
pl.col('place').rolling_mean(window_size=10**8, min_periods=1).shift(1).over('breedName_F').alias('mean')
)
df = df.join(
tmp_df.group_by(['raceId', 'breedName_F']).agg(pl.col('mean').first().alias('mean')),
on=['raceId', 'breedName_F'], how='left'
)
df.filter(
(pl.col('breedName_F')=='キングカメハメハ') & \
(pl.col('raceId')=='202205050211')
)
raceId | yearMonthDay | horseNumber | horseName | breedName_F | place | mean |
---|---|---|---|---|---|---|
str | str | i8 | str | str | i8 | f64 |
"202205050211" | "20221106" | 3 | "ユーキャンスマイル" | "キングカメハメハ" | 0 | 0.315442 |
"202205050211" | "20221106" | 16 | "ヒートオンビート" | "キングカメハメハ" | 1 | 0.315442 |
これで、同一レースのレース結果( ユーキャンスマイル
のレース結果)を除くことができました。
ローリング平均+シフト+同日の他のレース結果を除く
似たような話で、システムの仕様によっては同日の他のレース結果を除かなければなりません。
同日の確定したレース結果を取得しないシステムの場合、その情報を以降のレースの予測に使用することができないためです。
例えば、以下は同日のレース結果を考慮した 20221106
の父 キングカメハメハ
の平均です。
tmp_df = df
tmp_df = tmp_df.with_columns(
pl.col('place').rolling_mean(window_size=10**8, min_periods=1).shift(1).over('breedName_F').alias('mean')
)
df = df.join(
tmp_df.group_by(['raceId', 'breedName_F']).agg(pl.col('mean').first().alias('mean')),
on=['raceId', 'breedName_F'], how='left'
)
df.filter(
(pl.col('breedName_F')=='キングカメハメハ') & \
(pl.col('yearMonthDay')=='20221106')
)
raceId | yearMonthDay | horseNumber | horseName | breedName_F | place | mean |
---|---|---|---|---|---|---|
str | str | i8 | str | str | i8 | f64 |
"202235110602" | "20221106" | 2 | "フレスコバルディ" | "キングカメハメハ" | 0 | 0.31545 |
"202203030203" | "20221106" | 9 | "アレマーナ" | "キングカメハメハ" | 0 | 0.315438 |
"202203030204" | "20221106" | 4 | "リーブルミノル" | "キングカメハメハ" | 0 | 0.315426 |
"202203030208" | "20221106" | 5 | "キングスウェイ" | "キングカメハメハ" | 0 | 0.315413 |
"202203030208" | "20221106" | 13 | "ブルーロワイヤル" | "キングカメハメハ" | 1 | 0.315413 |
"202205050210" | "20221106" | 2 | "ガンダルフ" | "キングカメハメハ" | 1 | 0.315428 |
"202205050210" | "20221106" | 11 | "ヘライア" | "キングカメハメハ" | 0 | 0.315428 |
"202205050211" | "20221106" | 3 | "ユーキャンスマイル" | "キングカメハメハ" | 0 | 0.315442 |
"202205050211" | "20221106" | 16 | "ヒートオンビート" | "キングカメハメハ" | 1 | 0.315442 |
"202203030212" | "20221106" | 15 | "ポメランチェ" | "キングカメハメハ" | 0 | 0.315457 |
同日の父 キングカメハメハ
が出走した結果を受けて、 mean
が変化していることが分かります。
同日の確定したレース結果を取得しないシステムの場合、以下のようにします。
tmp_df = df
tmp_df = tmp_df.with_columns(
pl.col('place').rolling_mean(window_size=10**8, min_periods=1).shift(1).over('breedName_F').alias('mean')
)
df = df.join(
tmp_df.group_by(['yearMonthDay', 'breedName_F']).agg(pl.col('mean').first().alias('mean')),
on=['yearMonthDay', 'breedName_F'], how='left'
)
df.filter(
(pl.col('breedName_F')=='キングカメハメハ') & \
(pl.col('yearMonthDay')=='20221106')
)
raceId | yearMonthDay | horseNumber | horseName | breedName_F | place | mean |
---|---|---|---|---|---|---|
str | str | i8 | str | str | i8 | f64 |
"202235110602" | "20221106" | 2 | "フレスコバルディ" | "キングカメハメハ" | 0 | 0.31545 |
"202203030203" | "20221106" | 9 | "アレマーナ" | "キングカメハメハ" | 0 | 0.31545 |
"202203030204" | "20221106" | 4 | "リーブルミノル" | "キングカメハメハ" | 0 | 0.31545 |
"202203030208" | "20221106" | 5 | "キングスウェイ" | "キングカメハメハ" | 0 | 0.31545 |
"202203030208" | "20221106" | 13 | "ブルーロワイヤル" | "キングカメハメハ" | 1 | 0.31545 |
"202205050210" | "20221106" | 2 | "ガンダルフ" | "キングカメハメハ" | 1 | 0.31545 |
"202205050210" | "20221106" | 11 | "ヘライア" | "キングカメハメハ" | 0 | 0.31545 |
"202205050211" | "20221106" | 3 | "ユーキャンスマイル" | "キングカメハメハ" | 0 | 0.31545 |
"202205050211" | "20221106" | 16 | "ヒートオンビート" | "キングカメハメハ" | 1 | 0.31545 |
"202203030212" | "20221106" | 15 | "ポメランチェ" | "キングカメハメハ" | 0 | 0.31545 |
まとめ
バックテストとフォワードテストの一致確認により、データリークを見つけることはとても重要です。
また、アンチパターンとして、データリークが起こりうるデータの使い方を紹介しました。
話が複雑になってしまって説明が分かりづらい部分もあるかと思いますが、どれも共通して言える大事な考え方は、「予測時点で知りえない情報は特徴量に含んではいけない」に尽きます。
最後までお読みいただきありがとうございました。