Проект по визуализации: дашборд данных о фильмах на 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, о котором подробно писали в уроке про несколько графиков на одном полотне.
- Создаём датасет: 200 фильмов с жанрами, годами, рейтингами, бюджетами и сборами.
- Разведочный анализ (EDA): describe и groupby — цифры до картинок.
- Готовим агрегаты для каждой панели.
- Рисуем панели по одной, потом собираем в сетку 2x2.
- Формулируем выводы и прогоняем чек-лист готовности.
Датасет проекта: 200 фильмов своими руками
Настоящий датасет о кино — это CSV на сотни мегабайт, но в браузерной песочнице сеть закрыта (load_dataset из седьмого урока по этой причине не работает). Поэтому данные сгенерируем сами через NumPy — и это рабочий навык: синтетические данные с заложенными связями позволяют проверить, увидит ли ваш дашборд заранее известный ответ. Мы заложим три закономерности: мультфильмы собирают высокие рейтинги, ужасы — низкие, а большой бюджет в среднем тянет за собой большие сборы. Если дашборд эти факты покажет — он работает.
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Разберём три решения, из-за которых датасет «похож на правду». Первое: 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 и группировки, вы не знаете ни масштабов, ни выбросов — и рисуете график наугад. Две команды — и у нас уже есть половина выводов:
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»: все вычисления — до рисования. График, в котором считается что-то сложное по ходу дела, невозможно отладить. Соберём четыре числа и одну табличку, которые лягут на панели:
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 тот же, числа совпадают:
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()
График отображается под редактором
Линия скачет между 5 и 18 релизами вокруг средней десятки, и никакого тренда тут нет: ни роста индустрии, ни спада — честно показываем шум. Почему линия, а не столбцы? Время — непрерывная шкала, и соседние годы связаны; двадцать столбцов слились бы в частокол, а на линии глаз сразу видит уровень и колебания. ax.set_xticks(range(2000, 2020, 2)) — приём из второго урока: четырёхзначные годы через один читаются свободнее, чем все двадцать подряд.
Панель 2: bar по жанрам
Вторая панель отвечает на вопрос «какие жанры собирают рейтинги». Агрегат уже посчитан в EDA — groupby со средним и сортировкой. Возьмём горизонтальные столбцы:
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 рейтингов
Средний рейтинг — это сжатие. Гистограмма показывает форму распределения целиком:
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 бюджет против сборов
Самая содержательная панель. Каждый фильм — точка; по горизонтали бюджет, по вертикали сборы. Добавим пунктирную диагональ «сборы равны бюджету»: всё выше неё окупилось, всё ниже — провалилось:
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 целиком
Теперь весь проект в одном скрипте. Сорок строк — нормально для финального анализа: двадцать из них данные, десять — агрегаты, десять — собственно дашборд. Это готовый шаблон: поменяйте данные на свои, и каркас останется тем же:
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()
График отображается под редактором
Анатомия сборки. 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 доводит» из седьмого урока в действии:
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:
# fig — дашборд из предыдущего шага
fig.savefig("movies_dashboard.png", dpi=300, bbox_inches="tight")
print("Файл сохранён: movies_dashboard.png")
Файл сохранён: movies_dashboard.png
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?» на встрече. Со временем список уйдёт в мышечную память, но пока печатайте его и держите рядом с редактором.
Выводы: что говорит наш дашборд
График без вывода — это просто картинка. Вот четыре утверждения, каждое держится на своей панели:
- Поток релизов стабилен: от 5 до 18 фильмов в год, в среднем десять, тренда нет (панель 1).
- Жанровая линейка смещена к драмам и боевикам, но зрительские симпатии — за мультфильмами: 7.17 против 5.63 у ужасов (панель 2, с оговоркой про маленькие группы).
- Рейтинги прижаты к 6.6-6.7 с коротким левым хвостом: провальных по оценкам фильмов мало, средний фильм студии — крепкие шесть с половиной (панель 3).
- Бюджет и сборы связаны сильно (корреляция 0.86), но 43% фильмов не окупились: большой бюджет повышает шансы, а не гарантирует результат (панель 4).
Дальше начинаются вопросы, на которые дашборд не отвечает, но подсказывает, куда копать: ужасы плохи сами по себе или им просто достаются маленькие бюджеты? 43% провалов — это много или норма для индустрии? Для первого вопроса сравнили бы распределения рейтингов по жанрам boxplot'ами из восьмого урока, для второго — подтянули бы внешние данные, а связи между всеми пятью числами разом показала бы корреляционная heatmap из девятого урока про тепловые карты и pairplot.
Сам дашборд — каркас, а не финал: из него можно вырастить полноценный портфолио-проект. Пять направлений, от простого к сложному:
- Замените синтетику на реальные данные: открытые наборы о кино (TMDB, датасеты MovieLens) или любой CSV с вашей работы — pandas read_csv вместо генератора.
- Добавьте пятую панель: boxplot рейтингов по жанрам покажет и медиану, и разброс, и выбросы сразу.
- Постройте корреляционную heatmap всех числовых столбцов — проверьте, увидит ли она заложенные связи.
- Заверните сборку в функцию make_dashboard(df) — один параметр, и тот же дашборд работает на любом датасете.
- Выложите результат на 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())
1. Что возвращает plt.subplots(2, 2)?
2. Какая панель дашборда отвечает на вопрос «как распределены рейтинги»?
3. Почему для сравнения среднего рейтинга жанров выбран barh, а не bar?
4. Зачем в scatter добавили диагональ «сборы = бюджет»?
5. Чем fig.suptitle отличается от ax.set_title?
Добавьте к дашборду пятую панель — «Средний бюджет по жанрам»: посчитайте средний бюджет жанра через groupby, отсортируйте по возрастанию и нарисуйте горизонтальные столбцы с заголовком «Средний бюджет по жанрам» и подписью оси X «Млн $». Данные о фильмах уже сгенерированы в заготовке.
Как построить дашборд из нескольких графиков в 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-канал или свой блог — так о проекте узнают новые читатели.
Что читать дальше
Matplotlib / Seaborn · Урок 9
Тепловые карты и pairplot: heatmap и матрицы диаграмм
Пять строк — и весь датасет на экране: heatmap рисует корреляционную матрицу цветом, pairplot раскладывает все пары переменных в матрицу диаграмм. Разведочный анализ, который раньше занимал день, теперь занимает минуту.
Pandas · Урок 10
Проект на Pandas: полный анализ продаж интернет-магазина
Финальный проект раздела: сырая выгрузка заказов, чистка по чек-листу, выручка и средний чек, топ-товары, сводные по городам, тренд по месяцам и выводы для руководителя.
Matplotlib / Seaborn · Урок 6
Оформление графиков: легенды, аннотации, сетки и стили Matplotlib
Легенда, аннотации со стрелками, сетка, готовые стили и rcParams — доводим график Matplotlib до презентационного уровня и сохраняем в PNG для слайдов.
Похожие уроки по темам
Подобраны автоматически по пересечению тем и ключевых слов.
openpyxl · Урок 12
Диаграммы в Excel через openpyxl: BarChart и LineChart
Собираем настоящие диаграммы Excel из кода: BarChart по продажам недели и LineChart по выручке — Reference, add_data, категории, якорь и размеры в сантиметрах.
openpyxl диаграммадиаграмма excel python
FastAPI · Урок 10
Проект: REST API сервиса заметок с базой данных
Сквозной проект курса: сервис заметок с тегами, поиском, SQLite и JWT. Структура по файлам, контракты Pydantic, тесты и README — портфолио-проект за пару вечеров.
fastapi проектfastapi crud с базой данных
Matplotlib / Seaborn · Урок 4
Диаграммы рассеяния scatter: ищем связи между величинами
scatter для поиска связей между величинами: форма облака точек, цвет и размер как третье измерение, alpha против оверплоттинга и кейс с 300 измерениями.
диаграмма рассеяния pythonplt.scatter описание
Проверьте знания по Matplotlib / Seaborn
В челлендже — 20 задач по Matplotlib / Seaborn, по 2 из каждого урока этого раздела. Формат: фрагмент кода и четыре варианта — что напечатает. После ответа — вердикт и объяснение со ссылкой на урок-источник.
Тест по Matplotlib / Seaborn: 20 задач