Чтобы следовать этому руководству, у вас должно быть следующее:

  • Python 3.7 или новее.
  • Арктайп
  • Базовое понимание SQL.
  • Текстовый редактор.

Установка необходимых библиотек

Для этого урока необходимы следующие библиотеки:

  • numpy - фундаментальный пакет для научных вычислений с Python
  • pandas - библиотека, предоставляющая высокопроизводительные, простые в использовании структуры данных и инструменты анализа данных
  • request - единственная HTTP-библиотека без ГМО для Python, безопасная для употребления человеком. (люблю эту строку из официальных документов: D)
  • BeautifulSoup - библиотека Python для извлечения данных из файлов HTML и XML.

Чтобы установить библиотеки, необходимые для этого руководства, выполните следующие команды:

pip install numpy
pip install pandas
pip install requests
pip install bs4

Сборка Python Web Scraper

Теперь, когда у нас установлены все необходимые библиотеки, приступим к созданию нашего парсера.

Импорт библиотек Python

import numpy as np
import pandas as pd
import requests
from bs4 import BeautifulSoup
import json

Проведение исследования сайта

Первым шагом в любом проекте по очистке веб-страниц является исследование веб-страницы, которую вы хотите очистить, и изучение того, как она работает. Это очень важно для того, чтобы найти, где взять данные с сайта. В этом руководстве мы будем использовать http://understat.com.

На главной странице мы видим, что на сайте есть данные по шести европейским лигам. Однако мы будем извлекать данные только по пяти лучшим лигам (командам без РФПЛ).

Мы также можем заметить, что данные на сайте начинаются с 2014/2015 по 2020/2021. Давайте создадим переменные для обработки только необходимых нам данных.

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

Следующий шаг - выяснить, где хранятся данные на веб-странице. Для этого откройте Инструменты разработчика в Chrome, перейдите на вкладку «Сеть», найдите файл данных (в данном примере 2018 г.) и выберите вкладку «Ответ». Вот что мы получим после выполнения запросов.

После просмотра содержимого веб-страницы мы обнаружили, что данные сохраняются под элементом «скрипт» в переменной teamsData и закодированы в формате JSON. В результате нам нужно отследить этот тег, извлечь из него JSON и преобразовать его в структуру данных, читаемую Python.

Расшифровка данных JSON с помощью Python

season_data = dict()
for season in seasons:
url = base_url+'/'+league+'/'+season
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')
string_with_json_obj = ''
# Find data for teams
for el in scripts:
if 'teamsData' in str(el):
string_with_json_obj = str(el).strip()
# 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')
#print(json_data)

После выполнения приведенного выше кода Python вы должны получить кучу данных, которые мы очистили.

Понимание данных скребка

Когда мы начинаем изучать данные, мы понимаем, что это словарь словарей с тремя ключами: идентификатор, заголовок и история. Идентификаторы также используются в качестве ключей на начальном уровне словаря.

Следовательно, мы можем сделать вывод, что в истории есть информация о каждом матче, сыгранном командой в своей лиге (игры Кубка лиги или Лиги чемпионов не включены).

После просмотра словаря первого уровня мы можем приступить к составлению списка названий команд.

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

Мы видим, что имена столбцов появляются часто; поэтому мы занесли их в отдельный список. Также посмотрите, как появляются образцы значений.

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

Теперь давайте получим данные по всем командам. Раскомментируйте оператор печати в приведенном ниже коде, чтобы вывести данные на консоль.

# 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), представлены как общие суммы атакующих / защитных действий.

Однако они показаны как коэффициенты в исходной таблице. Давай уберем это.

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)

Теперь у нас есть все наши числа, но для каждой игры. Тотал для команды - это то, что нам нужно. Давайте посмотрим на столбцы, которые нам нужно сложить. Для этого мы вернулись к исходной таблице на веб-сайте и обнаружили, что все меры следует складывать вместе, и в конечном итоге в качестве средств остаются только 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']

Наконец, давайте посчитаем итоги и средние значения.

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']
cols_to_int = ['wins', 'draws', 'loses', 'scored', 'missed', 'pts', 'deep', 'deep_allowed']
full_stat[cols_to_int] = full_stat[cols_to_int].astype(int)

В приведенном выше коде мы переупорядочили столбцы для лучшей читаемости, отсортировали строки по точкам, сбросили индекс и добавили «положение» столбца.

Мы также добавили различия между ожидаемыми и реальными показателями.

Наконец, мы преобразовали числа с плавающей запятой в целые числа, где это необходимо.

Украшение окончательного вывода фрейма данных

Наконец, давайте украсим наши данные, чтобы они стали похожими на данные сайта на изображении выше. Для этого запустите приведенный ниже код Python.

python
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 = full_stat.set_index('position')
# print(full_stat.head(20))

Чтобы напечатать часть улучшенных данных, раскомментируйте оператор печати в приведенном выше коде.

Компиляция окончательного кода агрегатора данных Python

Чтобы получить все данные, нам нужно просмотреть все лиги и сезоны, а затем настроить их, чтобы их можно было экспортировать в виде файла CSV.

import numpy as np
import pandas as pd
import requests
from bs4 import BeautifulSoup
import json
# create urls for all seasons of all leagues
base_url = 'https://understat.com/league'
leagues = ['La_liga', 'EPL', 'Bundesliga', 'Serie_A', 'Ligue_1']
seasons = ['2016', '2017', '2018', '2019', '2020']
full_data = dict()
for league in leagues:
season_data = dict()
for season in seasons:
url = base_url+'/'+league+'/'+season
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')
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')
# convert JSON data into Python dictionary
data = json.loads(json_data)
# Get teams and their relevant ids and put them into separate dictionary
teams = {}
for id in data.keys():
teams[id] = data[id]['title']
# 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
# 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))
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)
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']
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']
cols_to_int = ['wins', 'draws', 'loses', 'scored', 'missed', 'pts', 'deep', 'deep_allowed']
full_stat[cols_to_int] = full_stat[cols_to_int].astype(int)
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 = full_stat.set_index('position')
# print(full_stat.head(20))
season_data[season] = full_stat
df_season = pd.concat(season_data)
full_data[league] = df_season

Чтобы проанализировать наши данные в Arctype, нам нужно экспортировать данные в файл CSV. Для этого скопируйте и вставьте приведенный ниже код.

python
data = pd.concat(full_data)
data.to_csv('understat.com.csv')

Анализ данных парсера с MySQL

Теперь, когда у нас есть чистый CSV-файл, содержащий данные о футболе, давайте создадим несколько визуализаций. Во-первых, нам нужно импортировать CSV-файл в таблицу MySQL.

Импорт данных CSV в MySQL

Чтобы использовать извлеченные данные, нам необходимо импортировать данные CSV в виде таблицы в нашу базу данных. Для этого выполните следующие действия:

Шаг 1
В меню базы данных щелкните значок с тремя точками и выберите «Импортировать таблицу». Нажмите «принять», чтобы принять схему.

Шаг 2
Введите имя таблицы как «soccer_data», затем переименуйте первые два столбца в «лига» и «год». Оставьте все остальные настройки и нажмите кнопку «Импортировать CSV», как показано на изображении ниже.

После выполнения описанных выше шагов таблица soccer_data должна быть заполнена данными из файла CSV, как показано на изображении ниже.

Теперь, когда мы импортировали наши данные, хранящиеся в файле CSV, мы можем сравнивать различные данные и визуализировать их на диаграммах данных.

Использование динамического SQL для создания сводной таблицы и гистограммы

Мы будем анализировать данные о забитых и пропущенных бросках для одной лиги за все годы, чтобы рассчитать соотношение бросков к голу каждой команды. Идеальная лига для проведения этого анализа - это «Бундеслига», поскольку эта лига известна тем, что делает много нестандартных бросков.

Создание сводной таблицы количества ударов по цели

Для этой визуализации нам понадобятся наши результаты в виде сводной таблицы с уникальным столбцом для каждого сезона в наборе данных. Это основная логика нашего запроса:

SELECT
	year,
	SUM(
		CASE 
			WHEN year = '2020' THEN (scored + missed) / scored 
			ELSE NULL
		END
	) AS `2020 Season`
FROM 
	soccer_data
WHERE
	league = 'Bundesliga'
GROUP BY
	team
ORDER BY
	team;

Таким образом, соотношение бросков к голу каждой команды в сезоне 2020 года выводится в столбце под названием 2020 Season. Но что, если мы хотим, чтобы пять отдельных столбцов делали одно и то же в течение пяти сезонов? Конечно, мы можем определить каждый из них вручную или можем использовать GROUP CONCAT() и пользовательские переменные, чтобы делать это динамически. Единственный динамический компонент нашего запроса - это столбцы сезона в нашем операторе SELECT, поэтому давайте начнем с SELECT вставки этой строки запроса в переменную (@sql).

SELECT
  GROUP_CONCAT(
    DISTINCT CONCAT(
      'SUM(case when year = ''',
      year,
      ''' then (scored + missed) / scored ELSE NULL END) AS `',
      year,
      ' Season`'
    )
    ORDER BY
      year ASC
  ) INTO @sql
FROM
  soccer_data;

Здесь DISTINCT CONCAT() генерирует определение SUM(CASE WHEN year=...) столбца для каждого отдельного значения в year столбце нашей таблицы. Если вы хотите увидеть точный результат, просто добавьте SELECT @sql; на новой строке и выполните запрос.

Теперь, когда у нас есть динамическая часть нашей строки запроса, нам просто нужно добавить все остальное вокруг нее, например:

SET
  @sql = CONCAT(
    'WITH pivot_data AS (SELECT team, ',
    @sql,
    'FROM understat_com 
	WHERE league=''Bundesliga'' 
	GROUP BY team 
	ORDER BY team) 
	SELECT * 
	FROM pivot_data 
	WHERE `2019 Season` IS NOT NULL 
	AND `2020 Season` IS NOT NULL;'
  );

Наконец, нам просто нужно подготовить оператор SQL из строки в @sql и выполнить его:

PREPARE stmt FROM @sql;
EXECUTE stmt;

Запустите, переименуйте и сохраните весь запрос выше.

Визуализация отношения количества голов к выстрелу с помощью гистограммы

Отличный способ визуализировать приведенный выше запрос - использовать гистограмму. Используйте столбец team для оси x и каждый из наших Season столбцов для оси y. В результате должна получиться гистограмма, подобная приведенной ниже:

Создайте круговую диаграмму «Топ-5 по сравнению с остальными» с помощью CTE

Мы будем анализировать данные по одной лиге за разные годы, чтобы сравнить общие победы пяти лучших команд с остальной лигой. Идеальная лига для проведения этого анализа - это «Серия А», поскольку в ней зафиксировано много побед.

Как разделить 5 лучших команд с помощью оператора WITH

Для этой визуализации мы, по сути, хотим, чтобы результаты нашего запроса выглядели так:

Имея это в виду, мы сначала сосредоточимся на строках для пяти лучших команд:

WITH top5 AS(
	SELECT 
		team,
		SUM(wins) as wins 
	FROM 
		soccer_data
	WHERE 
		league='Serie_A'
	GROUP BY 
		1
	ORDER BY 
		2 DESC
	LIMIT 5
)
SELECT * FROM top5

Здесь мы используем предложение WITH для создания общего табличного выражения (CTE) с именем top5 с названием команды и общим количеством побед для 5 лучших команд. Затем мы выбираем все в top5.

Теперь, когда у нас есть пять лучших команд, давайте с помощью UNION добавим остальные:

UNION
SELECT
	'Other' as team,
	SUM(wins) as wins
FROM
	soccer_data
WHERE
	league='Serie_A'
	AND team NOT IN (SELECT team FROM top5)

Запустите, переименуйте и сохраните весь запрос выше.

Визуализация пятерки лучших по сравнению с остальными с помощью круговой диаграммы

Отличный способ визуализировать приведенный выше запрос - использовать круговую диаграмму. Используйте столбец team для "категории" и wins для "значений". После добавления столбцов у нас должна получиться круговая диаграмма, как на изображении ниже. Как видите, на пятерку лучших команд приходится около 50% побед в лиге Серии А:

Сравнение соотношений выигрышей и проигрышей с CTE и динамическим SQL

Для этого запроса мы будем использовать CTE и динамический SQL, чтобы сравнить соотношение побед и поражений трех лучших команд в Серии А со всеми остальными командами. Нам нужно, чтобы наш набор результатов выглядел примерно так:

Основная логика запроса должна выглядеть примерно так:

SELECT 
	year, 
	MAX(CASE 
		WHEN team = 'team1' THEN wins / losses
		ELSE NULL
	END) AS `team 1`
	AVG(CASE
		WHEN team NOT in ('team1','team2','team3') THEN wins / losses
		ELSE NULL
	END) AS `other`
FROM 
	soccer_stats
WHERE
	league = 'Serie_A'
GROUP BY 
	team

Конечно, это не сработает без некоторой магии MySQL.

Разделение трех лучших команд

Перво-наперво, давайте разделим наши три лучшие команды с помощью CTE:

WITH top3 AS(
  SELECT
    team,
    AVG(wins / loses) as wins_to_losses
  FROM
    soccer_data
  WHERE
    league = 'Serie_A'
  GROUP BY
    team
  ORDER BY
    2 DESC
  LIMIT
    3
)

Создание динамических строк SQL внутри CTE

Поскольку каждой из этих команд потребуется отдельный столбец, нам потребуется использовать динамический SQL для генерации некоторых специальных операторов CASE. Нам также потребуется сгенерировать оператор CASE для нашего столбца «Другое». Для этого мы будем использовать динамический SQL внутри CTE:

variable_definitions AS(
	SELECT
	(
		GROUP_CONCAT(
			CONCAT(
				'''', 
				team, 
				''''
			)
		)
	) as team_names,
    (
		GROUP_CONCAT(
			DISTINCT CONCAT(
			'MAX(case when team = ''',
			team,
			''' then wins / loses ELSE NULL END) AS `',
			team,
			'`'
			)
		)
	) as column_definitions
	FROM top3
)

Затем возьмем строки team_names и column_definitions и INSERT их в переменные:

SELECT
  team_names,
  column_definitions 
INTO 
  @teams,
  @sql
FROM
  variable_definitions;

На этом этапе у нас должен быть список трех лучших команд в строковом формате, сохраненный в @teams, а наши операторы столбцов case для трех лучших команд сохранены в @sql. Нам просто нужно построить окончательный запрос:

SET
  @sql = CONCAT(
    'SELECT year, ',
    @sql,
    ', AVG(CASE WHEN team NOT IN (',
    @teams,
    ') THEN wins / loses ELSE NULL END) AS `Others` ',
    'FROM soccer_data WHERE league = ''Serie_A'' GROUP BY year;'
  );
 prepare stmt FROM @sql;
 EXECUTE stmt;

Вы можете найти полный запрос внизу этой статьи.

Визуализация соотношения выигрышей и проигрышей с помощью диаграммы с областями

Отличный способ визуализировать приведенный выше запрос - использовать диаграмму с областями. Чтобы создать эту диаграмму с областями, используйте year для оси x и всех остальных столбцов для оси y. Ваша диаграмма должна выглядеть примерно так:

Поскольку мы используем динамический SQL, мы можем легко добавить больше групповых столбцов, изменив LIMIT 3 в top3 CTE на LIMIT 5:

Заключение

В этой статье вы узнали, как извлекать спортивные данные с помощью Python с веб-сайта и использовать расширенные операции MySQL для их анализа и визуализации с помощью Arctype. Кроме того, вы увидели, насколько легко запускать SQL-запросы к базе данных с помощью Arctype, и получили возможность изучить некоторые из его основных функций и возможностей.

Исходный код скрипта python, файл CSV и другие данные доступны на Github. Если у вас есть вопросы, напишите мне в Twitter: @ LordChuks3.

Последний запрос SQL:

WITH top3 AS(
  SELECT
    team,
    AVG(wins / loses) as wins_to_losses
  FROM
    soccer_data
  WHERE
    league = 'Serie_A'
  GROUP BY
    team
  ORDER BY
    2 DESC
  LIMIT
    3
), variable_definitions AS(
  SELECT
    (GROUP_CONCAT(CONCAT('''', team, ''''))) as team_names,
    (
      GROUP_CONCAT(
        DISTINCT CONCAT(
          'MAX(case when team = ''',
          team,
          ''' then wins / loses ELSE NULL END) AS `',
          team,
          '`'
        )
      )
    ) as column_definitions
  FROM
    top3
)
SELECT
  team_names,
  column_definitions 
INTO 
  @teams,
  @sql
FROM
  variable_definitions;
SET
  @sql = CONCAT(
    'SELECT year, ',
    @sql,
    ', AVG(CASE WHEN team NOT IN (',
    @teams,
    ') THEN wins / loses ELSE NULL END) AS `Others` ',
    'FROM soccer_data WHERE league = ''Serie_A'' GROUP BY year;'
  );
 prepare stmt FROM @sql;
 EXECUTE stmt;

Больше контента на plainenglish.io