GNNを使って競馬の予測AIの作成にチャレンジしてみた

# はじめに
不労所得での生活は全人類の夢です。そんな夢を叶えるためにグラフニューラルネットワークを使って競馬の予測AIにチャレンジしてみました。なお、私自身もそんなにGNNに詳しいわけではないので間違えがあれば教えていただけると助かります。

# GNN(グラフニューラルネットワーク)とは
そもそもGNNって何よって人のために簡単な説明をすると
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/598584/b1e34672-8043-8877-4c1d-ee233a66919b.png" width="200">
この画像のようにノード(まる)とエッジ(線)からなる構造のことをグラフ構造といいます。
グラフニューラルネットワークとはこの各ノードがもつ情報を伝言ゲームのように隣のノードに伝えていくことで、各ノードの情報を伝搬し、学習するモデルになります。
わかりにくい場合はCNNのグラフ版と思っていてくれればそれで大丈夫です。

詳しい解説を知りたい人はこちら

https://qiita.com/shionhonda/items/d27b8f13f7e9232a4ae5

が非常にわかりやすいです。

# GNNを使う目的
競馬のレースをグラフとして考えた時に
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/598584/81aae328-5101-5a78-90ec-390d06eddd7c.png" width="200">
このような馬をノード、馬同士の関係性をエッジとしたようなグラフとすることで、全ての馬の情報を考慮した上での勝つ馬が予測できるのではないかと考えてGNNを採用しました。

# アプローチ方法
今回GNNでの予測方法としてのアプローチ方法として**グラフ分類**を考えました。

最大の馬数を18頭と仮定したときに、正解ラベルとして一位になった馬の番号を与えることで、18クラスのグラフ分類問題として設定しました。

ひとまず期待値などは考えず、一位の馬を予測することだけを目的としました。

# 全体の流れ
1. レース情報と馬の情報を[netkeiba.com](https://www.netkeiba.com/?rf=logo)からスクレイピング
1. 全体のデータを前処理
1. 各レースごとにグラフを作成
1. GCNを使って学習を行いモデルを作成
1. 最終的な予測精度を確認する

# スクレイピング
qiitaには**優秀な人たち**がすでに素晴らしいスクレイピングのコードを上げてくださっているのでここでは割愛します。
スクレイピングの範囲は2008〜2021の13年間としました。

# 利用する特徴量

| 特徴量名 | 概要 | エンコード方法 |
|:-:|:-:|:-:|
| number  | 枠番  | 数値  |
|  horse_num | 馬番  |  数値 |
|  horse_name | 馬名  | ordinary encoding  |
|  jockey | 騎手  |  ordinary encoding |
| trainer  | 調教師  | ordinary encoding  |
|  sex | 性別  | onehot encoding |
|  old | 年齢  | 数値  |
|  weight | 斤量  | 数値  |
|  horse_weight | 馬体重  | 数値  |
|  horse_weight_diff | 馬体重の変化量  | 数値  |
|  win_rate | 単勝オッズ  | 数値  |
|  popularity | 人気  | 数値  |
|  sub_title | 何歳以上何百万以下など  | onehot encoding  |
|  round | 何ラウンド目か  | 数値  |
|  race_type | 芝かダートか  | onehot encoding  |
|  condition | 馬場の状態  | onehot encoding  |
|  weather | 天気  | onehot encoding  |
|  race_line | inかoutか  | onehot encoding  |
|  distance | 距離  | onehot encoding  |
|  location | どこ開催か  | onehot encoding  |
|  leg | 脚質  | 数値  |
|  total winning | その時点での獲得賞金  | 数値  |
| last day  | 前回から何日経過したか  | 数値  |

# 前処理
基本的にはエンコード方法の通りに行っています。

# グラフの作り方
基本的にはレースごとに前処理をした後networkxを使ってグラフに変換します。

前処理にはcategory_encodersを利用しました。

後で考えると全体に前処理してからグラフにしたほうが良かった気がする。

# 使用モデル
グラフニューラルネットワークのライブラリである[Deep Graph Library](https://www.dgl.ai/)を使用します。
訓練データを2020年までのデータ、テストデータを2021年のデータとしました。
モデルのコードはDGLのグラフ分類のサンプルコードをそのまま流用しました。

```model.py
import dgl
import dgl.nn.pytorch as dglnn
import torch.nn as nn
import torch.nn.functional as F

class GNNClassifier(nn.Module):
    # 馬の最大頭数は18頭なので分類は18classとする
    def __init__(self, in_feat=in_feat, hidden_feat=hidden_feat, n_classifier=18):
        super(GATClassifier, self).__init__()
        self.conv1 = dglnn.GraphConv(in_feat, hidden_feat)
        self.conv2 = dglnn.GraphConv(hidden_feat, hidden_feat)
        self.fc1 = nn.Linear(hidden_feat, n_classifier)

    def forward(self, g, h):
        # Apply graph convolution and activation.
        h = F.relu(self.conv1(g, h))
        h = F.relu(self.conv2(g, h))
        with g.local_scope():
            g.ndata['h'] = h
            # Calculate graph representation by average readout.
            hg = dgl.mean_nodes(g, 'h')
            x = self.fc1(hg)
            return x
```

また、訓練のコードは

```train.py
import torch
import torch.nn as nn
import torch.nn.functional as F

import dgl
from dgl.dataloading import GraphDataLoader

def train(dataset, model, criterion, optim, batch_size, epochs, device):

    train_dataloader = GraphDataLoader(dataset, batch_size=batch_size, drop_last=True, shuffle=True)

    model = model.to(device)
    criterion = criterion
    optim = optim

    min_loss = 1e9

    model.train()

    for epoch in tqdm(range(epochs)):
        running_loss = 0
        total = 0
        correct = 0
        for batched_graph, labels in train_dataloader:
            optim.zero_grad()

            feats = batched_graph.ndata['feat'].to(device)
            outputs = model(batched_graph, feats)
            labels = labels.to(device)

            loss = criterion(outputs, labels)

            running_loss += loss.item()

            loss.backward()
            optim.step()

        print('epoch :{}'.format(epoch+1))
        print('loss     :{}'.format(running_loss))

        if min_loss >= running_loss:
            model_path = './data/weight'
            torch.save(model.state_dict(), model_path)
```

# 動かした結果
最終的な予測精度は**7%程度**ととても使い物にならない精度を叩き出しました。

原因として考えられるものとしては

- グラフの作り方が悪い
    - レースごとに前処理してからグラフにするのではなく全体を前処理してからグラフにしてみる
- 前処理に問題がある
    - めんどくさくて正規化標準化してないのでおそらくしたほうがいい
- 正解率の計算に問題がある
    - GNNの正解率の計算ぶっちゃけよくわからない

この辺が原因じゃないかなと思ってます。

# 今後試してみること

- 正規化標準化する
- グラフの作り方の見直し
- グラフ分類するのではなくノード分類としてノードごとの順位を予測してみる
- 18頭のレースのみのデータのみに絞ってみる

# 終わりに
それでもGNNはいつか最強の精度を叩き出してくれると信じています。

この記事はまだ途中なので今後も更新していきます。
よろしければ引き続き見守ってくださると嬉しいです。

# 更新情報

- 2021/12/23 記事をアップ