Веб-скрапінг розширеної футбольної статистики

Останнім часом я все обговорював роль удачі у футболі. А може, це не чиста удача, а майстерність? Чи команди завойовують свої ліги виключно на майстерності? Або важливість удачі досить велика? Кому щастить, а кому ні? Чи заслуговувала ця команда на виліт? І ще купа подібних питань.

Але, оскільки я – чувак даних, я подумав, так давайте отримаємо дані і знайдемо відповіді. Хоча, як ти виміряєш удачу? Як ти оціниш майстерність? Не існує такої єдиної метрики, як у комп’ютерних іграх FIFA або PES. Ми повинні дивитися на загальну картину, на довгострокові дані з декількома змінними і з урахуванням контексту кожної окремої гри. Тому що в окремі моменти гравцеві однієї команди просто не вистачає удачі, щоб забити переможний гол на останніх хвилинах після повного домінування над супротивником, і він закінчується нічиєю, і обидві команди отримують 1 бал, тоді як було ясно, що перша команда заслужила перемогу. Одній команді пощастило, іншій не дуже. Так, у цій ситуації це удача, оскільки одна команда зробила все, створила достатньо небезпечних моментів, але не забила. Таке стається. І тому ми любимо футбол. Тому що тут все може статися.

Хоча не можна виміряти удачу, але можна зрозуміти, як команда грала на основі відносно нової метрики у футболі – xG, або очікувані голи.

xG – це статистичний показник якості створених і упущених шансів

Ви можете знайти дані з цим показником на сайті understat.com. Звідси я й буду скрапити дані.

Розуміння даних

Отже, що ж це за хрінь це xG і чому воно важливе. Відповідь ми можемо знайти на домашній сторінці understat.com.

Очікувані голи (xG) – це нова революційна футбольна метрика, яка дозволяє оцінювати продуктивність команди та гравця.
У грі з низькою кількістю очок, таких як футбол, підсумковий рахунок не дає чіткої картини продуктивності.
Ось чому все більше і більше спортивних аналітиків звертаються до передових моделей, як xG, що є статистичним показником якості створених і упущених шансів.
Нашою метою було створити найбільш точний метод оцінки якості ударів по воротах.
Для цього ми підготували алгоритми прогнозування з нейронними мережами з великим набором даних (> 100 000 зразків, понад 10 параметрів для кожного).

understat.com

Дослідники навчили нейронну мережу на основі ситуацій, які призвели до голів, і тепер вона дає нам оцінку того, скільки реальних шансів команда мала під час матчу. Тому що ви можете мати 25 ударів протягом гри, але якщо всі вони знаходяться на великій відстані або під низьким кутом до воріт або занадто слабкі, – коротше, удари низької якості, вони не приведуть до взяття воріт. Хоча деякі «експерти», які не бачили гру будуть казати, що команда домінувала, створили тонни шансів бла-бла-бла-бла. Якість цих шансів має значення. І тут метрика xG стає в пригоді. З цим показником ви тепер розумієте, що Мессі створює цілі в умовах, де дуже важко забити, або воротар рятує команду, коли мав би бути гол. Всі ці речі складаються, і ми бачимо чемпіонів, які мають досвідчених гравців і яким трохи пощастило, і ми бачимо невдах, які можуть мати хороших гравців, але не вистачає удачі. І мета цього проекту – зрозуміти і представити ці цифри, щоб показати роль удачі в сьогоднішньому футболі.

Давайте розпочнемо

Ми починаємо з імпорту бібліотек, які будуть використовуватися в цьому проекті:

  • numpy – фундаментальний пакет для наукових обчислень з Python
  • pandas – бібліотека, що забезпечує високу продуктивність, зручність у використанні структур даних та інструментів аналізу даних
  • requests – це єдина HTTP-бібліотека без ГМО для Python, безпечна для споживання людиною. (люблю цей рядок з офіційних документації :D)
  • BeautifulSoup – бібліотека Python для виведення даних з файлів HTML і XML.
import numpy as np # лінійна алгебра
import pandas as pd # обробка даних
import requests
from bs4 import BeautifulSoup

Дослідження сайту та структура даних

У будь-якому скраппінг проекті перше, що потрібно зробити, це дослідити веб-сторінку, яку ви хочете зчитати, і зрозуміти, як вона працює. Це є фундаментальним. Тому ми починаємо звідти.

На домашній сторінці можна помітити, що на сайті є дані для 6 європейських ліг:

  • Іспанська Ла Ліга
  • Англійська Прем’єр Ліга
  • Німецька Бундес Ліга
  • Італійська Серія А
  • Французька Ліга 1
  • Російська Прем’єр Ліга

І ми також бачимо, що зібрані дані починаються з сезону 2014/2015. Інша замітка, яку ми робимо, – це структура URL: ‘https://understat.com/league' + ‘/ліга‘ + ‘/рік

Таким чином, ми створюємо змінні з цими даними, щоб мати можливість вибрати будь-який сезон або будь-яку лігу.

# створити urls для всіх сезонів кожної з ліг
base_url = 'https://understat.com/league'
leagues = ['La_liga', 'EPL', 'Bundesliga', 'Serie_A', 'Ligue_1', 'RFPL']
seasons = ['2014', '2015', '2016', '2017', '2018']

Наступним кроком є визначення місця розташування даних на веб-сторінці. Для цього відкриваємо Інструменти розробника в Chrome, переходимо до вкладки «Мережа», знаходимо файл з даними (у цьому випадку 2018) і перевіряємо вкладку «Відповідь». Це саме те, що ми отримаємо після запуску request.get(URL)

Після перегляду вмісту веб-сторінки ми бачимо, що дані зберігаються під тегом “script” і в JSON-і. Тому нам знадобиться знайти цей тег, отримати JSON з нього і перетворити його в читабельну структуру даних Python.

url = base_url+'/'+leagues[0]+'/'+seasons[4]
res = requests.get(url)
soup = BeautifulSoup(res.content, "lxml")

scripts = soup.find_all('script')

Робота з JSON

Ми виявили, що необхідні нам дані зберігаються в teamsData змінній, після створення супу html тегів вона стає просто рядком, тому ми знаходимо цей текст і витягуємо з нього JSON.

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

Після того як ми отримали наш JSON і очистили його, ми можемо перетворити його в словник Python і перевірити, як він виглядає (закоментований прінт).

Розуміння даних з Python

Коли ми починаємо досліджувати дані, ми розуміємо, що це словник словників з 3-х ключів: id, title і history. Перший шар словника також використовує ідентифікатори як ключі.

Також з цього ми розуміємо, що history має дані про кожний матч, який команда зіграла у своїй лізі (Кубок Ліги або ігри Ліги чемпіонів не включені).

Ми можемо зібрати назви команд із першого шару словника.

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

History – це масив словників, де ключі – це назви метрик (читай назви стовпчиків), а значення – значення, незважаючи на те, наскільки тавтологічно це звучить :D.

Ми розуміємо, що імена стовпців повторюються знову і знову, тому ми додаємо їх до окремого списку. Також перевіряємо, як виглядають значення вибірки.

# 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

Після поверхового сканування даних ми бачимо, що у Севілья має id = 138, тому отримуємо всі дані для цієї команди, щоб мати змогу відтворити ті самі кроки для всіх команд у лізі.

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

Задля того, щоб залишити цю статтю чистою, я не буду додавати вміст створеної DataFrame, але в кінці ви знайдете посилання на IPython Notebooks на Github і Kaggle з усім кодом та результатами. Тут тільки зразки для контексту.

Отож, вуаля, вітання! У нас є дані для всіх матчів Севільї в сезоні 2018-2019 в рамках La Liga! Тепер ми хочемо отримати ці дані для всіх іспанських команд. Давайте пробіжимося по даних!

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

Після завершення виконання цього коду ми маємо словник DataFrames, де ключем є ім’я команди, а значенням – DataFrame з усіма поєдинками цієї команди.

Маніпуляції для створення даних як у вихідному джерелі

Коли ми дивимося на вміст DataFrame, можна помітити, що такі метрики, як PPDA і OPPDA (ppda і ppda_allowed), представлені як загальні суми атакуючих / оборонних дій, але в початковій таблиці це показано як коефіцієнти. Давайте це виправимо.

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)

Тепер у нас є всі наші числа, але для кожної окремої гри. Нам потрібні підсумки для команди. Давайте знайдемо стовпці, які ми повинні підсумувати. Для цього ми повертаємося до оригінальної таблиці на understat.com і виявляємо, що всі метрики повинні бути сумами і тільки PPDA і OPPDA є середніми значеннями.

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']

Тепер ми готові розрахувати наші суми та середні. Для цього ми проходимо за допомогою циклу через словник dataframe-ів і викликаємо .sum() і .mean() DataFrame методи, які повертають Series, тому ми додаємо .transpose() до цих методів. Ми поміщаємо ці нові DataFrames у список і після цього їх об’єднуємо в нову 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)

Далі перевпорядковуємо стовпці для кращої читаності, сортуємо рядки на основі турнірних балів, скидаємо індекс і додаємо стовпець “позиція”.

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)

Також у вихідній таблиці ми маємо значення відмінностей між очікуваними метриками та реальними. Додамо їх теж.

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']

Перетворення float у цілі числа, де це доречно.

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

Останні мазки для кращого вигляду 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)
Так воно виглядає в Колабораторі

Оригінальна таблиця:

Тепер, коли ми отримали наші дані за один сезон з однієї ліги, ми можемо повторити код і покласти його в цикл, щоб отримати всі дані за всі сезони всіх ліг. Я не буду розміщувати цей код тут, але залишу посилання на остаточне рішення в Github і Kaggle.

Остаточний датасет

Пройшовшись по всіх лігах всіх сезонів і декількох кроків маніпуляції, щоб отримати дані, які можна експортувати, в мене вийшов ось такий-от файл CSV із зібраними даними. Датасет доступний тут.

Висновки

Сподіваюся, це було корисно і ви отримали деяку цінну інформацію. У всякому разі, якщо ви досягли цього моменту, я просто хочу сказати спасибі за читання, за те, що ви виділили свій час, енергію і увагу на мої 5 центівб а ще бажаю вам багато любові і щастя. Ти крутий!

  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 to Francisco Cancel reply

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