錦織選手からブレイクをもぎ取りたい!〜MLPによるサーブコースの予測〜

初めまして、Aidemy研修生の竹内です!

さてみなさん、いきなりですが、日本を代表するテニスのトッププロ選手といえば誰を思い浮かべますか・・・??

そう、錦織圭選手ですね!他の選手との体格差を感じさせない攻撃的なテニススタイルで、シングルス世界ランキング7位(2018年3月現在)を保持。僕自身もテニスを嗜んでおり、錦織選手の活躍にいつも元気をもらっています。

そんなトップクラスの選手に、一泡吹かせたい!ポイントを取ってやりたい!なんて思っている、無謀な方はいらっしゃいませんか?そんな方に朗報があるんです!!

なんと、錦織選手のサーブは多層パーセプトロン(MLP)を用いた機械学習によって、ある程度コースの予測が可能なんです!!

今回はその予測の流れと、実際ポイントを取って、錦織選手のサービスゲームをブレイクできるのかどうかについての考察を書きたいと思います!

  • 0.実行環境
  • 1.データ収集
  • 2.データの整形
  • 3.データの分割とスケーリング
  • 4.モデルの作成と学習(ランダムサーチ)
  • 5.モデルを用いた場合の得点率の計算
  • 6.4と5の過程をもう一度(ベイズ最適化)
  • 7.結論
  • 8.考察・今後の改善・感想
  • 9.参考文献

0.実行環境

Python 3.7.2

jupyter notebook

MacBook Pro (13-inch, 2016, Two Thunderbolt 3 ports)(10.14.1)

1.データ収集

今回、錦織選手のサーブデータの収集には、以下のサイトにあるcsvファイルを使わさせていただきました!

錦織選手のサーブデータ(コース・速度など)をダウンロードできるようにしました

今回利用したのは、2018年の公式戦における錦織選手のサーブデータです。スクレイピングしてきたデータは大会別になっているので、データをくっつけて一つのデータフレームを作ります。

import numpy as np
import pandas as pd
import requests
from bs4 import BeautifulSoup

#データの取得(サーフェスデータあり)
 r = requests.get("http://datatennis.net/archives/4611/")
 soup = BeautifulSoup(r.text,"lxml")

 df_serve_2018 = pd.DataFrame()

#0アウトドアハード1インドアハード2アウトドアグラス3アウトドアクレー
 surface_list = [1,1,0,1,1,0,0,0,2,2,3,3,3,3,0,1,1]
 url = soup.find_all("a")
 for i in range(17):
     df =    pd.read_csv(url[i+19].get("href"),encoding='shift_jis')
     df["Surface"] = surface_list[i]
     df_serve_2018 = pd.concat([df_serve_2018,df],axis =0

コートのサーフェス(地面の材質)も、判断材料になると考え、大会の開催場所によってサーフェスの種類ごとに数値を割り当てました!こちらのデータフレームに、AceDbF、Cource、FirstSecond、OpponentPlayer、Server、Set、Speed、TotalGame、Tournament、WinLose、ScoreServer、ScoreReturner、Side、WonA、WonB、Surfaceのデータが格納されています。

2.データの整形

このデータのうち、サーブを打つ前にわかっている、かつサーブコースに関係のありそうな情報である、

FirstSecond…1stサーブか2ndサーブか
Set…何セット目か
TotalGame…そのセット内で何ゲーム目なのか
ScoreServer…プレー開始前のサーバーのスコア
ScoreReturner…プレー開始前のレシーバーのスコア
Side…サーブを打つサイド
Surface…サーブの地面の材質

これらの情報を説明変数とし、

Course…サーブのコース。Body(b)、Center(c)、Wide(w)の三種類の分類。

これをラベルデータとします!

データの中には空欄や特殊なケース(フォルトなど)が混ざっており、それらのデータもこの作業で落としています。

#データの整形
 df_serve_2018 = df_serve_2018[(df_serve_2018["Server"] == "圭") 
& (df_serve_2018["Cource"] !="n") 
& (df_serve_2018["Cource"] !="o")]
 df_serve_2018 = df_serve_2018.drop(["Unnamed: 0","index","AceDbF","OpponentPlayer","Server","Speed","Tournament","WinLose","WonA","WonB"],axis=1)
 for i in range(len(df_serve_2018)):
     if not df_serve_2018.iloc[i,0] in ['w','b','c']:
         df_serve_2018.iloc[i,0] = np.nan
 df_serve_2018 = df_serve_2018.dropna(how="any")
 df_serve_2018.reset_index()
 X = df_serve_2018.iloc[:,1:8]
 y = df_serve_2018["Cource"]
 for i in range(len(X)):
     X.iloc[i,5] = 0 if X.iloc[i,5] == "Deuce" else 1
     X.iloc[i,4] = 45 if X.iloc[i,4] == "Ad" else int(X.iloc[i,4])
     X.iloc[i,3] = 45 if X.iloc[i,3] == "Ad" else int(X.iloc[i,3])
     X.iloc[i,0] = int(X.iloc[i,0]) if X.iloc[i,0] in ['1','2',1,2] else 1

3.データの分割とスケーリング

データをトレーニングデータとテストデータに分割し、スケーリングによって変数の重みを揃えます。

from sklearn.model_selection import train_test_split
from sklearn.preprocessing import MinMaxScaler

#トレーニングデータ、テストデータに分ける
 train_X,test_X,train_y,test_y = train_test_split(X,y,random_state=42)
#データのスケーリング
 scaler = MinMaxScaler(feature_range=(0, 1))
 scaler_train = scaler.fit(train_X)
 train_X = scaler_train.transform(train_X)
 test_X = scaler_train.transform(test_X)

4.モデルの作成と学習

いよいよモデルを作成していきます!今回はscikit-learnを用い、そこに実装されているMLPClassifierを学習機として多層パーセプトロンを実装していきます。

from sklearn.neural_network import MLPClassifier
from sklearn.metrics import confusion_matrix
from sklearn.model_selection import RandomizedSearchCV

#モデルの作成と学習
 model_param_set_random ={MLPClassifier(): {
     "hidden_layer_sizes":[(i,j,k) for i in range(1,101) for j in range(1,101) for k in range(1,101)],
     "activation":["identity","logistic","tanh","relu"],
     "solver":["lbfgs","sgd","adam"],
     "random_state":[42]}}
 max_score = 0
 best_param = None
 best_confusion_matrix = None
 best_clf = None
 
#ランダムサーチでパラメーターサーチ
 for model, param in model_param_set_random.items():
     clf = RandomizedSearchCV(model, param)
     clf.fit(train_X, train_y)
     pred_y = clf.predict(test_X)
     score = f1_score(test_y, pred_y, average="micro")
     if max_score < score:
         max_score = score
         best_param = clf.best_params_
         best_confusion_matrix = confusion_matrix(test_y,pred_y)
         best_clf = clf
 print("パラメーター:{}".format(best_param))
 print("ベストスコア:",max_score)
 print("混合行列:",best_confusion_matrix)
#出力-----------------------------------------------------
ベストスコア: 0.5432873274780426
 混合行列:
 [[  0  28  76]
  [  0  85 230]
  [  0  30 348]]

混同行列は、上の列から順に、実際bodyコースだったものから、center、wideとなっており、左の列から順に、モデルがbodyと予想したもの、center、wideとなっています。

ランダムサーチによりパラメーターの調整を行いました。正解率は54%と、2分の1を超えているのでまずまずといったところでしょうか。これで勝てればいいのだけれど…。

5.モデルを用いた場合の得点率の計算

さあ、モデルも完成しましたし、いよいよ錦織選手に挑戦です!

・・・とはいえ、本当に試合を挑むことはあらゆる手を尽くしても叶わないので、以下の流れの下ブレイクできたかどうか判定したいと思います!

1.1stサーブの時、レシーバーの得点率は29%、2ndサーブの時44%とする。(ATPのデータから引用)


2.(得点率)× 3 ×(モデルによって算出されたスコアのうち、実際に打たれたサーブに対応する値)を得点確率とし、乱数を用いて実際に得点したか否かを決定する。


3.テストデータ全てに対しこれを行い、得点率を求める。得点率が50%以上ならブレイク可能!

これだけだとわかりにくいと思うので、例を出して、モデルによる予測を用いた場合の意識の配分と、予測を用いない場合の意識の配分に分けて説明します。以下の説明は、あるテストデータで、説明変数が
「1stサーブ、1セット目、総ゲーム数3、サーバーの獲得ポイント40、レシーバーの獲得ポイント15、デュースサイド、インドアハードコート」
であり、ラベルデータがwideであるものを例にとっています。

・予測を用いない場合
この時、レシーバーはどのコースにも当確率でサーブが来る可能性があると考えるので、配分できる意識の合計値を1とすると、意識をbodyに0.33、centerに0.33、wideに0.33ずつ配分します。
錦織選手に対して1stサーブでこちらが得点できる確率は29%(ATP提供のデータに基づく)なので、改めて得点できる可能性を計算すると
29% × 3 × 0.33 = 29% ←(1stサーブの得点率) × 3 × (配分された意識)
となり、29%の確率でそのポイントを取ることができます。

・予測を用いる場合
モデルでは、bodyに1割、centerに4割、wideに5割の確率でサーブが来ると予測しました。
この結果をもとにして、意識をそのままの割合、つまりbodyに0.10、centerに0.40、wideに0.50配分します。
改めて得点できる確率を計算すると、
29% × 3 × 0.5 =43.5%
(1stサーブの得点率 × 3(補整) × 実際にサーブの来たコースであるwideに配分された意識)
となります。

つまりは、レシーバーは普段3つのコースを均等に意識して構えていると仮定して、モデルによって算出されたスコアに応じてその意識の配分割合を変えるとどうなるのかを検証することになります!

まず、何も意識しない、つまり予測を用いない場合、

得点率: 0.37641154328732745

このような結果となります。お世辞にもブレイクなんてできそうにないですね。

では、作成したモデルによる予測を適用するとどうなるのか。

以下のコードを実行して確かめてみます!

table = np.array(best_clf.predict_proba(test_X))
answers = test_y.values.tolist()
def cource_to_number(cource):
     if cource == "b":
         return 0
     elif cource == "c":
         return 1
     else:
         return 2
 won_count = 0
 for i in range(len(answers)):
     point_prob = 0
     if test_X[i,0] == 1:
         point_prob = 3*0.29*table[i][cource_to_number(answers[i])]
     else:
         point_prob = 3*0.44*table[i][cource_to_number(answers[i])]
 p = np.random.uniform() if p < point_prob:     won_count += 1
 print("得点率:",won_count/len(test_y))
#出力---------------------------------------------------
得点率: 0.4893350062735257

お・・・惜しい!得点率約49%と大健闘の末破れてしまいました。

こうみると、かなり予測によって得点率が向上していることがわかりますね!

しかしトッププロは一筋縄ではいきませんでしたね。かなり悔しいです!

6.再戦

それにしても悔しい。あまりに惜しい。

諦めきれなかった僕は、いくつか工夫をしてモデルを作成しなおしてみることにしました!

6-1.SMOTEによるデータの水増し

imbalanced-learn のSMOTEを用いて、少なかったbodyコースへのデータの水増しを試みました! これにより、モデルが少しでもbodyを的中させることが狙いです。以下が、ランダムサーチでSMOTEを利用した結果です。

...
from imblearn.over_sampling import SMOTE
#SMOTEによる不均衡の是正
 sm = SMOTE(random_state=42)
 train_X, train_y = sm.fit_sample(X,y)
...
#出力-----------------------------------------------------
パラメーター:{'solver': 'lbfgs', 'random_state': 42, 'hidden_layer_sizes': (76, 39, 20), 'activation': 'tanh'}
 ベストスコア: 0.37515683814303635
 混合行列: 
[[ 38  38  28]
  [101 113 101]
  [118 112 148]]
 0.437892095357591

確かにbodyの判別をしてくれるようにはなったものの、精度の向上には繋がりませんでした。

6-2 ベイズ最適化によるパラメータの決定

ベイズ最適化は、パラメータをむやみに試すのでなく、モデルが最適化されそうな値を予想してパラメータを試していってくれる手法です!bayes_optのBayesianOptimizationによって実装します。

モデルの最適化として、今回はモデルの精度の最大化ではなく、モデルを用いて試合をシミュレートした結果の、得点率の最大化を目指します。

from bayes_opt import BayesianOptimization
#最適化の下準備
 def cource_to_number(cource):
     if cource == "b":
         return 0
     elif cource == "c":
         return 1
     else:
         return 2
 table = np.array(clf.predict_proba(test_X))
 answers = test_y.values.tolist()
 #最適化する関数の定義
 def validate(h1,h2,h3,activation,solver,random_state):
     # 与えられたパラメータでMLPのモデルを初期化
     h1 = int(np.round(h1))
     h2 = int(np.round(h2))
     h3 = int(np.round(h3))
     activation_list = ["identity","logistic","tanh","relu"]
     solver_list = ["sgd","adam"]
     activation = activation_list[int(np.round(activation))]
     solver = solver_list[int(np.round(solver))]
     random_state = int(np.round(random_state))
     model = MLPClassifier(hidden_layer_sizes=(h1,h2,h3),activation=activation,solver=solver,random_state=random_state,max_iter=1000,shuffle=True,early_stopping=True)
     # 10-Foldで交差検証
     iterater = KFold(n_splits=10)
     results = []
     for train_indexes, test_indexes in iterater.split(train_X):
         new_X = pd.DataFrame(train_X).iloc[train_indexes]
         new_y = train_y[train_indexes]
         clf = model
         clf.fit(new_X,new_y)
      
         
    
 
 最適化するパラメータの下限・上限 (Cとgamma)
 pbounds = {
     'h1': (1,100),
     'h2': (1,100),
     'h3': (1,100),
     "activation":(-0.5,3.49999),
     "solver":(-0.5,1.49999),
     "random_state":(1,100)
 }
 関数と最適化するパラメータを渡す
 optimizer = BayesianOptimization(f=validate, pbounds=pbounds)
 最適化
 optimizer.maximize(init_points=10, n_iter=100,acq='ucb')

しれっと”solver”の選択肢から”lbfgs”を除いていますが、これは時間のかかる割に良いデータが得られなかったためです。

このモデルによる再戦の結果・・・

|   iter    |  target   | activa... |    h1     |    h2     |    h3     | random... |  solver   |
-------------------------------------------------------------------------------------------------
・・・
|  27       |  0.5041   |  2.752    |  99.41    |  7.51     |  99.78    |  5.415    | -0.2478   |
・・・

27回目の試行で0.5越え!パラメータは、以下のものでした。

activation=”relu”, hidden_layer_sizes= (99,8,100), random_state = 5, solver=”adam”

僅差にはなっていますが、見事勝利できました!!

リベンジ達成です!!!!!

7.考察・今後の改善・感想

しかし、勝利できたとは言ったものの、random_stateの値くらいでも揺らいでしまう勝利では意味がありません。今後は以下のような改良が考えられます。

今回用いたデータは、おそらくサーブコースの決定への寄与が小さく、実際の試合では相手選手の苦手等を分析したコースを増やすのではないかと考えます。そこで、対戦相手を固定したデータで検証を行えば精度が向上するのではないでしょうか。

また、戦況の要素として、プレー経験のある私としては試合の流れを捉えることも予測の大きな助けになると考えます。選手の有利不利による心理的な変化を捉えられなかった可能性もあります。「流れ」を捉えるなら時系列解析ですね。

モデルのチューニングについても、まだ工夫の余地があります。各モデルの損失関数の可視化なども用いて手がかりを探っていくという方法も考えられそうです。

錦織選手からブレイクできたかのシミュレーションのやり方に関しても実際の対戦に近づけられるよう工夫の余地があります。

でもまあ勝ちは勝ちです!やはりある程度の精度でサーブのコースは予測可能だったというわけです。引き続き改良を重ねれば、錦織選手から自信を持ってブレイクできるようになる日も近いでしょう。

8.参考文献

https://qiita.com/ishizakiiii/items/3b894b6e987fdf87093e

最後まで読んでいただき、ありがとうございました!

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です