【機械学習】ニューラルネットワークで競輪予想してみた(データセット準備編)

## はじめに
本記事は自身がニューラルネットワークで競輪予想をしてみた一連の流れをまとめたものになります．
全行程を一気にまとめると長くなりそうだったので今回は前半部分のデータセットの準備までをまとめました．


## 環境
Python 3.8.11

## 目次
* 問題設定
* データ収集
* データ前処理

## 問題設定
### そもそも競輪とは
1レース，7〜9人くらいでレースを行います．上位3人をいろんなかけ方で予想するといったものです．
競輪のルール，かけ方などは[こちら](https://morecadence.jp/keirin/110)に詳しく乗ってます．
### 条件設定
今回は問題を簡単化して，ある選手が3着以内に入るかどうかの二値分類問題にしようと思います！
### 特徴量
今回は特徴量として以下のような要素を選定しました．
*  予想　•••　新聞記者による予想
*  好気合　•••　初走以降で動きが好気合と評価した選手
*  総評　•••　新聞本紙で設定された選手のパワーランク(1~20)小さい方が強い
*  枠番　•••　枠の番号
*  年齢　•••　選手の年齢
*  級班　•••　選手の階級(A3~SS)
*  脚質　•••　選手の走りのタイプ
*  ギヤ倍数　•••　自転車の前と後ろについているギアの比率
*  競走得点　•••　出場したレースのランク•着順によって決まる点数(直近4ヶ月の合計)
*  同走路年間勝利度数（1着,2着,3着,着外）　•••　同じレース上の成績

なお，今回は選手単体での特徴量しか集めてません，開催場所や天候などのレース情報であったり，選手同士の関係などを考慮したモデルはまた別の機会にやってみようと思います．
## データ収集
条件が決まったところで早速データを集めていきたいと思います．今回はpythonのwebスクレイピングを使って集めたいと思います．
### webスクレイピングとは
　詳しい説明は割愛しますが，プログラミングを使ってwebサイトの情報(HTML)を取ってきて加工したりすることを言います．
　なお，スクレイピングはサイトの規約などを守ったうえで行うようにしましょう．注意事項などは[こちら](https://qiita.com/nezuq/items/c5e827e1827e7cb29011)に詳しくまとめられています．

### 使用するサイト
今回は数ある競輪サイトの中から[Rakuten Kドリームズ](https://keirin.kdreams.jp/)さんを選びました．選んだ理由としては，取得したい出走表のデータがまとまっていて集めやすいと感じたためです．

### 1. URLの取得
Kドリームズさんのサイト内で，先程設定した特徴量が取得できるテーブルが乗っているのが以下のページになります．
2021年9月9日の青森競輪での1レース目のレース情報
[https://keirin.kdreams.jp/aomori/racedetail/1220210909010001/](https://keirin.kdreams.jp/aomori/racedetail/1220210909010001/)
これが1レース分の情報で，これを大量に集めたいのですが，URLを一つ一つ集めるのは面倒なのでURL内に含まれる情報をうまく利用しようと思います．
<br>
URLを見た感じ，以下のような情報が含まれています．
レース場　/aomori/
レースID　/1220210909010001/
レースIDに関しては/レース場ID　年月日　何日目か　何レース目か/といった情報が並んでいるようです．
この部分をうまく変えていくことでURLを一気に取得したいと思ったのですが．．．
競輪は毎日違うレース場で開催されているためどの日にどこのレース場でレースが行われたかという情報がないと集められないことに気づきました．
<br>
そこで，いろいろと模索していたところ以下のページを発見しました．
[https://keirin.kdreams.jp/racecard/2021/09/09/](https://keirin.kdreams.jp/racecard/2021/09/09/)
日付だけでその日に行われるレースがわかる！！これでいける！！

したがって，まずは👆を必要日数分集めて，それをもとにその日行われたレースのURLを一気に集めたいと思います．

```python:get_Data.ipynb
def createURL(month, day):
    url = 'https://keirin.kdreams.jp/racecard/2021/' + str(month).zfill(2) + '/' + str(day).zfill(2) + '/'
    return url

seedURLs = [ createURL(i, j) for i in range(4, 8, 1) for j in range(1, 30, 1)]
seedURLs
```
![url1.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1477548/e6b40b5a-4d85-80c0-e2db-5112574c87e7.png)

こんな感じで2021年4月1日~7月29日までのレースURLが乗ったページのURLをゲットできました．
つぎはここにアクセスして1レースごとのURLをゲットしていきたいと思います．
その前にここからスクレイピングに入っていくのでライブラリをインポートしたいと思います．

```python:get_Data.ipynb
from bs4 import BeautifulSoup
import requests
import tqdm.notebook as tqdm
import time
```
スクレイピングするときは大体このへん入れとけばオッケー
続いてURLを取得していきます．先ほど取得したURLに一つ一つアクセスしてhtmlを取得し，その中から各レースのURLを抜き出していきます．
取得したURLはわかりやすいようにレースIDをキーとした辞書に入れていきます．

```python:get_Data.ipynb
def get_race_urls(sourceURLs):
    #URLを格納するための辞書を定義
    race_urls = {}
    #tqdmを使うことでループの進度が表示される
    for sourceURL in tqdm.tqdm(sourceURLs):
        try:
            #リクエストを作成
            req = requests.get(sourceURL)
            #htmlデータを取得
            soup = BeautifulSoup(req.content, 'html.parser')
            #1秒待機
            time.sleep(1)
            #レース情報のページのURLを取得する
            race_html = soup.find_all('a', class_='JS_POST_THROW')
            for html in race_html:
                url = html.get('href')
                #"一覧"のURL以外を取得
                if 'racedetail' in url:
                    race_id = re.sub(r'\D', '', url)
                    race_urls[race_id] = url
        except:
            break
    return race_urls

race_urls = get_race_urls(seedURLs)
race_urls
```
![url2.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1477548/912ac60e-c8bf-f8af-f701-4a118257ccba.png)
これで取得したかったURLがゲットできました！

### 2. データの取得
URLが手に入ったのであとはここにアクセスして必要なデータを取得していきます．

```python:get_Data.ipynb
main_colum = ['予想', '好気合', '総評', '枠番', '車番', '選手名府県/年齢/期別', '級班', '脚質', 'ギヤ倍数', '競走得点', '1着', '2着', '3着', '着外']
result_colum = ['予想', '着順', '車番', '選手名', '着差', '上り', '決まり手', 'S/B', '勝敗因']

def scrape_race_result(race_urls, pre_race_results={}):
    #取得途中のデータを途中から読み込む
    race_results = pre_race_results
    for race_id, url in tqdm.tqdm(race_urls.items()):
        if race_id in race_results.keys():
            continue       
        try:
            #ページ内ののテーブル(表)のhtmlを取得
            main = pd.read_html(url)

            #レース情報(特徴量データ)のテーブルを取得
            df = main[4][:-1]
            df.columns = main_colum

            #レース結果(教師データ)のテーブルを取得
            result_table = main[-2]
            result_table.columns = result_colum
            df_result = result_table.loc[ : , ['着順', '車番']]
            
            #文字列型に変換
            df = df.astype(str)
            df_result = df_result.astype(str)

            #特徴量データと教師データを一つにまとめる
            df = pd.merge(df_result, df, on='車番', how='left')
            race_results[race_id] = df

            #1秒待機
            time.sleep(1)
        except IndexError:
            print('IndexError: {}', url)
            continue
        except KeyError:
            print('keyerror: {}', url)
            continue
        except ValueError:
            print("ValueError: {}", url)
            continue
        except :
            traceback.print_exc()
            break
    return race_results

results = scrape_race_result(race_urls, results)
```
変数resultsにはレースIDをキーとしたpandasのデータフレームが格納されています．以下のようにレースIDを指定して上げるとしっかりとデータフレームが取得できていることがわかります．
![スクリーンショット 2021-09-12 17.38.25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1477548/41195313-554e-15a7-f261-3b4c4c47a1eb.png)

### 3. データを結合する
現状ではデータがレースごとに別れているのですべてを結合して一つのデータにしたいと思います．

```python:get_Data.ipynb
#各レースデータの行名をレースIDに変更
for key in results.keys():
    results[key].index = [key]*len(results[key])
#全データを結合
race_results = pd.concat([results[key] for key in results.keys()], sort=False)
race_results
```
![スクリーンショット 2021-09-12 17.45.14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1477548/0bccf9ff-511b-90f1-e6ff-2a6d44b0d32f.png)
このようにデータがひとまとまりになりました．

### 4. データを保存
最後にデータをpickleファイルに保存しましょう．

```python:get_Data.ipynb
race_results.to_pickle("data/race_data.pkl")
```
## データ前処理
続いては先程保存したデータを前処理していこうと思います．具体的に以下のようなことを行います．
* 列を分割
* ダミー変数化
* 文字列型の値を数値型に変換
* 正規化
* 特徴量データと教師データに分割

ここからは別ファイルで行っていきます．

```python:data_cleaning.ipynb
import pandas as pd
race_data = pd.read_pickle("data/race_data.pkl")
```
これで準備完了です．
### 1. 列を分割
収集したデータの中で選手名/年齢/期別という列に注目すると，3つの列が一つにまとまっている事がわかります．
これを分割していきたいと思います．

```python:data_cleaning.ipynb
#concatメソッドで列を分割し，もとの列を削除
race_data = pd.concat([race_data, race_data["選手名府県/年齢/期別"].str.split("/", expand=True)], axis=1).drop("選手名府県/年齢/期別", axis=1)
#選手名の列を削除
race_data = race_data.drop(0, axis=1)
#年齢と期別の列名を変更
race_data = race_data.rename(columns={1: "年齢", 2: "期別"})
```
![スクリーンショット 2021-09-12 18.41.33.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1477548/0951ad44-87cd-3aeb-995d-8ac0166d2947.png)
分割されて新しくなった列が最後に追加されています．これで完了です．

### 2. ダミー変数化
ダミー変数かとは，質的なデータを量的なデータに変換することを言います．
今回のデータでいうと予想，好気合，級班，脚質がダミー変数化の対象となります．
ダミー変数化は調べてみるとpandasのget_dummiesメソッドで簡単にできるようです．しかし，[こちら](https://www.haya-programming.com/entry/2019/08/17/184527)の記事にもある通り，機械学習の用途で使う際は注意が必要みたいです．

```python:data_cleaning.ipynb
#ダミー変数の対象と，カテゴリーを定義
dummy_targets = {"予想": ["nan", "×", "▲", "△", "○", "◎", "注"], \
                      "好気合": ["★"], \
                      "脚質": ["両", "追", "逃"], \
                      "級班": ["A1", "A2", "A3", "L1", "S1", "S2", "SS"] }

#定義したカテゴリーを指定しておく
for key, item in dummy_targets.items():
    race_data[key] = pd.Categorical(race_data[key], categories=item)

#ダミー変数化されたデータフレームを格納するリストと削除する列のリストを定義
dummies = [race_data]
drop_targets = []

#ダミー変数化してdummiesに代入
for key, items in dummy_targets.items():
    dummy = pd.get_dummies(race_data[key])
    dummies.append(dummy)
    drop_targets.append(key)

#ダミー変数化されたデータフレームを大元のデータフレームに結合
race_data = pd.concat(dummies, axis=1).drop(drop_targets,  axis=1)
```
![スクリーンショット 2021-09-12 18.55.28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1477548/556ee366-b980-7d51-e956-21f4c549d9e7.png)

これにて完了です．

### 3. 文字列型の値を数値型に変換
モデルにデータを入れるときは，数値型でないといけないので全データを数値型に変換していきたいと思います．
各列ごとの現在の型は,

```python
race_data.dtypes
```
で確認できます．
pandasのデータ型一覧:[https://pbpython.com/pandas_dtypes.html](https://pbpython.com/pandas_dtypes.html)
object型をfloat型に変更したいのですが，変換でエラーになる部分があるため個別に手直ししてから変換していきます．

```python:data_cleaning.ipynb
#落車などで順位が出なかった部分を9位として変換
race_data = race_data.replace(["失", "落", "故", "欠"], 9)

#ギヤ倍数の表示がおかしい部分を変換
race_data["ギヤ倍数"] = race_data["ギヤ倍数"].map(lambda x: x[:4] if len(x)>4 else x)

#期別に含まれる欠車の文字を除外
race_data["期別"] = race_data["期別"].map(lambda x: x.replace(" （欠車）", "") if "欠車"in x else x)

#着順の列を3着以内は1,それ以外は0に変換
race_data["着順"] = race_data["着順"].map(lambda x: 1 if x in ["1", "2", "3"] else 0)

#全データをfloat型に変換
race_data = race_data.astype("float64")
```
これですべてのデータが数値型のデータに変換できました．

### 4. 正規化
正規化とはデータの値を何らかの方法で0~1の範囲で表現することです．
要はそのままのデータだと，値が大きいものが重みに関係なく重要視されてしまうのでそれを避けようと言うことです．
詳しくは以下の記事が参考になります．
[https://qiita.com/yShig/items/dbeb98598abcc98e1a57](https://qiita.com/yShig/items/dbeb98598abcc98e1a57)

```python:data_cleaning.ipynb
#最大値が1最小値が0になるように正規化
def minmax_norm(columns):
    df = race_data[columns]
    for column in columns:
        race_data[columns] = (df - df.min()) / (df.max() - df.min())

minmax_columns = ["総評", "枠番", "ギヤ倍数", "競走得点", "1着", "2着", "3着", "着外", "年齢", "期別"]
minmax_norm(minmax_columns)
```
![スクリーンショット 2021-09-12 21.16.43.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1477548/97af15b6-3703-04bc-0890-419be0dda625.png)
今回は最大値が1，最小値が0となるように正規化を行いました．

### 5. 特徴量データと教師データに分割
```python:data_cleaning.ipynb
#特徴量データと教師データに分割
race_y = race_data['着順']
race_x = race_data.drop('着順', axis=1)
race_x = race_x.loc[:, ['車番', '総評', '枠番', 'ギヤ倍数', '競走得点', \
                        '1着', '2着', '3着', '着外', "年齢", "期別", 'nan', \
                        '×', '▲', '△', '○', '◎', '注', '★', '両', '追', '逃', \
                        'A1', 'A2', 'A3', 'L1', 'S1', 'S2', 'SS']]
#データを保存
race_x.to_pickle("data/race_x.pkl")
race_y.to_pickle("data/race_y.pkl")
```
これでようやくデータセットが完成しました．

## まとめ
無事に目的のデータセットを得ることができました！
今回初めてスクレイピングを使ったデータ収集や前処理の流れを一通り経験して，わからないことだらけでしたが，いい経験になりました．最後までお付き合いいただきありがとうございました！
モデル作成・学習編はまた後日記事にする予定です．
## 参考
* [競馬予想で始めるデータ分析・機械学習](https://www.youtube.com/channel/UCDzwXAWu1zIfJuPTTZyWthw)
* [ディープラーニングで競馬予想](https://qiita.com/shinmura0/items/50af5483b6dfda677b32)
* [機械学習の初心者がpythonで競馬予測モデルを作ってみた](https://qiita.com/kazuktym/items/7804a63338113653b04f)
* [競馬予想　機械学習(LightGBM)で回収率100%超えたと思ったら、やらかしてた話](https://qiita.com/gara_gara/items/3e099c24280d772e7086)
* [【Python】DataFrameで複数行・列をまとめて削除する](https://qiita.com/stokes/items/945115525ca36a3dcf7c)
* [ゆるふわPandasチートシート](https://qiita.com/tanemaki/items/2ed05e258ef4c9e6caac)