Una red de pases es una de las tantas métricas y visualizaciones comunes entre analistas de fútbol. Se usan para analizar la forma y los patrones de pases de un equipo durante un partido y determinar cuestiones como la participación de un jugador en el circuito de pases del equipo, conexiones entre jugadores particulares o la inclinación del equipo de jugar por un sector específico de la cancha, por citar algunos ejemplos.
Se habla de “red” en el sentido matemático (un grafo), dado que se modelan los datos mediante una representación que utiliza nodos (jugadores) conectados por aristas (pases). Algunos puntos para entender el gráfico:
- Hay una dirección de ataque (usualmente de izquierda a derecha)
- Cada nodo corresponde a un jugador (podría codificarse información adicional sobre el individuo usando el tamaño o el color del nodo, e.g: cantidad de pases, precisión, etc.)
- La posición del nodo refleja la posición promedio desde la que inició sus pases (aunque existen diferentes alternativas para elegir esta posición también, e.g.: posición promedio donde se recibieron los pases)
- El ancho de las aristas representa la cantidad de pases entre los jugadores conectados; línea más gruesa implica más pases (también se podría usar flechas y considerar la direccionalidad de los pases)

Código
A continuación el código utilizado para generar la imagen anterior, correspondiente a la red de pases de Francia durante la final de la Copa del Mundo 2018. La lógica es bastante genérica, utiliza los datos abiertos de statsbomb, y tiene comentarios que deberían ayudar a entender la idea.
Para correr el ejemplo tal cual está escrito es necesario tener instaladas las librerías kloppy y mplsoccer (además de pandas, matplotlib y numpy, que son dependencias de las anteriores).
Por un lado, kloppy nos permite reutilizar el mismo código con datos obtenidos de cualquier otro proveedor soportado, además de brindarnos una abstracción simple y uniforme de los datos de eventos de un partido. Y por el otro, mplsoccer nos simplifica el proceso de graficar la información usando como base una representación del terreno de juego.
import matplotlib.pyplot as plt
import numpy as np
from kloppy import datasets, to_pandas
from kloppy.domain import EventType, PassResult, Provider
from matplotlib.colors import to_rgba
from mplsoccer import Pitch
# check available matches in the statsbomb repo:
# https://github.com/statsbomb/open-data
# e.g. STATSBOMB_MATCH_ID = 7580 # WC2018: Francia - Argentina
MATCH_TITLE = "World Cup 2018 | Final"
STATSBOMB_MATCH_ID = 8658 # WC2018: Francia - Croacia
TEAM = 0 # 0 for home team, 1 for away team
dataset = datasets.load(
"statsbomb",
options={
"event_types": [EventType.PASS.value],
"coordinate_system": Provider.STATSBOMB,
},
match_id=STATSBOMB_MATCH_ID,
)
team = dataset.metadata.teams[TEAM]
# keep the team starting player names
starting_names = [p.name for p in team.players if p.starting]
# filter completed passes for the chosen team
dataset = dataset.filter(
lambda p: p.result == PassResult.COMPLETE and p.team == team
)
# get a pandas dataframe
df = to_pandas(
dataset,
additional_columns={
"player_name": lambda event: str(event.player),
"receiver_name": lambda event: str(event.receiver_player),
},
)
# get the index of the first pass involving a non-starting player
# and drop passes since that moment
non_starting_player_pass = df[
(~df.player_name.isin(starting_names)) |
(~df.receiver_name.isin(starting_names))
]["timestamp"].idxmin()
df = df.iloc[:non_starting_player_pass]
# add a key to identify involved players in a pass event,
# independently of passer/receiver
df["involved_players"] = df.apply(
lambda row: "-".join(sorted([row.player_name, row.receiver_name])), axis=1
)
# count the passes between pair of players
num_passes = (
df[["involved_players"]]
.groupby("involved_players")
.size()
.reset_index(name="pass_count")
.sort_values(["pass_count"], ascending=False)
)
# calculate average pass-starting position for each player
avg_positions_df = (
df[["player_name", "coordinates_x", "coordinates_y"]].groupby(
"player_name").mean()
)
# add avg starting/end positions to our passes dataframe
num_passes["start_x"] = num_passes.apply(
lambda row: avg_positions_df.loc[
row["involved_players"].split("-")[0]
].coordinates_x,
axis=1,
)
num_passes["start_y"] = num_passes.apply(
lambda row: avg_positions_df.loc[
row["involved_players"].split("-")[0]
].coordinates_y,
axis=1,
)
num_passes["end_x"] = num_passes.apply(
lambda row: avg_positions_df.loc[
row["involved_players"].split("-")[1]
].coordinates_x,
axis=1,
)
num_passes["end_y"] = num_passes.apply(
lambda row: avg_positions_df.loc[
row["involved_players"].split("-")[1]
].coordinates_y,
axis=1,
)
# get the maximum number of passes between 2 players
max_num_passes = num_passes.pass_count.max()
# plotting code
MAX_LINE_WIDTH = 15
MIN_TRANSPARENCY = 0.3
# define the color/transparency for each pass
# based on count value and max num passes
color = np.array(to_rgba("lightgreen"))
color = np.tile(color, (len(num_passes), 1))
c_transparency = [
(v / max_num_passes) * (1 - MIN_TRANSPARENCY) + MIN_TRANSPARENCY
for v in num_passes.pass_count.values
]
color[:, 3] = c_transparency
# setup the base mplsoccer pitch
pitch = Pitch(
pitch_color="#181818",
line_color="#e3e3e3",
goal_type="box",
line_zorder=1,
linewidth=1,
)
fig, axs = pitch.grid(
figheight=10,
title_height=0.03,
endnote_space=0,
axis=False,
title_space=0,
grid_height=0.9,
endnote_height=0.02,
)
fig.set_facecolor(pitch.pitch_color)
# set the title
title = team.name
subtitle = "vs {} | {}".format(
dataset.metadata.teams[(TEAM + 1) % 2].name, MATCH_TITLE
)
axs["title"].text(
0.5, 0.9, title, color=pitch.line_color,
va="center", ha="center", fontsize=30
)
axs["title"].text(
0.5, 0.05, subtitle, color=pitch.line_color,
va="center", ha="center", fontsize=15
)
# add direction-of-play arrow
pitch.arrows(50, 82, 70, 82, color=pitch.line_color, ax=axs["pitch"])
pitch.annotate(
"direction of play",
xy=(60, 84),
ha="center",
va="center",
size=8,
c=pitch.line_color,
ax=axs["pitch"],
)
# add the lines connecting players
# width and transparency reflect the number of passes between them
pass_lines = pitch.lines(
num_passes.start_x,
num_passes.start_y,
num_passes.end_x,
num_passes.end_y,
# normalize edge width
lw=[v / max_num_passes * MAX_LINE_WIDTH for v in num_passes.pass_count],
color=color,
zorder=2,
ax=axs["pitch"],
)
# add node for each player
# use the average starting position of his/her respective passes
pass_nodes = pitch.scatter(
avg_positions_df.coordinates_x,
avg_positions_df.coordinates_y,
s=400,
linewidth=4,
alpha=1,
zorder=2,
color=pitch.pitch_color,
edgecolors="white",
ax=axs["pitch"],
)
# add names to each node
for row in avg_positions_df.itertuples():
pitch.annotate(
row.Index,
xy=(row.coordinates_x, row.coordinates_y - 2.5),
ha="center",
va="center",
size=10,
weight="bold",
c=pitch.line_color,
ax=axs["pitch"],
)
# save to a file
fig.savefig("pass-network.png")
plt.close(fig)