TensorFlowでRankNetを実装して、競馬の着順予測モデルを作りました。

メリークリスマス。
どうみてもウマ娘やってる顔の民谷です。[^1]
[^1]: https://www.youtube.com/watch?v=VXmL-RUBouQ

本記事は[2021年BrainPadアドベントカレンダー](https://qiita.com/advent-calendar/2021/brainpad)の24日目の記事です。

# 前置き

2021年に流行ったスマホゲーであろう**ウマ娘**。
ウマ娘では実在した競走馬を擬人化して、史実に基づいたキャラクターだったりストーリーが実装されているので、そこが知識の深めどころであり、そこら辺がウケているように思えます。[^2]

[^2]: 艦これとかも
私はプレイする前まではディープインパクトとハルウララしか競馬知識はありませんでしたが、にわかなりに知識もついてきて、楽しくやっていました。
とはいっても無課金プレイヤー。新キャラも引けずモチベが下がっていたところで、ある本を見つけました。

http://www.amazon.co.jp/dp/4877835059

以前から競馬の予測モデルを作るという話はよく聞く話だったので、この本を買って、**競馬の着順予測モデルを作ってみることにしました。**

ちなみにこの本はタイトル通り、データ分析がメインで**モデリングに関することは一切載っていません。**
前処理などは助けられました。

# 概要
競馬の着順予測を行うモデルを作成しました。

その際にランキング学習の手法であるRankNetをTensorflowで実装し、そのコードを本記事で共有します。

評価では単純な回帰学習と比べて予測性能の改善も見られ、**単勝回収率は100%を達成しました。**


# 競馬とは？
複数の馬がコースを同時に走り、そのレースの着順を予測する公営競技です。
この着順予想には様々な賭け方があります。(本記事では単勝と複勝のみを扱います。)
参考: [馬券の種類](https://www.jra.go.jp/kouza/beginner/baken/)

- 単勝: １着の馬を予想する。
- 複勝: ３着以内に入る馬を予想する。
- 三連単: 1、2、3着の馬を着順通り予想する。
- etc...

この予想が当たった場合に、払戻金を得ることができます。[^3]
[^3]: 払戻金はみんなが買うような、当たりそうな馬券ほど少ないです。

# ランキング学習
一般的な回帰モデルでは正解ラベルとの差分を損失として、その最小化を行います。
競馬データを例とすると、あるレースに出走する馬の予測順位と正解順位から損失を計算します。
![無題のプレゼンテーション.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/927654/e94ddd19-dd8f-3e00-6fb3-b4eaa465602b.jpeg)


一方でランキング学習では複数のデータ間の関係を学習させます。
競馬データを例とすると、同じレースに出走した馬同士の順位関係を学習させます。
![無題のプレゼンテーション (2).jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/927654/102a0a29-b690-174a-63f1-17da6e812687.jpeg)


# RankNet
RankNetはニューラルネットのランキング学習手法として最もシンプルなものです。
元論文: [Learning to Rank using Gradient Descent](https://icml.cc/Conferences/2015/wp-content/uploads/2015/06/icml_ranking.pdf)

RankNetでは2つのペアをサンプリングして、その関係性を学習します。
そのペア間でランクが高い方をより高く、低い方をより低くするように学習します。

ここで、2つの説明変数$x_i$と$x_j$と、そのランクを$f(x_i)$と$f(x_j)$と表し、$f(x_i) > f(x_j)$の関係であることを$x_i > x_j$と記述します。

そして、$x_i > x_j$である確率$P_{ij}$を以下のシグモイド関数で表します。
$$P_{ij} = P(x_i > x_j) = \frac{1}{1 + e^{-o_{ij}}}$$
$$o_{ij} = f(x_i) - f(x_j)$$

そして、損失$C_{ij}$をクロスエントロピー関数で表します。
$$C_{ij} = C(o_{ij}) = -\bar{P_{ij}}\log{P_{ij}} - (1 - \bar{P_{ij}})\log{(1 - P_{ij})}$$

ここで真の分布$\bar{P_{ij}}$は以下とします。

$$ 
\bar{P_{ij}} = \begin{cases}
 1 & (x_i > x_j) \\\\
 0 & (x_i < x_j) \\\\
 \frac{1}{2} & (x_i == x_j)
\end{cases}
$$ 




# 実装

### ジェネレータ
RankNetの学習では2つのデータを入力する必要があります。
そのため、同一レースの2データをサンプルするジェネレータクラスを作ります。
ここでは**ランクが高い=着順が小さい**というように定義します。

```python
class RankNetGenerator(object):
    """
    Attribute:
        _X (pandas.Series): 説明変数.
        _y (pandas.Series): 目的変数(順位).
        _race_index_list (list(pandas.Series)): 同一レースのindexを保持するpandas.Seriesのリスト.
        _X_scaler (sklearn.preprocessing.StandardScaler): 説明変数の標準化スケーラ.
        _batch_size (int): バッチサイズ.
    """


    def __init__(self, X, y, race_index_list, X_scaler=None, batch_size=32):
        
        # 説明変数
        self._X = X
        # 着順
        self._y = y
        # 同一レースのindexを保持するpandas.Seriesのリスト
        self._race_index_list = race_index_list
        
        # 説明変数に適用する標準化スケーラ
        self._X_scaler = X_scaler 
        
        # バッチサイズ
        self._batch_size = batch_size
    
    def __iter__(self):
        return self
    
    def __len__(self):
        return len(self._X)
    
    def __next__(self):

        # レースをサンプリング
        race_indexes = np.random.randint(0, len(self._race_index_list), self._batch_size)
        race_indexes = [self._race_index_list[race_index].values for race_index in race_indexes]

        # 出走馬をサンプリング
        uma_indexes = [race_index[np.random.choice(race_index.shape[0], 2, replace=False)] for race_index in race_indexes]
        # タプルの先頭要素がランクが高くなるようにする。
        uma_indexes = [(idx_1, idx_2) if self._y.loc[idx_1].values < self._y.loc[idx_2].values else (idx_2, idx_1) for idx_1, idx_2 in uma_indexes]

        # 説明変数の設定
        X = np.array([(self._X.loc[uma_index[0]], self._X.loc[uma_index[1]]) for uma_index in uma_indexes])
        X = [self._X_scaler.transform(X[:, 0, :]), self._X_scaler.transform(X[:, 1, :])]

        # 真の分布\bar{p_{ij}}の設定
        # サンプルした2データの先頭が必ずランクが大きくなるため、全て１.
        y = np.ones(self._batch_size)

        return X, y
```

### モデル
まずパラメータ学習を行うモデルを実装します。
このモデルは1つの説明変数に対して1つの出力を行う単純なモデルです。

```python
def build_nn(input_dim, hidden_dim, output_dim, layer_num, activation, dropout_rate):
    """モデル構築

    """
    inputs = tf.keras.Input(shape=(input_dim,))

    x = tf.keras.layers.Dense(hidden_dim, activation=activation)(inputs)
    for i in range(layer_num):
        x = tf.keras.layers.Dense(hidden_dim, activation=activation)(x)
        x = tf.keras.layers.Dropout(dropout_rate)(x)

    outputs = tf.keras.layers.Dense(output_dim)(x)
    
    return tf.keras.Model(inputs=inputs, outputs=outputs)

```

次にRankNet学習を行うクラスを実装します。
ここでは2つの入力を受け付け、先のモデルの出力差分、Sigmoidを返すだけで、学習パラメータはありません。

```python
def build_ranknet(input_dim, hidden_dim, output_dim, layer_num, activation, dropout_rate):
    """モデル構築

    """
    inputs_1 = tf.keras.Input(shape=(input_dim,))
    inputs_2 = tf.keras.Input(shape=(input_dim,))

    nn = build_nn(input_dim=input_dim, 
                  hidden_dim=hidden_dim,
                  output_dim=output_dim, 
                  layer_num=layer_num,
                  activation=activation
                  dropout_rate=dropout_rate, 
    )
    
    x1 = nn(inputs_1)
    x2 = nn(inputs_2)

    subtract = tf.keras.layers.Subtract()([x1, x2])
    outputs = tf.keras.layers.Activation('sigmoid')(subtract)
    
    return tf.keras.Model(inputs=[inputs_1, inputs_2], outputs=outputs)

```


### 学習
RankNetの学習を行います。
開発データに対する損失が5エポック連続で改善されない場合はEarly Stoppingがかかるようにしています。

```python
import pandas as pd
import tensorflow as tf

from sklearn.preprocessing import StandardScaler

# ハイパーパラメータ
input_dim = 284                 # モデルの入力(説明変数)の次元数
output_dim = 1                  # モデルの出力次元数
hidden_dim = 128                # 隠れ層の次元数
layer_num = 3                   # 隠れ層数
activation = tf.nn.relu         # 活性化関数
dropout_rate = 0.1              # ドロップアウト率
epoch = 100                     # エポック数
train_steps = 1000              # 学習データでの１エポック当たりのステップ数
valid_steps = 100               # 開発データでの１エポック当たりのステップ数
loss = "binary_crossentropy"    # 損失関数
optimizer = "adam"              # オプティマイザ

# 標準化
X_scaler = StandardScaler()
_ = X_scaler.fit_transform(train_X.values)

# ジェネレータ
train_generator = RankNetGenerator(train_X, train_y, train_index_list, X_scaler=X_scaler, batch_size=batch_size)
valid_generator = RankNetGenerator(valid_X, valid_y, valid_index_list, X_scaler=X_scaler, batch_size=batch_size)

# モデル
ranknet = build_ranknet(input_dim, hidden_dim, output_dim, layer_num, activation, dropout_rate)

# オプティマイザーと損失関数を設定
ranknet.compile(optimizer=optimizer,
                loss=loss)

# Early Stopping
callbacks = [tf.keras.callbacks.EarlyStopping(monitor='val_loss', patience=5)]

# 学習
self._history = ranknet.fit(train_generator, 
                            validation_data=valid_generator, 
                            epochs=epoch,
                            batch_size=batch_size,
                            callbacks=callbacks, 
                            steps_per_epoch=train_steps,
                            validation_steps=valid_steps)

```


# データ
データは[JRA-VAN データラボ](https://jra-van.jp/dlb/)から2019年以降の中央競馬場、芝コースのデータを使いました。[^4]
[^4]: 月額2,090円で利用可能です。
以下のようにデータを分割しました。

| データ | 分割 | レース数 | 用途 |
|:-----------|:------------|:--------:|:------------:|
| 学習データ       |     2019/1/1 ~ 2021/6/30  | 4412 | モデル学習        |
| 開発データ     | 2021/7/1 ~ 2021/9/30     |479 | Early Stopping      |
| テストデータ       | 2021/10/1 ~ 2021/12/16 | 383| 評価         |

### 特徴量
説明変数はカテゴリ変数のダミー化等を行い、284次元になりました。
詳細については省略しますが、以下のような特徴量を使用しています。[^5]
[^5]: 今回の実験で一番時間をかかりました。おかげでpandas芸は上達したと思います。

 
> コース関係

> - 距離
- 右回り or 左回り
- 天気
- レース賞金


> 馬関係

> - 体重
- 直近n件の成績
- 過去獲得賞金

> などなど

# 学習結果
ここではランダム、通常の回帰学習、RankNet学習の3種類の**的中率**と**回収率**をテストデータで評価します。
結果、ランキング学習を行うことで的中率を改善することができました!
特にRankNetの単勝回収率は100%を超えているため、テストデータ期間において収支がプラスになっています！

| モデル |単勝的中率| 単勝回収率|複勝的中率|複勝回収率|
|:------|--------:|------------:|------------:|------------:|
| ランダム  | 0.1201  | 0.7127  | 0.2262  | 0.6706 |
| 回帰     | 0.0757 | 0.3916  | 0.2007 | 0.7123 |
| RankNet | 0.1801 | 1.0378  | 0.3899  | 0.7824 |

**的中率**: 単勝の場合は1着の1枚, 複勝の場合は1~3着の3枚の馬券を買った時の的中馬券率。
**回収率**: 各馬券を100円ずつ買った場合の払戻金/賭け金の割合。

# 実際に賭けよう
12/18のターコイズステークス, 12/19の朝日杯フューチュリティステークスの2つに対してモデルを動かしてみたいと思っていま**した。**

レース開始前のデータに対する前処理実装を失念していたため、実際に賭けられず。。。[^6]
[^6]: JRA-VAN データラボの出走前のレース情報を使う予定でしたが、出走後データと埋まっているデータ項目が異なる点に直前に気がつきました。

実際に賭ける想定での予測を実施すると以下の通り。

ターコイズステークス
モデルの予測は **単勝: 12**, **複勝: [12, 2, 15]**

|| 着順 |払戻金 |
|:------|--------:|------------:|:------------:|
| 単勝      | 2  |680円  |
| 複勝(1) | 2 |280円  |
| 複勝(2) | 9 |240円  |
| 複勝(3) | 15|750円  | 


朝日杯フューチュリティステークス
モデルの予測は **単勝: 13**, **複勝: [13, 1, 2]**

|| 着順 |払戻金 |
|:------|--------:|------------:|:------------:|
| 単勝      | 9  |780円  |
| 複勝(1) | 9 |200円  |
| 複勝(2) | 4 |120円  |
| 複勝(3) | 7|230円  | 

ターコイズステークスでは複勝で見事的中!
全てに100円ずつ賭けた場合、**330円プラス**になっていたはずですね！


# 改良点
### 特徴量チューニング
本記事では省略しましたが、特徴量チューニングを行うことで改善する余地はかなりありそうです。[^7]
[^7]: ネットで検索しても、使った特徴量を教えてくれる人はあまりいないですね。性能に直結する部分なのでしょうね。

- 騎手特徴量(騎手の戦績など)
- 血統特徴量(親の戦績など)

### モデルチューニング
RankNetはかなりシンプルなランキング学習手法であり、ListNet, LambdaNetなど拡張された学習手法が提案されています。
また本記事ではニューラルネットを使っていますが、LightGBMでもランキング学習が可能でした。[^8]
[^8]: かなり簡単に試せました。こっちにしとけばよかった。。。

どのアーキテクチャが好ましいか、またはアンサンブルもありか、など検討できる点は多いですね。

### 賭け方
今回は単勝と複勝という比較的当たりやすい賭け方をしています。
当たりやすい≒払戻金は少なくなるので、回収率を100%以上にするのは難しいです。

馬券をどの割合で買うかという問題は、ポートフォリオ最適化問題など適用できるかもしれません。[^9]
[^9]: 最近勉強したところなので、時間があればやりたかったところです。回収率がかなり改善するかもです。

# まとめ
競馬の着順予想モデルをRankNetで実装しました。
結果、通常の回帰より良い性能にはなりました。テストデータに対する回収率も単勝のみですが100%を超えることができたのは嬉しいです。

TensorFlow(Keras)でRankNetを実装した記事が見つからなかったので、需要あって欲しいです。