Web Scraping Advanced Football Statistics

Últimamente estaba debatiendo sobre el rol de la suerte en el fútbol. O tal vez no es pura suerte, sino una habilidad? ¿Los equipos ganan sus ligas puramente en habilidad? O la importancia de la suerte es bastante grande? ¿Quién tiene suerte y quién no? ¿Merecía ese equipo el descenso? Y muchos muchos mas.

Pero como soy un tipo de datos, pensé, así que obtengamos los datos y descubramos eso. Aunque, ¿cómo se mide la suerte? ¿Cómo se mide la habilidad? No existe una métrica única como la de los juegos de computadora FIFA o PES. Tenemos que mirar el panorama general, en un dato a largo plazo con múltiples variables y teniendo en cuenta el contexto de cada juego jugado. Porque en algunos momentos el jugador de un equipo simplemente no tiene suficiente suerte para marcar el gol de la victoria en los últimos minutos después de la dominación total sobre el oponente y termina como ecualizador y ambos equipos obtienen 1 punto, aunque quedó claro que el primero equipo mereció la victoria. Un equipo tuvo suerte, otro no. Sí, en esta situación es una suerte, porque un equipo hizo todo, creó suficientes momentos peligrosos, pero no anotó. Sucede. Y por eso nos encanta el fútbol. Porque todo puede pasar aquí.

Aunque no puedes medir la suerte, pero entiendes cómo jugó el equipo basándose en una métrica relativamente nueva en el fútbol: xG, o goles esperados.

xG – es una medida estadística de la calidad de las oportunidades creadas y concedidas

Puede encontrar los datos con esta métrica en understat.com. Esta es la web que estoy a punto de raspar.

Entendiendo los datos

Entonces, ¿qué diablos es xG y por qué es importante. Respuesta que podemos encontrar en la página de inicio de understat.com.

Los goles esperados (xG) es la nueva métrica del fútbol revolucionario, que le permite evaluar el rendimiento del equipo y del jugador.


En un juego de baja puntuación como el fútbol, el puntuación final del partido no proporciona una imagen clara del rendimiento.


Esta es la razón por la que cada vez más analistas deportivos recurren a modelos avanzados como xG, que es una medida estadística de la calidad de las oportunidades creadas y concedidas.


Nuestro objetivo era crear el método más preciso para la evaluación de la calidad del disparo.


Para este caso, entrenamos algoritmos de predicción de redes neuronales con el gran conjunto de datos (> 100,000 disparos, más de 10 parámetros para cada uno).

understat.com

Los investigadores entrenaron una red neuronal basada en situaciones que llevaron a los goles y ahora nos da una estimación de cuántas oportunidades reales tuvo el equipo durante el partido. Porque puedes tener 25 tiros durante el juego, pero si todos son de larga distancia o de ángulo bajo o demasiado débiles, tiros más cortos y de baja calidad, no te llevarán a la meta. Mientras que algunos “expertos” que no vieron el juego dirán que el equipo dominó, crearon muchas oportunidades bla-bla-bla-bla. La calidad de esas posibilidades importa. Y ahí es donde la métrica xG se vuelve muy útil. Con esta métrica, ahora entiendes que Messi crea goles en condiciones en las que es muy difícil de marcar, o que el portero hace el “save” donde debería haber sido el gol. Todas estas cosas se suman y vemos campeones que tienen jugadores hábiles y algo de suerte, y vemos perdedores que podrían tener buenos jugadores, pero no tienen suficiente suerte. Y mi intención con este proyecto es comprender y presentar estos números para demostrar el rol de la suerte en el fútbol de hoy.

Vamos a empezar

Comenzamos importando las librerías que se utilizarán en este proyecto:

  • numpy – paquete fundamental para la computación científica con Python
  • pandas – librería que proporciona alto rendimiento, estructuras de datos fáciles de usar y los datos de herramientas de análisis
  • requests – es la única librería HTTP sin OMG para Python, segura para el consumo humano (me encanta esta línea de documentos oficiales :D)
  • BeautifulSoup – una librería de Python para extraer datos de archivos HTML y XML.
import numpy as np # linear algebra
import pandas as pd # data processing, CSV file I/O (e.g. pd.read_csv)
import requests
from bs4 import BeautifulSoup

La investigación y la estructura de datos del sitio web

En la página de inicio podemos observar que el sitio tiene datos para 6 ligas europeas:

understat.com header menu
  • La Liga
  • EPL
  • BundesLiga
  • Serie A
  • Ligue 1
  • RFPL

Y también vemos que los datos recopilados comienzan a partir de la temporada 2014/2015. Otra noción que hacemos es la estructura de la URL. Es ‘https://understat.com/league' + ‘/name_of_the_league‘ + ‘/year_start_of_the_season

Así que creamos variables con estos datos para poder seleccionar cualquier temporada o liga.

# create urls for all seasons of all leagues
base_url = 'https://understat.com/league'
leagues = ['La_liga', 'EPL', 'Bundesliga', 'Serie_A', 'Ligue_1', 'RFPL']
seasons = ['2014', '2015', '2016', '2017', '2018']

El siguiente paso es comprender dónde se encuentran los datos en la página web. Para esto abrimos las Herramientas de desarrollo en Chrome, vaya a la pestaña “Red”, busque el archivo con datos (en este caso, 2018) y verifique la pestaña “Respuesta”. Esto es lo que obtendremos después de ejecutar requests.get(URL).

Después de revisar el contenido de la página web, encontramos que los datos se almacenan bajo la etiqueta “script” y están codificados en JSON. Así que tendremos que encontrar esta etiqueta, obtener JSON de ella y convertirla en la estructura de datos legible de Python.

# Starting with latest data for Spanish league, because I'm a Barcelona fan
url = base_url+'/'+leagues[0]+'/'+seasons[4]
res = requests.get(url)
soup = BeautifulSoup(res.content, "lxml")

# Based on the structure of the webpage, I found that data is in the JSON variable, under 'script' tags
scripts = soup.find_all('script')

Trabajando con JSON

Descubrimos que los datos que nos interesan se almacenan en la variable teamsData, después de crear una sopa de etiquetas html, se convierte en solo una string, por lo que encontramos ese texto y extraemos JSON de ella.

import json

string_with_json_obj = ''

# Find data for teams
for el in scripts:
    if 'teamsData' in el.text:
      string_with_json_obj = el.text.strip()
      
# print(string_with_json_obj)

# strip unnecessary symbols and get only JSON data
ind_start = string_with_json_obj.index("('")+2
ind_end = string_with_json_obj.index("')")
json_data = string_with_json_obj[ind_start:ind_end]

json_data = json_data.encode('utf8').decode('unicode_escape')

Una vez que hayamos obtenido nuestro JSON y lo hayamos limpiado, podemos convertirlo en el diccionario de Python y comprobar su aspecto (declaración print comentada).

Entendiendo los datos con Python

Cuando comenzamos a investigar los datos, entendemos que este es un diccionario de diccionarios de 3 claves: id, título e historia. La primera capa del diccionario usa también las ids como claves.

También de esto entendemos que history tiene datos con respecto a cada partido que el equipo jugó en su propia liga (los juegos de la Copa de la Liga o de la Liga de Campeones no están incluidos).

Podemos reunir los nombres de los equipos después de revisar el diccionario de la primera capa.

# Get teams and their relevant ids and put them into separate dictionary
teams = {}
for id in data.keys():
  teams[id] = data[id]['title']

El history es el conjunto de diccionarios donde las claves son nombres de métricas (nombres de columnas) y los valores son valores, a pesar de lo tautológico que es :D.

Entendemos que los nombres de las columnas se repiten una y otra vez, por lo que los agregamos a una lista separada. También verificando cómo se ven los valores de la muestra.

# EDA to get a feeling of how the JSON is structured
# Column names are all the same, so we just use first element
columns = []
# Check the sample of values per each column
values = []
for id in data.keys():
  columns = list(data[id]['history'][0].keys())
  values = list(data[id]['history'][0].values())
  break

Después de emitir algunas declaraciones impresas, encontramos que Sevilla tiene el id = 138, por lo que obtener todos los datos para que este equipo pueda reproducir los mismos pasos para todos los equipos de la liga.

sevilla_data = []
for row in data['138']['history']:
  sevilla_data.append(list(row.values()))
  
df = pd.DataFrame(sevilla_data, columns=columns)

Por el bien de dejar este artículo limpio, no agregaré el contenido del DataFrame creado, pero al final encontrarás enlaces a las notebooks IPython en Github y Kaggle con todos los códigos y resultados. Aquí solo muestras para el contexto.

Entonces, wualya, felicidades! ¡Tenemos los datos de todos los partidos de Sevilla en la temporada 2018-2019 dentro de La Liga! Ahora queremos obtener esa información para todos los equipos españoles. ¡Vamos a dar vueltas por esas mordeduras bebé!

# Getting data for all teams
dataframes = {}
for id, team in teams.items():
  teams_data = []
  for row in data[id]['history']:
    teams_data.append(list(row.values()))
    
  df = pd.DataFrame(teams_data, columns=columns)
  dataframes[team] = df
  print('Added data for {}.'.format(team))

Después de que este código termine de ejecutarse, tenemos un diccionario de DataFrames donde key es el nombre del equipo y value es el DataFrame con todos los juegos de ese equipo.

Manipulaciones para hacer datos como en la fuente original

Cuando observamos el contenido de DataFrame podemos observar que métricas como PPDA y OPPDA (ppda y ppda_allowed) se representan como cantidades totales de acciones de ataque / defensa, pero en la tabla original se muestran como coeficientes. ¡Vamos a arreglar eso!

for team, df in dataframes.items():
  dataframes[team]['ppda_coef'] = dataframes[team]['ppda'].apply(lambda x: x['att']/x['def'] if x['def'] != 0 else 0)
  dataframes[team]['oppda_coef'] = dataframes[team]['ppda_allowed'].apply(lambda x: x['att']/x['def'] if x['def'] != 0 else 0)

Ahora tenemos todos nuestros números, pero para cada juego. Lo que necesitamos son los totales para el equipo. Averigüemos las columnas que tenemos que resumir. Para esto volvemos a la tabla original en understat.com y encontramos que todas las métricas deben resumirse y que solo PPDA y OPPDA son medios al final.

cols_to_sum = ['xG', 'xGA', 'npxG', 'npxGA', 'deep', 'deep_allowed', 'scored', 'missed', 'xpts', 'wins', 'draws', 'loses', 'pts', 'npxGD']
cols_to_mean = ['ppda_coef', 'oppda_coef']

Estamos listos para calcular nuestros totales y medios. Para esto hacemos un bucle a través del diccionario de marcos de datos y llamamos a .sum() y .mean() Métodos de DataFrame que devuelven Series, por eso agregamos .transpose() a esas llamadas Ponemos estos nuevos DataFrames en una lista y luego los convertimos en un nuevo DataFrame full_stat.

frames = []
for team, df in dataframes.items():
  sum_data = pd.DataFrame(df[cols_to_sum].sum()).transpose()
  mean_data = pd.DataFrame(df[cols_to_mean].mean()).transpose()
  final_df = sum_data.join(mean_data)
  final_df['team'] = team
  final_df['matches'] = len(df)
  frames.append(final_df)
  
full_stat = pd.concat(frames)

A continuación, reorganizamos las columnas para una mejor legibilidad, ordenamos las filas según los puntos, restablecemos el índice y agregamos la columna “position”

full_stat = full_stat[['team', 'matches', 'wins', 'draws', 'loses', 'scored', 'missed', 'pts', 'xG', 'npxG', 'xGA', 'npxGA', 'npxGD', 'ppda_coef', 'oppda_coef', 'deep', 'deep_allowed', 'xpts']]
full_stat.sort_values('pts', ascending=False, inplace=True)
full_stat.reset_index(inplace=True, drop=True)
full_stat['position'] = range(1,len(full_stat)+1)

También en la tabla original tenemos valores de diferencias entre métricas esperadas y reales. Vamos a agregar esos también.

full_stat['xG_diff'] = full_stat['xG'] - full_stat['scored']
full_stat['xGA_diff'] = full_stat['xGA'] - full_stat['missed']
full_stat['xpts_diff'] = full_stat['xpts'] - full_stat['pts']

Convertir los flotantes a números enteros cuando sea apropiado.

cols_to_int = ['wins', 'draws', 'loses', 'scored', 'missed', 'pts', 'deep', 'deep_allowed']
full_stat[cols_to_int] = full_stat[cols_to_int].astype(int)

Prettifying la vista final de nuestra DataFrame

col_order = ['position','team', 'matches', 'wins', 'draws', 'loses', 'scored', 'missed', 'pts', 'xG', 'xG_diff', 'npxG', 'xGA', 'xGA_diff', 'npxGA', 'npxGD', 'ppda_coef', 'oppda_coef', 'deep', 'deep_allowed', 'xpts', 'xpts_diff']
full_stat = full_stat[col_order]
full_stat.columns = ['#', 'team', 'M', 'W', 'D', 'L', 'G', 'GA', 'PTS', 'xG', 'xG_diff', 'NPxG', 'xGA', 'xGA_diff', 'NPxGA', 'NPxGD', 'PPDA', 'OPPDA', 'DC', 'ODC', 'xPTS', 'xPTS_diff']
pd.options.display.float_format = '{:,.2f}'.format
full_stat.head(10)
Printscreen of Collaboratory output

Tabla original:

Printscreen understat.com

Ahora, cuando obtuvimos nuestros números para una temporada de una liga, podemos replicar el código y ponerlo en el bucle para obtener todos los datos de todas las temporadas de todas las ligas. No pondré este código aquí, pero dejaré un enlace a la solución de raspado completa en Github y Kaggle.

Dataset final

Después de loop por todas las ligas y todas las temporadas y unos pocos pasos de manipulación para hacer que los datos sean exportables, tengo un archivo CSV con números raspados. El conjunto de datos está disponible aquí.

Conclusión

Espero que lo hayas encontrado útil y tengas información valiosa. De todos modos, si llegaste a este punto solo quiero agradecerte por leer, por dedicar tu tiempo, energía y atención a mis 5 centavos y te deseo mucho amor y felicidad, ¡eres increíble!


Foto de Michael Lee en Unsplash

Karma +1 when you share it:

Leave a Reply

Your email address will not be published. Required fields are marked *