Soccermatics之三: 足球數(shù)據(jù)集計(jì)算xG(Expected Goal)
開始之前
本篇假設(shè)讀者熟悉python編程、matplotlib和pandas軟件包的使用。
代碼還是用到了足球數(shù)據(jù)解析軟件kloppy和機(jī)器學(xué)習(xí)軟件包scikit-learn,它們都是開源軟件。
開發(fā)環(huán)境和數(shù)據(jù)集參考本系列的第一篇。
為了方便,代碼中不再使用公制單位作為球場中的位置坐標(biāo),直接使用數(shù)據(jù)集中的原始單位碼(yard)。
鳴謝
數(shù)據(jù)集使用了Statsbomb公司(https://www.statsbomb.com)的開放數(shù)據(jù)(https://github.com/statsbomb/open-data)。
什么是xG
xG是Expected Goal的縮寫,用于表示一次射門可能的得分機(jī)會,在某些其他運(yùn)動中也有使用(比如冰球)。一次射門的xG通常是一個(gè)[0,1]之間的數(shù)字,如果一次射門進(jìn)球了,那么它的xG就是1。數(shù)字越大表示可能進(jìn)球的概率越大。雖然xG看起來像是概率,但媒體經(jīng)常會把球員多場比賽的xG表現(xiàn)或者球隊(duì)xG加起來衡量其表現(xiàn),因此xG更像一個(gè)數(shù)學(xué)期望。
目前xG還沒有一個(gè)特別統(tǒng)一的定義。
看起來xG像是數(shù)據(jù)分析人員才會關(guān)心的指標(biāo),實(shí)際上xG已經(jīng)興起并且被媒體接受,一些發(fā)布比賽結(jié)果以及簡單統(tǒng)計(jì)數(shù)據(jù)的網(wǎng)站,往往會同時(shí)發(fā)布球員和球隊(duì)的xG數(shù)據(jù)。比如愛奇藝在直播今年賽季歐冠的時(shí)候,也會展現(xiàn)球隊(duì)的xG數(shù)據(jù),愛奇藝把xG稱為“預(yù)期進(jìn)球”。
sky sports dominic calvert lewin xG
這里舉例,計(jì)算xG可能會使用到下面一些要素:
位置:射門發(fā)生球場的哪個(gè)位置;
射門部位:球員使用頭或者左腳還是右腳射門?這個(gè)數(shù)據(jù)在我們使用的公開數(shù)據(jù)集里是有的;
傳球類型:射門球員得到的傳球是直塞、回傳還是定位球?是高球還是地面球?這些數(shù)據(jù)在statsbomb公開數(shù)據(jù)集也有;
每一次射門都與數(shù)千次具有相似特征的射門進(jìn)行比較,以確定這次射門進(jìn)球的可能性。這個(gè)可能性就是預(yù)期目標(biāo)總數(shù)。0的 xG 是確定失誤,而1的 xG 是確定的進(jìn)球。如果 xG 為0.5,則表示如果嘗試同樣的射門10次,則預(yù)計(jì)會進(jìn)5球。點(diǎn)球的xG固定為0.75。
xG的一些用途如下:
比較 xG 和實(shí)際得分可以衡量一個(gè)球員的射門能力或運(yùn)氣;XG 可以用來評估球隊(duì)在各種情況下的能力,比如面對空門、任意球、角球等等;在國外會在betting中使用xG;
注意不同實(shí)體發(fā)布的xG的計(jì)算模型并不一致,也沒有哪家的數(shù)據(jù)特別權(quán)威。
在xG基礎(chǔ)上派生出了很多關(guān)聯(lián)的指標(biāo),比如xGA,xGBuildup,xGChain等等,但都不如xG這么流行,每家媒體的指標(biāo)也不同,因此就不一一展開了。
計(jì)算思路
計(jì)算xG采用了從歷史數(shù)據(jù)中提取特征,并進(jìn)行擬合。
過程還是機(jī)器學(xué)習(xí)老一套,確定特征、提取特征、劃分?jǐn)?shù)據(jù)集、擬合、評估。
特征選擇
特征選取首先要局限于事件數(shù)據(jù)能提供的范圍。這里使用了射門事件,根據(jù)Statsbomb開放數(shù)據(jù)的特點(diǎn),這里使用了如下的特征:
守門員位置射門距離與角度射門位置射門時(shí)封堵球員的數(shù)量射門時(shí)5碼范圍內(nèi)對方干擾球員的數(shù)量進(jìn)攻類型,是定位球還是正常進(jìn)攻射門的身體部位射門的技巧,比如凌空抽射是否是第一次觸球上半場還是下半場該事件持續(xù)時(shí)間
看起來挺多特征的,Statsbomb開放數(shù)據(jù)里面的信息挺豐富,特別是射門數(shù)據(jù),甚至還包含了射門瞬間其他球員的位置,稱為“凍結(jié)幀”。
目標(biāo)集是進(jìn)球與否。
擬合方法
機(jī)器學(xué)習(xí)常用的擬合方法很多,如果特征選擇和預(yù)處理做得好,擬合方法沒那么重要,這里沒有使用最近的網(wǎng)紅算法XGBoost,而是使用了簡單的Logistic回歸。
對于數(shù)據(jù)中的一些枚舉類型,比如射門身體部位等,簡單的采用了LabelEncoder編碼器。
計(jì)算代碼
我們選取開放數(shù)據(jù)中西甲2020~2021賽季巴塞羅那的比賽(有數(shù)據(jù)的一共35場)
import pandas as pd
import os
from kloppy import statsbomb
team = Barcelona
# 這些比賽id可以寫代碼從開放數(shù)據(jù)中解析讀取
ids = [3773631, 3773665, 3773497, 3773660, 3773593, 3773466, 3773585, 3773552, 3773672, 3773386, 3773565, 3773587, 3773656, 3773377, 3773457, 3773586, 3773372, 3773387, 3773695, 3773369, 3773661, 3773597, 3773523, 3773571, 3773428, 3764661, 3773526, 3773474, 3773625, 3773403, 3773547, 3773415, 3764440, 3773689, 3773477]
from kloppy.domain.models.event import EventDataset, Event
# 批量加載比賽數(shù)據(jù)集的代碼,利用了kloppy的數(shù)據(jù)結(jié)構(gòu)
class SbBatch:
def __init__(self, base_url=/location/to/your/data/statsbomb/data/):
self.base_url = base_url
url = f{self.base_url}competitions.json
self.comp = json.load(open(url))
def load_match_by_ids(self, ids):
events = []
meta = None
for id in ids:
dataset = statsbomb.load(
event_data=f{self.base_url}events/{id}.json,
lineup_data=f{self.base_url}lineups/{id}.json,
coordinates=statsbomb
)
for e in dataset.records:
events.append(e)
# 只保留一個(gè)metadata,這里metadata只是為了構(gòu)建EventDataset
# 里面的信息是不正確的
meta = dataset.metadata
dataset = EventDataset(events, meta)
return dataset
sb = SbBatch()
# 加載多場比賽的數(shù)據(jù)集。這里違反了kloppy的使用規(guī)范,dataset里的元數(shù)據(jù)metadata是錯(cuò)誤的
dataset = sb.load_match_by_ids(ids)
接下來進(jìn)行特征提?。?/p>
from kloppy.domain import ShotEvent
# 獲取守門員位置
def gk_pos(e: ShotEvent):
# 設(shè)定守門員的缺省位置
x = 120
y = 40
# 從射門事件中的凍結(jié)幀提取守門員位置
if freeze_frame in e.raw_event[shot]:
for p in e.raw_event[shot][freeze_frame]:
if p[teammate] == False and p[position][name] == Goalkeeper:
x = p[location][0]
y = p[location][1]
return (x, y)
# 計(jì)算射門時(shí)封堵射門路線的球員個(gè)數(shù)
def blocking_opponents_count(e: ShotEvent):
# 封堵射門路線的球員個(gè)數(shù)初始值為0
count = 0
x1 = e.coordinates.x
y1 = e.coordinates.y
# 球門位置
x2 = 120
y2 = 36
x3 = 120
y3 = 44
# 從射門事件中的凍結(jié)幀提取該時(shí)刻其他球員位置
if freeze_frame in e.raw_event[shot]:
for p in e.raw_event[shot][freeze_frame]:
# 如果封堵球員不是隊(duì)友且不是守門員
if p[teammate] == False and p[position][name] != Goalkeeper:
# 通過判斷每個(gè)球員是否位于射門點(diǎn)、球門亮點(diǎn)構(gòu)成的三角形來判斷封堵
xp = p[location][0]
yp = p[location][1]
c1 = (x2-x1)*(yp-y1)-(y2-y1)*(xp-x1)
c2 = (x3-x2)*(yp-y2)-(y3-y2)*(xp-x2)
c3 = (x1-x3)*(yp-y3)-(y1-y3)*(xp-x3)
if (c1<0 and c2<0 and c3<0) or (c1>0 and c2>0 and c3>0):
count = count + 1
return count
# 計(jì)算射門時(shí)5碼內(nèi)對方球員個(gè)數(shù)
def nearby_opponents_count(e: ShotEvent):
count = 0
x = e.coordinates.x
y = e.coordinates.y
# 從射門事件凍結(jié)幀中提取射門時(shí)其他球員位置
if freeze_frame in e.raw_event[shot]:
for p in e.raw_event[shot][freeze_frame]:
# 不是隊(duì)友也不是守門員
if p[teammate] == False and p[position][name] != Goalkeeper:
xp = p[location][0]
yp = p[location][1]
from math import sqrt
distance = sqrt((x-xp)*(x-xp) + (y-yp)*(y-yp))
if distance <= 5.:
count = count + 1
return count
# kloppy直接提供了射門距離與角度的計(jì)算
from kloppy.domain.services.transformers.attribute import (
AngleToGoalTransformer, DistanceToGoalTransformer
)
df = dataset.filter(shot).filter(
# 留下本隊(duì)的射門事件
lambda e: e.team.name==team
).to_df(
# 將dataset轉(zhuǎn)換成DataFrame
# 或保留或添加如下一些列
period_id, # 上下半場
event_id,
team,
player,
timestamp,
coordinates*, # 射門位置
lambda e: {duration: e.raw_event[duration]}, # 射門持續(xù)時(shí)間
lambda e: {statsbomb_xg: e.raw_event[shot][statsbomb_xg]}, # 讀取已計(jì)算的xG,最后可以拿來對照
body_part_type, # 射門身體部位
lambda e: {result: 1. if e.result.is_success else 0.}, # 射門是否得分
lambda e: {play_pattern: e.raw_event[play_pattern][name]}, # 進(jìn)攻類型
lambda e: {technique: e.raw_event[shot][technique][name]}, # 射門技巧
lambda e: {first_time: 1 if first_time in e.raw_event[shot] else 0}, # 是否第一次觸球
lambda e: {goalkeeper_x: gk_pos(e)[0]}, # 守門員x坐標(biāo)
lambda e: {goalkeeper_y: gk_pos(e)[1]}, # 守門員y坐標(biāo)
lambda e: {blocking_opponents_count: blocking_opponents_count(e)}, # 封堵球員數(shù)量
lambda e: {nearby_opponents_count: nearby_opponents_count(e)}, # 附加干擾球員數(shù)量
AngleToGoalTransformer(), # 射門角度
DistanceToGoalTransformer() # 射門距離
)
一些以字符串為字面量的枚舉類型無法直接參與計(jì)算,需要映射為數(shù)字。這里采用了LabelEncoder對特征進(jìn)行編碼:
from sklearn.preprocessing import LabelEncoder, OneHotEncoder
body_part_type_encoder = LabelEncoder()
player_pattern_encoder = LabelEncoder()
technique_encoder = LabelEncoder()
df[body_part_type]=body_part_type_encoder.fit_transform(df[body_part_type])
df[play_pattern]=player_pattern_encoder.fit_transform(df[play_pattern])
df[technique]=technique_encoder.fit_transform(df[technique])
劃分?jǐn)?shù)據(jù)集:
# 提取特征集
features = df.drop([result,
event_id, team, player,
timestamp, statsbomb_xg], axis=1)
# 提取目標(biāo)集
targets = df[result]
# 劃分為訓(xùn)練集與測試集
from sklearn.model_selection import train_test_split
X_train, X_test, y_train, y_test = train_test_split(features, targets, test_size=0.2)
接下來進(jìn)行擬合與預(yù)測
from sklearn.linear_model import LogisticRegression
# 迭代次數(shù)要多一些
log_reg = LogisticRegression(max_iter=10000, random_state=16)
log_reg.fit(X_train, y_train)
y_pred = logreg.predict_proba(X_test)
與statsbomb數(shù)據(jù)中自帶的xG數(shù)據(jù)進(jìn)行比較
xg = df[statsbomb_xg].to_list()
for i in range(len(y_pred)):
print(y_pred[i][1], xg[i], df[result].to_list()[i])
得到結(jié)果如下:
0.0877706743634692 0.08335477 0.0
0.12022498029802782 0.26389658 0.0
0.012459886396227053 0.06492457 0.0
0.04354703879653737 0.04293651 0.0
0.28178765172568637 0.02796916 0.0
0.6173757373601387 0.10423171 0.0
0.07648849141874221 0.028864022 0.0
0.12202760914351773 0.032932542 1.0
0.43569816910546594 0.183775 0.0
0.006054658046803704 0.16166444 0.0
0.07658743195139443 0.07484645 0.0
0.05125376103583803 0.056819215 1.0
0.25866250118166645 0.08732408 0.0
0.06128259188512852 0.04334966 0.0
0.13674399079749752 0.21101888 1.0
0.05742722800738721 0.020735633 0.0
0.5415984823277031 0.04135918 0.0
0.16510526445894433 0.026723294 0.0
0.1842802037847451 0.040642418 0.0
0.0903545841750892 0.04152512 0.0
0.32958785239392163 0.2157016 0.0
0.43364589918370233 0.027891573 0.0
0.21267786284722404 0.18859574 0.0
0.048520974438812065 0.119817905 0.0
0.30197973079119633 0.1121366 1.0
...
0.05945067768078112 0.45795894 0.0
0.5202560635419263 0.037930463 0.0
0.03916706575065915 0.43497095 1.0
0.04148460332780444 0.10470807 0.0
第一列為算法計(jì)算出來的xG,第二列是Statsbomb數(shù)據(jù)自帶的xG,第三列是進(jìn)球與否。
不清楚Statsbomb的xG算法,但應(yīng)該是類似思路,手頭也沒有Statsbomb那么豐富的數(shù)據(jù),因此結(jié)果有差異。
以上