先日の記事にて、機械学習を使ってバルセロナのゴール期待値(xG)を算出し、MSNの凄さを分析しました。
Expected Goals (xG)…シュートが得点につながる確率を0~1の範囲で表したもの
前回はいきなり分析結果を紹介しましたが、今回からはxGモデル構築について話していきます。
今日はデータ可視化編になります。
流れとしては、StatsBombのオープンデータを読み込み、シュートデータを可視化して理解する、というところまで。
StatsBomb Open Data
StatsBombは海外のデータ分析会社であり、メディアやチームに対してサッカーに関するデータを提供しています。
彼らはオープンデータも提供しており、その一部にメッシが出場したLa Ligaのイベントデータがあります↓

データベース作成には相当なリソースを割いたようで、公式ブログに苦労話が記されています。
彼らの熱意を無駄にしないためにも、存分に使わせてもらいましょう。
データの読み込み
それではデータを使っていきましょう。
今回使用したコードはGitHubに置いていますので、適宜ご参照ください。
データはjson形式で提供されています。
pandasなどでそのまま読み込んでもいいのですが、StatsBombデータをcsvに変換してくれる便利なパッケージがあるため、そちらを利用しましょう。
1 2 3 4 |
import statsbomb as sb # StatsBomb JSON parser # read the DataFrame of competiton comps = sb.Competitions().get_dataframe() |
competitions.jsonには大会・リーグ名が格納されています。
Competitions()クラスを使うと、competitons.jsonに納められているデータを一気に取得することができます。
中身を見てみましょう。

上の画像では見えませんが、表の下部にかけてLa Ligaの04/05~15/16シーズンのデータが納められています。
先述の通り、メッシが出場した試合に限定されているので注意です。
次に、La Ligaのcompetition_idが11であることを利用し、matches.jsonからLa Ligaの試合データを抜き出します。
1 2 3 4 5 6 7 8 9 10 |
# extract La Liga matches competition_id = 11 season_ids = comps['season_id'][comps['competition_id']==competition_id].tolist() season_names = comps['season_name'][comps['competition_id']==competition_id].tolist() for i, season_id in enumerate(season_ids): matches_ = sb.Matches(event_id=competition_id, season_id=season_id).get_dataframe() # convert season id to season name matches_['season'] = season_names[i] matches = matches_ if i == 0 else pd.concat([matches_, matches]) matches = matches.reset_index(drop=True) |
肝心のシュートデータはevents.jsonの中にあるため、match_idと紐付けてシュートデータだけ抜き出します。
1 2 3 4 5 6 7 8 9 10 |
# read shot data for i, event_id in tqdm(enumerate(matches['match_id'])): events = sb.Events(event_id=str(event_id)) data_ = events.get_dataframe(event_type='shot') # add the 'season' column to the DataFrame data_.insert(0, 'season', matches['season'][i]) # add the 'match_date' columns to the DataFrame data_.insert(1, 'match_date', matches['match_date'][i]) data = data_ if i == 0 else pd.concat([data, data_]) data = data.reset_index(drop=True) |
さらに、今回はバルサのシュートに着目したいので、チーム名を指定して該当のシュートだけ取ってきます。
1 2 |
# extract Barcelona's shot shots = data[data['team']=='Barcelona'].reset_index(drop=True) |
これでシュートデータの取得は完了です↓

次に、変数名(列名)をリストアップし、どのようなデータが納められているかチェックします。
中身が気になる変数に関してはユニーク要素もリストアップしてみましょう。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
# show the detail of data print('columns -', list(shots)) print() print('play_pattern -', (list(shots['play_pattern'].unique()))) print() print('type -', list(shots['type'].unique())) print() print('technique -', list(shots['technique'].unique())) print() print('outcome -', list(shots['outcome'].unique())) |

35個の変数があり、シュートに関する多くの情報が納められていることがわかります。
例えば、outcome列はその名の通りシュートの結果を示しています。その中には「宇宙開発」的なシュートを表すWaywardもあります苦笑
データの前処理
本企画の狙いはシュートのゴール確率を予測することなので、シュートがゴールだったか否かを示す教師データが必要です。
outcome列の中にGoal要素があるため、これを元にgoal列を作成しておきます。
1 2 3 |
# add new columns # goal or no goal shots['goal'] = shots['outcome'].apply(lambda x: 1 if x == 'Goal' else 0) |
さらに、新たな特徴量を作成しておきます。追加した特徴量は以下のとおり。
- body_part…”Left Foot”および”Right Foot”でのシュートを”Foot”に統一
- distance…シュート位置からゴール中心部までの距離
- angle…シュート位置→右ポストのベクトルと、シュート位置→左ポストのベクトルが成す角度
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
# body_part: Left Foot & Right Foot -> Foot shots['body_part'] = shots['body_part'].where((shots['body_part']!='Left Foot') & (shots['body_part']!='Right Foot'), 'Foot') # distance from shot-taker to goal shots['distance'] = np.sqrt(((120 - shots['start_location_x'])**2 + (40 - shots['start_location_y'])**2)) # angle between following two vectors: # vector from shot-taker to right post # vector from shot-taker to left post angles = [] for i in range(len(shots)): right_post = np.array([120 - shots['start_location_x'].iloc[i], 44 - shots['start_location_y'].iloc[i]]) left_post = np.array([120 - shots['start_location_x'].iloc[i], 36 - shots['start_location_y'].iloc[i]]) I = np.inner(right_post, left_post) R = np.linalg.norm(right_post) L = np.linalg.norm(left_post) angles.append(np.rad2deg(np.arccos(I / (R * L)))) shots['angle'] = angles |
次に欠損値処理を行います。
カテゴリカル変数は1 or nullで記録されているので、可視化の障害にならぬようnull値を0で置き換えておきます。
1 2 3 4 |
# handle vissing values # True -> 1, None -> 0 for col in ['under_pressure', 'first_time', 'one_on_one', 'open_goal']: shots[col] = shots[col].fillna(0) * 1 |
次回のために、この時点でのDataFrameをcsvとして保存しておきます。
1 2 |
# write shod data to csv file shots.to_csv('../data/input/shots.csv', index=False, encoding='utf-8-sig') |
データの可視化
前処理が終わったので可視化に入ります。
各変数に対するシュート数・ゴール率の関係性を調べ、データに対する理解を深めていきます。
18/19シーズンのシュートに対して予測を行いたいので、可視化段階では17/18シーズンまでのデータのみ可視化します(学習段階でleakageに繋がってしまうため)。
1 2 3 4 |
# use 04/05 - 17/18 season data for visualization num_test = len(shots[shots['season']=='2018/2019']) # the number of 18/19 season data # train: 04/05 - 17/18 season data train = shots.iloc[:-num_test] |
変数の数が多いので重要そうなものだけ見ていくことにします。
まずはplay_pattern。攻撃開始時のプレーの種類を表します。

注目すべきは、カウンター(From Counter)から生まれたシュートの決定率が3割と高い点でしょうか。
サッカーではカウンター時に生まれる得点が多いと言われるため、納得できる結果です。
その他(Other)が飛び抜けて決定率が高いですが、これはPKが含まれているからですね。
次はシュートの種類を示すtype列。

当然ですが、シュート数に関してはオープンプレーが最も多くなっています。
決定率はPKが最も高く、8割ほどの決定率。
各国リーグにおけるPKの決定率は8割程度と言われていますが、それはバルセロナも例外ではないようです。
次にシュート位置を見ていきますが、その前にピッチ座標についての理解を深めましょう。
StatsBombのデータでは、ピッチ縦方向をX軸、横方向をY軸とし、それぞれ0~120、0~80の範囲の座標系になっています↓

それを踏まえて、まずはX座標のグラフをご覧ください。

ピッチ縦方向に着目すると、100~110の位置で放たれたシュートが多いことがわかります。
ペナルティエリアに入ったあたりでのシュートが多いわけですね。
そして、当然ながらゴールへ近ければ近いほど決定率は高くなります。
センターライン付近(60)からの決定率が少し高くなっていますが、恐らく相手GKが飛び出していた状況でのシュートだったのでしょう。
ピッチ横方向に関しては、ゴール正面からのシュートが多く決定率も高いことが見て取れます。

distanceとangleについても可視化を行っておきましょう。
当然ながら、ゴールに近ければ近いほど、角度が大きければ大きいほどゴールは決まりやすくなっています。


もう少しビジュアル的に優れた可視化をしたいので、カーネル密度推定という手法を使い、シュート位置のヒートマップを作成してみます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
from scipy.stats.kde import gaussian_kde # visualize shot area x_mesh, y_mesh = np.mgrid[0:100:0.1, 0:100:0.1] # make grids in increments of 10 cm loc_mesh = np.vstack([x_mesh.ravel(), y_mesh.ravel()]) x_shot = train['start_location_x'].values * 100 / 120 # normalize x-coordinate y_shot = (80 - train['start_location_y'].values) * 100 / 80 # normalize y-coordinate loc_shot = np.vstack([x_shot, y_shot]) k_shot = gaussian_kde(loc_shot) # 2D Kernel Density Estimation z_shot = np.reshape(k_shot(loc_mesh).T, x_mesh.shape) fig, ax = draw_pitch() contour = ax.contourf(x_mesh, y_mesh, z_shot.reshape(x_mesh.shape), cmap='summer') ax.set_title("Shot area in Barcelona's games Messi has played") |

先程のグラフより直感的に理解しやすくなりました。
ペナルティエリア中央を中心として、楕円を描くようにシュート位置が分布しています。
バイタルエリアにも分布が広がっているので、ミドルシュートもそれなりに多かったみたいですね。
せっかくなのでゴール位置のヒートマップも作ってみましょう。
1 2 3 4 5 6 7 8 9 10 |
# visualize goal area x_goal = train['start_location_x'][train['goal']==1].values * 100 / 120 # normalize x-coordinate y_goal = (80 - train['start_location_y'][train['goal']==1].values) * 100 / 80 # normalize y-coordinate loc_goal = np.vstack([x_goal, y_goal]) k_goal = gaussian_kde(loc_goal) # 2D Kernel Density Estimation z_goal = np.reshape(k_goal(loc_mesh).T, x_mesh.shape) fig, ax = draw_pitch() contour = ax.contourf(x_mesh, y_mesh, z_goal.reshape(x_mesh.shape), cmap='summer') ax.set_title("Goal area in Barcelona's games Messi has played") |

おっと、一気にスポットが小さくなりました…!
色が濃い箇所の大部分がペナルティエリアに入っており、ボックス内での決定率の高さが伺えます。
おわりに
可視化編はこれにて終了です。
次回は今回得たシュートデータの肌感を活かし、機械学習モデルを構築していきます。

最後に…
先日DAZNでマンチェスター・ダービーを観ていたのですが、実況の下田さんが試合前にxGについて話していました…!
「xGを基準にして考えると、ユナイテッドはもっと点を取っていてもおかしくない」という現地の報道を紹介していました。
今後、試合中継にもxGが登場する機会が増えると嬉しいです。
コメントを残す