# (必須)モジュールのインポート
import os
import numpy as np
import pandas as pd
import matplotlib as mpl
import matplotlib.pyplot as plt
try:
    import japanize_matplotlib
except:
    pass

# 表示設定
np.set_printoptions(suppress=True, precision=3)
pd.set_option('display.precision', 3)    # 小数点以下の表示桁
pd.set_option('display.max_rows', 150)   # 表示する行数の上限
pd.set_option('display.max_columns', 5)  # 表示する列数の上限
%precision 3
'%.3f'
# (必須)カレントディレクトリの変更(自分の作業フォルダのパスをコピーして入力する)
os.chdir(r'/Users/narizuka/work/document/lecture/rissho/sport_programming/sport_data')

6. イベントデータの解析#

6.1. データセット#

6.1.1. Pappalardoデータセット#

Pappalardoデータセットはサッカーのイベントデータをまとめた大規模データセットであり,CC BY 4.0ライセンスの下で提供されている. 元のデータはWyscout社によって収集されたもので,それをL. Pappalardoらが編纂し2019年に公開された. 2022年時点で一般公開されている数少ない大規模サッカーデータである. データセットの詳細は以下の論文にまとめられている:

本章で用いるデータセットはPappalardoデータセットを加工・整形したものである.

6.1.2. データセットの内容#

対象試合

Pappalardoデータセットに含まれる試合は2017年度ヨーロッパリーグ,2018年度FIFAW杯,2016年度UEFAチャンピオンズリーグの全1941試合である.

リーグ・大会名

シーズン・年度

試合数

イベント数

選手数

スペイン1部リーグ(La Liga)

2017-2018

380

628659

619

イングランド1部リーグ(Premier League)

2017-2018

380

643150

603

イタリア1部リーグ(Serie A)

2017-2018

380

647372

686

ドイツ1部リーグ(Bundesliga)

2017-2018

380

519407

537

フランス1部リーグ(Ligue 1)

2017-2018

380

632807

629

FIFA World Cup

2018

64

101759

736

UEFA Euro Cup

2016

51

78140

552

1941

3251294

4299

データの種類
Pappalardoデータセットには,各試合のイベントログの他,下表のようなデータが含まれている. ほとんどのデータはjson形式で提供されており,figshareからダウンロード可能である.

データ

ファイル形式

各試合のイベントログ.
パス,シュートなど主にボールに関わるイベントの発生時刻,位置,その他付加情報

events_competition-name.json

リーグ・大会の情報

competitions.json

出場チームの情報

teams.json

出場選手の情報

players.json

審判の情報

referees.json

コーチの情報

coaches.json

イベントIDとイベント名の対応表

eventid2name.csv

イベントに付与されるタグの説明

tags2name.csv

6.1.3. サポートページ#

Pappalardoデータセットに含まれる全てのデータおよび付加情報は以下で取得できる.

  • figshare

    • データの入手先

    • ページ最上部でデータセットのバージョンを選択できる(2022年5月現在の最新版はVersion 5)

    • ページ最下部からzipファイルやjsonファイルをダウンロードできる

  • Wyscout API

    • Wyscout社のサポートページ

    • 各データ内容に関する詳細な情報を掲載

  • 日本語の解説サイト

    • 日本語によるデータセットの詳細な説明

    • 一部に情報が古い部分がある

    • どなたが作成されたか不明(作成者に感謝)

6.1.4. 本講義で用いる加工済みデータ#

Pappalardoデータセットに含まれるオリジナルのデータはjson形式で提供されており,このままではデータ分析がしづらい. そこで,まずはjson形式のデータを整形・加工し,PandasのDataFrameの形で保存する. この過程は本講義で扱った知識を総動員するだけでなく,文字列の処理などより高度な技術が必要である. そのため,本講義ではデータの整形・加工の過程は省略し,加工済みデータ(csvファイル)を用いたいくつかの分析例を示す

加工済みデータの内容

加工済みデータの詳細およびダウンロード用リンクを以下にまとめる.
※ W杯とCLのデータはヨーロッパリーグと試合数が異なるので,解析対象からは除外する.

内容

ファイル

ファイルサイズ

選手のプロフィールデータ

player.csv

172KB

チームのプロフィールデータ

team.csv

4KB

各試合の得点データ

game.csv

156KB

イベントIDとイベント名の対応

event_list.csv

0.9KB

イベントに付与されるタグの説明

tag_list.csv

2KB

各試合のイベントデータ(イングランド)

イベントログ:event_England.csv
イベントタグ:event_tag_England.csv

58MB
76.2MB

//(フランス)

イベントログ:event_France.csv
イベントタグ:event_tag_France.csv

57.6MB
74.8MB

//(ドイツ)

イベントログ:event_Germany.csv
イベントタグ:event_tag_Germany.csv

47.2MB
61.5MB

//(イタリア)

イベントログ:event_Italy.csv
イベントタグ:event_tag_Italy.csv

58.9MB
76.6MB

//(スペイン)

イベントログ:event_Spain.csv
イベントタグ:event_tag_Spain.csv

56.1MB
74.5MB

6.2. リーグ成績と順位表#

今,手元には2017年度ヨーロッパリーグ全試合の得点データ( game.csv )とチームプロフィール(team.csv)がある. これらを用いれば,チームごとに得点,失点,得失点差,勝敗などを算出することができる. 各リーグの最終的な順位は勝ち点によって決まる. 1試合で獲得する勝ち点は勝利が3,引き分けが1,負けが0である. よって,得点データを用いれば勝ち点を計算し,順位表を作成することができる.

以下では,イングランド・プレミアリーグの最終成績と順位表を作成してみよう. なお,公式に公開されている2017年度イングランド・プレミアリーグの最終成績と順位表は以下で確認できる:

6.2.1. データの読み込み#

まずはgame.csvをダウンロードして作業フォルダ(例えばOneDrive/sport_data/6_event)に移動し,GMという名前のDataFrameに読み込む.

GM = pd.read_csv('./6_event/game.csv', header=0)
GM.head(2)
game_id league ... away_score home_score
0 2499719 England ... 3 4
1 2499723 England ... 0 1

2 rows × 11 columns

このデータの各行には2017年度ヨーロッパリーグで行われた試合の情報が収められている. 各列の意味は下表の通りである. このうち,'away_score'列と'home_score'列がアウェイチームとホームチームの得点である. 例えば,第0行はアーセナル(ホーム)対レイチェスターシティ(アウェイ)の試合情報を表し,得点は4-3であることが分かる.

変数名

内容

game_id

試合の一意なID

league

リーグ名

section

節(全38節)

date

日付

venue

試合地

away

アウェイチーム名

away_id

アウェイチームID

home

ホームチーム名

home_id

ホームチームID

away_score

アウェイチームのスコア

homw_score

ホームチームのスコア

次にteam.csvをダウンロードして作業フォルダ(例えばOneDrive/sport_data/6_event)に移動し,TMという名前のDataFrameに読み込む.

TM = pd.read_csv('./6_event/team.csv', header=0)
TM.head()
name team_id city country league
0 Arsenal 1609 London England England
1 Chelsea 1610 London England England
2 Manchester_United 1611 Manchester England England
3 Liverpool 1612 Liverpool England England
4 Newcastle_United 1613 Newcastle_upon_Tyne England England

このデータの各行には2017年度ヨーロッパリーグに出場したクラブチームの情報が収められている. 各列の意味は下表の通りである. 例えば,第0行はイングランド・プレミアリーグに所属するアーセナルのチーム情報を表している.

変数名

内容

name

チームの俗称

team_id

チームID.playersデータの'currentTeamId'+'currentNationalTeamId'

city

チームの所在都市

country

チームの所在国

league

チームの所属リーグ

以下では,イングランド・プレミアリーグのデータを解析対象とする. そこで,条件付き抽出を用いて,TMGMからイングランド・プレミアリーグのデータだけ抽出する.

GM_E = GM.loc[GM['league']=='England']
TM_E = TM.loc[TM['league']=='England']

6.2.2. 1チームのリーグ成績#

まずはチームプロフィールTM_Eの先頭行のチーム(アーセナル)に対し,リーグ成績を求めてみよう. このチームのチームIDとチーム名を取得するには以下のようにiloc属性を用いて先頭行を抽出すれば良い.

tm_id = TM_E['team_id'].iloc[0]
tm_name = TM_E['name'].iloc[0]
print(tm_id)
print(tm_name)
1609
Arsenal

得点と失点

得点データGMでは,2チームをhome,awayによって区別している. よって,チームごとに得点と失点を集計するには,ホームゲームとアウェイゲームに分けて処理する必要がある. ホームゲームでは,home_score列が得点,away_score列が失点に対応し,アウェイゲームでは逆になる. このことに注意し,アーセナルのホームゲームの得点・失点をS_h,アウェイゲームの得点・失点をS_aに保存する. また,得失点差の列diffを追加する.

# 得点と失点(ホームゲーム)
S_h = GM_E.loc[(GM_E['home_id']==tm_id), ['home_score', 'away_score']]
S_h = S_h.rename(columns={'home_score': 'goal', 'away_score': 'loss'})  # 列ラベルのリネーム
S_h.head()
goal loss
0 4 3
31 3 0
51 2 0
61 2 0
91 2 1
# 得点と失点(アウェイゲーム)
S_a = GM_E.loc[(GM_E['away_id']==tm_id), ['away_score', 'home_score']]
S_a = S_a.rename(columns={'away_score': 'goal', 'home_score': 'loss'})  # 列ラベルのリネーム
S_a.head()
goal loss
11 0 1
20 0 4
44 0 0
78 1 2
82 5 2
# 得失点差列の追加
S_h['diff'] = S_h['goal'] - S_h['loss']  # ホーム
S_a['diff'] = S_a['goal'] - S_a['loss']  # アウェイ
S_h.head()
goal loss diff
0 4 3 1
31 3 0 3
51 2 0 2
61 2 0 2
91 2 1 1

試合結果

次に,試合結果の列resultを追加する. ここでは,勝ちを1,引き分けを0,負けを-1に対応させることにする. このようにすると,各試合の結果は得失点差から求めることができる. 求め方は色々と考えられるが,以下ではnp.sign関数を使って正の数を1,負の数を-1に変換している.

S_h['result'] = np.sign(S_h['diff']) # ホーム
S_a['result'] = np.sign(S_a['diff']) # アウェイ
S_h.head()
goal loss diff result
0 4 3 1 1
31 3 0 3 1
51 2 0 2 1
61 2 0 2 1
91 2 1 1 1

ホームゲームとアウェイゲームのデータを結合

次に,pd.concat関数を使ってホームゲームとアウェイゲームのデータを統合する.

S = pd.concat([S_h, S_a])
print(S.head())
print(S.tail())
    goal  loss  diff  result
0      4     3     1       1
31     3     0     3       1
51     2     0     2       1
61     2     0     2       1
91     2     1     1       1
     goal  loss  diff  result
286     1     2    -1      -1
303     1     3    -2      -1
335     1     2    -1      -1
353     1     2    -1      -1
377     1     0     1       1

勝ち点

勝ち点は勝ちの場合に3,引き分けの場合に1として計算する.

S['point'] = 0
S.loc[S['result']==1, 'point'] = 3
S.loc[S['result']==0, 'point'] = 1

最終成績

最後に各試合のデータを集計し,総得点,総失点,総得失点差,勝ち点を計算する.

gf = S['goal'].sum()  # 総得点
ga = S['loss'].sum()  # 総失点
gd = S['diff'].sum()  # 総得失点差
pt = S['point'].sum()
print('アーセナルの最終成績')
print(gf, ga, gd, pt)
アーセナルの最終成績
74 51 23 63

以上より,アーセナルのリーグ成績が計算できた. 他のチームの成績を統合することを考えて,以下のようにDataFrameの形に整形しておく.

pd.DataFrame([[tm_name, tm_id, gf, ga, gd, pt]],
              columns=['name', 'team_id', 'gf', 'ga', 'gd', 'pt'])
name team_id ... gd pt
0 Arsenal 1609 ... 23 63

1 rows × 6 columns

6.2.3. 全チームのリーグ成績と順位表#

全チームのリーグ成績を求めるには,for文を用いて上の手続きを繰り返せば良い. 以下では,Rankという名前のDataFrameに全チームのリーグ成績を保存する.

Rank = pd.DataFrame(columns=['name', 'team_id', 'gf', 'ga', 'gd', 'pt'])
for i in range(len(TM_E)):
    tm_id = TM_E['team_id'].iloc[i]
    tm_name = TM_E['name'].iloc[i]
    
    '''ホームゲーム'''
    # 得点と失点
    S_h = GM_E.loc[(GM_E['home_id']==tm_id), ['home_score', 'away_score']]
    S_h = S_h.rename(columns={'home_score': 'goal', 'away_score': 'loss'})

    # 得失点差
    S_h['diff'] = S_h['goal'] - S_h['loss']

    # 勝敗(勝:1,分:0,負:-1)
    S_h['result'] = np.sign(S_h['diff']) # 符号に応じて1,0,-1を返す
    
    '''アウェイゲーム'''
    # 得点と失点
    S_a = GM_E.loc[(GM_E['away_id']==tm_id), ['home_score', 'away_score']]
    S_a = S_a.rename(columns={'away_score': 'goal', 'home_score': 'loss'})

    # 得失点差
    S_a['diff'] = S_a['goal'] - S_a['loss']

    # 勝敗(勝:1,分:0,負:-1)
    S_a['result'] = np.sign(S_a['diff'])  # 符号に応じて1,0,-1を返す
    
    # 統合
    S = pd.concat([S_h, S_a])
    
    # 勝ち点
    S['point'] = 0
    S.loc[S['result']==1, 'point'] = 3
    S.loc[S['result']==0, 'point'] = 1
    
    # 順位表への統合
    gf = S['goal'].sum()  # 総得点
    ga = S['loss'].sum()  # 総失点
    gd = S['diff'].sum()  # 総得失点差
    pt = S['point'].sum()  # 勝ち点
    
    # チーム成績の結合
    df = pd.DataFrame([[tm_name, tm_id, gf, ga, gd, pt]], columns=Rank.columns)
    Rank = pd.concat([Rank, df])

最後に,勝ち点の順にソートし,インデックスを付け直す.

# ソートと再インデックス
Rank = Rank.sort_values(['pt'], ascending=False)
Rank = Rank.reset_index(drop=1)

以上により,イングランド・プレミアリーグの順位表が作成できた.

Rank
name team_id ... gd pt
0 Manchester_City 1625 ... 79 100
1 Manchester_United 1611 ... 40 81
2 Tottenham_Hotspur 1624 ... 38 77
3 Liverpool 1612 ... 46 75
4 Chelsea 1610 ... 24 70
5 Arsenal 1609 ... 23 63
6 Burnley 1646 ... -3 54
7 Everton 1623 ... -14 49
8 Leicester_City 1631 ... -4 47
9 AFC_Bournemouth 1659 ... -16 44
10 Crystal_Palace 1628 ... -10 44
11 Newcastle_United 1613 ... -8 44
12 West_Ham_United 1633 ... -20 42
13 Watford 1644 ... -20 41
14 Brighton_&_Hove_Albion 1651 ... -20 40
15 Huddersfield_Town 1673 ... -30 37
16 Southampton 1619 ... -19 36
17 Stoke_City 1639 ... -33 33
18 Swansea_City 10531 ... -28 33
19 West_Bromwich_Albion 1627 ... -25 31

20 rows × 6 columns

6.2.4. 演習問題#

  • 他のリーグについて,順位表を作成せよ

6.3. 得点分布#

サッカーの特徴は得点がランダムに入る(どちらのチームがいつ得点するかが予測不能)という点であり,このランダム性こそが人々を魅了する理由と考えられる. また,サッカーはほとんど点が入らないのも大きな特徴である. では,こうした特徴は統計的にはどのように定量化できるだろうか?

6.3.1. ポアソン分布#

二項分布からポアソン分布へ

成功確率が\(p\)の試行を独立に\(n\)回繰り返すことを考える. 例えば,サイコロを振って特定の目が出ることを成功とすると,\(p=1/6\)である. いま,\(n\)回中\(x\)回成功する確率を\(f(x)\)とすると,\(f(x)\)は二項分布

\[ f(x) = \binom{n}{x}p^{x}(1-p)^{n-x} \]

になることが知られている. この式において,\( p^{x}(1-p)^{n-x} \)は成功が\( x \)回,失敗が\( n-x \)回生じる確率を意味する. また,\( \binom{n}{x} \)\( n \)個から\( x \)を取り出す組み合わせの数\( _{n}C_{x} \)を表し,\( n \)回の中で何回目に成功するかの場合の数に対応する.

いま,成功確率\( p \)が小さく,かつ試行回数\( n \)が大きい極限を考える. ただし,極限を取る際に発散しないように平均値が一定値\( np=m \)になるようにする. このような条件で\(n\)回中\(x\)回成功する確率\(f(x)\)は,二項分布の式に\( np=m \)を代入し,極限\( p\to 0,\ n\to \infty \)を取ることで

\[ f(x) = \frac{m^{x}}{x!} \mathrm{e}^{-m} \]

と求まる. これをポアソン分布と呼ぶ. ポアソン分布は1つのパラメータ\( m \)だけで特徴づけられ,期待値と分散はともに\( m \)となる. ポアソン分布はその導出過程より,一定の期間内に発生確率の小さい稀な現象を多数回試行(観測)した場合に,その発生回数が従う分布である. 実際,以下の現象は全てポアソン分布に従うことが知られている:

  • 1日の交通事故件数

  • 1分間の放射性元素の崩壊数

  • 1ヶ月の有感地震の回数

  • プロシア陸軍で馬に蹴られて死亡した兵士の数

サッカーの得点分布

チームの強さや試合展開など細かいことはひとまず無視し,サッカーにおける得点イベントがランダムに発生すると仮定する. 特に,両チームが常に得点を目指し一瞬で得点チャンスが生まれることから,試合中のどの時点においても一定の得点確率があると見なし,得点確率\( p \)の試行を何度も繰り返す現象(\( n\to \infty \))と捉えることにする. また,各時点で得点する確率は非常に小さいとする(\( p \ll 1 \)). 以上のような仮定を置くと,サッカーにおける1試合の得点数はポアソン分布に従うことが期待される.

6.3.2. 得点データの要約#

まずはgame.csvをダウンロードして作業フォルダ(例えばOneDrive/sport_data/6_event)に移動し,GMという名前のDataFrameに読み込む.

GM = pd.read_csv('./6_event/game.csv', header=0)
GM.head(2)
game_id league ... away_score home_score
0 2499719 England ... 3 4
1 2499723 England ... 0 1

2 rows × 11 columns

この得点データを用いて,リーグごとにアウェイチームとホームチームの得点傾向を調べてみよう. 以下はアウェイチームとホームチームの得点の平均値および分散である. この結果からおおよそ以下のようなことが読み取れる

  • 1試合の得点の平均値はおおよそ1.2点くらいとなっており,サッカーが得点頻度の少ない競技であることが改めて分かる.

  • ホームとアウェイで比べると,ホームの方がやや平均得点が高い傾向にある.

  • 得点の平均値と分散はほぼ同じ値となっており,ポアソン分布の性質をおおよそ満たしている.

# England
print(GM.loc[GM['league']=='England', ['away_score', 'home_score']].mean())
print(GM.loc[GM['league']=='England', ['away_score', 'home_score']].var())
away_score    1.147
home_score    1.532
dtype: float64
away_score    1.387
home_score    1.796
dtype: float64
# France
print(GM.loc[GM['league']=='France', ['away_score', 'home_score']].mean())
print(GM.loc[GM['league']=='France', ['away_score', 'home_score']].var())
away_score    1.189
home_score    1.529
dtype: float64
away_score    1.267
home_score    1.817
dtype: float64
# Germany
print(GM.loc[GM['league']=='Germany', ['away_score', 'home_score']].mean())
print(GM.loc[GM['league']=='Germany', ['away_score', 'home_score']].var())
away_score    1.193
home_score    1.601
dtype: float64
away_score    1.291
home_score    1.644
dtype: float64
# Italy
print(GM.loc[GM['league']=='Italy', ['away_score', 'home_score']].mean())
print(GM.loc[GM['league']=='Italy', ['away_score', 'home_score']].var())
away_score    1.221
home_score    1.455
dtype: float64
away_score    1.413
home_score    1.721
dtype: float64
# Spain
print(GM.loc[GM['league']=='Spain', ['away_score', 'home_score']].mean())
print(GM.loc[GM['league']=='Spain', ['away_score', 'home_score']].var())
away_score    1.147
home_score    1.547
dtype: float64
away_score    1.408
home_score    1.900
dtype: float64

6.3.3. 得点分布#

平均値と分散の一致だけではポアソン分布に従う根拠として乏しい. そこで,リーグ別にホームチームの得点のヒストグラムを求めてみよう. 以下はイングランド・プレミアリーグのホームチームの得点分布である.

data = GM.loc[GM['league']=='England', 'home_score']

fig, ax = plt.subplots(figsize=(4,3))
ax.hist(data, 
        bins=np.arange(data.max()+2)-0.5, # 階級の左端の値を指定する
        histtype='bar',  # ヒストグラムのスタイル
        color='gray',    # バーの色
        edgecolor='k',   # バーの枠線の色
        rwidth=0.5
        )

ax.set_xlabel('1試合の得点', fontsize=12)
ax.set_ylabel('試合数', fontsize=12)
ax.set_xticks(np.arange(data.max()+2));
../_images/6_event_69_0.png

次に,上のヒストグラムがポアソン分布に従っているか調べるため,試合データから求めた平均値をパラメータとするポアソン分布を描いてみる. イングランド・プレミアリーグのホームチームの平均得点は1.53であったので, $\( f(x) = \frac{1.53^{x}}{x!} \mathrm{e}^{-1.53} \)$ のグラフを描けば良い.

from scipy.stats import poisson

fig, ax = plt.subplots(figsize=(4,3))
x = np.arange(0, 9)
fx = poisson.pmf(x, data.mean())
ax.plot(x, fx, '-ok')
[<matplotlib.lines.Line2D at 0x1c671e6a0>]
../_images/6_event_71_1.png

上のグラフを見比べると,確かに似た分布になっていることが分かる. そこで,最後に2つのグラフを合わせよう.

from scipy.stats import poisson
data = GM.loc[GM['league']=='England', 'home_score']

fig, ax = plt.subplots(figsize=(4,3))
ax.hist(data, 
        bins=np.arange(data.max()+2)-0.5, # 階級の左端の値を指定する
        histtype='bar',  # ヒストグラムのスタイル
        color='gray',    # バーの色
        edgecolor='k',   # バーの枠線の色
        rwidth=0.5
        )

x = np.arange(data.max()+2)
fx = data.size * poisson.pmf(x, data.mean())
ax.plot(x, fx, '-ok')

ax.set_xlabel('1試合の得点', fontsize=12)
ax.set_ylabel('試合数', fontsize=12)
ax.set_xticks(np.arange(data.max()+2));
../_images/6_event_73_0.png

実データ(棒グラフ)とポアソン分布(折れ線)の概形はおおよそ一致していることが分かる. これは,得点の平均値と分散が近い値になったことと共に,サッカーの得点分布がポアソン分布に従うことを裏付ける材料となる. もちろん,サッカーの得点分布が普遍的にポアソン分布に従うかどうかは,他のリーグのデータを調べなければわからない(確かめてみよ). また,\(\chi^2\)検定などを用いてより定量的な検証を行うことも必要である.

6.3.4. 演習問題#

  • 他のリーグについて,得点分布を求めよ

6.3.5. 発展問題:バスケの得点分布#

以下は2015年度NBAの得点データである:score_nba.csv
'away', 'home'列はアウェイチームとホームチームの得点,'total'列は両チームの得点の和を表す. このデータを用いてバスケットボールの得点傾向を調べ,サッカーとの違いを考察せよ.
※ レポート問題として取り組んでも良い.

# 得点データの読み込み
data = pd.read_csv('./6_event/score_nba.csv')
data.head()
away home total
0 37 37 74
1 38 37 75
2 35 41 76
3 29 36 65
4 34 39 73

6.4. イベントデータの解析#

Pappalardoデータセットのメインデータはイベントデータである. イベントデータはイベントログとイベントタグの2種類から成る:

  • イベントログ

    • パスやシュートなどのボールに関わるイベントに対して,起きた時刻,場所,関わった選手などの基本情報が紐付けられたデータ

    • 1試合あたり1500~2000イベント

  • イベントタグ

    • イベントログの各イベントに対して,より詳細な付加情報が紐付けられたデータ

イベントデータにはボールに関わるほぼ全てのプレー情報が含まれているため,詳細な試合展開を把握することができる. イベントデータは選手プロフィールや得点データに比べて格段に情報量が多いため,その扱いの難易度も高い. 基本的にExcelで解析するのは困難であり,Pandasの本領が最も発揮されるデータといえる.

ここでは,イングランド・プレミアリーグのデータを解析対象とする. 準備として,以下のファイルをダウンロードして作業フォルダ(例えばOneDrive/sport_data/6_event)に移動する:

これらを以下のように読み込んでおく.

# イベントデータと選手プロフィールの読み込み
EV = pd.read_csv('./6_event/event_England.csv')
EV_tag = pd.read_csv('./6_event/event_tag_England.csv')
PL = pd.read_csv('./6_event/player.csv', header=0)

また,以下の補助データも同じフォルダにダウンロードしておく:

6.4.1. イベントデータの詳細#

イベントログ

EV.head()
id game_id ... x2 y2
0 177959171 2499719 ... 31.0 78.0
1 177959172 2499719 ... 51.0 75.0
2 177959173 2499719 ... 35.0 71.0
3 177959174 2499719 ... 41.0 95.0
4 177959175 2499719 ... 72.0 88.0

5 rows × 14 columns

EVには380試合分のイベントログが含まれており,その行数は643150にのぼる. EVの各行は試合中の1イベントに対応し,各列にそのイベントに関する基本情報が収められている. 各列の内容は下表の通りである.

変数名

内容

id

1つのイベントに付与される識別ID(1行に対して1つのIDが付与される)

game_id

試合ID

half

1H(前半),2H(後半),E1(延長前半),E2(延長後半),P(ペナルティ)

t

イベントが起きた時間(ハーフ開始からの経過時間).単位は秒

team_id

チームID

player_id

選手ID

event

イベントタイプの名前.全7種類

event_id

イベントタイプのID

subevent

サブイベントタイプのID

subevent_id

サブイベントタイプの名前

x1

イベントの始まりの\(x\)座標(単位は%)

y1

イベントの始まりの\(y\)座標(単位は%)

x2

イベントの終わりの\(x\)座標(単位は%)

y2

イベントの終わりの\(y\)座標(単位は%)

イベントログの座標系

イベントの始まりの座標\((x_{1}, y_{1})\)と終わりの座標\((x_{2}, y_{2})\)が存在する. 座標系は以下の通りである:

  • 原点は左下

  • \(x,\ y\)座標の値はフィールドの横幅と縦幅の最大値に対する割合(単位は%)

    • \(0\le x \le 100\)

    • \(0\le y \le 100\)

  • HomeとAwayの攻撃方向は右方向に統一されている

    • チームや前後半に関係なく,\(x>50\)が相手陣,\(x<50\)が自陣

    • ※ 解析内容に応じて,チームごとに攻撃方向が逆になるように変換する必要がある

サッカーコートの公式規格は\(105\mathrm{m}\times 68\mathrm{m}\)なので,コートを描く際にはアスペクト比を以下のように設定する:

ax.set_aspect(68/105)
'''サッカーコートの描画'''
fig, ax = plt.subplots(figsize=(4, 4))
ax.set_aspect(68/105)

# ハーフウェイライン
ax.plot([50, 50], [0, 100], 'k--') 

# 描画範囲と軸ラベル
ax.set_xlim(0, 100); ax.set_ylim(0, 100)
ax.set_xlabel('$X$'); ax.set_ylabel('$Y$')
Text(0, 0.5, '$Y$')
../_images/6_event_91_1.png

イベントタグ

EV_tag.head()
id goal ... dangerous_ball_lost blocked
0 177959171 0.0 ... 0.0 0.0
1 177959172 0.0 ... 0.0 0.0
2 177959173 0.0 ... 0.0 0.0
3 177959174 0.0 ... 0.0 0.0
4 177959175 0.0 ... 0.0 0.0

5 rows × 58 columns

EV_tagはイベントログEVと同じ行数のDataFrameであり,各行が試合中の1イベントを表している. 一方,各列には'goal','assist'などのイベントに付与されたタグ(付加情報)が並んでおり,真ならば1,偽ならば0となっている. 例えば,'goal'列が1である行では,そのイベントにおいて得点が入ったことを意味する. タグの詳細情報はtag_list.csvにまとめられている. 主要なタグを下表にまとめる.

タグ名

内容

accurate

イベントの成功

not accurate

イベントの失敗

assist

アシスト

goal

得点

own_goal

オウンゴール

6.4.2. イベントデータ解析の基本#

イベントログEVとイベントタグEV_tagには,ボールに関わるイベントに関するほぼ全ての情報が含まれている. イベントデータ解析の目的はこれらのデータから意味のある情報を抽出することである. イベントデータを解析する際の手順は以下のようにまとめられる:

  1. イベントログ,イベントタグから必要なデータを条件付き抽出する

  2. 条件付き抽出したデータを集計する

  3. 集計したデータを可視化する

以下では,条件付き抽出の例をいくつか示す.

特定の試合・時間帯の抽出

# 特定の試合を抽出
ev = EV.loc[EV['game_id']==EV['game_id'].unique()[0]]
ev_tag = EV_tag.loc[EV['game_id']==EV['game_id'].unique()[0]]
ev.head()
id game_id ... x2 y2
0 177959171 2499719 ... 31.0 78.0
1 177959172 2499719 ... 51.0 75.0
2 177959173 2499719 ... 35.0 71.0
3 177959174 2499719 ... 41.0 95.0
4 177959175 2499719 ... 72.0 88.0

5 rows × 14 columns

ev_tag.head()
id goal ... dangerous_ball_lost blocked
0 177959171 0.0 ... 0.0 0.0
1 177959172 0.0 ... 0.0 0.0
2 177959173 0.0 ... 0.0 0.0
3 177959174 0.0 ... 0.0 0.0
4 177959175 0.0 ... 0.0 0.0

5 rows × 58 columns

# 前半のみ抽出
ev.loc[ev['half']==1].tail()
id game_id ... x2 y2
896 177960132 2499719 ... 11.0 61.0
897 177960129 2499719 ... 92.0 50.0
898 177960130 2499719 ... 0.0 0.0
899 177960121 2499719 ... 8.0 50.0
900 177960127 2499719 ... 100.0 100.0

5 rows × 14 columns

# 前半開始20秒までを抽出
ev.loc[(ev['half']==1) & (ev['t']<20)].tail()
id game_id ... x2 y2
6 177959186 2499719 ... 39.0 15.0
7 177959189 2499719 ... 33.0 20.0
8 177961218 2499719 ... 67.0 80.0
9 177959178 2499719 ... 59.0 61.0
10 177959179 2499719 ... 45.0 45.0

5 rows × 14 columns

特定のイベントの抽出

イベントログEVには'event'列と'subevent'列が存在する. 'event'列は'pass','foul'などの大分類,'subevent'列は'simple_pass'や'high_pass'などの小分類となっている. 'event'および'subevent'のリストはevent_list.csv にまとめられている.

# event列が'pass'の行を抽出
ev.loc[ev['event']=='pass'].head()
id game_id ... x2 y2
0 177959171 2499719 ... 31.0 78.0
1 177959172 2499719 ... 51.0 75.0
2 177959173 2499719 ... 35.0 71.0
3 177959174 2499719 ... 41.0 95.0
4 177959175 2499719 ... 72.0 88.0

5 rows × 14 columns

# subevent列が'simple_pass'の行を抽出
ev.loc[ev['subevent']=='simple_pass'].head()
id game_id ... x2 y2
0 177959171 2499719 ... 31.0 78.0
4 177959175 2499719 ... 72.0 88.0
5 177959177 2499719 ... 77.0 75.0
17 177959196 2499719 ... 37.0 8.0
18 177959197 2499719 ... 23.0 5.0

5 rows × 14 columns

# event列が'shot'の行を抽出
ev.loc[(ev_tag['goal']==1)].head()
id game_id ... x2 y2
46 177959212 2499719 ... 0.0 0.0
47 177959226 2499719 ... 12.0 59.0
91 177959280 2499719 ... 100.0 100.0
92 177959249 2499719 ... 4.0 48.0
554 177959759 2499719 ... 100.0 100.0

5 rows × 14 columns

イベントタグを用いた抽出

イベントタグEV_tagはイベントログEVと同じ行数で共通の行ラベル(インデックス)を持つ. よって,EV_tagで取得したブールインデックスを用いてEVから条件付き抽出することができる.

# イベント名が'pass'で,'accurate'タグが1である行(成功パス)を抽出
ev.loc[(ev['event']=='pass') & (ev_tag['accurate']==1)]
id game_id ... x2 y2
0 177959171 2499719 ... 31.0 78.0
1 177959172 2499719 ... 51.0 75.0
2 177959173 2499719 ... 35.0 71.0
3 177959174 2499719 ... 41.0 95.0
4 177959175 2499719 ... 72.0 88.0
... ... ... ... ... ...
1739 177961023 2499719 ... 72.0 83.0
1740 177961024 2499719 ... 69.0 74.0
1759 177961037 2499719 ... 25.0 72.0
1762 177961039 2499719 ... 7.0 53.0
1764 177961035 2499719 ... 73.0 58.0

679 rows × 14 columns

# イベント名が'shot'で,'goal'タグが1である行(成功シュート)
ev.loc[(ev['event']=='shot') & (ev_tag['goal']==1)]
id game_id ... x2 y2
46 177959212 2499719 ... 0.0 0.0
91 177959280 2499719 ... 100.0 100.0
554 177959759 2499719 ... 100.0 100.0
898 177960130 2499719 ... 0.0 0.0
1107 177960379 2499719 ... 100.0 100.0
1570 177960849 2499719 ... 0.0 0.0
1613 177960902 2499719 ... 0.0 0.0

7 rows × 14 columns

6.4.3. イベント別のヒートマップ#

条件付き抽出の応用として,イベント別にヒートマップを描いてみよう. まず,以下のようにヒートマップを描くevent_hmap関数を作成する. この関数は,\(x,\ y\)座標のデータを引数として受け取り,matplotlibのhist2d関数を用いてヒートマップを描く.

def event_hmap(x, y, cm='Greens'):
    
    fig, ax = plt.subplots(figsize=(4, 4))
    
    # アスペクト比の変更
    ax.set_aspect(68/105)
    
    # ヒートマップの描画
    ret = ax.hist2d(x, y,\
                    bins=[50, 25], range=[[0, 100], [0, 100]], cmap=cm, cmin=0)

    # カラーバーを追加
    fig.colorbar(ret[3], orientation='vertical', 
                 shrink=0.4, aspect=10, pad=0.05)
    
    # ハーフウェイライン
    ax.plot([50, 50], [0, 100], 'k--') 

    # 描画範囲とラベル
    ax.set_xlim(0, 100); ax.set_ylim(0, 100)
    ax.set_xlabel('$X$'); ax.set_ylabel('$Y$')

特定のイベントだけを条件付き抽出してその\(x,\ y\)座標をevent_hmap関数に渡せば,そのイベントが行われたフィールド上の位置をヒートマップで可視化することができる. 以下にいくつかの例を示す.

# パス
cond = (EV['event']=='pass')
x, y = EV.loc[cond, 'x1'], EV.loc[cond, 'y1']
event_hmap(x, y)
../_images/6_event_117_0.png
# 特定の選手のパス
cond = (EV['event']=='pass') & (EV['player_id']==EV['player_id'].unique()[4])
x, y = EV.loc[cond, 'x1'], EV.loc[cond, 'y1']
event_hmap(x, y, 'Blues')
../_images/6_event_118_0.png
# クロス
cond = (EV['subevent']=='cross')
x, y = EV.loc[cond, 'x1'], EV.loc[cond, 'y1']
event_hmap(x, y, 'Reds')
../_images/6_event_119_0.png
# デュエル
cond = (EV['event']=='duel')
x, y = EV.loc[cond, 'x1'], EV.loc[cond, 'y1']
event_hmap(x, y, 'Greys')
../_images/6_event_120_0.png
# デュエル(攻撃)
cond = (EV['subevent']=='ground_attacking_duel')
x, y = EV.loc[cond, 'x1'], EV.loc[cond, 'y1']
event_hmap(x, y, 'jet')
../_images/6_event_121_0.png
# シュート
cond = (EV['event']=='shot')
x, y = EV.loc[cond, 'x1'], EV.loc[cond, 'y1']
event_hmap(x, y)
../_images/6_event_122_0.png
# シュート(成功)
cond = (EV['event']=='shot') & (EV_tag['goal']==1)
x, y = EV.loc[cond, 'x1'], EV.loc[cond, 'y1']
event_hmap(x, y)
../_images/6_event_123_0.png

6.4.4. 選手のランキング#

シーズンが終了すると,チームのリーグ成績と共に選手の個人成績が発表される. 個人成績は,シュート数やゴール数などの部門別ランキングとなっている. ここでは,イベントデータを用いてこれらのランキングを求めてみよう. なお,どのようなプレーをシュートやパスと見なすかは用いるデータセットによって異なっており, 以下で求めるランキングが公式発表されたものと完全に一致するわけではない. 2017年度プレミアリーグの個人成績は例えば,

にて確認できるが,細かい数値は本データセットから求めたものと一致しない.

ランキングの作成方法は以下の通りである.

  • ランキング項目に応じて条件付き抽出する.

    • 例えば,パス数の場合は'event'列が'pass'である行を抽出する

  • 条件付き抽出後のDataFrameに対し,'player_id'ごとの出現回数を求める

    • DataFrameのvalue_countsメソッドを用いる

  • 選手プロフィールPLを用いて'player_id'を選手名に変換する

シュート数

PR_shot = EV.loc[(EV['subevent']=='shot') | (EV['subevent']=='free_kick_shot') | (EV['subevent']=='penalty'), 'player_id'].value_counts()
PR_shot = PR_shot.rename(index=dict(PL[['player_id', 'name']].values))  # 選手IDを選手名に変換する
PR_shot.iloc[:10]
H_Kane            175
Mohame_Salah      142
C_Eriksen          97
Richarlison        92
S_Agüero           91
K_D_Bruyne         91
A_Sánchez          85
R_Sterling         80
R_Lukaku           80
Robert_Firmino     80
Name: player_id, dtype: int64

パス数

PR_pass = EV.loc[(EV['event']=='pass'), 'player_id'].value_counts()
PR_pass = PR_pass.rename(index=dict(PL[['player_id', 'name']].values))  # 選手IDを選手名に変換する
PR_pass.iloc[:10]
G_Xhaka         2974
N_Otamendi      2964
Fernandinho     2842
Azpilicueta     2713
K_D_Bruyne      2672
N_Matić         2456
Davi_Silva      2382
J_Vertonghen    2370
K_Walker        2316
C_Eriksen       2196
Name: player_id, dtype: int64

アシスト数

PR_assist = EV.loc[(EV_tag['assist']==1), 'player_id'].value_counts()
PR_assist = PR_assist.rename(index=dict(PL[['player_id', 'name']].values))  # 選手IDを選手名に変換する
PR_assist.iloc[:10]
K_D_Bruyne      16
L_Sané          13
R_Mahrez        10
R_Sterling      10
Davi_Silva      10
H_Mkhitaryan     9
D_Alli           9
C_Eriksen        9
Mohame_Salah     8
P_Groß           8
Name: player_id, dtype: int64

ゴール数

PR_goal = EV.loc[((EV['event']=='shot') | (EV['event']=='free_kick')) & (EV_tag['goal']==1), 'player_id'].value_counts()
PR_goal = PR_goal.rename(index=dict(PL[['player_id', 'name']].values))  # 選手IDを選手名に変換する
PR_goal.iloc[:10]
Mohame_Salah      32
H_Kane            29
S_Agüero          21
J_Vardy           20
R_Sterling        18
R_Lukaku          16
Robert_Firmino    15
A_Lacazette       14
Gabrie_Jesus      13
G_Murray          12
Name: player_id, dtype: int64

6.4.5. ボールの軌跡の可視化#

イベントデータを用いると,パスやシュートなどのイベント単位で試合展開を追跡することができる. ここでは,特定の試合に対し,ボールの軌跡を可視化してみよう.

試合の抽出

# 後のエラー対処のために明示的に.copy()を付けている
ev = EV.loc[EV['game_id']==EV['game_id'].unique()[0]].copy()
ev_tag = EV_tag.loc[EV['game_id']==EV['game_id'].unique()[0]].copy()

チーム名の確認

tm_id = ev['team_id'].unique()
tm_id
array([1609, 1631])

座標の反転(片方のチーム)

元のデータでは,両チームの攻撃方向が右方向に統一されている. これだと,試合展開を可視化する際にわかりにくいので,一方のチームの攻撃方向が逆になるように変換する. 以下のように,片方のチーム('team_id'が1631)の\(x, y\)座標から最大値100を引き,絶対値を取ればよい.

ev.loc[ev['team_id']==tm_id[1], ['x1', 'x2']] = np.abs(ev.loc[ev['team_id']==tm_id[1], ['x1', 'x2']] - 100)
ev.loc[ev['team_id']==tm_id[1], ['y1', 'y2']] = np.abs(ev.loc[ev['team_id']==tm_id[1], ['y1', 'y2']] - 100)

# 座標の補正
ev.loc[(ev['x1']==0)|(ev['x2']==0), ['x2', 'y2']] = np.nan
ev.loc[(ev['x1']==100)|(ev['x2']==100), ['x2', 'y2']] = np.nan

ボールの軌跡の描画

イベントログにはイベントの始点と終点の座標が収められており,これがおおよそボールの軌跡に対応する. そこで,matplotlibplot関数を用いてイベントの始点と終点の座標を結ぶことで,ボールの軌跡を描いてみる. なお,イベント名が'duel'の場合,始点と終点の座標が同じで'team_id'が異なる2つの行が挿入されている.

ev.loc[ev['event']=='duel'].head()
id game_id ... x2 y2
7 177959189 2499719 ... 67.0 80.0
8 177961218 2499719 ... 67.0 80.0
12 177959191 2499719 ... 50.0 59.0
13 177959181 2499719 ... 50.0 59.0
22 177959205 2499719 ... 29.0 85.0

5 rows × 14 columns

これは,ボール保持チームが特定できないためと考えられる. そこで,イベント名が'duel'の場合には一方のチームの座標だけを黒線で描画し,それ以外はチームごとに色分けして描画することにする. 以下のball_trj関数は,時間帯を3つの引数half, ts, teで指定し,その時間帯でボールの軌跡を描く.

def ball_trj(half=1, ts=0, te=50):
    '''
    half: 前半1, 後半2
    ts: 始点に対応する時刻
    te: 終点に対応する時刻
    '''
    fig, ax = plt.subplots(figsize=(5, 5))
    ax.set_aspect(68/105)

    ev['tmp'] = np.nan
    cond = (ev['half']==1) & (ev['t'] > ts) & (ev['t'] < te)

    # チーム0のpass
    X0 = ev.loc[cond & (ev['team_id']==tm_id[0]) & (ev['event']!='duel'), ['x1', 'x2', 'tmp']].values.reshape(-1)
    Y0 = ev.loc[cond & (ev['team_id']==tm_id[0]) & (ev['event']!='duel'), ['y1', 'y2', 'tmp']].values.reshape(-1)
    ax.plot(X0, Y0, 'o-r', mfc='None')

    # チーム1のpass
    X1 = ev.loc[cond & (ev['team_id']==tm_id[1]) & (ev['event']!='duel'), ['x1', 'x2', 'tmp']].values.reshape(-1)
    Y1 = ev.loc[cond & (ev['team_id']==tm_id[1]) & (ev['event']!='duel'), ['y1', 'y2', 'tmp']].values.reshape(-1)
    ax.plot(X1, Y1, '^-b', mfc='None')

    # duel
    X2 = ev.loc[cond & (ev['team_id']==tm_id[1]) & (ev['event']=='duel'), ['x1', 'x2', 'tmp']].values.reshape(-1)
    Y2 = ev.loc[cond & (ev['team_id']==tm_id[1]) & (ev['event']=='duel'), ['y1', 'y2', 'tmp']].values.reshape(-1)
    ax.plot(X2, Y2, '-k')

    # ハーフウェイライン
    ax.plot([50, 50], [0, 100], 'k--') 

    # 描画範囲とラベル
    ax.set_xlim(0, 100); ax.set_ylim(0, 100)
    ax.set_xlabel('$X$'); ax.set_ylabel('$Y$')
ball_trj(half=1, ts=50, te=100)
../_images/6_event_149_0.png
ball_trj(half=2, ts=2000, te=2050)
../_images/6_event_150_0.png

6.4.6. 選手間のパス数の可視化#

最後に,特定の試合における選手間のパス数を可視化してみよう. 本来,このような解析にはnetworkxという専用のライブラリを使うべきだが,以下ではpandasとseabornという可視化ライブラリを用いて実装する.

試合の抽出

# 後のエラー対処のために明示的に.copy()を付けている
ev = EV.loc[EV['game_id']==EV['game_id'].unique()[0]].copy()
ev_tag = EV_tag.loc[EV['game_id']==EV['game_id'].iloc[0]].copy()

パスリストの作成

選手間のパス数を求めるには,パスの出し手と受け手の情報が必要である. しかし,イベントログEVにはパスの出し手の情報しかないので,受け手の情報を加える必要がある. イベント名が'pass'の行については,次の行の選手IDがパスの受け手に対応するので,以下のようにパスリストpsを作成できる.

ps = ev.loc[ev['event']=='pass', ['player_id', 'team_id']]
ps['player_id2'], ps['team_id2'] = 0, 0
ps['player_id2'].iloc[:-1] = ps['player_id'].iloc[1:].values
ps['team_id2'].iloc[:-1] = ps['team_id'].iloc[1:].values
ps.head()
player_id team_id player_id2 team_id2
0 25413 1609 370224 1609
1 370224 1609 3319 1609
2 3319 1609 120339 1609
3 120339 1609 167145 1609
4 167145 1609 3319 1609

選手名の追加

イベントログには選手ID('player_id')の情報しかないので,選手プロフィールPLのデータを用いて選手名を追加する. 以下のように,replaceメソッドを用いて,選手ID('player_id')を選手名('name')に置換すれば良い.

ps['name'] = ps['player_id'].replace(PL['player_id'].values, PL['name'].values)
ps['name2'] = ps['player_id2'].replace(PL['player_id'].values, PL['name'].values)
ps.head()
player_id team_id ... name name2
0 25413 1609 ... A_Lacazette R_Holding
1 370224 1609 ... R_Holding M_Özil
2 3319 1609 ... M_Özil Mohame_Elneny
3 120339 1609 ... Mohame_Elneny Bellerín
4 167145 1609 ... Bellerín M_Özil

5 rows × 6 columns

パス数行列の作成

チーム内の選手\(i\)\(j\)間のパス数を要素とする行列をパス数行列と呼ぶことにする. パス数行列は非対称な行列であり,行列の\((i, j)\)成分は選手\(i\)から\(j\)へのパス,\((j, i)\)成分はその逆を表す. パス数行列の作成方法はいくつか考えられるが,以下ではfor文を用いて実装している.

pl_id0 = ps.loc[ps['team_id']==tm_id[0], 'name'].unique()
pl_id1 = ps.loc[ps['team_id']==tm_id[1], 'name'].unique()
A0 = pd.DataFrame(index=pl_id0, columns=pl_id0)
A1 = pd.DataFrame(index=pl_id1, columns=pl_id1)
for i in pl_id0:
    for j in pl_id0:
        A0.loc[i, j] = len(ps.loc[(ps['name']==i) & (ps['name2']==j)])

for i in pl_id1:
    for j in pl_id1:
        A1.loc[i, j] = len(ps.loc[(ps['name']==i) & (ps['name2']==j)])
        
A0 = A0.astype(int)
A1 = A1.astype(int)

パス数行列の可視化

パス数行列を可視化する方法はいくつか考えられる. 例えば,選手を点,選手間のパス数を線の太さに対応させた図で表す方法がある. このような図はネットワーク呼ばれ,サッカーのデータ分析における標準的な手法となっている. しかし,ネットワークの分析と可視化にはnetworkxなどの専用ライブラリの知識が必要となるので,ここではより直接的にヒートマップを用いた可視化方法を採用する. 以下のplot_corr_mat関数は,seabornという可視化ライブラリを用いてパス数行列をヒートマップで可視化する.

import seaborn
def plot_corr_mat(mat, cm='jet'):
    fig, ax = plt.subplots(figsize=(5, 5))
    seaborn.heatmap(mat, ax=ax, linewidths=0.1, cbar=True, annot=True,\
                    square=True, cmap=cm, linecolor='w', cbar_kws={"shrink": .7})
    ax.set_xticklabels(mat.columns, fontsize=8)
    ax.set_yticklabels(mat.index, fontsize=8)
    ax_clb = ax.collections[0].colorbar
    ax_clb.ax.tick_params(labelsize=8)
plot_corr_mat(A0, 'Reds')
../_images/6_event_168_0.png
plot_corr_mat(A1, 'Greens')
../_images/6_event_169_0.png