軽いノリで競馬予想AIを作り始めたらデータサイエンス総合格闘技みたいになった

タイトルに書いた通りです。

自己研鑽として取り組むのに加えて、成果が出せれば利益も出るだろう……と思い、競馬予想AIを構築してみることにしましたが、なかなかにつらいです。

現在進行中のためまだ解が出ていないですが、ここまで取り組んだ内容について整理しておきます。誰かに査読を依頼したわけではないため、文中で用いる用語にゆらぎがあったり意味が厳密ではない箇所があること、あらかじめご承知おきください。

## 目的

馬券を買って儲けるため、です。シンプル。

一応真面目な理由も述べておくと、実務でPythonを書く頻度が減っており、設計やコーディングのカンを取り戻したり、GitHub Copilotの使用感を確かめたりしたかったから、という目的もあります。

ただ、この理由だけで猛者だらけの機械学習コンペに殴り込みに行く気にはならなかったので、モチベーションを「うまくいけばカネになるから」にセットしています。

## 「競馬」の範囲

今回AIで予測するのは、JRA（日本中央競馬会）の主催する範囲にとどめています。
これは、自身が『ダービースタリオン』や『ウマ娘』で培った知識が使える範囲とおおよそ一致するためです。

ドメイン知識が乏しい、あるいはドメイン知識を得る当てがない場合、特徴量エンジニアリングで詰むだろう……というのは実務同様です


## 競馬予想は「データサイエンス総合格闘技」である(私見)

ぼんやりと[レース結果](https://www.jra.go.jp/JRADB/accessS.html?CNAME=pw01sde1007202404021120241201/A5)を眺めると単なるテーブルデータに見えるのですが、それほど話は単純ではありませんでした。

現時点で悩みどころ・ハマりどころは以下のポイントです。それぞれの詳細は後述します。

* 馬券種類のバリエーションと目的変数の選定
* 出走頭数は常に可変である
* 時系列を考慮しなければならない
* ORMの必要性

なお、実装が複雑化し、現状はモデルの評価まで辿り着いていません。つらい。


## 悩みどころ・ハマりどころ

### 馬券種類のバリエーションと目的変数の選定

#### 今回の課題設定は「勝ち馬予想」ではない

「勝ち馬を予想する」という予測モデルを構築するだけであれば、目的変数に悩むことはありません。1着の馬に1,それ以外に0を設定すればよいだけです。しかし、今回の目的は「馬券を買って儲ける」であり、購入する馬券種類を踏まえて目的変数を設定する必要があります。

#### 「勝馬投票券」の種類

一般に「馬券」と呼ばれますが、正式名称は[「勝馬投票券」](https://jra.jp/kouza/yougo/w411.html)です。

中央競馬にて販売される勝馬投票券には、どの着順まで当てる必要があるか、何頭を指定して投票するか、等の違いでバリエーションが存在し、2024年現在、以下の９種類が販売されています。

* 単勝: １着となる馬を当てるもの。
* 複勝: ２着以内または３着以内に入る馬を当てるもの。出走頭数が５〜７頭の場合は２着まで、それ以上の場合は３着までを当てれば的中となる。
  (参考: [JRA-VAN 競馬の複勝とは？ もっとも的中に近い馬券＝複勝を買ってみよう](https://jra-van.jp/fun/baken/index13.html#:~:text=%E3%82%8C%E3%81%A6%E3%81%84%E3%81%BE%E3%81%99%E3%80%82-,%E8%A4%87%E5%8B%9D%E3%81%AE%E3%83%AB%E3%83%BC%E3%83%AB,-%E5%87%BA%E8%B5%B0%E9%A0%AD%E6%95%B0))
* 連勝複式: 複数の馬を組み合わせて投票する方式。
  * 枠連: １着と２着になる馬の「枠番号」を的中させる投票法。組合せが的中していれば着順は問わない。
  * 馬連: １着と２着になる馬の「馬番号」の組合せを的中させる投票法。枠連同様に着順は問わない。
  * 馬単: １着と2着になる馬の馬番号を**着順通りに**的中させる投票法。
  * ３連複: １着、２着、３着となる馬の馬番号の組合せを的中させる投票法。組合せが的中していれば着順は問わない。
  * ３連複: １着、２着、３着となる馬の馬番号を、**着順通りに**的中させる投票法。
  * ワイド: ３着までに入る２頭の組合せを馬番号で的中させる投票法。馬連より的中させやすい(はず)。
  * WIN5: 5つの対象レースについて、すべての１着を予想する投票方式。

※連勝複式の説明は、いずれも[JRA 競馬用語辞典](https://jra.jp/kouza/yougo/c10050.html)を参考に記載した。

#### 目的変数の検討 

前述の９種類のうち、どの馬券を購入するか？により目的変数が異なりますし、予測の難易度も異なります。

たとえば馬連を購入する場合、１着・２着の馬の組合せを直接予測しようとすると、組合せの数が多くなることから、モデルが複雑になり正解率が落ちたり学習に必要なデータ量が増えたりする懸念があります。

また、仮に複勝を購入するとして、「モデルに複勝馬券が的中する確率を予測させる(買うべき馬券を予測させる)」のか、あるいは「モデルには１着になる馬を予測させ、それを複勝で購入する」のか、という観点での検討が必要です(モデルをどう運用するか決める、という話)。

また、複数のモデルを構築してアンサンブルする場合、構築するモデルごとに上記のような検討を行う必要があるため、レース結果(着順)のデータが用意されていても、ラベルを付与する処理を複数実装することになります。

#### 筆者の暫定解

暫定ですが、筆者は目的変数とアルゴリズム(モデルの構造)を以下のように決めました。

1. Transformerによる実装
  * 目的変数は「レースで１着ならば1,それ以外は0」および「レースで３着以内ならば1, それ以外は0」の２種類
  * 馬券を購入する際は、モデルの予測結果をもとに、的中率が高いと考えられる馬連またはワイドを購入する。
      * 購入馬券の選定も何かのアルゴリズムに委ねる予定だが詳細未定。
2. LightGBMによる実装
  * ランク学習により、上位の着順を直接予想させる。
  * 馬券を購入する際は、予測結果をもとに馬連またはワイドを購入する。
    * 評価結果がよければ馬単を買う方針にするかもしれない。

とりあえずこの二つを実装した後にアンサンブルしようと思っていたのですが、どうやってアンサンブルするんだろう……(遠くを見つめる)

#### 評価指標

今のところ、シンプルに的中率で評価しよう、と考えています。　

的中率が良いモデルが常に収益を最大化するとは限らない・と言われればそうなのですが、最初からそこまで見据えると実験設計だけでものすごい時間がかかってしまいますし、失敗してもお金以外の資産が自分に貯まるので、今回はこのまま突っ切ります。

(超優秀な人は、このあたりも時間かけずに検討できるんでしょうね、きっと……。私は凡百なので無理です。)


### 出走頭数は常に可変である

AIで予測させるにあたって意外と面倒なのは、出走頭数がレースごとに異なることです。１日のなかでもレースごとに異なりますし、同じ名前のレースでも前年と出走頭数が異なることも通例です。

たとえば、GIレース『天皇賞(秋)』の出走頭数を過去5年遡ると、以下のように異なります。

年|頭数|勝ち馬|
--|--|--|
2020年 | 12 | アーモンドアイ
2021年 | 16 | エフフォーリア	
2022年 | 15 | イクイノックス	
2023年 | 11 | イクイノックス	
2024年 | 15 |ドウデュース	 

入力データのサイズが毎回のように異なる、ということなので‪──

* 入力データが可変長でもうまく予測できるアルゴリズムを選ぶ
* 最大長に合わせてデータをパディングする

‪──という対応が必要となります。‬

筆者が(暫定的に)採用したTransformerは可変長のシーケンスでも問題ないはずで、LightGBMは決定木系であることから、出走頭数を特徴量として渡せばパディングされた部分を無視してくれるだろう、と期待しています。
(学習まで進めていないので、現状では"期待"でしかない)


### 時系列を考慮しなければならない

これが一番つらいところで、また実装が複雑化する要因です。
回避策が何もないので愚直に実装しています。つらい。

#### 特徴量のひとつが「過去の戦歴」である

素朴な予測の方法として「過去の戦績が優秀な競走馬に投票する」が考え得られることから、出走馬の過去の戦績‪‪──‬これまでの勝利数や勝率‪──を特徴量に含めることにします。

‬これを特徴量に含めると「学習で使用して良いのは、予測する時点で入手可能な情報のみである」という制約が生じます。

たとえば、ある競走馬の戦績が、引退時点で10戦5勝であるとしても、その最終成績は、現役時代には知ることはできません。モデルに学習させる際には、戦歴のデータに含まれるのが「前走までの結果」であるように調整しなければなりません。

以下はダミーデータですが、手元のデータセットから下表にあるような「出走前勝利数」や「出走前勝率」を算出し、それを特徴量としなければなりません。

#|出走日|距離[m]|出走前勝利数/出走数|出走前勝率|出走日の着順|
--|--|:--:|:--:|--:|:--:|
1|2022/07/02|1000| 0 / 0| 0 %|8|
2|2022/08/06|1200| 0 / 1| 0 %|7|
3|2022/09/17|1200| 0 / 2| 0 %|7|
4|2022/11/20|1600| 0 / 3| 0 %|8|
5|2022/12/11|2200| 0 / 4| 0 %|1|
6|2023/02/25|2000| 1 / 5| 20 %|1|
7|2023/04/09|2000| 2 / 6| 33 %|2|
8|2023/05/09|2400| 2 / 7| 29 %|1
9|2023/07/23|2000| 3 / 8| 38 %|1|
10|2023/08/20|2000| 4 / 9| 44 %|1|
--| 最終成績 | ----| 5//10| 50 % | -

なお、特徴量に「騎手の戦績」を含める場合には、上記と同様の操作を騎手のデータにも適用することになります。

#### ORMの必要性

時系列が絡む特徴量を使用する場合には、前項に記載した処理を各レースの出走日別に適用する必要があります。

また、そもそもに「レース」と「出走馬」および「騎手」の関係をモデリングしてデータセット全体を構築する必要があるため、Pandasと自作関数だけでデータ処理を行うよりも、各エンティティをクラスとして定義してデータを表現するほうが、(実装途中は面倒でも)前処理がシンプルになります。

まぁ、前処理が複雑になってもよいなら無理に設計・実装する必要はないのですが、複数のモデルを構築することを考えると、腹をくくってデータモデリングを行うほうが、トータルのコスト(費やす工数と流す涙の量)が少なくなると判断し、設計・実装を行いました。

（嘘です。コード書きながら設計を考えてます。）

先に挙げたような処理‪──‬戦歴のデータのうち、前走までの結果のみを取り出す処理およびその結果から特徴量を計算する処理‪──‬は、対応するクラスのメソッドとして実装しています。また、クラス間で重複する処理が多いため、それらのメソッドは別クラスに定義した上で[ミックスイン](https://qiita.com/akeyi2018/items/ce24dba2451c8ad0f034)しています。


### そのほか

今回のテーマとは直接関係ありませんが、ほかにトレーニングの一環として、以下のようにコーディングしています。

* 各クラスはできるだけ関連するもの同士でモジュール化する
* なるべくDRY原則を守る
    * ただしデータ処理の都合上、Repeatされているものもある。後でリファクタしたい。
* 型ヒントは可能な限り記載する
    * `list`の要素や`dict`のKey/Valueもできるだけ書く
    * `None`が入りうるところも型ヒントで明示すると、LSPが「メソッド実行してるけど、ここ`None`が入るよ」という趣旨のエラーを返してくれる
* 静的解析のエラーが消えるように型判定と分岐処理を実装し、ロバストなコードを書く

## 終わりに

今回は競馬予想AIの話でしたが、ほかにも(KaggleやSIGNATEではないところで)データ取得から機械学習モデル構築、評価までをフルスクラッチで実装すると、多くの場合は『データサイエンス総合格闘技』になるのではないかと思います。
最近業務で刺激が足りない、Kaggleもなんだかなぁ、というひとは、ぜひ”自由研究”にチャレンジしてみてください。