Анализируем хоккеистов с помощью Python. Часть 1 — о том, как тесен мир

—

от автора

Привет, Хабр! Эта статья посвящена исследованию о том, насколько тесен мир хоккея.

Меня зовут Рашит Гафаров, я начинающий дата-инженер и выпускник Яндекс Практикума. Мы с наставницей Юлией Муртазиной и ещё пятью студентами проанализировали с помощью 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, а также личные данные игроков КХЛ (рост, вес, дата рождения, награды и т. п.) и игровая статистика.

  1. Анализируем датасеты и выводим статистику по ним:

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))
  1. Дальше идёт предобработка: замены типов данных, приведение названий столбцов к нижнему регистру методом lower() и другие операции. В статье её опустим.

  2. Следующим шагом нужно найти одноклубников. Для этого объединяем датафрейм с игроками с самим собой (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))
Цвета на графе показывают, в каком количестве команд играл хоккеист. Чёрный — одна команда. Светло-бирюзовый — 10 команд — столько у Лемтюгова.

Цвета на графе показывают, в каком количестве команд играл хоккеист. Чёрный — одна команда. Светло-бирюзовый — 10 команд — столько у Лемтюгова.

Теперь методом 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». А в конце обучения можно прийти в Мастерскую, чтобы воплотить в проекте свою идею из интересующей области или идею реального заказчика.

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


ссылка на оригинал статьи https://habr.com/ru/articles/762850/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *