Перейти к содержанию

172 уроков, 13 библиотек и челлендж «Что выведет код?» — бесплатно, код прямо в браузере

Начать обучение
Урок 10 из 10 Продвинутый 60 мин 150 XP

Проект по визуализации: дашборд данных о фильмах на Matplotlib и Seaborn

Финальный проект курса: датасет из 200 фильмов, EDA, дашборд 2x2 с трендом, жанрами, гистограммой и scatter — плюс чек-лист «график готов к показу» и итоги десяти уроков.

Редакция Питоники

Пятница, 17:40. Руководитель прокатной студии подходит с вопросом: «У нас 200 фильмов за двадцать лет — окупаются ли они и что вообще происходит?» Можно открыть таблицу и начать читать строки, можно писать отчёт на десять страниц, а можно к понедельнику принести одну картинку: четыре панели, каждая отвечает на свой вопрос, все выводы видны за минуту. Этот урок — финальный проект по визуализации на Python: мы соберём дашборд matplotlib от пустой таблицы до презентационной картинки и заодно повторим весь курс.

Проект сквозной, и это принципиально: понадобится почти всё, что было в предыдущих девяти уроках — figure и axes, линейные графики, bar и hist, scatter, subplots, оформление и Seaborn. Нового синтаксиса будет мало, зато решений — много: какой график взять, что подписать, что выбросить. Кода в этом уроке больше обычного, и каждый блок можно запустить прямо на странице.

Постановка задачи: четыре панели, четыре вопроса

Дашборд — это одна фигура с несколькими панелями, где каждая панель отвечает ровно на один вопрос. Это определение я усвоил на собственном шраме: первый мой «дашборд» был свалкой из семи графиков, на которые никто не смотрел, потому что никто не понимал, зачем они. Теперь правило простое: нет вопроса — нет панели.

ПанельТип графикаВопрос зрителя
1. Релизы по годамЛинейный графикМенялось ли число выходящих фильмов?
2. ЖанрыСтолбчатая диаграмма barhКакие жанры собирают высокие рейтинги?
3. РейтингиГистограмма histКак распределены оценки — где норма и где выбросы?
4. Бюджет и сборыДиаграмма рассеяния scatterГарантирует ли большой бюджет большие сборы?

Узнаёте набор? Это шпаргалка «какой график для каких данных» из первого урока в действии: динамика во времени, сравнение категорий, распределение, связь двух величин. Четыре базовых вопроса — четыре базовых типа. Компоновать панели будем через subplots, о котором подробно писали в уроке про несколько графиков на одном полотне.

  1. Создаём датасет: 200 фильмов с жанрами, годами, рейтингами, бюджетами и сборами.
  2. Разведочный анализ (EDA): describe и groupby — цифры до картинок.
  3. Готовим агрегаты для каждой панели.
  4. Рисуем панели по одной, потом собираем в сетку 2x2.
  5. Формулируем выводы и прогоняем чек-лист готовности.

Датасет проекта: 200 фильмов своими руками

Настоящий датасет о кино — это CSV на сотни мегабайт, но в браузерной песочнице сеть закрыта (load_dataset из седьмого урока по этой причине не работает). Поэтому данные сгенерируем сами через NumPy — и это рабочий навык: синтетические данные с заложенными связями позволяют проверить, увидит ли ваш дашборд заранее известный ответ. Мы заложим три закономерности: мультфильмы собирают высокие рейтинги, ужасы — низкие, а большой бюджет в среднем тянет за собой большие сборы. Если дашборд эти факты покажет — он работает.

movies.py — генерируем датасет
import numpy as np
import pandas as pd

rng = np.random.default_rng(42)     # seed: данные одинаковы при каждом запуске
n = 200

# Жанры и их вероятности (порядок согласован!)
genres = ["драма", "комедия", "боевик", "фантастика", "ужасы", "мультфильм"]
genre_prob = [0.22, 0.20, 0.18, 0.16, 0.12, 0.12]

df = pd.DataFrame({
    "genre": rng.choice(genres, size=n, p=genre_prob),
    "year": rng.integers(2000, 2020, size=n),
})

# Рейтинг: базовое нормальное распределение плюс сдвиг по жанру
df["rating"] = np.round(np.clip(
    rng.normal(6.6, 0.9, size=n) + df["genre"].map({
        "драма": 0.3, "комедия": 0.0, "боевик": -0.2,
        "фантастика": 0.1, "ужасы": -0.8, "мультфильм": 0.7,
    }), 1, 10), 1)

# Бюджет: логнормальное распределение, умноженное на цену жанра
df["budget"] = np.round(rng.lognormal(3.2, 0.6, size=n) * df["genre"].map({
    "драма": 0.7, "комедия": 0.8, "боевик": 1.6,
    "фантастика": 1.4, "ужасы": 0.5, "мультфильм": 2.0,
}), 1)

# Сборы: бюджет умножить на долю, которая растёт вместе с рейтингом
mult = (rng.uniform(0.2, 2.0, size=n) + (df["rating"] - 6.6) * 0.3).clip(lower=0.15)
df["revenue"] = np.round(df["budget"] * mult, 1)

print(df.shape)
print(df.head(3).to_string())
print(df["genre"].value_counts())
Вывод
(200, 5)
    genre  year  rating  budget  revenue
0   ужасы  2019     5.5     7.4      7.7
1  боевик  2015     6.3    58.5    107.0
2   ужасы  2006     5.6    23.5     19.3
genre
драма         45
боевик        41
комедия       37
фантастика    30
ужасы         26
мультфильм    21
Name: count, dtype: int64
Первый запуск загружает numpy и pandas — это займёт до минуты, повторные срабатывают мгновенно

Разберём три решения, из-за которых датасет «похож на правду». Первое: rng.choice с параметром p — жанры выпадают не поровну, драм заметно больше, чем мультфильмов. Второе: df["genre"].map(словарь) — pandas-способ применить справочник к столбцу: каждому жанру свой сдвиг рейтинга и своя цена бюджета. Третье: логнормальное распределение для бюджета — rng.lognormal даёт много маленьких значений и редкий длинный хвост больших. Это ровно то, как бюджеты устроены в жизни: дешёвых фильмов сотни, а блокбастеров за 180 млн — единицы.

Зачем фиксировать seed

np.random.default_rng(42) создаёт генератор псевдослучайных чисел, а аргумент 42 — стартовое зерно. Числа выглядят случайными, но последовательность всегда одна и та же: ваш head() совпадёт с моим до последней цифры, а график, построенный сегодня, совпадёт с графиком завтра. Без seed любой пример в учебнике был бы невоспроизводим. В рабочих ноутбуках я ставлю seed в первой ячейке по привычке: однажды «проплывший» между запусками результат стоил мне часа поиска несуществующей ошибки.

EDA: сначала посмотрим, потом нарисуем

EDA (exploratory data analysis, разведочный анализ) — этап, который новички пропускают чаще всего и зря. Пока вы не посмотрели на describe и группировки, вы не знаете ни масштабов, ни выбросов — и рисуете график наугад. Две команды — и у нас уже есть половина выводов:

eda.py — смотрим на числа
import numpy as np
import pandas as pd

# Данные из movies.py: тот же seed, тот же датасет
rng = np.random.default_rng(42)
n = 200
df = pd.DataFrame({
    "genre": rng.choice(["драма", "комедия", "боевик", "фантастика", "ужасы", "мультфильм"],
                        n, p=[0.22, 0.20, 0.18, 0.16, 0.12, 0.12]),
    "year": rng.integers(2000, 2020, n),
})
df["rating"] = np.round(np.clip(rng.normal(6.6, 0.9, n)
                        + df["genre"].map({"драма": 0.3, "комедия": 0.0, "боевик": -0.2,
                                           "фантастика": 0.1, "ужасы": -0.8, "мультфильм": 0.7}), 1, 10), 1)
df["budget"] = np.round(rng.lognormal(3.2, 0.6, n) * df["genre"].map(
    {"драма": 0.7, "комедия": 0.8, "боевик": 1.6, "фантастика": 1.4, "ужасы": 0.5, "мультфильм": 2.0}), 1)
df["revenue"] = np.round(df["budget"] * (rng.uniform(0.2, 2.0, n)
                         + (df["rating"] - 6.6) * 0.3).clip(lower=0.15), 1)

print(df[["rating", "budget", "revenue"]].describe().round(1).to_string())
print()
by_genre = df.groupby("genre").agg(movies=("rating", "size"), avg_rating=("rating", "mean"))
print(by_genre.sort_values("avg_rating", ascending=False).round(2).to_string())
Вывод
       rating  budget  revenue
count   200.0   200.0    200.0
mean      6.6    31.8     37.9
std       1.0    27.3     47.0
min       3.7     4.1      0.7
25%       5.9    12.6      9.7
50%       6.7    23.8     22.6
75%       7.4    41.4     50.6
max       8.9   181.2    293.1

            movies  avg_rating
genre
мультфильм      21        7.17
драма           45        7.04
фантастика      30        6.90
боевик          41        6.52
комедия         37        6.48
ужасы           26        5.63

Что я выписал бы на стикер из этой таблицы. Средний рейтинг 6.6 при стандартном отклонении 1.0 — оценки прижаты к середине, экстремумов мало. Медианный бюджет 23.8 млн против максимального 181.2 — тот самый правый хвост логнормального распределения. Медианные сборы 22.6 млн почти равны медианному бюджету — типичный фильм едва выходит в ноль. И жанровая раскладка уже готова: мультфильмы сверху с 7.17, ужасы снизу с 5.63. Заметьте: мы ещё не нарисовали ни одного графика, а вторую панель дашборда уже можно обсудить с руководителем.

Готовим агрегаты: цифры для панелей

Второе правило аналитика после «сначала EDA»: все вычисления — до рисования. График, в котором считается что-то сложное по ходу дела, невозможно отладить. Соберём четыре числа и одну табличку, которые лягут на панели:

aggregates.py — четыре цифры для дашборда
import numpy as np
import pandas as pd

rng = np.random.default_rng(42)
n = 200
df = pd.DataFrame({
    "genre": rng.choice(["драма", "комедия", "боевик", "фантастика", "ужасы", "мультфильм"],
                        n, p=[0.22, 0.20, 0.18, 0.16, 0.12, 0.12]),
    "year": rng.integers(2000, 2020, n),
})
df["rating"] = np.round(np.clip(rng.normal(6.6, 0.9, n)
                        + df["genre"].map({"драма": 0.3, "комедия": 0.0, "боевик": -0.2,
                                           "фантастика": 0.1, "ужасы": -0.8, "мультфильм": 0.7}), 1, 10), 1)
df["budget"] = np.round(rng.lognormal(3.2, 0.6, n) * df["genre"].map(
    {"драма": 0.7, "комедия": 0.8, "боевик": 1.6, "фантастика": 1.4, "ужасы": 0.5, "мультфильм": 2.0}), 1)
df["revenue"] = np.round(df["budget"] * (rng.uniform(0.2, 2.0, n)
                         + (df["rating"] - 6.6) * 0.3).clip(lower=0.15), 1)

per_year = df["year"].value_counts().sort_index()
corr = df["budget"].corr(df["revenue"])
hit_share = (df["revenue"] > df["budget"]).mean()

print(f"Корреляция бюджета и сборов: {corr:.2f}")
print(f"Доля фильмов, окупивших бюджет: {hit_share:.2f}")
print(f"Фильмов в год: от {per_year.min()} до {per_year.max()}, в среднем {per_year.mean():.1f}")
print()
print(df.nlargest(3, "revenue")[["genre", "year", "budget", "revenue"]].to_string())
Вывод
Корреляция бюджета и сборов: 0.86
Доля фильмов, окупивших бюджет: 0.57
Фильмов в год: от 5 до 18, в среднем 10.0

          genre  year  budget  revenue
131  мультфильм  2003   117.2    293.1
143      боевик  2003   181.2    285.6
164      боевик  2017   106.8    252.4

Корреляция 0.86 — сильная связь: бюджет и сборы идут в ногу, но не идеально. Окупили бюджет 57% фильмов — то есть 43% провалились, и это уже готовый вывод для заголовка дашборда. Релизы колеблются от 5 до 18 в год без всякого тренда. А самый кассовый фильм студии — мультфильм 2003 года: бюджет 117.2 млн, сборы 293.1 млн, отдача в два с половиной раза. Эту точку мы ещё подсветим стрелкой на scatter.

Панель 1: линейный тренд по годам

Строим панели по одной, каждую — как отдельный график с полным оформлением. Верхний левый угол дашборда занимает динамика релизов. Чтобы блоки можно было запускать независимо, генерацию данных я сжал до тех же самых строк — seed тот же, числа совпадают:

Панель 1 — сколько фильмов выходило по годам
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

rng = np.random.default_rng(42)
n = 200
df = pd.DataFrame({
    "genre": rng.choice(["драма", "комедия", "боевик", "фантастика", "ужасы", "мультфильм"],
                        n, p=[0.22, 0.20, 0.18, 0.16, 0.12, 0.12]),
    "year": rng.integers(2000, 2020, n),
})
df["rating"] = np.round(np.clip(rng.normal(6.6, 0.9, n)
                        + df["genre"].map({"драма": 0.3, "комедия": 0.0, "боевик": -0.2,
                                           "фантастика": 0.1, "ужасы": -0.8, "мультфильм": 0.7}), 1, 10), 1)
df["budget"] = np.round(rng.lognormal(3.2, 0.6, n) * df["genre"].map(
    {"драма": 0.7, "комедия": 0.8, "боевик": 1.6, "фантастика": 1.4, "ужасы": 0.5, "мультфильм": 2.0}), 1)
df["revenue"] = np.round(df["budget"] * (rng.uniform(0.2, 2.0, n)
                         + (df["rating"] - 6.6) * 0.3).clip(lower=0.15), 1)

per_year = df["year"].value_counts().sort_index()

fig, ax = plt.subplots(figsize=(9, 3.5))
ax.plot(per_year.index, per_year.values, marker="o", linewidth=2, color="#0072B2")
ax.set_title("Сколько фильмов выходило каждый год")
ax.set_xlabel("Год")
ax.set_ylabel("Число релизов")
ax.set_xticks(range(2000, 2020, 2))     # годы через один — подписи не наезжают
ax.grid(True, alpha=0.3)
plt.show()
Вывод
График отображается под редактором
Первый запуск загружает matplotlib — до минуты

Линия скачет между 5 и 18 релизами вокруг средней десятки, и никакого тренда тут нет: ни роста индустрии, ни спада — честно показываем шум. Почему линия, а не столбцы? Время — непрерывная шкала, и соседние годы связаны; двадцать столбцов слились бы в частокол, а на линии глаз сразу видит уровень и колебания. ax.set_xticks(range(2000, 2020, 2)) — приём из второго урока: четырёхзначные годы через один читаются свободнее, чем все двадцать подряд.

Панель 2: bar по жанрам

Вторая панель отвечает на вопрос «какие жанры собирают рейтинги». Агрегат уже посчитан в EDA — groupby со средним и сортировкой. Возьмём горизонтальные столбцы:

Панель 2 — средний рейтинг по жанрам
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

rng = np.random.default_rng(42)
n = 200
df = pd.DataFrame({
    "genre": rng.choice(["драма", "комедия", "боевик", "фантастика", "ужасы", "мультфильм"],
                        n, p=[0.22, 0.20, 0.18, 0.16, 0.12, 0.12]),
    "year": rng.integers(2000, 2020, n),
})
df["rating"] = np.round(np.clip(rng.normal(6.6, 0.9, n)
                        + df["genre"].map({"драма": 0.3, "комедия": 0.0, "боевик": -0.2,
                                           "фантастика": 0.1, "ужасы": -0.8, "мультфильм": 0.7}), 1, 10), 1)
df["budget"] = np.round(rng.lognormal(3.2, 0.6, n) * df["genre"].map(
    {"драма": 0.7, "комедия": 0.8, "боевик": 1.6, "фантастика": 1.4, "ужасы": 0.5, "мультфильм": 2.0}), 1)
df["revenue"] = np.round(df["budget"] * (rng.uniform(0.2, 2.0, n)
                         + (df["rating"] - 6.6) * 0.3).clip(lower=0.15), 1)

genre_rating = df.groupby("genre")["rating"].mean().sort_values()

fig, ax = plt.subplots(figsize=(9, 4))
ax.barh(genre_rating.index, genre_rating.values, color="#009E73")
ax.set_title("Средний рейтинг по жанрам")
ax.set_xlabel("Средний рейтинг")
ax.set_xlim(0, 10)                       # честная шкала: весь возможный диапазон
plt.show()
Вывод
График отображается под редактором

Два решения здесь — этические, не технические. Первое: barh вместо bar, потому что жанровые подписи длинные и на вертикальной оси им тесно; сортировка по значению (sort_values) ставит мультфильмы сверху, ужасы снизу — глаз читает рейтинг как таблицу. Второе: set_xlim(0, 10). Matplotlib по умолчанию обрезал бы шкалу до диапазона данных 5.4-7.3, и разница между ужасами и мультфильмами выглядела бы трёхкратной. Полная десятибалльная шкала показывает честно: разрыв есть, но он умеренный. Правило «не врать данными» из описания курса начинается именно с таких мелочей.

Панель 3: hist рейтингов

Средний рейтинг — это сжатие. Гистограмма показывает форму распределения целиком:

Панель 3 — как распределены рейтинги
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

rng = np.random.default_rng(42)
n = 200
df = pd.DataFrame({
    "genre": rng.choice(["драма", "комедия", "боевик", "фантастика", "ужасы", "мультфильм"],
                        n, p=[0.22, 0.20, 0.18, 0.16, 0.12, 0.12]),
    "year": rng.integers(2000, 2020, n),
})
df["rating"] = np.round(np.clip(rng.normal(6.6, 0.9, n)
                        + df["genre"].map({"драма": 0.3, "комедия": 0.0, "боевик": -0.2,
                                           "фантастика": 0.1, "ужасы": -0.8, "мультфильм": 0.7}), 1, 10), 1)
df["budget"] = np.round(rng.lognormal(3.2, 0.6, n) * df["genre"].map(
    {"драма": 0.7, "комедия": 0.8, "боевик": 1.6, "фантастика": 1.4, "ужасы": 0.5, "мультфильм": 2.0}), 1)
df["revenue"] = np.round(df["budget"] * (rng.uniform(0.2, 2.0, n)
                         + (df["rating"] - 6.6) * 0.3).clip(lower=0.15), 1)

fig, ax = plt.subplots(figsize=(9, 4))
ax.hist(df["rating"], bins=20, color="#D55E00", edgecolor="white")
ax.axvline(df["rating"].mean(), color="black", linestyle="--", linewidth=1.5)
ax.annotate("Среднее 6.64", xy=(6.64, 20), xytext=(4.6, 22),
            arrowprops=dict(arrowstyle="->"))
ax.set_title("Распределение рейтингов")
ax.set_xlabel("Рейтинг")
ax.set_ylabel("Число фильмов")
plt.show()
Вывод
График отображается под редактором

bins=20 даёт ширину интервала около 0.26 балла — форма читается, но не измельчена. Распределение почти симметричное с пиком около 6.6-6.7 и коротким левым хвостом: эти низкие оценки — вклад ужасов со средним 5.63. Пунктирная axvline отмечает среднее, а annotate со стрелкой из шестого урока называет его словами — зритель не обязан прикидывать положение линии на глаз. Вместе панели 2 и 3 работают в паре: bar отвечает «кто лучше», hist — «насколько разбросаны все остальные».

Панель 4: scatter бюджет против сборов

Самая содержательная панель. Каждый фильм — точка; по горизонтали бюджет, по вертикали сборы. Добавим пунктирную диагональ «сборы равны бюджету»: всё выше неё окупилось, всё ниже — провалилось:

Панель 4 — окупаются ли большие бюджеты
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

rng = np.random.default_rng(42)
n = 200
df = pd.DataFrame({
    "genre": rng.choice(["драма", "комедия", "боевик", "фантастика", "ужасы", "мультфильм"],
                        n, p=[0.22, 0.20, 0.18, 0.16, 0.12, 0.12]),
    "year": rng.integers(2000, 2020, n),
})
df["rating"] = np.round(np.clip(rng.normal(6.6, 0.9, n)
                        + df["genre"].map({"драма": 0.3, "комедия": 0.0, "боевик": -0.2,
                                           "фантастика": 0.1, "ужасы": -0.8, "мультфильм": 0.7}), 1, 10), 1)
df["budget"] = np.round(rng.lognormal(3.2, 0.6, n) * df["genre"].map(
    {"драма": 0.7, "комедия": 0.8, "боевик": 1.6, "фантастика": 1.4, "ужасы": 0.5, "мультфильм": 2.0}), 1)
df["revenue"] = np.round(df["budget"] * (rng.uniform(0.2, 2.0, n)
                         + (df["rating"] - 6.6) * 0.3).clip(lower=0.15), 1)

fig, ax = plt.subplots(figsize=(8, 6))
ax.scatter(df["budget"], df["revenue"], s=45, alpha=0.6, color="#CC79A7")
ax.plot([0, 300], [0, 300], linestyle="--", color="grey",
        label="Сборы = бюджет (диагональ окупаемости)")
top = df.loc[df["revenue"].idxmax()]
ax.annotate("Самый кассовый: мультфильм 2003", xy=(top["budget"], top["revenue"]),
            xytext=(55, 275), arrowprops=dict(arrowstyle="->"))
ax.set_title("Бюджет против сборов: 57% фильмов выше диагонали")
ax.set_xlabel("Бюджет, млн $")
ax.set_ylabel("Сборы, млн $")
ax.legend(loc="upper left")
plt.show()
Вывод
График отображается под редактором

Корреляция 0.86 видна глазом: облако тянется вдоль диагонали снизу вверх. Но scatter показывает и то, что среднее скрывает: разброс огромный — при одном бюджете сборы различаются в разы, а дорогие фильмы в основном сидят выше линии, дешёвые чаще ниже. alpha=0.6 против наложения точек, annotate выводит рекордсмена, легенда объясняет пунктир. Диагональ окупаемости — мой любимый приём в таких графиках: она превращает абстрактное облако в утверждение, которое можно проверить пальцем — 57% точек выше линии, ровно как считал pandas в агрегатах.

Собираем дашборд 2x2: subplots целиком

Теперь весь проект в одном скрипте. Сорок строк — нормально для финального анализа: двадцать из них данные, десять — агрегаты, десять — собственно дашборд. Это готовый шаблон: поменяйте данные на свои, и каркас останется тем же:

dashboard.py — весь проект одним скриптом
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

# ---------- 1. Данные ----------
rng = np.random.default_rng(42)
n = 200
df = pd.DataFrame({
    "genre": rng.choice(["драма", "комедия", "боевик", "фантастика", "ужасы", "мультфильм"],
                        n, p=[0.22, 0.20, 0.18, 0.16, 0.12, 0.12]),
    "year": rng.integers(2000, 2020, n),
})
df["rating"] = np.round(np.clip(rng.normal(6.6, 0.9, n)
                        + df["genre"].map({"драма": 0.3, "комедия": 0.0, "боевик": -0.2,
                                           "фантастика": 0.1, "ужасы": -0.8, "мультфильм": 0.7}), 1, 10), 1)
df["budget"] = np.round(rng.lognormal(3.2, 0.6, n) * df["genre"].map(
    {"драма": 0.7, "комедия": 0.8, "боевик": 1.6, "фантастика": 1.4, "ужасы": 0.5, "мультфильм": 2.0}), 1)
df["revenue"] = np.round(df["budget"] * (rng.uniform(0.2, 2.0, n)
                         + (df["rating"] - 6.6) * 0.3).clip(lower=0.15), 1)

# ---------- 2. Агрегаты ----------
per_year = df["year"].value_counts().sort_index()
genre_rating = df.groupby("genre")["rating"].mean().sort_values()

# ---------- 3. Дашборд 2x2 ----------
fig, axs = plt.subplots(2, 2, figsize=(12, 8))

axs[0, 0].plot(per_year.index, per_year.values, marker="o", linewidth=2, color="#0072B2")
axs[0, 0].set_title("Сколько фильмов выходило по годам")
axs[0, 0].set_ylabel("Число релизов")
axs[0, 0].grid(True, alpha=0.3)

axs[0, 1].barh(genre_rating.index, genre_rating.values, color="#009E73")
axs[0, 1].set_title("Средний рейтинг по жанрам")
axs[0, 1].set_xlabel("Средний рейтинг")
axs[0, 1].set_xlim(0, 10)

axs[1, 0].hist(df["rating"], bins=20, color="#D55E00", edgecolor="white")
axs[1, 0].axvline(df["rating"].mean(), color="black", linestyle="--", linewidth=1.5)
axs[1, 0].set_title("Распределение рейтингов")
axs[1, 0].set_xlabel("Рейтинг")
axs[1, 0].set_ylabel("Число фильмов")

axs[1, 1].scatter(df["budget"], df["revenue"], s=40, alpha=0.6, color="#CC79A7")
axs[1, 1].plot([0, 300], [0, 300], linestyle="--", color="grey")
axs[1, 1].set_title("Бюджет против сборов")
axs[1, 1].set_xlabel("Бюджет, млн $")
axs[1, 1].set_ylabel("Сборы, млн $")

fig.suptitle("Кинопрокат 2000-2019: 200 фильмов в четырёх картинках", fontsize=15)
fig.tight_layout()
plt.show()
Вывод
График отображается под редактором
Первый запуск загружает numpy, pandas и matplotlib — до минуты

Анатомия сборки. plt.subplots(2, 2, figsize=(12, 8)) вернул фигуру и массив axs два на два — панель выбирается двумя индексами: axs[0, 0] верх-лево, axs[1, 1] низ-право. У каждой панели свой заголовок через ax.set_title, а над всеми ними висит общий fig.suptitle — разные уровни, не путайте. fig.tight_layout() раздвигает панели, чтобы заголовки и подписи не слипались. Палитра — четыре цвета Okabe-Ito из шестого урока: панели согласованы между собой, хотя данные у всех разные. Подписи осей я оставил только там, где без них непонятно: годы в подписи не нуждаются, а жанры и так подписаны у столбцов. Аннотацию у scatter в маленькой панели я убрал — в четверти холста она мешает; на отдельном большом графике она оправдана.

Слайд и экспорт: доводим до презентационного уровня

Четвёртую панель можно усилить Seaborn'ом за одну строку: раскрасить точки по жанрам. Связка «Seaborn рисует — Matplotlib доводит» из седьмого урока в действии:

Панель 4 на Seaborn: жанры цветом
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns

rng = np.random.default_rng(42)
n = 200
df = pd.DataFrame({
    "genre": rng.choice(["драма", "комедия", "боевик", "фантастика", "ужасы", "мультфильм"],
                        n, p=[0.22, 0.20, 0.18, 0.16, 0.12, 0.12]),
    "year": rng.integers(2000, 2020, n),
})
df["rating"] = np.round(np.clip(rng.normal(6.6, 0.9, n)
                        + df["genre"].map({"драма": 0.3, "комедия": 0.0, "боевик": -0.2,
                                           "фантастика": 0.1, "ужасы": -0.8, "мультфильм": 0.7}), 1, 10), 1)
df["budget"] = np.round(rng.lognormal(3.2, 0.6, n) * df["genre"].map(
    {"драма": 0.7, "комедия": 0.8, "боевик": 1.6, "фантастика": 1.4, "ужасы": 0.5, "мультфильм": 2.0}), 1)
df["revenue"] = np.round(df["budget"] * (rng.uniform(0.2, 2.0, n)
                         + (df["rating"] - 6.6) * 0.3).clip(lower=0.15), 1)

sns.set_theme(style="whitegrid")
ax = sns.scatterplot(data=df, x="budget", y="revenue", hue="genre",
                     palette="colorblind", s=45, alpha=0.8)
ax.set_title("Бюджет и сборы по жанрам")
ax.set_xlabel("Бюджет, млн $")            # seaborn подписал бы "budget"
ax.set_ylabel("Сборы, млн $")
plt.show()
Вывод
График отображается под редактором

hue="genre" добавил легенду и палитру автоматически, palette="colorblind" сделал цвета различимыми для всех зрителей. Обратите внимание на set_xlabel: Seaborn подписывает оси именами столбцов — «budget» вместо «Бюджет, млн $», — и человеческие названия возвращаем руками. Так дашборд остаётся читаемым и для тех, кто не знает английских имён колонок.

Последний шаг — экспорт. Для проектора хватает dpi=150: 12 на 8 дюймов дают 1800 на 1200 пикселей, а для печати и портфолио я сохраняю dpi=300:

Сохраняем дашборд в PNG
# fig — дашборд из предыдущего шага
fig.savefig("movies_dashboard.png", dpi=300, bbox_inches="tight")
print("Файл сохранён: movies_dashboard.png")
Вывод
Файл сохранён: movies_dashboard.png
В песочнице файл уходит в виртуальную файловую систему и живёт до перезагрузки страницы — проверяйте экспорт локально. Напоминание из первого урока: savefig вызывают до show(), иначе рискуете сохранить пустой лист.

12 дюймов на 300 dpi — это 3600 пикселей по ширине: запас для любого экрана и печати. bbox_inches="tight" обрежет пустые поля вокруг suptitle. Если для слайда нужна только одна панель, не вырезайте её из PNG вручную: скопируйте код панели в отдельную маленькую фигуру — пять строк, зато каждый слайд воспроизводится из кода, а не выковыривается из скриншота.

Чек-лист: график готов к показу

Прежде чем отправлять картинку руководителю, я прогоняю этот список. Он короткий, но каждый пункт здесь — чья-то реальная ошибка, в том числе моя:

  • У каждой панели есть заголовок и подписи осей с единицами измерения (млн $, число фильмов).
  • Сформулирован вопрос, на который отвечает панель; если вопроса нет — панель удалена.
  • Столбцы отсортированы по значению, а не по алфавиту и не в порядке выборки.
  • Шкала честная: рейтинги на полном диапазоне 0-10, ось Y гистограммы начинается с нуля.
  • bins в hist подобраны: форма видна, но шум не раздроблен (для 200 точек хватило 20).
  • У плотного scatter стоит alpha, у линий — маркеры, если точек меньше двадцати.
  • Каждая серия с уникальным цветом имеет легенду; цвета различимы при дальтонизме.
  • Дашборд: figsize под сетку панелей, suptitle сверху, tight_layout в конце.
  • Цвет не единственный носитель смысла: важные точки подписаны, средние отмечены линией.
  • Для слайда сохранено через savefig с dpi 300 — и обязательно до show().

Проверка занимает три минуты и экономит объяснения в духе «а что тут по оси X?» на встрече. Со временем список уйдёт в мышечную память, но пока печатайте его и держите рядом с редактором.

Выводы: что говорит наш дашборд

График без вывода — это просто картинка. Вот четыре утверждения, каждое держится на своей панели:

  1. Поток релизов стабилен: от 5 до 18 фильмов в год, в среднем десять, тренда нет (панель 1).
  2. Жанровая линейка смещена к драмам и боевикам, но зрительские симпатии — за мультфильмами: 7.17 против 5.63 у ужасов (панель 2, с оговоркой про маленькие группы).
  3. Рейтинги прижаты к 6.6-6.7 с коротким левым хвостом: провальных по оценкам фильмов мало, средний фильм студии — крепкие шесть с половиной (панель 3).
  4. Бюджет и сборы связаны сильно (корреляция 0.86), но 43% фильмов не окупились: большой бюджет повышает шансы, а не гарантирует результат (панель 4).

Дальше начинаются вопросы, на которые дашборд не отвечает, но подсказывает, куда копать: ужасы плохи сами по себе или им просто достаются маленькие бюджеты? 43% провалов — это много или норма для индустрии? Для первого вопроса сравнили бы распределения рейтингов по жанрам boxplot'ами из восьмого урока, для второго — подтянули бы внешние данные, а связи между всеми пятью числами разом показала бы корреляционная heatmap из девятого урока про тепловые карты и pairplot.

Сам дашборд — каркас, а не финал: из него можно вырастить полноценный портфолио-проект. Пять направлений, от простого к сложному:

  1. Замените синтетику на реальные данные: открытые наборы о кино (TMDB, датасеты MovieLens) или любой CSV с вашей работы — pandas read_csv вместо генератора.
  2. Добавьте пятую панель: boxplot рейтингов по жанрам покажет и медиану, и разброс, и выбросы сразу.
  3. Постройте корреляционную heatmap всех числовых столбцов — проверьте, увидит ли она заложенные связи.
  4. Заверните сборку в функцию make_dashboard(df) — один параметр, и тот же дашборд работает на любом датасете.
  5. Выложите результат на GitHub вместе с выводами: график без текста в портфолио не убедит никого.

Итоги курса: десять уроков до дашборда

Оглянитесь: в первом уроке вы рисовали одну линию и путали Axes с Axis. Путь, который вы прошли:

  • Урок 1: figure и axes, pyplot против объектного стиля, savefig.
  • Урок 2: plot — цвета, маркеры, форматные строки, подписи осей.
  • Урок 3: bar и hist — сравнение категорий и распределения.
  • Урок 4: scatter — корреляция глазами, alpha против оверплоттинга.
  • Урок 5: pie и subplots — несколько графиков на одном полотне.
  • Урок 6: оформление — легенды, аннотации, стили, rcParams, dpi.
  • Уроки 7-9: Seaborn — темы, countplot и barplot, boxplot и violinplot, heatmap и pairplot.
  • Урок 10: этот проект — от датасета до презентации.

Финальный совет без пафоса. Возьмите данные, которые у вас уже есть: шаги за месяц из приложения, расходы из банковской выписки, расписание сериалов. Соберите по шаблону из dashboard.py дашборд 2x2 о себе любимом — и вы поймёте весь курс за один вечер, потому что свои данные хочется разбирать сильнее, чем чужие. Дашборд из этого урока собирается за час, а вопросы снимает на неделю: считайте, это и есть возврат инвестиций в визуализацию. Курс пройден; самоучитель Matplotlib останется шпаргалкой, если какой-то шаг из десяти забудется.

Что выведет код?

Сначала предскажи ответ в голове — это главный навык программиста.

import matplotlib.pyplot as plt

fig, axs = plt.subplots(2, 2)
print(axs.shape)
import pandas as pd

df = pd.DataFrame({
    "genre": ["боевик", "драма", "боевик", "ужасы"],
    "rating": [6.5, 7.2, 6.8, 5.9],
})
means = df.groupby("genre")["rating"].mean()
print(means.idxmax())
Проверь себя
0 / 5

1. Что возвращает plt.subplots(2, 2)?

2. Какая панель дашборда отвечает на вопрос «как распределены рейтинги»?

3. Почему для сравнения среднего рейтинга жанров выбран barh, а не bar?

4. Зачем в scatter добавили диагональ «сборы = бюджет»?

5. Чем fig.suptitle отличается от ax.set_title?

Карточки терминов
Запомнено: 0 / 6
Практика

Добавьте к дашборду пятую панель — «Средний бюджет по жанрам»: посчитайте средний бюджет жанра через groupby, отсортируйте по возрастанию и нарисуйте горизонтальные столбцы с заголовком «Средний бюджет по жанрам» и подписью оси X «Млн $». Данные о фильмах уже сгенерированы в заготовке.

practice.py
Вопросы и ответы по уроку

Как построить дашборд из нескольких графиков в Matplotlib?

Создайте сетку панелей: fig, axs = plt.subplots(2, 2, figsize=(12, 8)). Затем рисуйте в каждую панель через axs[0, 0], axs[0, 1] и так далее, дайте каждой панели заголовок ax.set_title, добавьте общий fig.suptitle и завершите fig.tight_layout(), чтобы подписи не наезжали друг на друга.

Сколько графиков должно быть в дашборде?

Столько, на сколько вопросов нужно ответить; чаще всего хватает сетки 2×2, как в этом проекте. Опора — правило «нет вопроса — нет панели»: свалка из семи графиков, у которых нет своего вопроса, никому не нужна.

Откуда взять датасет для проекта по визуализации?

Три источника: свои данные (CSV с работы, выгрузки из приложений), открытые наборы вроде MovieLens или TMDB и синтетические данные через np.random.default_rng с фиксированным seed. В браузерной песочнице сеть закрыта, поэтому там данные создают руками через pd.DataFrame — все приёмы урока работают на любых таблицах.

Как сохранить дашборд Matplotlib в PNG для презентации?

fig.savefig("movies_dashboard.png", dpi=300, bbox_inches="tight") — и обязательно до plt.show(), иначе сохранится пустой лист. Дашборд 12x8 дюймов при dpi=300 даёт картинку 3600x2400 пикселей, которой хватает и для проектора, и для печати.

Что вынести в портфолио аналитика из этого проекта?

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

Понравился урок? Сошлитесь на него

«График без вывода — это просто картинка.»

Скопируйте готовую ссылку в формате HTML, Markdown или чистый адрес и вставьте в статью на Habr, VC, Telegram-канал или свой блог — так о проекте узнают новые читатели.

TelegramVK

Похожие уроки по темам

Подобраны автоматически по пересечению тем и ключевых слов.

Проверьте знания по Matplotlib / Seaborn

В челлендже — 20 задач по Matplotlib / Seaborn, по 2 из каждого урока этого раздела. Формат: фрагмент кода и четыре варианта — что напечатает. После ответа — вердикт и объяснение со ссылкой на урок-источник.

Тест по Matplotlib / Seaborn: 20 задач