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 str(el):
      string_with_json_obj = str(el).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

12 ideas con respecto a “Web Scraping Advanced Football Statistics”

  1. Hi, thanks for this article!

    I just have a question about this part :
    “teams = {}
    for id in data.keys():
    teams[id] = data[id][‘title’]”

    Where the “data” (data.keys) was defined before ? I have an error for this part on my notebook…

    1. Hi,

      data appears when you read the json into it with this line of code:
      data = json.loads(json_data)

      I missed it in the article. Please, check my notebook at Kaggle for working code here

  2. Hi Sergi!

    Your article is awesome! it is really helpful! I love it!!!

    I have a question for you!

    I would like to edit the code for getting the same information (matches, xg, xga), but only for home games and away games. I do not want to have the overall table. I want to have two different tables: home and away

    What do I have to edit in your code in order to only have home games stats and away games stats?

    I am a really beginner, so if you can explain it step by step would be great

    I really hope you can help me out with this! I would really appreciate it

    Thank you

  3. Hello Sergi!

    Thanks for the article and the dataset!

    My plan is to make an analysis taking into consideration the home and away results.

    I have 2 questions for you:

    – which changes need to be made in the code in order to get 2 tables, one for home and other for away games?

    – also, i was looking at the code and this error came up:

    ind_start = string_with_json_obj.index(“(‘”)+2
    ValueError: substring not found

    Can you help me solve it?

    I still have little experience in web scrapping, so your help would be much aprecciated.

    Keep up with your good work!

    Thanks in advance,
    Francisco

    1. Thank you Francisco for kind feedback!

      In order to achieve that you have to separate the data by column ‘h_a’ before summing everything up. If you want to do that on your own you have to stop before the paragraph “Manipulations to make data as in the original source”. In the dataframe you get in that step there will be all raw data and “home/away” column (‘h_a’).

      Here you can find my Kaggle notebook without summing up the data. It contains all the data per every game – the output you get there can be just splitted by column ‘h_a’ manually in Excel or just add an additional line in the code and export 2 CSVs.
      https://www.kaggle.com/slehkyi/web-scraping-football-statistics-per-game-data

      Also, if you don’t want to play too much with scraping, here is the dataset I maintain https://www.kaggle.com/slehkyi/extended-football-stats-for-european-leagues-xg – it has both summary and game records. Updating twice a year.

      Hope it helps! If you still have questions you can reach me on social or by email 🙂 all info in the footer 🙂

  4. Hola Sergi,

    Realizando los pasos que mencionas al llegar a esta parte del código

    import json

    string_with_json_obj = ”

    for el in scripts:
    if’teamsData’ in el.text:
    string_with_json_obj = el.text.strip()

    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’)

    me salta un error : substring not found ; podrías indicarme como se puede resolver?

    1. Hola 🙂

      Bastante probable que no has descargado los datos en el paso anterior. Para revisar esto añade un par de prints para entender dónde no tienes datos.

      Por ejemplo aquí puedes comprobar si el variable scripts tiene datos:
      print(scripts)
      for el in scripts:
      # aquí puedes ver si hay algunos elementos en script
      print(el)
      if 'teamsData' in el.text:
      string_with_json_obj = el.text.strip()
      # aquí por ejemplo puedes revisar si string_with_json_obj tiene algún dato
      print(string_with_json_obj)

      Y así puedes validar otras cosas. Con simples print()

      Espero que eso ayude 😉

      1. Hello Sergi,

        I’m sorry to bother you, but I’m a beginner and I have about the same problem as the questioner above me.
        I have data in the script variable ( var teamsData = JSON.parse(‘\x7B\x2……………), but if I try to use .text, it won’t return anything. Has anything changed or am I just missing something?
        I’ve tried it on another, simpler, page and it works there, but here it looks like the .text (or get_text ()) function has stopped working here.
        Don’t you know where the problem might be?
        Thanks a lot

        Tomas

        1. Hey 🙂

          You have to use .text on the pile of data that is in the scripts tag, while looping through each tag. If you already extracted that text, your data is in the “string” type, so you have to deal with it as regular string.

          Also, if it doesn’t return anything maybe you didn’t catch any data… I just ran my notebook in Kaggle and it gets all the numbers.

          Try to debug your code: print content of any variable you introduce or change, even if you are sure about the output. Print all the data from scripts and manually check if there is a string ‘teamsData’ and check the type of that data, then print only ‘teamsData’ and its type and so on. Pretty sure you will find what’s wrong.

          Hope that helps 🙂 if not – find me on social or shoot me an email and we will discuss it more in depth.

          Cheers and have a great day 🙂

  5. Great tutorial!

    I’m trying to add this to a Java program I made, could you help me with the encoding and decoding in Java?

    I’m talking about this part:

    encode(‘utf8’).decode(‘unicode_escape’)

    Thank you!

Leave a Reply

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