【機械学習】初心者が競艇予想ツール作成して金儲け大作戦

##はじめに
初心者がチームを組んで、月一ペースで開発を行うプロジェクトの第4段です！
今回は「機械学習編」ということで、競艇好きのメンバーの情報を頼りに、
予想ツールの作成までやっていきたいと思います！

##環境
Googleが提供している、Colaboratory環境を使用します。
言語はPython3
ライブラリはscikit-learn、BeautifulSoupを使用します。

##スクレイピング
機械学習をするにあたって、学習用のデータ収集を行います。
今回は競艇の予想になるため、必要な情報にアタリをつけ、過去のレース結果を取得します。

予想に必要と検討したデータ項目は以下です。
※予想精度を上げるため、会場は大村に固定にします。
< 出走者情報 (計6コース分) >
・級別
・全国勝率
・当地勝率
・モーター2,3連率

< 直前情報 >
・展示タイム

< 結果 >
・レース結果

###URLの特定
上記項目が取得できそうなサイトを探していきます。
以下のスクレイピングを行いやすい基準を元に、最適なサイトを見つけました。

**・パスパラメータの有無**
過去分を1つずつ取得は現実的に不可能なので、ループロジックを組めるかどうかで判断します。
例えばURLが以下のようになっており、パスパラメータの書き換え可能かどうかを見ます。

http://www.example.com/past/<font color="red">?date=yyyymmdd</font>
※赤文字がパスパラメータ

**・ページ間の一貫性**
結果ページごとにHTMLレイアウトが異なる場合、スクレイピングを行うソース量が大変なことになります。
あとでHTMLの解析を行いますが、ざっくりテーブルのレイアウトに一貫性があるかを事前に確認しましょう。

###HTMLの解析
今回はSeleniumとBeautiful Soupを使用しスクレイピングを行いました。
[こちら](https://qiita.com/ftoyoda/items/fe3e2fe9e962e01ac421)を参考にWebページを取得します。

**・ライブラリの導入**
Colaboratoryの環境上で以下のソースを実行します。
コマンド実行は頭に"!"を入れることで実行可能です。
※Colaboratory上でランタイムの再割り当てが行われると環境がまっさらになるため、
毎回実行を行う必要があります。

```
!apt-get update
!apt install chromium-chromedriver
!cp /usr/lib/chromium-browser/chromedriver /usr/bin
!pip install selenium
```


**・Webページの取得**
SeleniumのOptionについては、詳しく解説されている[こちら](https://vaaaaaanquish.hatenablog.com/entry/2017/06/06/194546)を参考にしました。

```python:sample.py
#SeleniumとBeautifulSoupのライブラリをインポート
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from bs4 import BeautifulSoup

# ブラウザをheadlessモード（バックグラウンドで動くモード）で立ち上げてwebsiteを表示、生成されたhtmlを取得し、BeautifulSoupで綺麗にする。
options = webdriver.ChromeOptions()
# 必須
options.add_argument('--headless')
options.add_argument('--disable-gpu')
options.add_argument('--no-sandbox')
# エラーの許容
options.add_argument('--ignore-certificate-errors')
options.add_argument('--allow-running-insecure-content')
options.add_argument('--disable-web-security')
# headlessでは不要そうな機能
options.add_argument('--disable-desktop-notifications')
options.add_argument("--disable-extensions")
# 言語
options.add_argument('--lang=ja')
# 画像を読み込まないで軽くする
options.add_argument('--blink-settings=imagesEnabled=false')
driver = webdriver.Chrome('chromedriver',options=options)

# Webページ取得
driver.get("スクレイピングを行いたいサイトのURL")
html = driver.page_source.encode('utf-8')
soup = BeautifulSoup(html, "html.parser")

# HTML出力
print(soup.prettify())

```

**・Webページの解析・取得**
上記ソースを実行するとHTMLが整形され出力されるため、今回はその出力結果を元にデータをどのように取得すれば良いかを考えました。
※Webブライザの開発者モードなど、使い慣れたツールを適宜選択してください。

基本的な取得ロジックの考えは以下になります。

**1. 取得対象項目の直近のタグの特定**
**2. そのタグのidがあれば、それを指定し取得する**
**3. そのタグのclassがあれば、それを指定し取得する**
**4. そのタグを特定するシグニチャがなければ、タグを辿り取得する**

例を見ていきましょう。

```html:sample.html
<!-- head省略 -->
<body>
<div>
   <div id="target1">
      <h1>取得対象1</h1>
   </div>
</div>
<div class="target2">
   <p>取得対象2</p>
</div>
<div class="target2">
   <p>取得不要項目</p>
</div>
<div>
   <p>取得不要項目,取得対象3</p>
</div>
```

上記のようなソースの場合

```python:sample.py
#target1(idで直取得)
target1 = soup.find(id="target1").find("p").text

#target2(classで間接取得し、取得したリスト番号で絞る)
target2 = soup.findall(class="target2")[0].find("p").text

#divタグ指定で間接取得し、取得したリスト番号で絞る
target3_pre = soup.findall("div")[4].find("p").text
#target3(カンマ区切りの文字列をリスト化し、index指定で取得)
target3 = target3_pre.split()[1]
```

※BeautifulSoupの基本的な使用方法は[こちら](https://qiita.com/itkr/items/513318a9b5b92bd56185)の記事が大変参考になりました。

##csv出力
取得した項目をcsvに出力します。
Colaboratory環境なので、GoogleDriveに保存を行いました。

```python:sample.py
# Google Driveへマウントする
from google.colab import drive
drive.mount('/content/drive')
```

```python:sample.py

# ----------------csvファイルへの書き出し準備Start----------------

# 出力先のGoogle Driveのディレクトリを指定
os.chdir("/content/drive/My Drive")

# 書き込み権限を与えたcsvファイルを生成
csvFile = open("出力するファイル名（例：boat_race_data.csv)",'wt',newline ='',encoding="utf-8")

# CSVファイルの書き込み（出力）用クラスを生成
writer=csv.writer(csvFile)

# CSVのヘッダー行用のオブジェクトを生成する
index = []

# CSVのヘッダー項目を追加する
index.extend(["各ヘッダーの列項目（例：日時、着順）",...])

# CSVにヘッダー行を追加する
writer.writerow(index)

# ----------------csvファイルへの書き出し準備End----------------

#取得したレース結果(配列にレース情報が入っている)
raceDataList

#レースデータ加工
for oneRaceData in raceDataList:

   #カンマ区切りの文字列に変換
   oneRaceResult = ','.join(oneRaceData)
   
   #CSVにレースデータを行追加する
   writer.writerow(oneRaceResult)

#CSVファイルをクローズして作成完了！
csvFile.close()
```

##前処理
機械学習を行う前に、以下の作業を行いました。

1.選手クラス(A1,A2など)の数値化
2.csv読み込み時点で文字列データの数値化
3.不要カラムの除去
4.目的変数の作成


```python:sample.py
#csvの読み込み
csv = pd.read_csv("boatRace_omura_20110101__20190712.csv",encoding="utf-8")

#不要カラムの除去
csv = csv.dropna()

#選手クラス(A1,A2など)の数値化
csv = csv.replace('A1','1')

#csv読み込み時点で文字列データの数値化
csv['1st-National_Win_Rate'] = csv['1st-National_Win_Rate'].astype(np.float64)

#目的変数の作成
csv = csv.assign(result3 = csv['1st-Rank'] + csv['2nd-Rank'] + csv['3rd-Rank'])
```
##モデル選定
今回はscikit-learnに定義されているモデルを返却するall_estimatorsメソッドを使用し、
3連単予想の精度が上位のモデルを採用していく方法をとりました。
ソースは以下です。

```python:sample.py
#訓練データ作成
train_X,test_X,train_Y,test_Y=train_test_split(csv_data,csv_target,train_size=0.8,test_size= 0.2,shuffle=True)

#StandardScalerを使用した説明変数の標準化
sc = StandardScaler()
sc.fit(train_X)
train_X_std = sc.transform(train_X)
test_X_std = sc.transform(test_X)

#全モデル検証
all = all_estimators(type_filter="classifier")
for (name, classifier) in all:
   try:
      print(name + " : 開始")
      clf = classifier()
      clf.fit(train_X_std, train_Y)
      #結果表示
      print(name,"train_scores=",clf.score(train_X_std, train_Y))
      print(name,"test_scores=",clf.score(test_X_std, test_Y)) 
   except:
      pass
```

###結果
出力結果は以下のようになりました。
過学習を起こしているものも含め、まちまちな結果ですが、
スコア10%を超えているモデルもあるため、まずまずの結果でしょう。
※数があるので、スコア7.0以上になったモデルに絞っています。
※2011年から2019年までのレース結果、計19634件を元に算出しています。

```
・score7.0以上
AdaBoostClassifier train_scores= 0.09246443675509419
AdaBoostClassifier test_scores= 0.09072270630445925
ExtraTreesClassifier train_scores= 1.0
ExtraTreesClassifier test_scores= 0.0725269092772937
LinearDiscriminantAnalysis train_scores= 0.12129950019223376
LinearDiscriminantAnalysis test_scores= 0.09456688877498719
LogisticRegression train_scores= 0.12001794181724977
LogisticRegression test_scores= 0.10507432086109687
LogisticRegressionCV train_scores= 0.08137895681148276
LogisticRegressionCV test_scores= 0.08534085084572014
MLPClassifier train_scores= 0.20017941817249776
MLPClassifier test_scores= 0.07739620707329574
RandomForestClassifier train_scores= 0.9952582340125593
RandomForestClassifier test_scores= 0.07713992824192721
RidgeClassifier train_scores= 0.10175573497372806
RidgeClassifier test_scores= 0.10097385955920041
RidgeClassifierCV train_scores= 0.10175573497372806
RidgeClassifierCV test_scores= 0.10123013839056894
SVC train_scores= 0.18217352300397283
SVC test_scores= 0.10148641722193746
```

ついでに2連単・単勝の予想も出して見ました。

```
・2連単(score25.0以上)
LinearDiscriminantAnalysis train_scores= 0.2754609684645873
LinearDiscriminantAnalysis test_scores= 0.26981392143349414
LogisticRegression train_scores= 0.2757194554540755
LogisticRegression test_scores= 0.27015851137146796
LogisticRegressionCV train_scores= 0.2677925211097708
LogisticRegressionCV test_scores= 0.2684355616815989
RidgeClassifier train_scores= 0.2622781320006893
RidgeClassifier test_scores= 0.26636802205375604
RidgeClassifierCV train_scores= 0.2621919696708599
RidgeClassifierCV test_scores= 0.2653342522398346
SVC train_scores= 0.3373255212820955
SVC test_scores= 0.26981392143349414

・単勝(score65.0以上)
LogisticRegression train_scores= 0.656298466310529
LogisticRegression test_scores= 0.6574776016540317
LogisticRegressionCV train_scores= 0.6531104601068413
LogisticRegressionCV test_scores= 0.6543762922122675
RidgeClassifier train_scores= 0.6459589867310012
RidgeClassifier test_scores= 0.6509303928325293
RidgeClassifierCV train_scores= 0.6459589867310012
RidgeClassifierCV test_scores= 0.6509303928325293
```

##予想ツール作成
チューニングなどはとりあえず置いといて、使用できるツールを作成します。
良い結果の出たモデルを使用し、3連単・2連単を予想させ、
出力結果を片っ端から買っていく考えです。

※省略してますが、予想対象のデータはスクレイピングで取得してきています。

```python:sample.py
#3連単
sc = StandardScaler()
sc.fit(train_X_3)
train_X_std_3 = sc.transform(train_X_3)
test_X_std_3 = sc.transform(test_X_3)

#予想対象データ
oneRaceData_std = sc.transform(oneRaceData)

#モデル生成
abc = AdaBoostClassifier()
etc = ExtraTreesClassifier()
lda = LinearDiscriminantAnalysis()
lr =LogisticRegression()
lrc = LogisticRegressionCV()
mc = MLPClassifier()
rfc = RandomForestClassifier()
rc = RidgeClassifier()
rcc = RidgeClassifierCV()
svc = SVC()

dict = {"AdaBoostClassifier":abc, "ExtraTreesClassifier":etc, "LinearDiscriminantAnalysis":lda,"LogisticRegression":lr,"LogisticRegressionCV":lrc,
       "MLPClassifier":mc,"RandomForestClassifier":rfc,"RidgeClassifier":rc,"RidgeClassifierCV":rcc,"SVC":svc}


for key, value in dict.items():
   value.fit(train_X_std_3, train_Y_3)
   #結果出力
   print(value.predict(oneRaceData))
    
#2連単
sc = StandardScaler()
sc.fit(train_X_2)
train_X_std_2 = sc.transform(train_X_2)
test_X_std_2 = sc.transform(test_X_2)
                 
#モデル生成
lda = LinearDiscriminantAnalysis()
lsvc = LinearSVC()
lr =LogisticRegression()
lrc = LogisticRegressionCV()
rc = RidgeClassifier()
rcc = RidgeClassifierCV()
svc = SVC()

dict = {"LinearDiscriminantAnalysis":lda,"LinearSVC":lsvc,"LogisticRegression":lr,"LogisticRegressionCV":lrc,
       "RidgeClassifier":rc,"RidgeClassifierCV":rcc,"SVC":svc}

for key, value in dict.items():
   value.fit(train_X_std_2, train_Y_2)
   #結果出力
   print(value.predict(oneRaceData))

```

###結果出力
とりあえず、7/19日開催「ＢＴＳ松浦開設３周年記念」の1レース目を予測させましょう。
結果は「1-3-2」に対して、どのように予測してくれるのか、、、、

```
-------------3連単-------------
AdaBoostClassifier : train_scores= 0.08208381391772396
AdaBoostClassifier : test_scores= 0.08124038954382368
['563']
ExtraTreesClassifier : train_scores= 1.0
ExtraTreesClassifier : test_scores= 0.0743208610968734
['134']
LinearDiscriminantAnalysis : train_scores= 0.12296552607971294
LinearDiscriminantAnalysis : test_scores= 0.09610456176319836
['356']
LogisticRegression : train_scores= 0.12008201973599897
LogisticRegression : test_scores= 0.10148641722193746
['356']
LogisticRegressionCV : train_scores= 0.08272459310521595
LogisticRegressionCV : test_scores= 0.08047155304971809
['123']
MLPClassifier : train_scores= 0.20607458669742407
MLPClassifier : test_scores= 0.0858534085084572
['462']
RandomForestClassifier : train_scores= 0.996475714468794
RandomForestClassifier : test_scores= 0.06099436186570989
['123']
RidgeClassifier : train_scores= 0.10502370882993721
RidgeClassifier : test_scores= 0.09764223475140954
['134']
RidgeClassifierCV : train_scores= 0.10502370882993721
RidgeClassifierCV : test_scores= 0.09764223475140954
['134']
SVC : train_scores= 0.18281430219146483
SVC : test_scores= 0.09892362890825218
['124']
-------------2連単-------------
LinearDiscriminantAnalysis : train_scores= 0.26605151864667437
LinearDiscriminantAnalysis : test_scores= 0.2555099948744234
['51']
LinearSVC : train_scores= 0.25387671408432655
LinearSVC : test_scores= 0.2485904664274731
['13']
LogisticRegression : train_scores= 0.2629117006279636
LogisticRegression : test_scores= 0.25627883136852897
['51']
LogisticRegressionCV : train_scores= 0.22696398820966296
LogisticRegressionCV : test_scores= 0.22962583290620195
['12']
RidgeClassifier : train_scores= 0.25144175317185696
RidgeClassifier : test_scores= 0.2485904664274731
['13']
RidgeClassifierCV : train_scores= 0.2513776752531078
RidgeClassifierCV : test_scores= 0.24884674525884162
['13']
SVC : train_scores= 0.3227604767397155
SVC : test_scores= 0.25166581240389546
['12']
```

予想結果まちまちですね、、、
3連単は総スカンですが、2連単はHITがありました！
ただ、3連単¥880、2連単¥310というかなり固いレースなので、
もう少し精度が欲しいところですね。

##感想
上記結果を受けて、特徴量の削除・過去分のデータ削除など行いましたが、
スコアに大きな変化はありませんでした。
そもそもの特徴量の不足(コース別3連対率や波風など)がスコアを頭打ちにしている原因だと考えています。

今のままでも、10%の精度はあるので全く使用できないわけではないですが、
次回余裕があれば、Deep learningで予想するものも作れたらと思います。