Анализ изменения возраста и антропометрических данных игроков Национальной Хоккейной Лиги

В последнее время от экспертов и игроков Национальной Хоккейной Лиги (NHL) все чаще слышишь тезис о том, что лига становится моложе и делается уклон в сторону более низких и легковесных, но юрких хоккеистов. Хоккей с его огромными хоккеистами уходит в прошлое, а габариты таких «гигантов», как нападающий New York Ranger Matt Rempe с его ростом 200 см и весом 109 кг обсуждается больше, чем сама игра хоккеиста.

Я взял с сайта NHL данные о последних 10 сезонах по игрокам, который провели больше 10 матчей в сезоне.

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

Данные

У нас есть файлы с датасетами по сезонам, в которых присутствуют данные по игрокам, которые сыграли больше 10 матчей в сезоне NHL, которые сыграли больше 10 матчей в сезоне, взятые с официального сайта Национальной хоккейной лиги. Оставим столбцы, которые нас интересуют: Позиция, Дата рождения, Рост в дюймах, Вес в фунтах, Количество сыгранных матчей, сезон, 1-й сезон игрока.

Давайте посмотрим на 5 случайных строк

Pos

DOB

Ht

Wt

GP

season

1st Season

279

D

1983–06–08

71

180

43

2014–2015

20062007

853

C

1987–08–07

71

200

80

2015–2016

20052006

4411

C

1988–04–29

74

201

70

2019–2020

20072008

1935

C

1991–02–22

75

200

19

2016–2017

20142015

5379

D

1994–07–25

72

181

57

2021–2022

20132014

Описательная статистика

Рассчитаем основные статистические показатели (среднее, медиана, стандартное отклонение) для возраста, роста и веса игроков по каждому сезону.

# Функция для расчета основных статистических показателей
def calculate_statistics(data, season, column):
    stats = data.groupby(season)[column].agg(['mean', 'median', 'std'])
    return stats

# Расчет статистических показателей для возраста, роста и веса
age_stats = calculate_statistics(data,'season', 'age')
height_stats = calculate_statistics(data,'season', 'Ht')
weight_stats = calculate_statistics(data,'season', 'Wt')
height_stats_cm = calculate_statistics(data,'season', 'Ht_cm')
weight_stats_kg = calculate_statistics(data,'season', 'Wt_kg')


# Вывод результатов
print("Статистические показатели для возраста:")
print(age_stats)
print("\nСтатистические показатели для роста:")
print(height_stats_cm)
print("\nСтатистические показатели для веса:")
print(weight_stats_kg)

Статистические показатели для возраста:
mean median std
season
2014–2015 27.865753 27.0 4.548903
2015–2016 27.573370 27.0 4.436711
2016–2017 27.427989 27.0 4.430596
2017–2018 27.362319 27.0 4.341212
2018–2019 27.212516 27.0 4.090101
2019–2020 27.463315 27.0 4.105912
2020–2021 27.575549 27.0 4.110550
2021–2022 27.683168 27.0 4.212581
2022–2023 28.000000 28.0 4.081955
2023–2024 28.239637 28.0 4.213909

Статистические показатели для роста:
mean median std
season
2014–2015 185.649315 185.0 5.279594
2015–2016 185.884511 185.0 5.330337
2016–2017 185.705163 185.0 5.273089
2017–2018 185.566535 185.0 5.272569
2018–2019 185.674055 185.0 5.215526
2019–2020 185.710598 185.0 5.318094
2020–2021 185.901099 185.0 5.359289
2021–2022 185.923267 185.0 5.485504
2022–2023 186.042636 185.0 5.443297
2023–2024 185.879534 185.0 5.565527

Статистические показатели для веса:
mean median std
season
2014–2015 92.153096 92.08 6.704483
2015–2016 92.150014 92.08 6.875161
2016–2017 91.576182 91.17 6.844911
2017–2018 91.259802 90.72 6.806483
2018–2019 91.076063 90.72 6.912537
2019–2020 90.994918 90.72 7.004026
2020–2021 90.877074 90.72 6.756815
2021–2022 90.740681 90.72 6.973008
2022–2023 90.836021 90.72 6.884751
2023–2024 90.535557 90.72 7.058457

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

Визуализация данных

# Получаем уникальные сезоны
seasons = data['season'].unique()

# Устанавливаем количество строк и колонок для субплотов
nrows = 5
ncols = 2

# Функция для построения гистограмм с кривыми плотности по сезонам
def plot_histograms_with_density(column, title, xlabel):
    fig, axes = plt.subplots(nrows=nrows, ncols=ncols, figsize=(15, 30))
    fig.suptitle(title, fontsize=20)
    axes = axes.flatten()
    for i, season in enumerate(seasons):
        season_data = data[data['season'] == season][column].to_numpy()
        if column == 'Wt':
            bins = int(((np.max(season_data) - np.min(season_data)))//4)
        else:
            bins = int((np.max(season_data) - np.min(season_data))) +1
        axes[i].hist(season_data, 
                     bins=bins, density = False, 
                     range = [np.round(np.min(season_data)).astype(int), np.round(np.max(season_data).astype(int)+1)])
        axes[i].set_title(season)
        axes[i].set_xlabel(xlabel)
    plt.tight_layout(rect=[0.03, 0.03, 1, 0.95])
    plt.subplots_adjust(top=0.95)
    plt.show()
        # Draw the density plot
    plt.figure(figsize=(12, 8))
    sns.set(style="whitegrid")
    for season in seasons:
        season_data = data[data['season'] == season][column]
        season_data.plot.density(ind = np.arange(min(season_data), max(season_data)+1))

        # Plot formatting
    plt.legend(seasons)
    plt.title('Density Plot, '+ column)
    plt.xlabel(xlabel)
    plt.ylabel('Density')
    plt.show()
    
# Построение гистограмм с кривыми плотности для возраста, роста и веса
plot_histograms_with_density('age', 'Распределение возраста игроков по сезонам', 'Возраст')
plot_histograms_with_density('Ht', 'Распределение роста игроков по сезонам', 'Рост (дюйм)')
plot_histograms_with_density('Wt', 'Распределение веса игроков по сезонам', 'Вес (фунт)')

1023bc5a1b02e05c56417d31f8c5fe19.png35b33e5b9b50ae7d813899a3a1a3b24c.pngf7e01bbb5234d50af233ced48f5e7912.pngcef7a12335eed412e71bed54c72473c2.png5cf773643c641bf73c18fc97673fd057.pngc8074a29839d9df9d2c88341da1b68d0.png

По графикам тоже сложно однозначно сделать какие-то выводы, перейдем к регрессионному анализу

Регрессия временных рядов

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

Важно, чтобы остатки (ошибки предсказания модели) были нормально распределены. Распределение остатков мы проверим с помощью построения QQ-плота и теста Шапиро-Уилка

# Функция регрессии временных рядов
def regresion_model(stats):
    # Подготовка данных для регрессии
    X = stats.index.values.reshape(-1, 1)
    y_mean = stats['mean'].values
    # Линейная регрессия
    model = LinearRegression()
    model.fit(X, y_mean)
    trend_line = model.predict(X)
    return trend_line, model. intercept_ , model. coef_[0]

# Функция для построения временных рядов и регрессионной линии
def plot_trend_with_regression(stats, title, ylabel):
    
    y_mean = stats['mean'].values
    std_dev = stats['std'].values
    trend_line, _, _ = regresion_model(stats)
    residuals = y_mean - trend_line
    # Построение графика
    plt.figure(figsize=(12, 8))
    sns.set(style="whitegrid")
    plt.plot(stats.index.to_numpy(), y_mean, marker='o', label='Среднее значение')
    plt.plot(stats.index.to_numpy(), trend_line, color='red', label='Трендовая линия')
    plt.fill_between(stats.index, stats['mean'] - stats['std'], stats['mean'] + stats['std'], color='gray', alpha=0.2, label='Стандартное отклонение')
    for i in range(len(stats)):
        plt.annotate(f'{y_mean[i]:.2f}', (stats.index.to_numpy()[i], y_mean[i]), 
                     textcoords="offset points", xytext=(0,10), ha='center')
        plt.annotate(f'±{std_dev[i]:.2f}', (stats.index.to_numpy()[i], y_mean[i] - std_dev[i]), 
                     textcoords="offset points", xytext=(0,-10), ha='center', color='blue')
    plt.title(title, fontsize=20)
    plt.xlabel('Сезон', fontsize=15)
    plt.ylabel(ylabel, fontsize=15)
    plt.legend()
    plt.show()
    
    # Проверка нормальности остатков
    sm.qqplot(residuals, line ='45')
    plt.title('QQ-плот остатков')
    plt.show()
    
   
    stat, p_value = shapiro(residuals)
    print(f'Шапиро-Уилк тест: Статистика={stat}, p-значение={p_value}')
    if p_value > 0.05:
        print('Остатки распределены нормально')
    else:
        print('Остатки не распределены нормально')


age_stats = calculate_statistics(data, 'season_numeric', 'age')
height_stats = calculate_statistics(data,'season_numeric', 'Ht')
weight_stats = calculate_statistics(data, 'season_numeric', 'Wt')
# Построение графиков для возраста, роста и веса
plot_trend_with_regression(age_stats, 'Тренд изменения среднего возраста игроков по сезонам', '')
plot_trend_with_regression(height_stats, 'Тренд изменения среднего роста игроков по сезонам', 'Рост (дюймы)')
plot_trend_with_regression(weight_stats, 'Тренд изменения среднего веса игроков по сезонам', 'Вес (фунты)')

2f6b08e0ccd68aae218d888e3a9fbfd5.pngd9316b08d258859dc01f18a974cd5d7d.png

Шапиро-Уилк тест: Статистика=0.9729418158531189, p-значение=0.9166999459266663 Остатки распределены нормально

e4edff7281c6bf896497210bb187c730.png1025693bdafb5e9d83e14a66dc7819ac.png

Шапиро-Уилк тест: Статистика=0.933466374874115, p-значение=0.48284029960632324 Остатки распределены нормально

37a809c5726d1170aa4c2b0854a680d8.png338bf3d2358c781626478865a5b2e267.png

Шапиро-Уилк тест: Статистика=0.9445896148681641, p-значение=0.6051148176193237 Остатки распределены нормально

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

Анализ регрессии. Тест Манна-Кендалла

Для выявления тренда мы будем использовать Тест Манна-Кендалла. Он является мощным инструментом для выявления трендов во временных рядах, особенно когда данные не обязательно распределены нормально.

features = ['age', 'Ht', 'Wt']
result = []
for column in features:
    MK = mk. original_test (data.groupby('season')[column].agg('mean'))
    stats = calculate_statistics(data, 'season_numeric', column)
    _, x0, x = regresion_model(stats)
    stats = [MK.p, MK.trend, x0,x, 10*x, MK.Tau]
    result.append(stats)

Давайте взглянем на показатели регрессии для возраста, роста и веса за сезоны »2014–2015» — »2023–2024»

p-value

regression

slope

intercept

effect

Tau

Age

0.152406

no trend

-76.611793

0.051623

0.516228

0.377778

Height

0.107405

no trend

49.991625

0.011496

0.114960

0.422222

Weight

0.000172

decreasing

987.935300

-0.389616

-3.896159

-0.955556

где:

  • p-value — значение p-value теста

  • regression — показатель наличия и направления тренда

  • slope и intercept — коэффициенты регрессии (наклон и свободный коэффициент)

  • effect — Средняя разница между показателями в начале и в конце исследуемого периода

  • Tau — коэффициент корреляции Кенделла. Значение от -1 до 1. Значение 1 указывает на идеальную положительную корреляцию (все данные увеличиваются), значение -1 указывает на идеальную отрицательную корреляцию (все данные уменьшаются).

Мы видим четкий тренд на снижение возраста веса игроков. За 10 лет регрессия составила 3,896 фунта или примерно 1,77 кг. С одной стороны, это не такая большая разница, с другой значение коэффициент корреляции Кенделла Tau говорит об устойчивом отрицательном тренде

Регрессия для каждой позиции

Давайте посмотрим, что покажет Тест Манна-Кендалла для показателей веса игроков на каждой позиции.

result = []
for position in [defenders_weight_stats, forwards_weight_stats]:
    MK = mk. original_test (position['mean'])
    #stats = calculate_statistics(defenders_weight_stats, 'season_numeric', 'mean')
    _, x0, x = regresion_model(position)
    stats = [MK.p, MK.trend, x0,x, 10*x, MK.Tau]
    result.append(stats)

p-value

regression

slope

intercept

effect

Tau

Defenders

0.000677

decreasing

1274.143826

-0.529516

-5.295163

-0.866667

Forwards

0.012266

decreasing

836.922875

-0.315804

-3.158039

-0.644444

Мы видим, что тренд для снижения веса присутствует как для, защитников, так и для нападающих, но для защитников он более выражен. За 10 лет средний вес защитников сократился на 5,3 фунта или примерно 2,4 кг.

Анализ регрессии для новичков

Давайте проанализируем, как изменились показатели роста и веса, для игроков, который проводили свои дебютные сезоны в лиге. Это позволит нам понять, меняются ли требования к росту и весу для новичков, которые начинают свой путь в лиге.

a8fbb6c22ee09aaf1944c0f5ef9e54f2.png755d1418d7b64b187e38dfb8751bc27e.png

Результаты теста Манна-Кендалла

p-value

regression

slope

intercept

effect, lb

effect, kg

Tau

Height

0.474274

no trend

-8.207392

0.040279

0.402788

0.182701

0.2

Weight

0.474274

no trend

996.715891

-0.395773

-3.957726

-1.795193

-0.2

Мы видим, что на графиках есть маленький уклон в сторону увеличения роста и уменьшения и веса игроков, проводящих 1 сезон в лиге. Однако, тест Манна-Кендалла говорит об отсутствии трендов. Значит клубы не стремятся брать более легких и низких игроков, а смотрят на другие показатели (скилы хоккеистов). А отрицательный тренд для веса игроков НХЛ может говорить о том, что игроков просто приводят в оптимальные кондиции в соответствии с их физиологическими показателями.

Регрессия индекса массы тела

Давайте рассчитаем индекс массы тела для хоккеистов, чтобы посмотреть меняется ли он от сезона к сезону.
Формула для расчета следующая:

BMI = ( Weight in Pounds / (Height in inches) * (Height in inches) ) * 7038dd1aea7ac03dd56cb70e335d762640d.png

Визуально виден по тренд на снижение показателей BMI. Давайте посмотрим, что покажет тест Манна-Кендалла

p-value

regression

slope

intercept

effect

Tau

BMI index

0.000083

decreasing

146.917965

-0.059696

-0.596957

-1.0

Все показатели говорят об устойчивом тренде на уменьшение показателя BMI. Хотя эффект за 10 лет не такой значительный, но тренд на снижение устойчивый. Однако, по показателям ВОЗ, хоккеисты находятся в зоне избыточного веса, что, возможно, вполне нормально для такого вида спорта

Вывод

Целью исследования было изучить как изменяются основные физиологичесике показатели игроков Национально Хоккейно Лиги (рост, вес, возраст) за последние 10 лет. В ходе анализа данных мы получили следующие результаты:

  • За последние 10 лет наболюдается тренд на снижение веса игроков НХЛ.

  • Тренд на снижение возраста и роста отсутствет. То есть утверждение «лига молодеет» — неверно. Так же мы проанализировали игроков, который проводят первый год в лиге и не выяявили тренды. Это значит, что игроки заходит в лигу примерно с одинаковыми средними показателями роста и веса каждый сезон. А уменьшение веса идет уже в ходе тренировочного процесса в клубе. Мы проверили как менялся индекс массы тела хоккеистов и нашли устойчивый тренд к его понижению. Хотя в абсолютных значениях цифры снижения веса и BMI индекса не такие большие. Однако тенденция присутствует. Игроки НХЛ постепенно становятся легче.

Открытый код проекта размещен в моем гите https://github.com/permyakov-andrew/Hockey/tree/main/NHL_players_analyst

P.S. Это моя первая работа, просьба, не судить строго. Все комментарии и конструктивные замечания строго приветствуются:)

© Habrahabr.ru