Анализ изменения возраста и антропометрических данных игроков Национальной Хоккейной Лиги
В последнее время от экспертов и игроков Национальной Хоккейной Лиги (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', 'Распределение веса игроков по сезонам', 'Вес (фунт)')
По графикам тоже сложно однозначно сделать какие-то выводы, перейдем к регрессионному анализу
Регрессия временных рядов
Применяя Линейную регрессию, построим график временных рядов и трендовой регрессионной линии для каждого признака. Для выполнения линейной регрессии не обязательно, чтобы сами данные (например, возраст игроков) были распределены нормально.
Важно, чтобы остатки (ошибки предсказания модели) были нормально распределены. Распределение остатков мы проверим с помощью построения 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, 'Тренд изменения среднего веса игроков по сезонам', 'Вес (фунты)')
Шапиро-Уилк
тест: Статистика=0.9729418158531189, p-значение=0.9166999459266663 Остатки распределены нормально
Шапиро-Уилк
тест: Статистика=0.933466374874115, p-значение=0.48284029960632324 Остатки распределены нормально
Шапиро-Уилк
тест: Статистика=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 кг.
Анализ регрессии для новичков
Давайте проанализируем, как изменились показатели роста и веса, для игроков, который проводили свои дебютные сезоны в лиге. Это позволит нам понять, меняются ли требования к росту и весу для новичков, которые начинают свой путь в лиге.
Результаты теста Манна-Кендалла
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. Давайте посмотрим, что покажет тест Манна-Кендалла
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. Это моя первая работа, просьба, не судить строго. Все комментарии и конструктивные замечания строго приветствуются:)