Bar chart race и карта чемпионов на Python: анимируем статистику ЧМ на matplotlib
В прошлой статье я показал 96 лет статистики чемпионатов мира по футболу в анимированных гонках. Здесь техническая изнанка: как распарсить не самый дружелюбный датасет, подмешать к нему результаты идущего ЧМ-2026 и собрать bar chart race без единой специализированной библиотеки. Спойлер: это проще, чем кажется, а грабли по дороге поучительнее результата.
Весь код лежит в пяти коротких скриптах: prepare_data.py, race.py, static_charts.py, get_flags.py и map_champions.py. Стек: Python, pandas, matplotlib, ffmpeg. Как забрать мои готовые скрипты, скажу в конце, а исходный датасет открытый, лежит на Kaggle.
Чтобы было ради чего продираться через код, вот один из результатов, анимированная карта чемпионов мира за 96 лет. Как она собирается из тех же данных, разберём ближе к концу.
Карта чемпионов мира: страны загораются золотом по числу титулов ЧМ, 1930–2026.
Датасет: 964 матча и одна большая колонка-простыня
Основа это таблица всех матчей ЧМ 1930–2022. Помимо привычных колонок вроде счёта и посещаемости, в ней есть колонки-«простыни»: авторы голов, карточки и замены упакованы в строки такого вида:
Ángel Di María · 36|Lionel Messi · 108
Один гол это одна запись, разделитель |, после · минута. Парсер получается на пять строк:
def parse_scorers(cell: str) -> list[str]:
if not isinstance(cell, str) or not cell.strip():
return []
names = []
for part in cell.split("|"):
name = part.split("·")[0].replace("(P)", "").strip()
if name:
names.append(name)
return names
Дальше начинаются грабли, рассказываю в порядке наступания.
Грабли №1: пенальти живут отдельно
Первая версия парсера выдала Месси 9 голов вместо 13. Полез разбираться: голы с игры лежат в home_goal/away_goal, а реализованные пенальти в отдельных колонках home_penalty_goal/away_penalty_goal. Месси, как штатный пенальтист Аргентины, пострадал первым. Мораль стара как мир: прежде чем агрегировать, проверь на сущности, про которую знаешь ответ заранее.
Грабли №2: послематчевые серии это не голы
Рядом лежат соблазнительные колонки *_penalty_shootout_goal_long. Удары в послематчевой серии голами не считаются, в официальную статистику бомбардиров они не входят. Если сгрести их в общую кучу, топ бомбардиров поедет целиком (штатные пенальтисты вроде Месси получат по несколько лишних «голов»). Эти колонки я использовал только для подсчёта количества серий.
Грабли №3: две Германии
В данных есть Germany и West Germany, и это одна и та же федерация: ФИФА официально считает их одной сборной, просто страна успела объединиться. Если не склеить, «Германия» в гонке появляется из ниоткуда в 1994 году с нулём голов. Склеиваем словарём замен, а вот ГДР (East Germany) оставляем отдельной строкой, это действительно другая сборная.
Проверяем итог на известных фактах, прямо assert'ами в скрипте:
assert totals["Miroslav Klose"] == 16
assert totals["Ronaldo"] == 15
assert fontaine_1958 == 13
Если завтра я пересоберу данные и что-то разъедется, скрипт упадёт сам, не придётся ловить ошибку глазами в готовом видео.
Грабли №4: живой турнир это другой источник
ЧМ-2026 идёт прямо сейчас, и в историческом датасете его, конечно, нет. Пришлось собрать срез отдельно (структурированные таблицы Википедии плюс кросс-проверка счётов по спортивным сайтам) в два маленьких csv: сыгранные матчи и бомбардиры. Дальше обычная гигиена интеграции двух источников:
- Словарь имён. В свежих данных сборная называется
Türkiye, в историческом датасетеTurkey. Один словарь замен решает, но сначала его надо найти: я просто вывел все команды 2026 года, которых нет в справочнике, и список сюрпризов оказался из одиннадцати строк. - Разная гранулярность. Исторические голы я парсил по матчам, свежие пришли уже агрегированными по игрокам. В кумулятивной таблице это неважно, обе версии сводятся к «игрок × турнир».
- Пометка о неполноте. Турнир не закончен, поэтому строка 2026 года это срез на дату, и подпись на графике обязана об этом говорить. Незавершённые данные без пометки это классический способ соврать графиком, не соврав ни в одной цифре.
И главное: sanity-check снова отработал. После мержа assert на сумму голов Месси (13 исторических плюс 7 свежих равно 20) поймал бы и дубль, и потерю, и расхождение написания имени. Не поймал, значит, склеилось.
Все четыре грабли одной таблицей, чтобы не искать по тексту:
| Грабля | В чём подвох | Решение |
|---|---|---|
| Пенальти живут отдельно | голы с пенальти лежат в других колонках | суммировать home_goal и home_penalty_goal |
| Серии это не голы | послематчевые пенальти в статистику бомбардиров не входят | брать только для подсчёта числа серий |
| Две Германии | ФРГ и Германия это одна федерация | склеить словарём замен, ГДР оставить отдельно |
| Живой турнир | ЧМ-2026 нет в историческом датасете | отдельный срез плюс assert после мержа |
Готовим данные для гонки: одна таблица решает всё
Вся анимация строится из одной структуры, кумулятивной таблицы «строка = турнир, колонка = игрок (или сборная)»:
cum = (
player_year.pivot(index="Year", columns="player", values="goals")
.reindex(years) # все турниры, даже если игрок не играл
.fillna(0)
.cumsum() # нарастающий итог
)
23 строки, больше тысячи колонок. Каждая строка это «стоп-кадр» истории: сколько у кого голов после каждого турнира.
Bar chart race: вся магия в интерполяции рангов
Есть готовая библиотека bar_chart_race, но я собрал гонку руками на FuncAnimation, и советую хотя бы раз сделать так же: контроля больше, а кода на удивление мало.
Наивный подход, рисовать бары по значениям каждого кадра, даёт дёрганую картинку: при обгоне полосы просто меняются местами между кадрами. Плавность даёт интерполяция двух вещей одновременно:
- Значения линейно между соседними турнирами.
- Позиции полос тоже линейно, между рангами на соседних турнирах.
# ключевые кадры: значения и ранги на каждом турнире
order = np.argsort(-values, axis=1, kind="stable")
ranks[rows, order] = np.arange(n_players)
# внутри перехода между турнирами i и j, f это доля пути от 0 до 1
val = values[i] * (1 - f) + values[j] * f
rnk = ranks[i] * (1 - f) + ranks[j] * f # полоса «плывёт» на новое место
Когда игрок обгоняет соседа, его полоса не телепортируется, а физически проезжает мимо, и глаз воспринимает это как непрерывное движение. Тем, кто выпал из топ-15, я ставлю ранг «за кадром» (top_n + 6): полоса плавно уезжает вниз за край, а не исчезает. Номер места при этом не интерполируется, а считается заново в каждом кадре по текущим значениям, поэтому цифры слева переключаются ровно в момент обгона.
Ещё несколько мелочей, которые сильно влияют на восприятие:
- Easing. Линейная интерполяция даёт рваный ритм: полосы стартуют и замирают рывком. Один smoothstep (
f * f * (3 - 2 * f)) вместо гологоf, и каждое движение получает разгон и торможение. Одна строка кода, а разница на глаз колоссальная. - Пауза на каждом турнире. Десяток кадров полосы стоят на месте, зритель успевает прочитать цифры. Без паузы гонка превращается в кашу.
- Финальный стоп-кадр. Последний турнир висит в три раза дольше: именно на нём делают скриншоты.
- Подпись в «тихих» сегментах. У кумулятивного топа есть честная особенность: бывают турниры, когда лидеры не забивают, и таблица замирает (в гонке бомбардиров так выглядят 1994-й и 2018-й: Стоичков с Саленко и Кейн своё забили, но в вечный топ не пробились). Чтобы пауза не читалась как «данные кончились», внизу каждого сегмента живёт подпись: лучший бомбардир турнира или его чемпион. Замирание это данные, но зрителю нужно это показать.
- CRF вместо битрейта. Первая версия кодировалась с фиксированным битрейтом 2600 kbps, и на зелёном фоне видео выглядело дёрганым. Тайминги были ни при чём, это артефакты сжатия: больших плоских областей стало больше, и кодеку перестало хватать бит. Лечится одним флагом ffmpeg:
-crf 19(постоянное качество вместо постоянного битрейта) плюс-pix_fmt yuv420pдля совместимости с плеерами.
Отрисовка каждого кадра это обычный barh с очисткой осей; на 15 полос уходит меньше миллисекунды, узкое место это кодирование видео.
Стадион вместо белого листа
Последний слой это оформление «под газон»: тёмно-зелёный фон, чередующиеся полосы стриженой травы (обычные axvspan через одну), белая «меловая» разметка с центральным кругом и золотая окантовка у лидера. Один нюанс: центральный круг рисуется Ellipse, а не Circle, потому что оси у гонки в разных масштабах (голы по x, места по y), и настоящая окружность в координатах данных сплющилась бы в щепку. Второй нюанс: на зелёном фоне пришлось пересобрать палитру сборных (тёмно-зелёная Португалия сливалась с полем, стала бордовой, как форма) и научить подписи выбирать цвет по яркости полосы: на светло-жёлтой Бразилии текст тёмный, на чёрно-серой Германии белый. Это три строки кода про относительную светимость, а читаемость меняют радикально.
Флаги вместо легенды
По цвету полосы «Германия» от «Уругвая» отличит не каждый, а фамилия Женгеллера мало кому подскажет страну. Решение это флаг на торце каждой полосы. Emoji-флаги matplotlib рисовать не умеет (шрифт с ними цветной, а движок нет), поэтому берём те же картинки, из которых сделаны emoji: набор Twemoji, PNG 72×72 на каждый флаг мира, скачивается по коду страны. Дальше OffsetImage плюс AnnotationBbox, и картинка едет вместе с баром:
ab = AnnotationBbox(OffsetImage(flag_png, zoom=0.42), (value, y),
frameon=False, box_alignment=(0.5, 0.5))
ax.add_artist(ab)
Нюанс для истории футбола: у СССР, Чехословакии, Югославии и ГДР emoji-флагов не существует, эти сборные едут по гонке без флага, и это честнее, чем подставлять флаг страны-наследницы.
Бонус: та же таблица, только это карта
Ту самую карту из начала статьи я собрал из того же prepare_data.py: кумулятивную таблицу титулов скормил не в полосы, а в мировую карту, где страна загорается золотом в год первого кубка и темнеет с каждым новым титулом. Когда данные уже собраны, сменить тип графика дёшево. Три вещи, которые стоит знать, если повторяете:
- Карта без geopandas. Тяжёлый гео-стек не нужен. Беру готовый GeoJSON стран (Natural Earth, около 800 КБ) и каждую страну рисую обычным matplotlib: полигон превращаю в
PathсMOVETOна каждое кольцо, затем вPathPatch. Никаких проекций и зависимостей, простая равнопрямоугольная развёртка по долготе и широте. - Цвет сам по себе не читается. Первая версия была чистой choropleth: чем больше титулов, тем темнее золото. И по ней вообще непонятно, у кого сколько. Пришлось добавить прямые подписи «страна плюс звёзды по числу титулов», а мелкие и скученные (европейский кластер, Уругвай) вынести в бокс рядом с линией-выноской. Мораль: если для чтения графика нужна легенда, лучше подписать прямо на объекте.
- Появление по событию. Подпись страны не висит всегда, а зажигается в год её первой победы и наращивает звёзды с каждым титулом. Это тот же приём прогрессивного раскрытия, что и уезжающие за край полосы в гонке.
Одна честная оговорка вынесена прямо на карту: в мировом GeoJSON нет отдельной Англии, поэтому её титул закрашивает весь остров Великобритании (Шотландия, Уэльс и Северная Ирландия это отдельные футбольные сборные, но геометрия одна). А ещё скрипт карты параметризован языком, так что английская версия для англоязычной аудитории это тот же код с флагом en.
Экспорт: MP4, а не GIF
anim = FuncAnimation(fig, draw, frames=len(timeline), interval=1000 / 30)
writer = FFMpegWriter(fps=30, extra_args=["-crf", "19", "-pix_fmt", "yuv420p"])
anim.save("race.mp4", writer=writer)
Почти тысяча кадров, 33 секунды, около 4 МБ. Наивный GIF той же длины весил бы под сотню мегабайт и выглядел бы хуже: у GIF потолок 256 цветов. Единственная зависимость это установленный ffmpeg (brew install ffmpeg на маке). Если ffmpeg нет, matplotlib умеет писать GIF через PillowWriter тем же кодом, просто поменяйте writer.
Отдельная история это соцсети, где видео не пропускают (например, часть Reddit). Там выручает двухпроходный GIF через палитру ffmpeg (palettegen плюс paletteuse с lanczos): кодек сначала подбирает оптимальные 256 цветов под конкретный ролик, а потом кодирует по ним. На статичной карте, где меняются только подписи, такой GIF выходит меньше мегабайта; на пёстрой гонке он тяжелее, но всё равно приличный. Одной командой это делает скрипт to_gif.sh.
Кириллица, кстати, не потребовала ничего: дефолтный шрифт matplotlib (DejaVu Sans) её знает. Единственное место, где пришлось повозиться, это русские имена: автоматическая транслитерация уверенно превращала Дьюлу Женгеллера в нечитаемое, так что около сотни фамилий из топов я замапил словарём руками. Иногда самый честный инструмент аналитика это словарь на сотню строк.
Что в итоге
Четыре скрипта плюс пара вспомогательных, ноль специализированных библиотек и пара вечеров против недели, которую я собирался на это потратить. Весь пайплайн:
| Скрипт | Что делает |
|---|---|
prepare_data.py | парсинг истории, мерж среза ЧМ-2026, assert-проверки |
get_flags.py | скачивает Twemoji-флаги сборных (один раз) |
race.py | четыре MP4-гонки по кумулятивным таблицам |
map_champions.py | анимированная карта чемпионов (choropleth плюс выноски, ru/en) |
static_charts.py | статичные PNG для статьи |
to_gif.sh | конвертация MP4 в GIF для соцсетей без видео |
Исходный датасет со всеми матчами лежит на Kaggle. А весь мой готовый пайплайн, скрипты плюс собранный срез ЧМ-2026, отдаю лично: зарегистрируйтесь на onlyanalyst.ru и напишите мне в Telegram, пришлю весь набор. Если возьмёте и повторите, покажите, что получилось. А если найдёте в данных ещё одну историю уровня «Германия перехватила лидерство по голам ровно в год, когда выиграла 7:1», тем более.
Бесплатный гайд по пайплайну (PDF). Пошаговая инструкция со всеми командами, требованиями, данными и решёнными граблями, в фирменном оформлении. Скачать PDF