LoginSignup
2
1

More than 3 years have passed since last update.

Dota2の試合データで学ぶPythonによるデータ分析

Posted at

はじめに

Dota2というMOBAゲームがあります。
5人ずつ2陣営に分かれて戦うというPvPゲームなのですが、試合後の結果のデータをAPIから取得することができます。
今回はそのゲームの試合データを使用して、Pythonでのデータ分析手法を学んでいこうと思います。

どうやったか

データ準備

こちらの手順に従い、ランクマッチ20万試合からheroのデータを200万レコード生成しました。
データはローカルのMySQLに格納されています。

作業環境

  • Python3.7
  • Windows 10

分析手法

  • pandas,seabornによるデータ集計、可視化
  • randomforestによる特徴量抽出

データ集計、可視化

では実際に分析を行っていきます。

import pandas as pd
import mysql.connector

まずはpandasmysql.connectorをimportします。
pandasによってDBから取得したデータをオブジェクト的に扱うことが可能になります。
DBとの接続は以下のように行います。

conn = mysql.connector.connect(user=USER, password=PASSWORD, host=HOST,database=DATABASE)
get_data_sql = ("SOME SQL TO GET DATA FROM DB")

df = pd.read_sql(get_data_sql,conn)

まずはDBへのコネクションと発行したいSQL文をそれぞれ用意しておきます。
その後、pandas.read_sql(SQL文,DBへのコネクション)を使用することで、DBに接続してクエリを発行した結果をpandasのデータフレームとして返すことができます。
今回は自前で用意した200万レコードほどのheroの試合後のデータを読み込みます。

#行数、列数の確認
df.shape
#columnsの確認
df.columns
#各columnの型を確認
df.dtypes
#データの最初の10行の確認
df.head(10)

上記の各メソッドを使用し、データフレームの中身の確認を行います。
出力として、以下のような結果が得られます。


>>> df.shape
(2324690, 14)
>>> df.columns
Index(['localized_name', 'kills', 'deaths', 'assists', 'last_hits', 'denies',
       'gold_per_min', 'xp_per_min', 'hero_damage', 'hero_healing',
       'tower_damage', 'level', 'role', 'win'],
      dtype='object')
>>> df.index
Int64Index([      0,       1,       2,       3,       4,       5,       6,
                  7,       8,       9,
            ...
            2324680, 2324681, 2324682, 2324683, 2324684, 2324685, 2324686,
            2324687, 2324688, 2324689],
           dtype='int64', length=2324690)
>>> df.dtypes
localized_name     object
kills               int64
deaths              int64
assists             int64
last_hits           int64
denies              int64
gold_per_min        int64
xp_per_min          int64
hero_damage         int64
hero_healing        int64
tower_damage        int64
level               int64
role              float64
win                 int64
dtype: object

>>> df.head(10)
  localized_name  kills  deaths  assists  last_hits  denies  gold_per_min  xp_per_min  hero_damage  hero_healing  tower_damage  level  role  win
0   Chaos Knight      2       7       15        148      14           365         525        19390             0          2496     22   2.0    0
1          Razor      3      12       19        154       8           399         597        31253             0          3280     25   4.0    1
2           Puck     14       4       11        117      12           648         629        20550             0          4926     17   1.0    1
3     Windranger     17      10       10        272      15           561         837        45332             0          2573     25   2.0    1
4  Bounty Hunter      9      15       14         79       4           376         389        19695             0           568     19   2.0    0
5         Medusa      2       8        7        342       5           466         554        18195             0          1221     23   1.0    0
6    Drow Ranger      4      11       11        264      12           463         612        14212             0          3907     24   3.0    1
7    Wraith King      0      10        5        110       8           278         341         8672           653           136     15   3.0    0
8          Viper      6       6       13        160       6           357         515        40166             0           207     22   3.0    0
9          Viper      9       9       18        321      14           544         765        37989          3344          3441     25   3.0    1

これでなんとなくデータフレームの中身が分かりました。
次はデータの集計を行っていきます。

>>> df[df['localized_name']=="Wraith King"].groupby('role').mean()
         kills    deaths    assists   last_hits    denies  gold_per_min  xp_per_min   hero_damage  hero_healing  tower_damage      level       win
role
1.0   8.148059  5.080573  14.404602  319.447215  9.224476    605.831597  689.485982  29056.807468   1943.241533   7506.423583  23.024315  0.581814
2.0   6.305348  6.056100  14.952829  260.457731  8.572455    521.927788  622.459381  23908.871882   2271.675143   5684.855673  22.174512  0.570805
3.0   5.043701  6.973615  15.006102  207.631926  7.273747    454.209433  561.282487  19626.316788   2461.276550   4234.463885  21.211082  0.532322
4.0   3.978129  7.995140  14.980964  166.241798  5.795869    383.629810  504.052653  16742.079789   2639.248279   3049.780478  20.347104  0.455650
5.0   2.725076  8.490433  13.041289  115.559919  4.201410    305.891239  400.220544  12426.337362   2446.492447   1887.459215  17.639476  0.373615

色々なメソッドを合わせて使用し、Wraith Kingのrole毎の各データの平均値を集計してみました。
gpmが高いほど勝率が高くなるcarryheroの典型ですね。しかも彼に関してはpos3となっていても53%の勝率を誇ります。
一方pos4,5に関しては一気に勝率が下がるので、support適正は低いと言えそうですね。
という感じでpandasを使用することで、データを集計して見える形で出力することができます。

では各手順について順番に解説していきます。
まずdf['column名']は選択したcolumnのみのデータフレームを表します。
df[df['column名']=='抽出したいデータ']であるcolumnについて、データを絞って抽出できます。今回はlocalized_nameがWraith Kingであるデータ群を抽出しています。
データフレームの持つメソッドgroupby('集計したいcolumn名')を使用することで、あるcolumnについてデータを集計することができます。
集計後のオブジェクトに対してmean()メソッドを使用することで、その平均値を集計したデータを出力することができます。

次にseabornを使用し、データの可視化を行います。

import seaborn as sns
import matplotlib.pyplot as plt

df = df.dropna(how='any')
df_viper = df.query("localized_name == 'Viper'").drop('localized_name', axis=1)

まずはライブラリのimportを行います。
そしてdf.dropna(how='any')で欠損値の削除を行います。
引数howanyを指定することで、一つでも欠損値を含む行を削除することができます。
尚、デフォルトのhowの引数はanyで設定されているため、dropna()でも同じ処理が実行できます。

今回はViperというheroに関して見ていこうと思います。
なので、新たにデータフレームを作製します。
df.query()は抽出を可能にするメソッドで、SQLに近い形で条件を指定することができます。
可視化する際にheroの名前を示すlocalized_nameは不要かつデータフレーム名で明言を行なっているため、dropメソッドを使用することでcolumnの削除を行います。
引数axisに1を指定することで列の削除を行います。ちなみに0を指定すると行の削除が可能になります。

ヒストグラム
fig1, ax1 = plt.subplots(1,1)
sns.distplot(df_viper['gold_per_min'], kde=False, bins=100, ax=ax1)
ax1.set_xlim(0,1000)

まずはplt.subplots(行数,列数)を使用しfigure,axesのオブジェクトを作製します。
次にseaborndistplotメソッドを使用し、ヒストグラムを描画していきます、
引数としては、まず目的のデータを配列で渡します。
また、kdeはデフォルトでTrueになっていて、その場合密度関数を表示してくれます。
今回はFalseに設定したのでヒストグラムだけの表示で、y軸はx軸のvaluecountを表します。
binsはbinのサイズを指定します。今回は100で設定しています。
axには先ほど作製したaxesオブジェクトを渡すことで、指定したaxesオブジェクにグラフを描画することができます。
最後にaxesオブジェクトのメソッドset_xlimを使用することで、x軸の表示範囲を指定することができます。今回は0から1000の範囲を指定しています。
この結果として以下のようなグラフが得られます。

viper_gpm_722d.png
上記からgpm500あたりにピークが見られることがわかります。
全体としてgpm400-550あたりが割合の高い傾向が見られますね。

ヒートマップ
fig2, ax2 = plt.subplots(1,1)
sns.heatmap(df_viper.corr())

再び、figure,axesオブジェクトを生成します。
df.corr()はデータフレームの各列間の相関係数を返します。
sns.heatmapはその名の通りヒートマップを描くことができます。
ヒートマップを描くことで、以下のように各列の相関が一目でわかるようになります。
Unknown.png
上記の図のwinに注目すると、assists,gpm,towerdamageなどに正の相関が見られますね。
そんなの当たり前だろうという感じですが、、、
killsよりもassistsの方が正の相関が高いようなので、carryというよりはpos3くらいの立ち位置が勝利に貢献できるようですね。

RandomForestによる特徴量抽出

次はメジャーな機械学習手法であるRandomForestを使用し、特徴量抽出を行います。
RandomForestでは複数の決定木モデルを量産し、その多数決をとってアウトプットとします。
特徴として、変数、データセットから非復元抽出して各決定木を作製することで、バリアンスの低減が実現できます。
今回はWitch Doctorというheroのデータを使用します。

from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import cross_validate
from sklearn.model_selection import GridSearchCV

まずはPythonの機械学習ライブラリのScikit-learnからRandomForestClassifier,cross_validate,GridSearchCVをimportします。

今回は、Cross Validationという手法でモデルの学習、評価を行います。
この手法ではデータセットをn分割し、n-1個のデータセットでモデル学習を行い、残り1つのデータセットで評価をする、ということをn回繰り返します。
こうすることでn個の別々のデータセットによる学習済みモデルを得ることで、汎化精度の向上が狙えます。

また、ハイパーパラメータの選定にGridSearchを使用します。
この手法では、パラメータのリストを用意し、それぞれに対して学習を行い、最適なパラメータを発見できます。
GridSearchCVではその評価にCross Validationを使用することでより精度の高い探索を行うことができます。

clf = RandomForestClassifier(n_jobs=-1)
params = {'n_estimators':[100,500,1000],'max_features':["auto","log2"]}
gs = GridSearchCV(estimator=clf,param_grid=params,cv=5,n_jobs=-1)
output = cross_validate(gs,df_wd.drop('win',axis=1),df_wd['win'],return_estimator =True,cv=5)

RandomForestClassifier()でランダムフォレストのオブジェクトを生成します。
引数に用いたn_jobsは並列処理のコア数を指定できます。-1を入力することで実行している環境の最大のコア数で処理をしてくれます。
次にパラメータのdictを用意します。それぞれ試行したいパラメータをkeyとし、具体的な数値などをvalueとしてlistで用意します。

そしてGridSearchCV()でcross validationをしながらGrid Searchを行うオブジェクトを生成します。
estimatorに使用したいモデルのオブジェクト、param_gridに評価したいパラメータのdictcvに分割方法、n_jobsに並列数を指定します。

最後に、cross_validate()でcross validationを実行できます。
実行結果として、scoreなどの要素をkeyとして持つdictが返ってきます。
引数として、先ほどのGridSearchCVのオブジェクト、説明変数、目的変数、return_estimatorcvを用います。
return_estimatorにTrueを与えると、返ってくるdictの要素にestimatorを追加できます。
estimatorvalueとして、cross valitationで学習したそれぞれのモデルのオブジェクトを持ちます。

f_impo_mean = np.array([np.array([model.best_estimator_.feature_importances_[x] for i, model in enumerate(output['estimator'])]).mean() for x in range(len(df_wd.drop('win',axis=1).columns))])
best_model = output['estimator'][0].best_estimator_
feature_importances = pd.DataFrame(f_impo_mean,
                                       index = df_wd.drop('win',axis=1).columns,
                                        columns=['importance']).sort_values('importance', ascending=False)
print(f"Scores:{output['test_score'].mean()}")
print(feature_importances)

すごい煩雑なコードを書いてしまいましたが、上記によって学習したモデルの平均スコアと平均の特徴量の重要度が得られます。

GridSearchCV().best_estimator_によって、最適なパラメータで学習を行ったモデルが得られます。
そのモデル、つまりRandomForestClassifierのオブジェクトは.feature_importances_によって特徴量の重要度が配列として得られます。
今回は最適パラメータで学習したモデルの特徴量の重要度を全て抽出し、平均を出しています。
また、スコアについてはcross_valitadeが出力したdictの要素としてそれぞれのモデルのスコアを配列として持っているので、その平均を取得します。
これにより今回得たモデルのスコア及び特徴量の重要度が以下のように得られます。

Scores:0.869820195786567
              importance
tower_damage    0.318008
gold_per_min    0.124985
deaths          0.095011
assists         0.093349
hero_damage     0.071653
hero_healing    0.070373
last_hits       0.057846
xp_per_min      0.056232
role            0.029596
kills           0.028958
denies          0.027598
level           0.026391

ぼちぼちなスコアなのではないでしょうか。
目的変数を勝敗にしてあるので、特徴量の重要度としてtower_damageとgpmが来るのは当たり前といえば当たり前ですね、、、
assistsが高くkillが低めの数値であるので、集団戦には積極的に参加することが求められる一方、killは取らなくてもゲームの勝敗に関与はしなさそうですね。
また、hero_damageと同じくらいhero_healingの重要度が高いことがわかります。
Witch Doctorは回復スキルを持っているのですが、それを適切に使用することも勝敗には重要みたいですね。
まぁ勝ち試合であればずっと回復していられるからというのもあるかもしれませんが、、、

ともかく、以上のようにCross Validation, Grid Searchを使用したRandomForestによるモデルを利用して汎化精度が高い形で特徴量抽出が実現できます。

終わりに

軽くですが、Pythonによる分析手法が学べたと思います。
今後として
-word2vecでのheroのベクトル化
-前処理、分析のフロー作製
-更なる手法の探索
などにトライしていこうと考えています。

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1