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

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

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

Комментарии (0)