SkillCorner Open Data #4: Visualización de carreras sin balón
.
En las entregas anteriores de nuestra serie Open Data, hemos utilizado métricas agregadas para representar gráficamente los atributos de los jugadores, compararlos y crear perfiles. En esta ocasión profundizamos en los datos de Game Intelligence a nivel de evento para ver cómo visualizar las carreras sin balón (Off-Ball Runs / OBR).
Como parte de nuestra publicación abierta de la A-League australiana 2024/25, hemos incluido 10 partidos de datos de Dynamic Events: una amplia colección de datos y marcadores contextuales derivados de nuestros datos de tracking que constituyen la base de nuestra suite Game Intelligence.
Los partidos, y sus ID correspondientes, son los siguientes:
- Auckland FC 2 - 0 Newcastle (30/11/2024): 1886347
- Auckland FC 2 - 1 Wellington Phoenix (07/12/2024): 1899585
- Brisbane Roar 0 - 1 Perth Glory (21/12/2024): 1925299
- Central Coast Mariners 1 - 1 Melbourne City (31/12/2024): 1953632
- Sydney FC 4 - 1 Adelaide United (01/02/2025): 1996435
- Melbourne City 2 - 0 Macarthur FC (07/03/2025): 2006229
- Wellington Phoenix 2 - 3 Melbourne Victory (12/04/2025): 2011166
- Western United 1 - 0 Sydney FC (27/04/2025): 2013725
- Western United 4 - 2 Auckland FC (03/05/2025): 2015213
- Melbourne Victory 0 - 1 Auckland FC (17/05/2025): 2017461
La profundidad y la calidad de los datos disponibles en estos archivos abren un enorme abanico de posibilidades de análisis. En este artículo, nos centraremos en las carreras sin balón (OBR) y en cómo visualizarlas sobre un campo.
1 - Cargar Dynamic Events y filtrar los desmarques sin balón
Nuestro primer paso es cargar el archivo de Dynamic Events de un partido concreto.
En este artículo tomamos como ejemplo el partido del 01/02/2025 entre el Sydney FC y el Adelaide United, pero puedes seleccionar fácilmente otro encuentro introduciendo el match_id correspondiente (indicado arriba) en la URL desde la que importamos el archivo.
# Instalar librerías
!pip install numpy pandas mplsoccer skillcorner skillcornerviz
import os
import io
import pandas as pd
from mplsoccer import VerticalPitch
from skillcornerviz.utils import constants
# Cargar el archivo CSV de Dynamic Events directamente desde Open Data en GitHub
# Cambia la URL para indicar el ID del partido deseado. Se usa el ID 1996435 por defecto
url = "https://raw.githubusercontent.com/SkillCorner/opendata/master/data/matches/1996435/1996435_dynamic_events.csv"
df_dynamic = pd.read_csv(url)
df_dynamic.head()
Si lo deseas, puedes ejecutar a continuación el siguiente código para obtener una lista de las columnas disponibles en el archivo de Dynamic Events.
#Obtener una lista de las columnas disponibles
print(list(df_dynamic.columns))
Por último, creamos un nuevo dataframe a partir del archivo completo de Dynamic Events, filtrando únicamente las carreras sin balón.
#Filtrar los desmarques sin balón
df_obr = df_dynamic[df_dynamic["event_type"] == "off_ball_run"].reset_index(drop=True)
print(f"Loaded {len(df_obr)} off-ball run events")
df_obr.head()
2 - Visualizar los desmarques sin balón de un jugador
El archivo de Dynamic Events no solo nos permite identificar el número de desmarques sin balón realizados por cada jugador, sino también visualizar exactamente en qué parte del campo se produjeron y añadir información complementaria, como la velocidad media o las acciones resultantes.
Como punto de partida, podemos ejecutar el siguiente código para obtener una lista de todos los jugadores que participaron en el partido seleccionado y su número de desmarques sin balón.
# Obtener una lista de los jugadores del partido y el recuento de sus desmarques sin balón
player_summary = (df_obr.groupby(['player_id', 'player_name', 'team_shortname'])
.size()
.reset_index(name='run_count'))
# Ordenar por número de desmarques
player_summary = player_summary.sort_values('run_count', ascending=False)
print(player_summary)
En este partido, Joe Lolley del Sydney FC destaca como el jugador con más carreras sin balón, por lo que parece un buen candidato para la visualización.
Utilizamos su player_id para filtrar y crear un nuevo dataframe con sus carreras sin balón:
# Cambia el player_id según sea necesario para visualizar los desmarques de otros jugadores
viz = df_obr[df_obr['player_id'] == 18573].copy()
Y ya estamos listos para visualizarlas.
Para esta tarea, utilizamos la función de dibujo de campos del paquete mplsoccer. También aplicaremos una serie de detalles visuales para destacar información adicional sobre los desmarques:
- Resaltado en verde: para las carreras sin balón a velocidades de alta intensidad, que abarcan las carreras a alta velocidad (de 20 a 25 km/h) y los sprints (>25 km/h).
- Círculo blanco: para las carreras sin balón en las que el jugador recibió un pase.
- Línea discontinua: la trayectoria del pase que encontró la carrera sin balón.
El código es el siguiente:
# --- Configurar el campo ---
pitch = VerticalPitch(
pitch_type='skillcorner',
line_alpha=0.5,
pitch_length=105,
pitch_width=68,
half=False, #Para mostrar el campo completo; establécelo en "True" para visualizar solo el campo de ataque
pitch_color='#e8e8e6',
line_color=constants.TEXT_COLOR,
linewidth=1.5
)
fig, ax = pitch.grid(figheight=8, endnote_height=0.05, title_height=0.08, axis=False)
ax['title'].text(0.5, 0.5, 'J. Lolley: Off-Ball Runs\nSydney FC vs. Adelaide United | 01/02/2025',
ha='center', va='center', fontsize=12, linespacing=1.5)
ax['endnote'].text(0.5, 0.5, 'Created using SkillCorner Open Data',
va='center', ha='center', fontsize=10)
# --- Dibujar cada desmarque sin balón ---
for i, row in viz.iterrows():
# Resaltar franjas de velocidad específicas
# Franjas de velocidad: 'running' < 'hsr' < 'sprinting'
if row['speed_avg_band'] in ['hsr', 'sprinting']:
line_color = constants.PRIMARY_HIGHLIGHT_COLOR
else:
line_color = constants.TEXT_COLOR # TODOS LOS DEMÁS TIPOS SE MANTIENEN
# Dibujar el desmarque
# xy = punta de la flecha (inicio del desmarque), xytext = cola de la flecha (final del desmarque)
# Nota: VerticalPitch espera (y, x) porque el campo está girado 90°
ax['pitch'].annotate(
text='',
xy=(row['y_start'], row['x_start']), # Punta de la flecha en el inicio del desmarque
xytext=(row['y_end'], row['x_end']), # Cola en el final del desmarque
arrowprops=dict(
arrowstyle="wedge,shrink_factor=0.5,tail_width=0.4",
facecolor=line_color,
edgecolor=line_color,
lw=0.1,
alpha=0.8
),
zorder=1
)
# Dibujar un pequeño punto en la posición final del desmarque
ax['pitch'].plot(row['y_end'], row['x_end'], marker='o', color=line_color, markersize=4)
# Visualizar los desmarques con recepción mediante un círculo blanco
if True: # Cámbialo a True si quieres mostrar la recepción
if row['received'] == True:
ax['pitch'].scatter(
row['y_end'],
row['x_end'],
c='white',
edgecolor=constants.TEXT_COLOR,
alpha=1,
lw=0.5,
s=200
)
ax['pitch'].plot(
[row['player_in_possession_y_end'], row['y_end']],
[row['player_in_possession_x_end'], row['x_end']],
c=constants.TEXT_COLOR,
lw=0.9,
alpha=1,
ls='--'
)
Y el resultado final es:

También puedes utilizar este código como base para cambiar los colores y los detalles visuales en función de variables como el tipo de carrera (event_subtype) u otra información contextual.
3 - Visualizar los desmarques sin balón de alta intensidad de un equipo
También podemos visualizar las carreras sin balón a nivel de equipo. En este caso, tomamos el mismo partido que en el ejemplo del jugador y filtraremos para crear un nuevo dataframe que solo incluya las carreras sin balón realizadas a velocidades de alta intensidad (>20 km/h).
#Filtrar los desmarques de alta intensidad (hsr + sprinting)
# Definir las franjas de velocidad que quieres conservar
target_bands = ['hsr', 'sprinting']
# Crear el dataframe filtrado
viz_teams = df_obr[df_obr['speed_avg_band'].isin(target_bands)].copy()
# Comprobar que el filtro ha funcionado
print(f"Remaining speed bands: {viz_teams['speed_avg_band'].unique()}")
Ahora, podemos visualizar los desmarques de cada equipo uno al lado del otro utilizando la función pitch.grid del paquete mplsoccer. Por coherencia, mantendremos los mismos detalles visuales que en el gráfico a nivel de jugador, aunque en este caso solo estamos visualizando carreras sin balón de alta intensidad.
Este es el código:
# 1. Configurar la cuadrícula con 2 columnas
# Establecer ncols=2 crea dos campos uno al lado del otro
fig, ax = pitch.grid(ncols=2, figheight=8, endnote_height=0.05,
title_height=0.15, axis=False)
# Obtener los equipos únicos
teams = viz_teams['team_shortname'].unique()
# 2. Añadir el título y la nota final
ax['title'].text(0.5, 0.5, 'High-Intensity Off-Ball Runs\nSydney FC vs. Adelaide United | 01/02/2025',
ha='center', va='center', fontsize=12, linespacing=1.5)
ax['endnote'].text(0.5, 0.5, 'Created using SkillCorner Open Data',
va='center', ha='center', fontsize=10)
# 3. Recorrer los equipos y sus ejes correspondientes
for i, team in enumerate(teams):
# Seleccionar el eje actual (ax['pitch'] es ahora un array [0, 1])
current_ax = ax['pitch'][i]
# Filtrar los datos solo para este equipo
team_data = viz_teams[viz_teams['team_shortname'] == team]
# Establecer un subtítulo para cada campo
current_ax.set_title(team, fontsize=12, pad=10)
# --- Dibujar cada desmarque sin balón de este equipo ---
for _, row in team_data.iterrows():
# Resaltar franjas de velocidad específicas
# Franjas de velocidad: 'running' < 'hsr' < 'sprinting'
if row['speed_avg_band'] in ['hsr', 'sprinting']:
line_color = constants.PRIMARY_HIGHLIGHT_COLOR
else:
line_color = constants.TEXT_COLOR
# Dibujar el desmarque en current_ax
current_ax.annotate(
text='',
xy=(row['y_start'], row['x_start']),
xytext=(row['y_end'], row['x_end']),
arrowprops=dict(
arrowstyle="wedge,shrink_factor=0.5,tail_width=0.4",
facecolor=line_color, edgecolor=line_color,
lw=0.1, alpha=0.8
),
zorder=1
)
# Visualizar los desmarques con recepción en current_ax
if row['received']:
current_ax.scatter(row['y_end'], row['x_end'], c='white',
edgecolor=constants.TEXT_COLOR, s=150, zorder=2)
current_ax.plot([row['player_in_possession_y_end'], row['y_end']],
[row['player_in_possession_x_end'], row['x_end']],
c=constants.TEXT_COLOR, lw=0.9, ls='--')
# Dibujar el punto final en current_ax
current_ax.plot(row['y_end'], row['x_end'], marker='o', color=line_color, markersize=4)
Y este es el resultado:

De nuevo, se trata de un punto de partida básico que puede modificarse para visualizar distintos subconjuntos de carreras sin balón. La función pitch.grid también podría utilizarse a nivel de jugador: por ejemplo, para visualizar en un único gráfico las carreras sin balón de cada uno de los laterales o delanteros de un equipo.
4 - Ve más allá
Existen muchas otras formas de segmentar y visualizar las carreras sin balón. Podrías filtrar o colorear por event_subtype, dividir por zonas del campo (con las distintas columnas channel_, third_, x_ e y_) o incluso utilizar las columnas associated_off_ball_run para relacionarlos con pases que rompen líneas u otras situaciones contextuales, con la ayuda del documento de especificación de Dynamic Events.
El código completo de este artículo está disponible en un notebook de Google Colab para que puedas adaptarlo fácilmente.
Siempre te animamos a compartir tu trabajo en redes sociales. Si lo haces, cita a SkillCorner como fuente de los datos y etiqueta nuestra cuenta correspondiente en X/Twitter o LinkedIn.
Esperamos que hayas disfrutado de esta entrega de nuestra serie Open Data. Permanece atento a nuestras redes sociales para no perderte las próximas entregas.


