← Все статьи
2026-07-06·9 мин чтения·

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, и советую хотя бы раз сделать так же: контроля больше, а кода на удивление мало.

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

  1. Значения линейно между соседними турнирами.
  2. Позиции полос тоже линейно, между рангами на соседних турнирах.
# ключевые кадры: значения и ранги на каждом турнире
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

Поделиться:✈ Telegram◉ VK

Читайте также

Комментарии