機械学習で競艇の3連単予測アプリを作ってみた

# はじめに
[競艇の予測アプリ](https://ny3.herokuapp.com/ "競艇予測アプリ")を作成したので、制作の流れをまとめていきます。

なお、初心者ゆえ、勘違いしている部分等あれば色々教えて突っ込んでいただけるとありがたいです。
あとUIをよくすることに関してはほぼ勉強していないので、原始的な仕上がりとなっています点ご容赦ください。

# 結論
2022年1月〜2022年6月15日までのレースを学習させ、2022年6月16日〜8月31日の約12,000レースにおいて実践。
結果としては、なんと **回収率98％** まで持っていくことができました！！
オッズ1000倍の3連単を当てたりしているので、人間が見ているところとは違う観点から順位を予測することもあるとわかります。


# 制作の流れ
①データ収集
↓
②データクレンジング
↓
③モデル作成
↓
④モデル学習
↓
⑤検証

## ①データ収集
特徴量として使えそうなデータを探したところ、以下のサイトから全競艇場の番組表と競走成績がダウンロードできることがわかりました。

https://www.boatrace.jp/owpc/pc/extra/data/download.html

テキストファイルなのでこんな感じです。。。


~~~text:番組表
STARTB
24BBGN
ボートレース大　村   　１月　１日  ウルトラセブン杯　　  第　７日

                            ＊＊＊　番組表　＊＊＊

          ウルトラセブン杯　　　　　　　　　　　　　　　　　

   第　７日          ２０２２年　１月　１日                  ボートレース大　村

               －内容については主催者発行のものと照合して下さい－


　１Ｒ  一般　　　　          Ｈ１８００ｍ  電話投票締切予定１５：１５ 
-------------------------------------------------------------------------------
艇 選手 選手  年 支 体級    全国      当地     モーター   ボート   今節成績  早
番 登番  名   齢 部 重別 勝率  2率  勝率  2率  NO  2率  NO  2率  １２３４５６見
-------------------------------------------------------------------------------
1 4966田川大貴24長崎53B1 4.76 25.00 5.22 30.98 47 39.47 64 30.43 341 42666 13 8
2 4705吉川勇作33長崎54B1 4.22 19.51 4.54 24.82 30 45.61 32 46.51 4 245 15666  9
3 5055眞鳥章太25長崎53B1 2.88 14.29 3.97 20.93 75 28.44 53 40.00 4 535 45565  6
4 5011高木圭大24長崎52B1 3.65 14.81 3.20 12.36 71 27.87 34 33.33 4 654 44556  5
5 4969町田洸希30長崎54B1 4.07 16.82 4.12 21.01 61 27.87 16 28.35 5 161 4654S  6
6 4299中島浩哉39長崎56B1 4.98 28.57 4.63 26.49 73 21.15 74 36.94 363 616 S62  7

　２Ｒ  一般　　　　          Ｈ１８００ｍ  電話投票締切予定１５：４４ 
-------------------------------------------------------------------------------
艇 選手 選手  年 支 体級    全国      当地     モーター   ボート   今節成績  早
番 登番  名   齢 部 重別 勝率  2率  勝率  2率  NO  2率  NO  2率  １２３４５６見
-------------------------------------------------------------------------------
1 4248岡本　大39長崎54B1 4.25 20.25 5.15 23.76 52 25.41 27 28.57 5 554 62423  7
2 5086本村　大22長崎51B1 3.56 14.00 2.80  5.88 27 26.67 47 32.14 5 553 3S134  6
3 4498宮本夏樹34長崎54B1 5.16 35.96 5.57 38.76 63 47.17 63 28.57 655 2 216546  
4 3151落合敬一57長崎57B1 4.33 17.02 4.35 21.78 37 34.65 18 29.06 5 354 53455  6
5 3843上之晃弘45長崎55B1 4.76 28.87 5.34 36.81 26 38.46 72 21.67 643 353 5222 9
6 3777樋口　亮46長崎52B1 5.44 34.83 5.57 39.71 31 27.05 22 24.53 422 433 1621 8
別 勝率  2率  勝率  2率  NO  2率  NO  2率  １２３４５６見
------62 7.57 62.87 76 38.52 75 45.24 16216 121213 7
2 4259真庭明志37長崎53A2 5.99 39.01 5.82 39.90 28 34.13 37 31.50 3 1535213 33 6
3 4315山崎昂介37長崎53B1 5.09 34.04 5.16 31.88 29 46.61 52 37.37 243 33433 13 3
4 4566塩田北斗34福岡52A1 7.26 57.86 6.30 45.00 67 21.74 70 37.17 3 1222514 14 8
5 3527中嶋誠一50長崎51A2 5.90 37.68 5.76 35.71 69 26.55 36 32.26 42125 343344 4
6 4241大串重幸36長崎53B1 5.13 32.98 5.27 34.87 45 33.33 12 25.74 333 4 143444 5
~~~
~~~text:競走成績
STARTK
24KBGN
大　村［成績］      1/ 1      ウルトラセブン杯　　  第 7日

                            ＊＊＊　競走成績　＊＊＊

          ウルトラセブン杯　　　　　　　　　　　　　　　　　

   第 7日          2022/ 1/ 1                             ボートレース大　村

               －内容については主催者発行のものと照合して下さい－

   [払戻金]       ３連単           ３連複           ２連単         ２連複
           1R  1-5-3    5460    1-3-5    1130    1-5     920    1-5     720
           2R  2-1-6   12850    1-2-6    1780    2-1    2020    1-2     570
           3R  1-2-3     870    1-2-3     470    1-2     330    1-2     280
           4R  1-3-4    1110    1-3-4     530    1-3     270    1-3     140
           5R  1-2-5    1220    1-2-5     790    1-2     190    1-2     200
           6R  4-1-3    2540    1-3-4     300    4-1     680    1-4     140
           7R  4-3-1    7090    1-3-4     410    4-3    3190    3-4    2090
           8R  1-3-4    2060    1-3-4    1330    1-3     430    1-3     290
           9R  1-2-6    1010    1-2-6     460    1-2     330    1-2     270
          10R  1-2-4     640    1-2-4     290    1-2     260    1-2     220
          11R  1-2-3     670    1-2-3     360    1-2     240    1-2     240
          12R  2-4-3   66850    2-3-4    4990    2-4   12380    2-4    3450



   1R       一般　　　　                 H1800m  晴　  風  北西　 1m  波　  1cm
  着 艇 登番 　選　手　名　　ﾓｰﾀｰ ﾎﾞｰﾄ 展示 進入 ｽﾀｰﾄﾀｲﾐﾝｸ ﾚｰｽﾀｲﾑ 逃げ　　　
-------------------------------------------------------------------------------
  01  1 4966 田　川　　大　貴 47   64  6.84   1    0.12     1.55.0
  02  5 4969 町　田　　洸　希 61   16  6.83   5    0.12     2.00.5
  03  3 5055 眞　鳥　　章　太 75   53  6.86   3    0.12     2.03.5
  04  4 5011 高　木　　圭　大 71   34  6.87   4    0.18     2.04.8
  05  6 4299 中　島　　浩　哉 73   74  6.86   6    0.09      .  . 
  S0  2 4705 吉　川　　勇　作 30   32  6.79   2    0.07      .  . 

        単勝     1          180  
        複勝     1          230  5          480  
        ２連単   1-5        920  人気     4 
        ２連複   1-5        720  人気     3 
        拡連複   1-5        310  人気     5 
                 1-3        130  人気     1 
                 3-5        820  人気    14 
        ３連単   1-5-3     5460  人気    18 
        ３連複   1-3-5     1130  人気     6 


   2R       一般　　　　                 H1800m  晴　  風  北西　 1m  波　  1cm
  着 艇 登番 　選　手　名　　ﾓｰﾀｰ ﾎﾞｰﾄ 展示 進入 ｽﾀｰﾄﾀｲﾐﾝｸ ﾚｰｽﾀｲﾑ 抜き　　　
-------------------------------------------------------------------------------
  01  2 5086 本　村　　　　大 27   47  6.87   2    0.22     1.48.8
  02  1 4248 岡　本　　　　大 52   27  6.87   1    0.22     1.49.8
  03  6 3777 樋　口　　　　亮 31   22  6.83   6    0.23     1.52.4
  04  4 3151 落　合　　敬　一 37   18  6.85   4    0.29     1.53.0
  05  5 3843 上　之　　晃　弘 26   72  6.80   5    0.19      .  . 
  06  3 4498 宮　本　　夏　樹 63   63  6.85   3    0.23      .  . 

        単勝     2          840  
        複勝     2          270  1          140  
        ２連単   2-1       2020  人気     7 
        ２連複   1-2        570  人気     3 
        拡連複   1-2        320  人気     4 
                 2-6        760  人気    10 
                 1-6        470  人気     7 
        ３連単   2-1-6    12850  人気    44 
        ３連複   1-2-6     1780  人気     9 
~~~

これらのデータを1日分ずつダウンロードしていきます。これは手作業。

## ②データクレンジング
このテキストファイルから必要部分をデータフレームに入れ込んでいきます。
この時には以下のような感じでゴリゴリに正規表現を使って必要部分を収集していきました。
~~~python:
import glob
import os
import re
from string import ascii_letters
from string import digits
import pandas as pd

files_K = glob.glob("/content/drive/MyDrive/Colab Notebooks/ブログ/K/*")
files_B = glob.glob("/content/drive/MyDrive/Colab Notebooks/ブログ/B/*")

# Bデータを全て取り込み結合する
df_all_B = pd.DataFrame()

for file in files_B:

  #データ読み込み
  with open(file, encoding='shift-jis') as f:
    data1 = f.readlines()

  #レース日付読み込み
  date = os.path.basename(file)
  date = re.sub(r"\D", "", date)

  #余分なスペース、改行を消去
  data2 = [s.replace('\u3000', '').replace('\n', '') for s in data1]

  #全角文字を半角文字に変換
  han = ascii_letters + digits
  table = {c + 65248: c for c in map(ord, han)}
  data3 = [name.translate(table) for name in data2]

  #必要データ抽出
  data4 = [row for row in data3 if re.match('^[0-9]', row)]

  #行検索用
  pattern_place1 = '\d{2}[B][B]'
  pattern_race_num1 = '\d+[R]'
  pattern_racer1 = '^[1-6]\s\d{4}'
  pattern_place_re1 = re.compile(pattern_place1)
  pattern_race_num_re1 = re.compile(pattern_race_num1)
  pattern_racer_re1 = re.compile(pattern_racer1)


  #データ取得用
  pattern_place2 = '(\d{2})[B][B]'
  pattern_race_num2 = '(\d+)[R]'
  pattern_racer2 = '^([1-6])\s(\d{4})([^0-9]+)(\d{2})([^0-9]+)(\d{2})([AB]\d{1})\s(\d.\d{2})\s*(\d+.\d{2})\s*(\d+.\d{2})\s*(\d+.\d{2})\s*(\d+)\s*(\d+.\d{2})\s*(\d+)\s*(\d+.\d{2})'
  pattern_place_re2 = re.compile(pattern_place2)
  pattern_race_num_re2 = re.compile(pattern_race_num2)
  pattern_racer_re2 = re.compile(pattern_racer2)

  #必要データ取得
  values = []
  for row in data4:
    if re.match(pattern_place_re1, row):
      place = re.match(pattern_place_re2, row).groups()
      place_elm = place[0]

    elif re.match(pattern_race_num_re1, row):
      race_num = re.match(pattern_race_num_re2, row).groups()
      race_num_elm = race_num[0].zfill(2)

    elif re.match(pattern_racer_re1, row):
      value = re.match(pattern_racer_re2, row).groups()
      val_li = []
      for i in value:
        val_li.append(i)
      val_li.append(place_elm)
      val_li.append(race_num_elm)
      val_li.append(date)
      values.append(val_li)

  #データフレーム作成
  column = ['艇番', '選手登番', '選手名', '年齢', '支部', '体重', '級別', '全国勝率', '全国2連率', '当地勝率', '当地2連率', 'モーターNO', 'モーター2連率', 'ボートNO', 'ボート2連率', '開催地', 'レース番号', '日付']
  df_B = pd.DataFrame(values, columns=column)

  #開催地、レース番号、日付(すべてstr型)を元ににレースIDを追加
  df_B['レースID'] = df_B['日付'] + df_B['開催地'] + df_B['レース番号']

  df_all_B = pd.concat([df_all_B, df_B])


# Kデータを全て取り込み結合する
df_all_K = pd.DataFrame()

for file in files_K:

  #データ読み込み
  with open(file, encoding='shift-jis') as f:
    data5 = f.readlines()

  #レース日付読み込み
  date = os.path.basename(file)
  date = re.sub(r"\D", "", date)

  #余分なスペース、改行を消去
  data6 = [s.replace('\u3000', '').replace('\n', '') for s in data5]

  #全角文字を半角文字に変換
  han = ascii_letters + digits
  table = {c + 65248: c for c in map(ord, han)}
  data7 = [name.translate(table) for name in data6]

  #必要データ抽出
  data8 = [row for row in data7 if re.match('[0-9]', row) or re.match('\s\s[0-9]', row) or re.match('\s\s\s[0-9]', row)]


  #行検索用
  pattern_place1 = '\d{2}[K][B]'
  pattern_race_num1 = '\s+\d+[R]'
  pattern_racer1 = '\s+\d+\s+[1-6]\s\d{4}'
  pattern_place_re1 = re.compile(pattern_place1)
  pattern_race_num_re1 = re.compile(pattern_race_num1)
  pattern_racer_re1 = re.compile(pattern_racer1)

  #データ取得用
  pattern_place2 = '(\d{2})[K][B]'
  pattern_race_num2 = '\s+(\d+)[R]'
  pattern_racer2 = '\s+(\d+)\s+[1-6]\s(\d{4})'
  pattern_place_re2 = re.compile(pattern_place2)
  pattern_race_num_re2 = re.compile(pattern_race_num2)
  pattern_racer_re2 = re.compile(pattern_racer2)


  #必要データ取得
  values = []
  for row in data8:
    if re.match(pattern_place_re1, row):
      place = re.match(pattern_place_re2, row).groups()
      place_elm = place[0]

    elif re.match(pattern_race_num_re1, row):
      race_num = re.match(pattern_race_num_re2, row).groups()
      race_num_elm = race_num[0].zfill(2)

    elif re.match(pattern_racer_re1, row):
      value = re.match(pattern_racer_re2, row).groups()
      val_li = []
      for i in value:
        val_li.append(i)
      val_li.append(place_elm)
      val_li.append(race_num_elm)
      val_li.append(date)
      values.append(val_li)

  #データフレーム作成
  column = ['実着順', '選手登番', '開催地', 'レース番号', '日付']
  df_K = pd.DataFrame(values, columns=column)

  #開催地、レース番号、日付(すべてstr型)を元ににレースIDを追加
  df_K['レースID'] = df_K['日付'] + df_K['開催地'] + df_K['レース番号']
  df_K = df_K[['実着順', '選手登番', 'レースID']]

  df_all_K = pd.concat([df_all_K, df_K])


#レース予定Bとレース結果Kデータ結合
df = df_all_K.merge(df_all_B, how='left', on=['レースID', '選手登番'])

#フライング等着順つかなかったところを埋める
df['実着順'] = df['実着順'].fillna('06')

#体重、年齢、艇番をstr型からint型に変換
df[['体重', '年齢', '艇番', 'レースID']] = df[['体重', '年齢', '艇番', 'レースID']].astype(int)


#不成立レース（有効選手2名以下）を削除
df_del = df.groupby('レースID').count()['実着順']
df_del = df_del[df_del<=2]
for i in df_del.index:
  df.drop(df[df['レースID']==i].index, inplace=True)
~~~

で、きれいにしたデータフレイムがこんな感じ。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2883836/bba10e01-3b03-e010-5ca8-d85667778f98.png)


各列の選手がどのレースに出たかを把握するため、レースIDを別途振るようにしてます。
例えば、2022年1月1日桐生（会場コード01）の第2レースだとすると、
202201010102という風にIDを振りました。

ここまで辿り着くのに一番時間が掛かったと思います。

が、後から知ったのですが正規表現チェッカーなるものがいくつもあるらしく、そういったものを使っておけばこんなに時間は掛からなかったと思います。

## ③モデル作成
今回はカテゴリカルな特徴量が多いこともあるので、CatBoostという勾配ブースティングの手法を利用しました。
当初はこちらのブログを参考にLightGBMでモデル作成していたのですが、プログラミングスクールの先生からアドバイスいただき路線変更しました。

~~~python
#訓練5ヶ月、検証半月、テスト半月パラメータ設定
date_split_valid = 2206010000 # 訓練データと検証データの分岐点（年+月+日+4桁（会場、レース番号）)
date_split_test = 2206160000 # 検証データとテストデータの分岐点（年+月+日+4桁（会場、レース番号））

# 訓練データ・テストデータに分割
df_train = df[df['レースID'] < date_split_valid]
df_valid = df[(df['レースID'] >= date_split_valid) & (df['レースID'] < date_split_test)]
df_test = df[df['レースID'] >= date_split_test]

#説明変数
X_train = df_train.drop(['実着順'], axis=1)
X_valid = df_valid.drop(['実着順'], axis=1)
X_test = df_test.drop(['実着順'], axis=1)

#目的変数
y_train = df_train['実着順']
y_valid = df_valid['実着順']
y_test = df_test['実着順']
~~~
~~~python
from catboost import CatBoostRanker, Pool

cat_features = ['選手登番', '選手名', '支部', '級別', 'モーターNO', 'ボートNO', '開催地', 'レース番号', '日付']


train_pool = Pool(data=X_train, label=y_train, group_id=X_train['レースID'], cat_features=cat_features)
valid_pool = Pool(data=X_valid, label=y_valid, group_id=X_valid['レースID'], cat_features=cat_features)
test_pool = Pool(data=X_test, label=y_test, group_id=X_test['レースID'], cat_features=cat_features)
param = {'loss_function':'YetiRank', 'learning_rate': 0.05, 'iterations': 200,
        'depth': 7, 'use_best_model':True}
model = CatBoostRanker(**param)
~~~

こんな感じ。
ハイバーパラメーターのdepthとかは色々変えて試した結果、7が一番精度高かったので採用しました。

## ④モデル学習
これはシンプルですね。
~~~python
model.fit(train_pool, eval_set=valid_pool)
~~~

## ⑤検証
ここもシンプルです。
~~~python
pred_score = model.predict(X_test)
~~~

予測X_testのデータを読み込んで予測を出すことができました。
あとは算数の力を使って計算すると
 **的中率：0.09444584488698429**
 **収益率：0.9852617427106966**
という個人的には満足できる結果が出てくれました！！！

# まとめ
今回はCatBoostを使って競艇予測を行いました。
次は競馬でもチャレンジしてみて、収益率100％越えを狙っていきたいと思います！