Как я хоккейные команды ранжировал

Введение

Привет, хабравчане. С детства мне нравились цифры и возможность измерить всё и вся. Когда в средней школе я увлекся шахматами, побочным эффектом оказалось знакомство с системой рейтинга Эло. Мне (как и многим другим людям в мире) она показалась удобной и логичной, и с тех пор ко мне периодически возвращалась идея применения этой системы к разнообразным спортивным соревнованиям. Первой попыткой было её применение к многострадальному чемпионату России по футболу, и было мне тогда лет 13. Поскольку на тот момент я не только не владел навыками программирования, но даже элементарно не имел компьютера, все расчеты велись в тетрадке, что делало поддержание информации в актуальном состоянии довольно трудоемкой задачей. Спустя годы я вернулся к этой идее, выбрав своей мишенью НХЛ.

Что такое рейтинг Эло?

Рейтинг Эло — это система, разработанная в середине XX века американским ученым венгерского происхождения Арпадом Эло для применения в шахматах. Сначала она была взята на вооружение Шахматной федерацией США, а потом, в 1970 году, и ФИДЕ. Данная система применяется и в наши дни для оценки силы шахматистов.

В основе данной системы лежит простое предположение. Если рейтинг игрока А больше рейтинга игрока Б, то игрок А должен выигрывать у игрока Б. Чем больше разница рейтингов, тем больше вероятность победы. Если игрок А у игрока Б не выигрывает, значит рейтинги не отражают реального соотношения силы играющих, и должны быть скорректированы. Просто? Просто. Логично? Логично.

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

P_{a} = \frac{1}{1 + 10^{\frac{R_{b}-R_{a}}{400}}}

где Ra и Rb — это рейтинги игроков А и Б соответственно, а Pa — вероятность победы игрока А. Таким образом, разница в рейтингах в 400 пунктов дает 90% вероятность победы игрока с более высоким рейтингом. Соответственно, вероятность победы второго игрока будет 1 — Pa. Далее вероятность победы вычитается из реального результата игрока в конкретной игре (1 — победа, 0 — поражение, 0,5 — ничья), эта разность умножается на коэффициент K, и таким образом получается количество пунктов, на которое должен измениться рейтинг игрока.

Например, у если у игрока А рейтинг 1100, а у игрока Б — 1050, то вероятность победы первого игрока будет 0,57, а игрока Б соответственно 0,43. И если игрок А победит, то его рейтинг увеличится на (1 — 0,57)K, а рейтинг игрока Б увеличится на (0 — 0,43)K, то есть уменьшится, т. к. коэффициент положительный. Коэффициент K по сути выбирается произвольно, от его величины зависит, насколько каждый конкретный результат влияет на рейтинг игрока. Изначально в системе Эло этот коэффициент был принят за 10, так что в нашем примере игрок А приобрел бы 4,3 очка рейтинга, а игрок Б потерял бы такое же количество (изменения рейтингов игроков в этой системе всегда симметричны).

Почему НХЛ?

Надо заметить, что идея использовать систему Эло за пределами шахматного мира далеко не нова. Система используется для создания рейтингов также в шашках и го, а в 2018 году система на основе рейтинга Эло взята на вооружение и ФИФА (международной федерацией футбола). Также существует альтернативный рейтинг Эло футбольных сборных.

Так уж получилось, что в последние годы я являюсь большим поклонником НХЛ. НХЛ (Национальная хоккейная лига) — это лига по хоккею с шайбой, объединяющая сильнейшие клубы США и Канады. Благодаря большим финансовым возможностям и престижу данная лига привлекает лучших игроков со всего мира, в том числе и из России. Именно потому, что НХЛ мне интересна и я активно за ней слежу, выбор пал на нее. Никаких объективных причин не было.

Проведя быстрый поиск в интернете, я обнаружил несколько сайтов, реализующих идею ранжирования команд НХЛ с применением рейтинга Эло. Но некоторые из них были заброшены лет пять назад, а некоторые давали просто текущие рейтинги команд. Я же хотел иметь возможность просмотра рейтингов команд на любой день в истории лиги.

Реализация

Итак, идея есть, теперь нужно ее реализовать. Для начала нам нужны результаты игр. Без них рейтинг не посчитать. Результаты можно было бы собрать на различных спортивных сайтах автоматически или, в случае крайней необходимости, даже вручную. Но, к счастью, у сайта НХЛ есть открытое API, через которое можно получить статистические данные не только по играм, но и по командам и игрокам за всю историю существования лиги.

Для начала я выкачал данные за один сезон, сохранил их в CSV и написал простенькую консольную программку на Python, которая по этим данным рассчитывала рейтинг. Успех. Затем я выкачал результаты всех игр с 1941 года. Почему с 1941? Несмотря на то, что лига существует с 1917 года, в первые годы ее существования клубы изрядно колбасило. Многие из них исчезали, просуществовав всего несколько лет (этому поспособствовала в том числе и Великая депрессия). Более-менее стабильный состав лиги сформировался к 1942 году (начало эры Большой шестерки). Один год я прибавил для того, чтобы к 1942 рейтинги уже имели какие-то осмысленные, отличные от начальных значения. С помощью своей консольной программулины я попытался посчитать рейтинги команд, и тут вскрылся один нюанс, о котором я расскажу чуть позже.

Итак, настало время хранить данные каким-то более приличным способом, чем csv-файлы на диске. Мне для этих целей вполне подошла PosgreSQL. Была создана таблица с командами и таблица с результатами игр. А теперь время рассказать про тот самый нюанс. Команды (а точнее, франшизы), порой меняют название. Хуже того, они иногда еще и переезжают. Что еще хуже, иногда две команды с одним названием из разных эпох — это две разные франшизы. Давайте более конкретно. Была в городе Виннипег команда Winnipeg Jets. Из-за финансовых проблем, связанных в том числе с падением канадского доллара, в середине 90-х вместе со всеми игроками она переехала в американский город Финикс, что в штате Аризона, и стала называться Phoenix Coyotes. А с середины 2010-х — Arizona Coyotes. И этот клуб исторически правопреемник того Виннипега. И рейтинг этой франшизы нужно считать непрерывно, от Виннипега через Финикс до Аризоны. А в 2011 клуб из американского города Атланта под названием Atlanta Thrashers нашел себе новый дом в Виннипеге и стал называться… да-да, Winnipeg Jets. И этот Виннипег — правопреемник Атланты, и никакого отношения к тому Виннипегу из 90-х не имеет. Такие дела. Пришлось создавать отдельную таблицу с названиями команд, их связью idшниками франшиз и периодами действия того или иного названия.

Поскольку в итоге я хотел получить вебсайт, на котором можно будет просматривать рейтинги, нужно было выбрать стек, на котором этот сайт будет построен. Так как у меня уже была написана вышеупомянутая программа на Python, было решено писать бекэнд на ее основе. Для этого был взят Flask, т. к. очевидно Django был бы overkill для такой задачи. В качестве библиотеки для фронтенда я взял React, поскольку я хотел сделать SPA с бесшовным переходом между страницами и датами (откуда у нас несколько страниц, я покажу позже). В качестве библиотеки компонентов я взял Material UI, хотя оглядываясь назад, стоит признать, что пару табличек я мог бы и вручную сверстать, не подключая целую библиотеку.

Надо заметить, что изначально я написал просто бекэнд на Flask, отдающий статику. То есть просто html с табличкой, как в старые добрые времена. Идея разделить фронт и бэк и прикрутить на фронт React появилась позже. Примерно в то же время я для пробы написал версию бекэнда на NodeJS (Express) и обнаружил, что она обрабатывает запросы в 2–3 раза быстрее, чем версия на Flask. Таким образом я выкинул Python + Flask и внедрил NodeJS + Express. SQLAlchemy уступила место Sequelize в качестве ORM.

Выбор параметров

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

Во-первых, это начальный рейтинг. Он ни на что не влияет, так как главное значение имеет разница рейтингов, а не их абсолютная величина. Единственным моим пожеланием было чтобы рейтинги не уходили в минус. Я взял за начальный рейтинг число 1000 и, как показала практика, мог бы с таким же успехом взять, например, 500, поскольку колебания рейтингов команд на протяжении всей истории не превышали ±300 пунктов.

Во-вторых, это коэффициент K. И этот выбор уже имеет значение. Как я уже упоминал выше, от этого зависит, насколько результат каждой отдельной игры будет влиять на рейтинг команд. Я рассуждал следующим образом: рейтинг должен как можно более точно отражать силы команд. Чем меньше рейтинг меняется после игры, тем точнее он отражал силы команд до игры. Таким образом, мы можем рассчитать рейтинги команд до и после каждой игры за почти 80 лет, подставляя разные коэффициенты, суммируя при этом изменения рейтингов. Чем меньше сумма изменений — тем лучше подходит коэффициент. Чуть позже я нашел статью, автор которой предлагает такой же подход, что помогло мне убедиться, что я на верном пути. Данным методом я подобрал коэффициент K=9,2. Его я и использовал в дальнейшем для всех своих расчетов рейтинга. Таким же образом я выяснил, что более точные результаты дает подход, при котором окончание игры в овертайме (дополнительном времени) расценивается как ничья (исход игры 0,5 в рамках расчета рейтинга). Что объяснимо, т. к. если команды за основное время не смогли выявить победителя, логично предположить, что в рамках этой конкретной игры их силы примерно равны.

Я часто встречал подход, при котором при расчете рейтинга Эло в командных видах спорта к рейтингу команды, играющей дома добавляется некая поправка, так как считается, что у домашней команды есть преимущество. Я не стал применять этот подход, поскольку мне он не нравится. В конце концов, в шахматах при расчете рейтинга не делают поправку на игру белыми, и все с этим как-то живут. Также я не стал делать разные коэффициенты для игр регулярного чемпионата и плей-офф. Несомненно, игры плей-офф более важные, и команды выкладываются в них сильнее, чем в играх регулярки, но не уверен, что стоит более сильно изменять рейтинг на основе игр плей-офф.

Кроме этого, нужно упомянуть, что в межсезонье команды часто теряют одних игроков и приобретают других. Игроки по-разному проводят межсезонье и кто-то оказывается в лучшей форме, чем другие. Таким образом, рейтинги перед началом сезона (оставшиеся с прошлого сезона) не всегда точно отражают реальную силу команд в новом сезоне. В связи с этим в начале сезона (скажем, в первой четверти чемпионата) можно было бы использовать повышенный коэффициент K, чтобы рейтинги подстроились под отражать реальную силу команд, но я не стал этого делать, чтобы не усложнять модель.

Технические моменты

Те, кто дочитал до этого места, наверное, заметили, что рейтинг на конкретную дату в прошлом — величина постоянная, и будучи однажды рассчитанным, меняться уже не будет. Таким образом, можно было бы один раз рассчитать рейтинг-листы на каждый день с 1941 года по текущий момент и хранить их в базе. Но на мой взгляд, такой подход не совсем оптимален из-за большого объема таких данных. Но вариант каждый раз рассчитывать рейтинг для каждой даты с нуля тоже не очень хорош, поскольку если для расчета рейтинга на какую-либо дату в 40-х нам бы потребовалось обработать пару тысяч игр, то для расчета рейтинга на текущий момент — уже более 50 тысяч. Разница во времени расчета получалась довольно заметная (миллисекунды против сотен миллисекунд). Поэтому я пошел на компромисс. Я сохранил значения рейтинга каждой команды на момент начала каждого сезона, и промежуточные значения на любую дату внутри сезона рассчитываются не на основе всех игр с нуля, а на основе значений на начало конкретного сезона. Как вы понимаете, на результат расчета это не влияет, зато положительно сказывается на скорости.

Также помимо результата на каждую дату мне хотелось иметь графики, показывающие, как изменялись рейтинги команд со временем. Для таких графиков нужно рассчитать рейтинги через определенные промежутки времени и по этим рейтингам построить точки. В идеале это должны быть данные на каждый день, для наибольшей точности. Но если мы хотим строить график по всем командам с 1941 года по наше время, то при расчете рейтингов на каждый день мы получаем объем данных, прилетающий с бэка порядка 2–3 мегабайт. К тому же, т. к. нам нужно пройтись по каждому дню с самого начала, наши сохраненные в базе рейтинги на начало сезона становятся не особо полезными. Все равно приходится обрабатывать все игры. Таким образом, запрос обрабатывался порядка секунды, что мне категорически не нравилось. Я не рассчитывал, что у моего сайта будут тысячи пользователей, но всё равно такой расклад меня не особо устраивал. Сначала я решил использовать Redis для кеширования запросов к этому endpoint’у. Первый запрос за день рассчитывался с нуля, далее результат кешировался, и все последующие запросы отдавались уже за ~20 миллисекунд. Шикарно. Но по итогу я всё равно отказался от этой идеи. Я решил, что разрешение в один день на графике за 80 лет абсолютно ни к чему. Сейчас на моем сайте доступно 5 вариантов графика: за последние 30 дней, за год, за 5 лет, 10 лет и за всё время. И если график за 30 дней имеет разрешение 1 день, то график за всё время — полгода. Да, с таким разрешением теряются некоторые экстремумы, которые пришлись до или после расчетной точки. Но я решил, что это приемлемо. Напишите в комментариях ваше мнение на этот счет.

Ах да, забыл самое главное. Как всё это дело обновляется. Просто обновляется. Написан скриптец, который делает запрос к вышеупомянутому API НХЛ и получает данные о свежесыгранных матчах. Скриптец дергается кроном несколько раз за ночь (время начала матчей колеблется от 7:30 вечера до 6:30 утра по Москве, так что я предпочитаю делать обновления несколько раз за игровой день).

Скриншоты

Настал этот момент. Все, кто не сдался и дочитал (а также все, кто просто умеет пользоваться скроллом) вознаграждаются скриншотами того, что получилось. Я не стал прикладывать ссылку на сам сайт, поскольку это вроде как получится реклама своего ресурса, а Хабр этого не одобряет, насколько я понял.

Итак, первый скриншот. Так выгляди таблица рейтингов всех команд на 25 октября 2021 года. Мой любимый Анахайм аккурат последний, зараза. Помимо самих рейтингов мы видим как изменялись рейтинги команд за последние 7 и 30 дней (да, есть косяк с 30-дневным изменением рейтинга Сиэтла, т.к. команда новая, 30 дней назад у нее еще не было рейтинга. И пофиксить я еще это не успел). Вверху вкладки есть переключатель дат, с помощью которого можно выбрать, на какую дату показывать рейтинг.

Скриншот

Таблица рейтинга на 25 октября 2021 годаТаблица рейтинга на 25 октября 2021 года

Вкладка Schedule на той же основной странице. Расписание на конкретную дату. Здесь мы видим результаты, вероятности победы той или иной команды, рассчитанные на основе рейтингов команд до игры, сами рейтинги до и после игры, а также как они изменились в результате игры (и как изменились места команд в рейтинг-листе).

Скриншот

Расписание на 25 октября 2021 годаРасписание на 25 октября 2021 года

Далее мы видим страницу графиков, выбран вариант за последние 5 лет. Линии команд нарисованы цветами этих команд, но в таком хаосе все равно трудно что-то разобрать. Хотя при наведении на каждую точку будет показано название команды, дата и рейтинг. К счастью есть настройка Choose visible teams, которая позволяет выбрать, какие команды отображать на графике. На втором скрине мы видим сравнение того, как изменялись рейтинги команд Anahaim Ducks (оранжевый) и Tampa Bay Lightning (синий) за последние пять лет. Согласитесь, так уже гораздо проще разобраться в графике.

Скриншот

График изменения рейтинга команд за последние 5 летГрафик изменения рейтинга команд за последние 5 летГрафик по выбранным командамГрафик по выбранным командам

Ну и последняя страница, топы. Не особо полезная, но мне придумалось, что будет забавно иметь такие данные. Здесь представлены самые большие изменения рейтингов в лучшую и худшую стороны за сезон и за календарный месяц. Прошу прощения за немного корявый скриншот. Поверьте, никакой полезной информации потеряно не было.

Скриншот

Своеобразные Своеобразные «рекорды»

А что дальше?

Еще год назад (когда я и занимался всем вышеописанным) у меня были планы планы расширять функциональность данного сайта. Я хотел добавить больше интересных вариантов просмотра рейтинга (разбиение по конференциям и дивизионам, например), вероятности различных исходов в плей-офф, расчет сложности расписания игр для каждой команды и прочее. Также хотел перевести сайт на NextJs, чтобы был серверный рендеринг. К сожалению, энтузиазм поугас, и сейчас его едва хватает на то, чтобы поддерживать то, что есть. Если вдруг я найду время и желание, то возможно что-то и будет. Но вероятность этого не так и велика. Если кто-то дочитал досюда, большое спасибо за внимание. Кто не дочитал — тоже спасибо, но они этого не увидят ;)

© Habrahabr.ru