原文:
towardsdatascience.com/football-and-geometry-passing-networks-6e201ceff6ef
足球分析
通过分析拜仁勒沃库森的传球网络来理解网络
图片由Clint Adair在Unsplash提供
长时间不见…但有一个很好的理由。
几个月后,我回到了 Medium,今天我们将两个激动人心的世界合并:足球和几何。
具体来说,我们将触及网络的主题,但像往常一样,通过一个实际案例。我们将研究足球传球网络,重点关注去年拜耳勒沃库森的比赛。
拜仁慕尼黑队在上赛季在哈维·阿隆索的带领下打出了惊人的赛季,踢出了出色的足球。我想调查这是如何转化为数学术语的,并通过对他们的传球网络来了解他们的比赛风格和最相关的球员。
虽然网络在研究节点间互连的重要性已经确立,但在足球中的应用并没有不同。实际上,这是基本的东西,但为那些尚未看到的人写一篇帖子是值得的。
Statsbomb[1]拥有高质量的数据,幸运的是,他们免费并使所有人都能访问去年拜耳勒沃库森的所有比赛。
今天我们将探讨以下内容:
-
传球网络简介
-
构建网络
-
指标与分析
-
结论
在继续之前,我想分享的是,这篇帖子中所有的代码都是我写的,但受到了大卫·萨姆珀特创建的神奇 Soccermatics 课程的启发。您可以在本帖末尾的资源部分找到这个广泛教育的链接[2]。
传球网络简介
传球网络是球员在足球比赛中相互作用的图形表示,可视化队友之间传球流动。它包含两个主要元素:
-
节点——代表球员,位于球员传球或接球时的平均位置。
-
边(或线条)——代表通过该线条连接的两个球员(节点)之间的传球。
边的粗细通常代表传球的频率,节点的尺寸代表球员的传球次数。
这种视觉和分析工具越来越多地用于评估现代足球中的球队形状、球员参与度和战术模式。如果我们作为数据科学家使用额外的数学来计算其他指标,我们将获得关于球队传球特性的更深入见解。
这里有一些传球网络可以派上用场的具体情况:
-
理解球队结构。因为它包含了这些球员在这些传球事件中的平均位置,我们可以了解球队的结构,因此了解他们的打法。例如,一个紧凑而密集的网络可能表明球队更喜欢短传和控球足球,而一个分布更广的网络可能表明球队倾向于直接或反击的打法。
-
识别关键球员。传球网络中的一个关键指标是中心性,它用于评估网络是否主要依赖于一小部分球员。例如,如果我们看到皮尔洛是意大利传球网络的关键,我们可能想在下次与意大利队比赛时对他进行严密的防守。
-
战术分析。教练和分析师可以找出战术上的优势和劣势。例如,如果一个球队的传球网络在左侧显示出强大的连接,这可能表明球队依赖通过那个边路进攻。这也可以用来比较不同的比赛,并分析它如何从一个对手变化到另一个对手。
构建网络
我们将使用 Statsbomb 的免费数据和通过statsbombpy[3] Python 库访问它。让我们首先导入库并检索 2023/24 赛季德甲的所有事件(因为我们只使用免费数据,所以我们只会收到与拜耳勒沃库森比赛相关的信息):
from collections import Counter
from mplsoccer import Pitch
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from statsbombpy import sb
events_df = sb.competition_events(
country="Germany",
division="1\. Bundesliga",
season="2023/2024",
gender="male"
).sort_values(['match_id', 'minute', 'second'])
现在我们在这个数据框中有许多事件和列,所以我们应该过滤掉不需要的行。此外,我们还将通过将位置和 _pass_end位置列拆分为两个单独的列(一个用于 x 轴,另一个用于 y 轴)来进行一些特征工程:
passes_df = events_df[
(events_df['type'] == 'Pass')
& (events_df['team'] == 'Bayer Leverkusen')
& (events_df['pass_outcome'].isna()
)].reset_index(drop=True)[['match_id', 'location', 'pass_end_location', 'player', 'pass_recipient']]
# Define start and end positions
ini_locs_df = pd.DataFrame(passes_df["location"].to_list(), columns=['x', 'y'])
end_locs_df = pd.DataFrame(passes_df["pass_end_location"].to_list(), columns=['end_x', 'end_y'])
passes_df = pd.concat([passes_df, ini_locs_df, end_locs_df], axis=1)
# Reshape and rename columns
passes_df.drop(columns=['location', 'pass_end_location'], inplace=True)
passes_df.columns = ['match_id', 'player_name', 'pass_recipient_name', 'x', 'y', 'end_x', 'end_y']
在只保留我们感兴趣的列之后,现在应该做出一些决定。拜耳勒沃库森,像任何其他球队一样,他们的阵容中不仅仅有 11 名球员。如果我们想展示传球网络,我们只想展示 11 名球员(就像一场比赛的阵容一样)。但是必须决定排除球员的方法。
可能的一个好方法是使用使用频率最高的球队的阵型和为每个位置选择在该位置上出场时间最长的球员。但这种方法对于这篇帖子来说似乎过于复杂,所以我决定让它简单一些:使用使用频率最高的阵容(我的意思是,使用开始比赛的 11 名球员的最常用的阵容)。
这是处理这个问题的代码,之前将球员名字转换为只显示姓氏:
# Show surname only
passes_df["player_name"] = passes_df["player_name"].apply(lambda x: str(x).split()[-1])
passes_df["pass_recipient_name"] = passes_df["pass_recipient_name"].apply(lambda x: str(x).split()[-1])
passes_df.loc[:, ["player_name", "pass_recipient_name"]] = passes_df.loc[:, ["player_name", "pass_recipient_name"]].replace('García', 'Grimaldo')
# Select most-used lineup and keep those players
used_lineups = []
for match_id in passes_df.match_id.unique():
match_lineup = sb.lineups(
match_id=match_id
)['Bayer Leverkusen']
match_lineup['starter'] = match_lineup['positions'].apply(
lambda x: x[0]['start_reason'] == 'Starting XI' if x else False
)
match_lineup["player_name"] = match_lineup["player_name"].apply(lambda x: str(x).split()[-1])
match_lineup.loc[:, "player_name"] = match_lineup.loc[:, "player_name"].replace('García', 'Grimaldo')
starters = sorted(match_lineup[match_lineup['starter']==True].player_name.tolist())
used_lineups.append(starters)
most_used_lineup_players = Counter([', '.join(c) for c in used_lineups]).most_common()[0][0].split(", ")
我最终减少了数据框的维度:
# Show surname only
passes_df["player_name"] = passes_df["player_name"].apply(lambda x: str(x).split()[-1])
passes_df["pass_recipient_name"] = passes_df["pass_recipient_name"].apply(lambda x: str(x).split()[-1])
# Manually correct Grimaldo's name
passes_df.loc[:, ["player_name", "pass_recipient_name"]] = passes_df.loc[:, ["player_name", "pass_recipient_name"]].replace('García', 'Grimaldo')
passes_df = passes_df[['x', 'y', 'end_x', 'end_y', "player_name", "pass_recipient_name"]]
这就是数据框在这个阶段的样子:
passes_df 数据框的前 5 行 – 作者提供的图片
现在我们需要获取每个球员的传球次数和他们的位置(节点大小和位置)以及球员对之间的传球次数(边厚度)。让我们从创建一个新的数据框开始,处理第一个:
# Create DF with average player positions
nodes_df = pd.concat([
passes_df[["player_name", 'x', 'y']],
passes_df[["pass_recipient_name", 'end_x', 'end_y']].rename(columns={'pass_recipient_name': 'player_name', 'end_x': 'x', 'end_y': 'y'})
]).groupby('player_name').mean().reset_index()
nodes_df = nodes_df[nodes_df['player_name'].isin(most_used_lineup_players)]
# Add number of passes made
nodes_df = nodes_df.merge(long_df.groupby('player_name').agg(passing_participation=('x', 'count')).reset_index())
# Add marker_size column to have it relative to the number of passes made
nodes_df['marker_size'] = (nodes_df['passing_participation'] / nodes_df['passing_participation'].max() * 1500)
我们首先创建一个只包含三个列(player_name, x, y)的 DF,然后根据球员名称进行分组以计算平均 x 和 y。之后,我们过滤掉那些不在最常见的阵容中的球员,并将这个操作的成果与之前按球员分组的 passes_df 合并,其中包含每位球员参与的总传球次数。这样,我们最终得到一个每行一个球员的 DF,包括他的平均投球位置和传球次数。
最后一步是添加标记大小,这将在以后使用,看起来是这样的:
Top 5 rows of nodes_df – image by the author
让我们继续处理 _edges*df*。我们需要在 _passes*df* 中创建一个新列,以展示该事件中的球员对。由于我们不关心方向性,我们将过滤掉不需要的球员,并按字母顺序对这些对进行排序:
edges_df = passes_df.copy()
edges_df = edges_df[edges_df['player_name'].isin(most_used_lineup_players)]
edges_df['player_pair'] = edges_df.apply(
lambda x: "-".join(sorted([x["player_name"], x["pass_recipient_name"]])),
axis=1)
这将用作下一个 groupby 的键:
edges_df = edges_df.groupby(["player_pair"])
.agg(passes_made=('x', 'count'))
.reset_index()
filtered_edges_df = edges_df[edges_df['passes_made'] > 238]
为了创建这个 _edges*df*,我正在根据我们刚刚创建的新列进行分组,并计算每对球员之间的传球次数。然后,我们过滤掉那些传球次数少于 238 次的球员,因为我们只想看到那些平均传球率为每场比赛 7 次传球的球员。
这并不准确,因为并非所有球员都参加了 34 场比赛,所以他们的平均值可能更高,但仍可能被排除在外……但我们想保持简单。
不管怎样,让我们看看它:
Top 5 rows of player-pairs and passes made – image by the author
我们现在准备好绘图了,我们将使用 mplsoccer[4] 来显示一个绘图区域。负责可视化的完整代码如下所示:
pitch = Pitch(line_color='grey')
fig, ax = pitch.grid(grid_height=0.9, title_height=0.06, axis=False,
endnote_height=0.04, title_space=0, endnote_space=0)
pitch.scatter(nodes_df.x, nodes_df.y, s=nodes_df.marker_size, color='rosybrown', edgecolors='lightcoral', linewidth=1, alpha=1, ax=ax["pitch"], zorder = 3)
for i, row in nodes_df.iterrows():
pitch.annotate(row.player_name, xy=(row.x, row.y), c='black', va='center', ha='center', weight = "bold", size=16, ax=ax["pitch"], zorder = 4)
all_players = nodes_df["player_name"].tolist()
for i, row in filtered_edges_df.iterrows():
player1 = row["player_pair"].split('-')[0]
player2 = row['player_pair'].split('-')[1]
if player1 not in all_players or player2 not in all_players:
continue
player1_x, player1_y = nodes_df.loc[nodes_df["player_name"] == player1, ['x', 'y']].values[0]
player2_x, player2_y = nodes_df.loc[nodes_df["player_name"] == player2, ['x', 'y']].values[0]
line_width = (row["passes_made"] / lines_df['passes_made'].max() * 10)
pitch.lines(player1_x, player1_y, player2_x, player2_y,
alpha=1, lw=line_width, zorder=2, color="lightcoral", ax = ax["pitch"])
fig.suptitle("Bayer Leverkusen's Passing Network (2023/24)", fontsize = 25)
plt.show()
最后,让我们看看最终的传球网络:
使用最常用阵容并仅显示超过 238 次传球的拜耳勒沃库森传球网络 - 图片由作者提供
很不错,对吧?
指标和分析
作为数据科学家,我们不能止步于此。编写代码和创建可视化很重要,但我们需要从中得出一些见解。否则,它就毫无用处。
因此,让我们定义一些额外的指标,例如网络中心性和传球成功率。
-
传球率指的是每分钟控球时成功传球的次数。
-
网络中心性之前已经解释过了,但它衡量的是球员在球队传球结构中的影响力。
托马斯·格伦德已经为我们完成了这项工作,通过检查传球率与球队得分概率之间的关系。简而言之,他发现传球率为每分钟 5 次成功传球的球队比传球率为每分钟 3 次的球队多 20%的进球。
因此,这是一个很好的指标来衡量攻击表现(至少是进球概率)。让我们为今天的案例研究计算一下:
events_df['total_minutes'] = (events_df['second']/60) + events_df['minute']
events_df['event_duration'] = events_df.groupby('match_id')['total_minutes'].diff().shift(-1)
# Agrupar per equip i sumar
possession_minutes = events_df.groupby(['possession_team'])['event_duration'].sum()['Bayer Leverkusen']
passing_rate = len(passes_df)/possession_minutes
以下就是前面代码片段所做的工作:
-
创建一个新的列,包含时间(分钟)。
-
创建一个新的列来找出事件与事件之间经过的分钟数(作为衡量每个事件持续时间的手段)。
-
计算拜仁慕尼黑在整个赛季中持球的总分钟数。
-
使用球队成功传球次数(包含在
_passes*df*中)除以控球时间来计算传球率。
结果是:10.87。
因此,如果从每分钟 3 次成功传球增加到每分钟 5 次成功传球可以转化为 20%更多的进球,那么想象一下一支球队的传球率为 10.87 会怎样。这就是拜仁慕尼黑有多出色。
为了更清楚地说明,你还记得西班牙 2012 年欧洲杯的球队吗?布斯克茨、哈维、伊涅斯塔、哈维·阿隆索……他们在对阵克罗地亚的比赛中的传球率为 9.65。拜仁慕尼黑在整个赛季中保持的平均传球率 10.87 令人印象深刻,考虑到他们如何比许多人认为的最好的球队拥有更好的指标。
哈维·阿隆索试图复制(并且成功)他作为球员时已经做过的事情,现在作为教练,并且带领一支不那么有才华的球队(尽管仍然是一支非常好的球队)。
接下来是网络中心性,这个指标同样关键,可以知道比赛是否依赖于少数球员。有许多方法可以计算它,例如使用度中心性指标、中介中心性或特征向量中心性(仅举几例)。
为了简单起见,我们将坚持大卫·萨姆珀在《足球数学学》[2]中计算的方法,但略有改变:我们将一个球员成功传球/接收的最大次数与每个球员成功传球/接收的次数之间的差异相加,然后除以所有传球的总和乘以网络中的节点数减 1:
#find one who made most passes
max_passing_participations = nodes_df['passing_participation'].max()
#calculate the denominator - 10*the total sum of passes
denominator = 10*(nodes_df['passing_participation'].sum()/2)
#calculate the nominator
nominator = (max_passing_participations - nodes_df['passing_participation']).sum()
#calculate the centralization index
centralization_index = nominator/denominator
使用这种方法,我们得到一个集中度指数为 0.1988,或者说 19.88%。越接近 0,其集中度越低,所以大约 20%仍然可以被认为是低的,并且这与得分概率增加 8%相关。
为了提供更多的上下文,在上述西班牙与克罗地亚比赛的同一场比赛中,西班牙的集中度指数为 14.6%。因此,拜仁慕尼黑的指标,在整个赛季中汇总(而不仅仅是像欧洲杯那样的一场比赛),非常接近那个 14.6%。
结论
今天我们学习了网络的核心理念——节点和边——以及它们的属性意味着什么(大小和宽度)。此外,我们还学习了网络中的中心性概念。
我们通过一个真实案例场景完成了所有这些工作,利用拜耳勒沃库森 2023/24 赛季的数据,通过分析他们的传球网络并对其进行数学分析。
为了提取一些见解,我们发现他们的网络是一个擅长控球足球的球队,而我们计算的两个指标与 2012 年欧洲杯西班牙对阵克罗地亚时的指标相似。
Thanks for reading the post!
I really hope you enjoyed it and found it insightful. There's a lot more to
come, especially more AI-based posts I'm preparing.
Follow me and subscribe to my mail list for more
content like this one, it helps a lot!
@polmarin
资源
[1] StatsBomb. (n.d.). 首页. StatsBomb. 2024 年 9 月 14 日检索,来自 statsbomb.com
[2] Soccermatics. (n.d.). Soccermatics 文档. 2024 年 9 月 13 日检索,来自 soccermatics.readthedocs.io
[3] StatsBombPy. GitHub. 2024 年 9 月 14 日检索,来自 github.com/statsbomb/statsbombpy
[4] mplsoccer. (n.d.). mplsoccer 文档. 2024 年 9 月 14 日检索,来自 mplsoccer.readthedocs.io/en/latest/index.html
[5] Grund, T. U. (2012). 网络结构与团队绩效:以英格兰超级联赛足球队为例. Social Networks, 34(4), 682–690. doi.org/10.1016/j.socnet.2012.08.004
基于传球网络的足球数据分析
283

被折叠的 条评论
为什么被折叠?



