Привет, Хабр! Эта статья посвящена исследованию о том, насколько тесен мир хоккея.
Меня зовут Рашит Гафаров, я начинающий дата-инженер и выпускник Яндекс Практикума. Мы с наставницей Юлией Муртазиной и ещё пятью студентами проанализировали с помощью Pytnon связи между хоккеистами в КХЛ.
За референс взяли расчёт числа Эрдёша-Бэйкона — шуточное расстояние между математиками или актёрами. Характеристика названа так, потому что рассчитывается:
1. От учёного до венгерского математика Пала Эрдёша по совместным научным публикациям;
2. Через роли в кино, связывающие данного актёра и Кевина Бэйкона.
Чем меньше число — тем ближе человек к Эрдёшу или Бэйкону.
Идея проекта
В книге по прикладной математике «Симпсоны и их математические секреты» я наткнулся на описание расчёта числа Эрдёша-Бэйкона. В 1969 году аналитик и исследователь данных Каспер Гоффман опубликовал статью, где рассчитал связь научных работ современных математиков с трудами Пала Эрдёша. Это один из самых продуктивных учёных, который опубликовал больше 1500 статей.
Как фанат хоккея, я решил рассчитать такое число для хоккеистов и выбрал Николая Лемтюгова. Это не самый известный игрок, зато он сменил больше 10 команд, а значит, у него может быть много одноклубников, как у Эрдёша — соавторов.
С этой идеей я пришёл в Мастерскую Яндекс Практикума. Мастерская — это место, где выпускники вместе с наставниками делают командные проекты, в том числе от реальных заказчиков. Эту задачу параллельно решали 50 выпускников, а в статью вошли работы Дианы Легранд, Любови Жулиной, Кирилла Воронова, Руслана Бакирова и Андрея Иванова.
Работа с данными
Первым делом мы установили и подключили библиотеки: pandas
, numpy
, scipy.stats
, pingouin
, matplotlib.pyplot
, seaborn
, plotly
, holoviews
, requests
, queue
, tqdm
, scipy
и networkx
.
Для работы над проектом мы использовали два датасета. Они получены путём парсинга сайтов Elite Prospects и КХЛ. Оттуда были собраны данные о командах, их участниках с 2008 по 2023 г., трансферах на июнь 2023, а также личные данные игроков КХЛ (рост, вес, дата рождения, награды и т. п.) и игровая статистика.
Анализируем датасеты и выводим статистику по ним:
dfs = [players, transfers, pers_data, stats, players_1, stats_1]
for df in dfs:
name =[x for x in globals() if globals()[x] is df][0]
print(name)
display(df.sample(5))
print(df.info())
print('=' * 30)
Альтернативный путь, который не был реализован в проекте, — профайлинг. Он занимает больше времени, но показывает распределение переменных, пропуски, количество уникальных значений и строит корреляции при запуске кода.
#for df in dfs:
#display(pandas_profiling.ProfileReport(df))
Дальше идёт предобработка: замены типов данных, приведение названий столбцов к нижнему регистру методом
lower()
и другие операции. В статье её опустим.Следующим шагом нужно найти одноклубников. Для этого объединяем датафрейм с игроками с самим собой (self-join) и проверяем пересечение у хоккеистов в команде. Будем считать одноклубниками тех игроков, которые играли в одной команде в одно время.
Ищем одноклубников двумя способами:
Способ №1
# Объединяем датафреймы по полю team
df = players_1.merge(players_1, on='team', how = 'left')
# Убираем пересечение игрока с самим собой
df = df.query('player_x != player_y').copy()
# Находим пересечение интервалов: игрок Х пришёл в команду не позже, чем из неё ушёл игрок Y и игрок X ушёл из команды не раньше, чем в неё пришёл игрок Y
df['teammate'] = False
df['teammate'] = df.teammate.mask(((df.start_date_x <= df.end_date_y) & (df.end_date_x >= df.start_date_y)), True)
# Выводим результат
df.teammate.value_counts()
Способ №2
# Создаём словарь с одноклубниками
result = defaultdict(set)
# overlapping_rows = []
overlapping_rows = pd.DataFrame()
for row in players_1.itertuples():
player_start = row.start_date
player_end = row.end_date
player_name = row.player
player_team = row.team
# Фильтруем других хоккеистов, у которых периоды пересекаются с текущим хоккеистом
overlapping_rows = players_1[(players_1['start_date'] <= player_end)
& (players_1['end_date'] >= player_start) &
(players_1['player'] != player_name) &
(players_1['team'] == player_team)]
# Добавляем найденные пересекающиеся периоды в список
# overlapping_rows.append(overlapping_rows)
overlapping_rows = pd.concat([overlapping_rows, overlapping_rows], axis=0)
# Добавляем игроков в словарь result
teammates = overlapping_rows['player'].unique().tolist()
result[player_name].update(teammates)
result = dict(sorted(result.items(), key=lambda x: len(x[1]), reverse=True))
# Смотрим топ-5 игроков по количеству одноклубников
for position, (k, v) in enumerate(islice(result.items(), 5), 1):
print(f"{position}) Игрок: {k}, Кол-во сокомандников: {len(v)}")
Расчёт числа Лемтюгова
Число Лемтюгова мы рассчитывали двумя способами, кому как привычнее.
Способ №1
Сначала создадим кросс-таблицу, в которой пересечём игроков друг с другом.
# Создаём кросс-таблицу
cross_table = pd.crosstab(df_tm.player_x, df_tm.player_y)
# Сохраняем список игроков
players = cross_table.index.values
Затем преобразуем её в таблицу смежности через библиотеку графов networkX
и с помощью алгоритма Флойда-Уоршелла вычислим, сколько рукопожатий было между игроками.
# Преобразуем кросс-таблицу в таблицу смежности
graph = nx.from_pandas_adjacency(cross_table)
# Вычисляем кратчайшие пути между всеми парами игроков
lemtyugov_matrix = nx.floyd_warshall_numpy(graph)
# Воссоздаём DataFrame с «числом отдалённости» и соответствующими именами игроков
lemtyugov_df = pd.DataFrame(lemtyugov_matrix, index=players, columns=players)
# Считаем число Лемтюгова
lemtyugov_df.loc[:, ['Nikolai Lemtyugov']].sort_values(by='Nikolai Lemtyugov', ascending=False)
Расчёт показал, что Николай Лемтюгов связан с другими игроками максимум через 3 рукопожатия — другими словами, число Лемтюгова равно 3. Напомню, что число равно 1, если Лемтюгов играл с этим хоккеистом в одно время в одном клубе.
При этом максимальное число отдалённости игроков друг от друга — 4.
print('Максимальная отдалённость игроков КХЛ друг от друга:',
int(lemtyugov_df.max().max()))
То есть даже те, кто играл только один сезон в единственной команде, связаны с другими игроками максимум через 4 рукопожатия.
В КХЛ нет игроков без связей: если бы они были, значение числа Лемтюгова для них было бы np.inf
.
Способ №2
Теперь рассчитаем число Лемтюгова с помощью словаря и поиска в ширину.
def calculate_lem_numbers(teammates_dict, source):
lem_numbers = {} # Словарь для хранения чисел Лемтюгова
# Инициализируем очередь для поиска в ширину
queue = deque()
queue.append(
(source, 0)) # Запускаем с исходного хоккеиста и расстоянием 0
# Словарь для отслеживания посещённых хоккеистов
visited = {source}
while queue:
current, distance = queue.popleft()
# Сохраняем число Лемтюгова для текущего хоккеиста
lem_numbers[current] = distance
# Продолжаем поиск, добавляя в очередь хоккеистов, с которыми текущий хоккеист играл
for teammate in teammates_dict[current]:
if teammate not in visited:
queue.append((teammate, distance + 1))
visited.add(teammate)
return lem_numbers
# Расчёт числа Лемтюгова
source_hockeyist = 'Nikolai Lemtyugov'
# Считаем число Лемтюгова
lem_numbers = calculate_lem_numbers(result, source_hockeyist)
# Проверим длину получившегося словаря, она должна быть равна 3720 — числу хоккеистов в данных
len(lem_numbers)
# Получаем значения из словаря
values = list(lem_numbers.values())
# Рассчитываем медиану и среднее значение
max_value = np.max(values)
print(f'Число Лемтюгова: {max_value}')
# Проверяем max расстояние между игроками
lem_numbers_dict = {}
for player in players_1['player']:
lem_numbers = calculate_lem_numbers(result, player)
lem_numbers_dict[player] = lem_numbers
max_lem_number = max(
max(lem_numbers.values()) for lem_numbers in lem_numbers_dict.values())
print('Максимальная отдалённость игроков КХЛ друг от друга:', max_lem_number)
Число Лемтюгова получилось равным 3, как и предыдущим способом, а максимальная удалённость игроков друг от друга — 4.
Визуализация графа
Теперь визуализируем связи между хоккеистами. Сначала построим общий граф с помощью библиотеки holoviews
.
# Выбираем конкретную вершину
source_node = 'Nikolai Lemtyugov'
# Создаём граф
G = graph
# Получаем соседей выбранной вершины
neighbors = list(G.neighbors(source_node))
# Создаём подграф, включающий выбранную вершину и её соседей
subgraph = G.subgraph([source_node] + neighbors)
# Добавим цвет по количеству команд, в которых играл хоккеист
for node in G.nodes:
G.nodes[node]['teams'] = players_1.query('player == @node')['team'].nunique()
kwargs = dict(width=1100, height=800, xaxis=None, yaxis=None)
opts.defaults(opts.Nodes(**kwargs), opts.Graph(**kwargs))
# Преобразуем граф NetworkX в граф Holoviews
graph_hv = hv.Graph.from_networkx(subgraph, nx.spring_layout)
# Настроим параметры графа
colors = ['#000000'] + list(hv.Cycle('Category20').values)
graph_hv.opts(cmap=colors,
node_color='teams',
node_size=10,
edge_line_width=0.2,
node_line_color='gray',
edge_color='gray',
clabel='teams',
title='Лемтюгов и его первый круг, цвет по числу команд')
# Инициализация Bokeh и сохранение графа в файл HTML
hv.extension('bokeh')
output_file('test.html')
show(hv.render(graph_hv))
Теперь методом bundle_graph
отобразим связи между игроками, «уплотнённые» в общие направления. Так удобнее читать и определять кластеры между хоккеистами.
bundled = bundle_graph(graph_hv)
hv.extension('bokeh')
hv.output(size=100)
output_file('test.html')
show(hv.render(bundled))
Графы показывают, что в первый круг Лемтюгова вошло 357 хоккеистов.
# Считаем одноклубников
df_tm.groupby('player_x').nunique().sort_values(by='player_y',
ascending=False).reset_index().query('player_x == "Nikolai Lemtyugov"')
Стоит отметить, что он не лидер по количеству одноклубников — лишь на 71-м месте. Причём хоккеисты с бо́льшим количеством одноклубников не находятся в первом круге Лемтюгова.
# Создаём датафрейм, в котором сохраним значение числа Лемтюгова для хоккеистов из КХЛ
lem_number = lemtyugov_df.loc[:, ['Nikolai Lemtyugov']]\
.sort_values(by='Nikolai Lemtyugov', ascending=False)\
.reset_index()\
.rename(columns={'index': 'player', 'Nikolai Lemtyugov': 'Число Лемтюгова'})
lem_number['Число Лемтюгова'] = lem_number['Число Лемтюгова'].astype(int)
# Считаем размеры первого круга у всех хоккеистов
first_circle = df_tm.groupby('player_x').nunique()\
.sort_values(by='player_y',ascending=False).reset_index()\
.rename(columns={'player_x': 'player', 'player_y': 'qvt_tm'})
# Добавляем значение числа Лемтюгова
first_circle = first_circle.merge(lem_number)
# Рисуем график
ax = plt.figure(figsize=(10, 6))
ax = sns.barplot(first_circle.head(20),
x='qvt_tm',
y='player',
dodge=False,
width=0.5,
hue='Число Лемтюгова')
for index, value in enumerate(first_circle.head(20).values):
plt.text(value[1], index, str(value[1]), va='center', fontsize=8)
plt.xlabel('Количество одноклубников')
plt.ylabel(None)
plt.title('Топ игроков по количеству одноклубников')
sns.move_legend(ax, bbox_to_anchor=(1.02, 1), loc='upper left');
Выводы
Гипотеза, что мир хоккея тесен, подтвердилась: у каждого из почти 4000 хоккеистов КХЛ есть цепочка знакомых из не более 3 человек, которые приводят его к Николаю Лемтюгову. А самое большое возможное расстояние между хоккеистами — 4.
Медианное значение числа Лемтюгова — 2. То есть большинство хоккеистов в КХЛ связаны с Николаем Лемтюговым через других игроков и команды всего через 2 рукопожатия. Это говорит о тесной связанности игроков в хоккейной среде. Для сравнения, медианное значение числа Эрдёша среди математиков равняется 5.
В 2008–2009 годах, во время первого сезона КХЛ, между хоккеистами было меньше связей друг с другом, поэтому значение числа Лемтюгова в этот период очень высокое. Это можно объяснить тем, что на старте лиги у хоккеистов было меньше возможностей играть вместе — цепочки между ними были длиннее. С развитием КХЛ и увеличением числа сезонов связи между хоккеистами становились более тесными, что отражается в уменьшении числа Лемтюгова.
Решать такие задачи с помощью Python можно научиться на курсах «Аналитик данных» и «Специалист по Data Science». А в конце обучения можно прийти в Мастерскую, чтобы воплотить в проекте свою идею из интересующей области или идею реального заказчика.
В следующей части расскажу, как мы проверили с помощью данных, зависит ли успешная карьера хоккеиста от даты его рождения.