15 приёмов EDA на Python, которые работают лучше красивого дизайна

Страницы:  1

Ответить
 

Professor Seleznov


pic
Каждый раз, когда вы делаете EDA, вы стоите перед выбором: нарисовать быстрый df.plot() — или потратить 10–20 минут на оформление, которое скажет что-то важное о ваших данных. В нашем курсе в Школе аналитиков данных МТС мы проверили этот выбор экспериментально: 44 студента сделали 220 EDA-графиков, мы получили 6000 попарных сравнений и проанализировали через CrowdBT (кстати, уже второй раз!). Результат: победители используют не больше данных, а больше контекста. Фоновые зоны, медианы, адаптивная перекраска, inset-axes — именно эти приемы отличают скучный график от графика, который меняет решения.
В статье — cookbook из 15 рецептов с кодом «до» и «после» на Python. Данные — встроенный seaborn.load_dataset("diamonds"), копируйте, запускайте, вдохновляйтесь.
Setup: базовый стиль
Все графики в статье используют единый набор rcParams. Скопируйте этот блок — он задает шрифты, палитру, сетку и убирает лишние spine:

Показать код

import matplotlib.pyplot as plt
import seaborn as sns
import numpy as np
import pandas as pd
# ── rcParams ──────────────────────────────────────────────────
plt.rcParams.update({
"font.family": "sans-serif",
"font.sans-serif": ["Inter", "Helvetica Neue", "Arial"],
"font.size": 12,
"axes.titlesize": 16,
"axes.titleweight": "bold",
"axes.labelsize": 13,
"xtick.labelsize": 11,
"ytick.labelsize": 11,
"text.color": "#2C3E50",
"axes.labelcolor": "#2C3E50",
"xtick.color": "#555555",
"ytick.color": "#555555",
"axes.spines.top": False,
"axes.spines.right": False,
"axes.edgecolor": "#CCCCCC",
"axes.grid": True,
"grid.color": "#E8E8E8",
"grid.linewidth": 0.6,
"axes.axisbelow": True,
"figure.facecolor": "white",
"savefig.facecolor": "white",
"savefig.dpi": 150,
"figure.dpi": 100,
})
BLUE = "#2E86C1"
RED = "#E74C3C"
GREEN = "#27AE60"
ORANGE = "#E67E22"
PURPLE = "#8E44AD"
TEAL = "#1ABC9C"
DARK = "#2C3E50"
CAT_COLORS = [BLUE, RED, GREEN, ORANGE, PURPLE, TEAL, "#F39C12", "#9B59B6"]
Все студенты работали с одним датасетом — seaborn.load_dataset("diamonds"): ~54 000 бриллиантов с числовыми признаками (цена, караты, глубина, таблица, размеры) и категориальными (огранка, цвет, чистота). Встроенный датасет с хорошим сочетанием типов данных, выбросами и нелинейными зависимостями — идеальный полигон для EDA.
Категория 1: Распределения

1. Histogram + KDE + цветовые зоны (axvspan)
Гистограмма с наложением KDE и фоновыми цветовыми зонами через axvspan. Прием из графика № 1 в рейтинге — фоновые зоны добавляют смысловой контекст (бюджет/средний/премиум), а KDE поверх hist дает плавную оценку плотности. Без контекстных зон гистограмма — просто столбики, а с ними — уже аналитический инструмент.
До:
diamonds = sns.load_dataset("diamonds")
prices = diamonds["price"]
fig, ax = plt.subplots(figsize=(8, 5))
ax.hist(prices, bins=50, color="steelblue")
ax.set_title("Distribution of Diamond Prices")
ax.set_xlabel("Price ($)")
ax.set_ylabel("Count")
plt.tight_layout()
После:

Показать код

diamonds = sns.load_dataset("diamonds")
prices = diamonds["price"]
fig, ax = plt.subplots(figsize=(12, 6))
# Фоновые зоны
budget_color, mid_color, premium_color = "#C4DBCC", "#FDEBD0", "#D5A6BD"
ax.axvspan(326, 2500, color=budget_color, alpha=0.4, label="Budget")
ax.axvspan(2500, 10000, color=mid_color, alpha=0.4, label="Mid-range")
ax.axvspan(10000, 20000, color=premium_color, alpha=0.4, label="Premium")
# Histogram + KDE
sns.histplot(
prices, bins=60, kde=True,
color=BLUE, alpha=0.5,
edgecolor="white", linewidth=0.8,
line_kws={"color": BLUE, "linewidth": 2.5},
ax=ax,
)
# Подписи зон
ymax = ax.get_ylim()[1]
for x, label in [(1400, "Budget"), (6250, "Mid-range"), (15000, "Premium")]:
ax.text(x, ymax * 0.92, label, ha="center", va="top",
fontsize=12, fontweight="bold", color="#333",
bbox=dict(facecolor="white", edgecolor="none",
alpha=0.75, boxstyle="round,pad=0.3"))
ax.set_title("Diamond Price Distribution by Segment",
fontsize=16, fontweight="bold", pad=15)
ax.set_xlabel("Price ($)", fontsize=13)
ax.set_ylabel("Count", fontsize=13)
ax.set_xlim(0, 20000)
ax.grid(axis="x", visible=False)
plt.tight_layout()
plt.show()
pic
Рецепт 1: Histogram + KDE + axvspan

2. Dual KDE: сравнение распределений с медианами
Несколько KDE-кривых на одном графике с вертикальными линиями медиан. Студенты из топ-3 использовали fill=True + common_norm=False — это ключевая комбинация для корректного сравнения групп. Медианы сразу показывают «центр» каждого распределения, а path_effects с белым контуром гарантируют читаемость текста поверх заливки.
До:
top_cuts = diamonds[diamonds["cut"].isin(["Ideal", "Premium", "Good"])]
fig, ax = plt.subplots(figsize=(8, 5))
for cut in ["Ideal", "Premium", "Good"]:
subset = top_cuts[top_cuts["cut"] == cut]
sns.kdeplot(subset["price"], label=cut, ax=ax)
ax.set_title("Price by Cut")
ax.legend()
plt.tight_layout()
После:

Показать код

import matplotlib.patheffects as pe
top_cuts = diamonds[diamonds["cut"].isin(["Ideal", "Premium", "Good"])]
fig, ax = plt.subplots(figsize=(12, 6))
palette = dict(zip(["Ideal", "Premium", "Good"],
[CAT_COLORS[0], CAT_COLORS[1], CAT_COLORS[2]]))
sns.kdeplot(
data=top_cuts, x="price", hue="cut",
hue_order=["Ideal", "Premium", "Good"],
palette=palette, fill=True, common_norm=False,
alpha=0.35, cut=0, clip=(0, 15000),
linewidth=2.2, ax=ax,
)
# Медианы
for i, cut in enumerate(["Ideal", "Premium", "Good"]):
median = top_cuts[top_cuts["cut"] == cut]["price"].median()
color = palette[cut]
ax.axvline(median, color=color, ls="--", lw=1.5, alpha=0.8)
ax.text(
median + 200, ax.get_ylim()[1] * (0.85 - i * 0.2),
f"median: ${median:,.0f}",
color=color, fontsize=11, fontweight="bold",
path_effects=[pe.withStroke(linewidth=3, foreground="white")],
)
ax.set_title("Diamond Price Distribution by Cut Quality",
fontsize=16, fontweight="bold", pad=15)
ax.set_xlabel("Price ($)", fontsize=13)
ax.set_ylabel("Density", fontsize=13)
ax.grid(axis="x", visible=False)
plt.tight_layout()
plt.show()
pic
Рецепт 2: Dual KDE с медианами

3. KDE + inset scatter (график внутри графика)
KDE-график плотности с миниатюрным scatter-графиком в углу через inset_axes из mpl_toolkits. Прием из рейтинга топ-2 — inset дает два уровня детализации одновременно: общую форму распределения и сырые точки. Без inset пришлось бы делать два отдельных графика.
До:
sample = diamonds.sample(3000, random_state=42)
fig, ax = plt.subplots(figsize=(8, 5))
ax.scatter(sample["carat"], sample["price"], alpha=0.3, s=10)
ax.set_title("Carat vs Price")
plt.tight_layout()
После:

Показать код

from mpl_toolkits.axes_grid1.inset_locator import inset_axes
sample = diamonds.sample(3000, random_state=42)
fig, ax = plt.subplots(figsize=(12, 6))
# Основной KDE
sns.kdeplot(
data=sample, x="price", fill=True,
color=BLUE, alpha=0.4,
cut=0, linewidth=2.2, ax=ax,
)
ax.set_title("Diamond Price Density with Carat Overview",
fontsize=16, fontweight="bold", pad=15)
ax.set_xlabel("Price ($)", fontsize=13)
ax.set_ylabel("Density", fontsize=13)
ax.grid(axis="x", visible=False)
# Inset scatter
ax_inset = inset_axes(ax, width="35%", height="35%",
loc="upper right", borderpad=1.5)
ax_inset.scatter(
sample["carat"], sample["price"],
c=TEAL, alpha=0.15, s=8, edgecolors="none",
)
ax_inset.set_xlabel("Carat", fontsize=9, labelpad=2)
ax_inset.set_ylabel("Price ($)", fontsize=9, labelpad=2)
ax_inset.set_title("Carat vs Price", fontsize=9,
fontweight="bold", pad=4)
ax_inset.tick_params(labelsize=7)
ax_inset.set_facecolor("#F8F9FA")
for spine in ax_inset.spines.values():
spine.set_color("#CCCCCC")
plt.tight_layout()
plt.show()
pic
Рецепт 3: KDE + inset scatter

4. Histogram с программной перекраской bins
Гистограмма, где каждый столбец можно перекрасить по условию. Студент из топ-4 использовал это для выделения «зоны стресса» в распределении доходностей. Ключевой прием — patches.set_facecolor() позволяет программно задавать цвет каждого отдельного столбца гистограммы. Визуально сразу видно, где начинается аномалия.
До:
fig, ax = plt.subplots(figsize=(8, 5))
ax.hist(prices, bins=50, color="steelblue")
ax.set_title("Diamond Prices")
plt.tight_layout()
После:

Показать код

import matplotlib.ticker as mticker
prices = diamonds["price"]
threshold = prices.quantile(0.90) # Top 10%
fig, ax = plt.subplots(figsize=(12, 6))
n, bins, patches = ax.hist(
prices, bins=50,
color="#BDC3C7", edgecolor="white", linewidth=0.8,
)
# Перекраска: премиум-зона
for i in range(len(patches)):
if bins >= threshold:
patches.set_facecolor(RED)
patches.set_alpha(0.85)
# Линия порога
ax.axvline(threshold, color=RED, ls=":", lw=1.5, alpha=0.7)
# Подписи
ax.text(
threshold - 500, ax.get_ylim()[1] * 0.85,
f"Top 10%\n≥ ${threshold:,.0f}",
color=RED, fontsize=12, fontweight="bold", ha="right",
bbox=dict(facecolor="white", edgecolor=RED,
alpha=0.9, boxstyle="round,pad=0.3"),
)
ax.text(
prices.median(), ax.get_ylim()[1] * 0.85,
f"Median\n${prices.median():,.0f}",
ha="center", fontsize=11, color=DARK,
)
ax.set_title("Diamond Prices: Highlighting the Premium Segment",
fontsize=16, fontweight="bold", pad=15)
ax.set_xlabel("Price ($)", fontsize=13)
ax.set_ylabel("Count", fontsize=13)
ax.xaxis.set_major_formatter(
mticker.FuncFormatter(lambda x, _: f"${x:,.0f}"))
ax.grid(axis="x", visible=False)
plt.tight_layout()
plt.show()
pic
Рецепт 4: Histogram с перекраской bins
-
Категория 2: Временные ряды

5. Line plot + CI bands + контекстные зоны (recessions)
Временной ряд со скользящим средним, доверительными интервалами (fill_between) и фоновыми зонами рецессий. Студент из топ-3 использовал этот паттерн для макроданных — зоны рецессий через axvspan превращают линейный график в аналитический инструмент с историческим контекстом.
До:
fig, ax = plt.subplots(figsize=(10, 4))
ax.plot(df["date"], df["gdp_growth"], color="steelblue")
ax.set_title("GDP Growth Over Time")
plt.tight_layout()
После:

Показать код

from matplotlib.patches import Patch
# Сгенерированные данные
np.random.seed(42)
dates = pd.date_range("1960-01-01", "2024-01-01", freq="QE")
n = len(dates)
gdp = np.cumsum(np.random.normal(0.6, 1.5, n))
gdp[160:165] -= 8 # кризис 2008
gdp[80:84] -= 5 # кризис 1990
df = pd.DataFrame({"date": dates, "gdp_growth": gdp})
recessions = [
("1969-12-01", "1970-11-01"), ("1973-11-01", "1975-03-01"),
("1980-01-01", "1982-11-01"), ("1990-07-01", "1991-03-01"),
("2001-03-01", "2001-11-01"), ("2007-12-01", "2009-06-01"),
("2020-02-01", "2020-06-01"),
]
# Rolling CI
df["gdp_ma"] = df["gdp_growth"].rolling(4, center=True).mean()
df["gdp_std"] = df["gdp_growth"].rolling(4, center=True).std()
df["ci_upper"] = df["gdp_ma"] + 1.96 * df["gdp_std"]
df["ci_lower"] = df["gdp_ma"] - 1.96 * df["gdp_std"]
fig, ax = plt.subplots(figsize=(14, 6))
# Фоновые зоны рецессий
for start, end in recessions:
ax.axvspan(pd.Timestamp(start), pd.Timestamp(end),
alpha=0.12, color="grey", zorder=0)
# Линия + CI
ax.plot(df["date"], df["gdp_ma"],
color=BLUE, lw=2, zorder=3)
ax.fill_between(df["date"], df["ci_lower"], df["ci_upper"],
color=BLUE, alpha=0.15, zorder=2,
label="95% CI (rolling)")
ax.axhline(0, color=RED, ls="-", lw=0.8, alpha=0.4)
ax.annotate("Global\nFinancial\nCrisis",
xy=(pd.Timestamp("2009-03-01"),
df["gdp_ma"].iloc[
df["date"].get_indexer(
[pd.Timestamp("2009-03-01")], method="nearest")[0]]),
xytext=(-80, 30), textcoords="offset points",
fontsize=10, color="#555",
arrowprops=dict(arrowstyle="->", color="grey", lw=1.2),
bbox=dict(boxstyle="round,pad=0.3",
facecolor="#FFF3CD", alpha=0.9))
ax.set_title("US GDP Growth with Recession Periods (1960–2024)",
fontsize=16, fontweight="bold", pad=15)
ax.set_ylabel("GDP Growth (%)", fontsize=13)
legend_elements = [
Patch(facecolor="grey", alpha=0.2, label="Recessions (NBER)"),
plt.Line2D([0], [0], color=BLUE, lw=2,
label="GDP Growth (4Q MA)"),
]
ax.legend(handles=legend_elements, loc="upper left", fontsize=10)
plt.tight_layout()
plt.show()
pic
Рецепт 5: Line + CI + recession zones

6. Twin-axis: line + bar + аннотации событий
Двойная ось Y, линейный график + столбцы + аннотации ключевых событий. Студент из топ-2 использовала twinx() для совмещения частоты терактов и числа жертв. Ключ: столбцы на заднем плане (серые), линия на переднем (цветная), аннотации событий через axvline + text.
До:
fig, ax = plt.subplots(figsize=(10, 4))
ax.plot(df["year"], df["attacks"], color="blue", label="Attacks")
ax.plot(df["year"], df["victims"], color="red", label="Victims")
ax.set_title("Attacks vs Victims Over Time")
ax.legend()
plt.tight_layout()
После:

Показать код

# Сгенерированные данные
np.random.seed(42)
years = np.arange(1980, 2018)
n = len(years)
attacks = (np.random.poisson(300, n) +
np.linspace(0, 1500, n)).astype(int)
attacks += np.where(
(years >= 2003) & (years <= 2015),
np.random.randint(500, 2000, n), 0)
victims = (attacks *
np.random.uniform(1.5, 4.0, n)).astype(int)
victims[years == 2001] = 25000
df = pd.DataFrame({"year": years, "attacks": attacks,
"victims": victims})
fig, ax1 = plt.subplots(figsize=(14, 6))
ax2 = ax1.twinx()
# Столбцы (victims) — задний план
ax2.bar(df["year"], df["victims"],
color="#E8E8E8", alpha=0.7, width=0.8,
label="Total victims")
ax2.set_ylabel("Total Victims", fontsize=13, color="#888")
ax2.tick_params(axis="y", colors="#888")
# Линия (attacks) — передний план
ax1.plot(df["year"], df["attacks"], color=RED,
lw=2.5, zorder=5, label="Number of attacks")
ax1.fill_between(df["year"], df["attacks"],
alpha=0.1, color=RED)
ax1.set_ylabel("Number of Attacks",
fontsize=13, color=RED)
ax1.tick_params(axis="y", colors=RED)
# Аннотации событий
events = {2001: "9/11 Attacks",
2007: "Iraq Violence Peak",
2014: "ISIS Caliphate"}
for year, label in events.items():
ax1.axvline(year, color="black", ls="--",
lw=0.7, alpha=0.5)
ax1.text(year, ax1.get_ylim()[1] * 0.95, label,
ha="center", fontsize=9, color="#333",
bbox=dict(facecolor="white", edgecolor="none",
alpha=0.8, boxstyle="round,pad=0.2"))
ax1.set_title(
"Global Terrorism: Attack Frequency vs Victim Count (1980–2017)",
fontsize=16, fontweight="bold", pad=15)
lines1, labels1 = ax1.get_legend_handles_labels()
lines2, labels2 = ax2.get_legend_handles_labels()
ax1.legend(lines1 + lines2, labels1 + labels2,
loc="upper left", fontsize=10)
ax1.grid(axis="x", visible=False)
plt.tight_layout()
plt.show()
pic
Рецепт 6: Twin-axis + аннотации

7. Stacked bar + inline проценты + totals
Stacked bar chart с процентами внутри сегментов и тоталами на оси X. Inline-проценты + подписи n= на оси X убирают необходимость смотреть на ось Y и легенду — все видно на самом графике. Студентка из топ-2 использовала это для структуры терактов по организациям.
До:
pd.crosstab(df_top["color"], df_top["cut"]).plot(
kind="bar", stacked=True)
После:

Показать код

import matplotlib.ticker as mticker
# Топ-5 цветов
top_colors = diamonds["color"].value_counts().nlargest(5).index
df_top = diamonds[diamonds["color"].isin(top_colors)]
table = pd.crosstab(df_top["color"], df_top["cut"],
normalize="index")
table = table[["Ideal", "Premium", "Very Good", "Good", "Fair"]]
totals = df_top.groupby("color").size()
fig, ax = plt.subplots(figsize=(12, 6))
colors = CAT_COLORS[:5]
table.plot(kind="bar", stacked=True, color=colors,
width=0.7, alpha=0.9, ax=ax)
# Inline проценты
for container, cut_type in zip(ax.containers, table.columns):
for bar, value in zip(container, container.datavalues):
if value < 0.04:
continue
x = bar.get_x() + bar.get_width() / 2
y = bar.get_y() + bar.get_height() / 2
text_color = ("white" if cut_type == "Ideal"
else "black")
ax.text(x, y, f"{value:.0%}", ha="center", va="center",
fontsize=9, color=text_color, fontweight="bold")
# Totals в подписях оси X
new_labels = [f"{name}\n(n={totals[name]:,})"
for name in table.index]
ax.set_xticklabels(new_labels, rotation=0, fontsize=11)
ax.yaxis.set_major_formatter(mticker.PercentFormatter(1))
ax.set_title("Diamond Cut Distribution by Color (Top 5 Colors)",
fontsize=16, fontweight="bold", pad=15)
ax.set_xlabel("")
ax.set_ylabel("Share of Cut Types", fontsize=13)
ax.legend(title="Cut", bbox_to_anchor=(1.02, 1),
loc="upper left", fontsize=10)
ax.grid(axis="x", visible=False)
plt.tight_layout()
plt.show()
pic
Рецепт 7: Stacked bar + inline проценты
-
Категория 3: Корреляция и матрицы

8. Heatmap с иерархической кластеризацией
Корреляционная матрица, переупорядоченная через scipy.cluster.hierarchy.linkage с методом Ward. Когда признаков больше пяти, обычная heatmap превращается в кашу. Кластеризация автоматически группирует похожие признаки — глаз сразу видит блоки. Студент из топ-5.
До:
corr = diamonds[["carat", "depth", "table", "price", "x", "y", "z"]].corr()
sns.heatmap(corr, annot=True, cmap="coolwarm", center=0)
После:

Показать код

from matplotlib.colors import LinearSegmentedColormap
from scipy.cluster.hierarchy import linkage, leaves_list
num_cols = ["carat", "depth", "table", "price", "x", "y", "z"]
corr = diamonds[num_cols].corr()
# Кластеризация
link = linkage(corr, method="ward")
order = leaves_list(link)
corr_ordered = corr.iloc[order, order]
# Кастомная палитра
cmap_custom = LinearSegmentedColormap.from_list(
"eda_cookbook",
["#2E4057", "#5E7A94", "#B8C9D9", "#FAF8F5",
"#F5D6C6", "#D4844C", "#8B1A1A"],
N=256,
)
fig, ax = plt.subplots(figsize=(10, 8))
mask = np.triu(np.ones_like(corr_ordered, dtype=bool), k=1)
sns.heatmap(
corr_ordered, mask=mask, annot=True, fmt=".2f",
cmap=cmap_custom, center=0, vmin=-1, vmax=1,
square=True, linewidths=0.8, linecolor="white",
cbar_kws={"shrink": 0.75, "label": "Pearson r",
"ticks": [-1, -0.5, 0, 0.5, 1]},
annot_kws={"size": 10}, ax=ax,
)
ax.set_title(
"Clustered Correlation Matrix\n(Diamonds, Ward Linkage)",
fontsize=16, fontweight="bold", pad=15)
ax.tick_params(axis="both", labelsize=11, length=0)
plt.setp(ax.get_xticklabels(), rotation=35, ha="right")
plt.setp(ax.get_yticklabels(), rotation=0)
plt.tight_layout()
plt.show()
pic
Рецепт 8: Heatmap с кластеризацией

9. Heatmap + side bar (LogNorm + адаптивный цвет текста)
Тепловая карта с LogNorm (логарифмическая шкала для данных с большим разбросом) и боковой bar-панелью с тоталами. Ключевой трюк — проверка яркости фона для выбора цвета текста: brightness = r*0.299 + g*0.587 + b*0.114. Это гарантирует читаемость чисел в любой ячейке. Студент из топ-2.
До:
table = pd.crosstab(diamonds["clarity"], diamonds["cut"])
sns.heatmap(table, annot=True, fmt="d", cmap="Reds")
После:

Показать код

from matplotlib.colors import LogNorm
table = pd.crosstab(diamonds["clarity"], diamonds["cut"])
table = table[["Fair", "Good", "Very Good", "Premium", "Ideal"]]
table = table.loc[table.sum(axis=1).sort_values(
ascending=False).index]
row_totals = table.sum(axis=1)
fig, axes = plt.subplots(
1, 2,
gridspec_kw={"width_ratios": [4, 1.2], "wspace": 0.02})
hm = sns.heatmap(
table, cmap="Reds",
norm=LogNorm(vmin=max(table.min().min(), 1),
vmax=table.max().max()),
linewidths=0.5, linecolor="white",
cbar=False, annot=False, ax=axes[0],
)
mesh = hm.collections[0]
cmap, norm = mesh.cmap, mesh.norm
# Адаптивный цвет текста
for i in range(table.shape[0]):
for j in range(table.shape[1]):
value = table.iloc[i, j]
rgba = cmap(norm(value))
brightness = (rgba[0]*0.299 + rgba[1]*0.587
+ rgba[2]*0.114)
text_color = ("white" if brightness < 0.5
else "black")
pct = value / row_totals.iloc * 100
axes[0].text(j+0.5, i+0.4, f"{value:,}",
ha="center", va="center", fontsize=11,
fontweight="bold", color=text_color)
axes[0].text(j+0.5, i+0.72, f"{pct:.1f}%",
ha="center", va="center", fontsize=8,
color=text_color)
axes[0].set_xticklabels(
axes[0].get_xticklabels(), rotation=30, ha="right")
# Side bar: row totals
y_pos = np.arange(len(row_totals))
axes[1].barh(y_pos, row_totals.values, color=RED,
height=0.8, edgecolor="white", linewidth=0.5)
axes[1].set_ylim(len(row_totals)-0.5, -0.5)
for i, v in enumerate(row_totals.values):
axes[1].text(v*0.5, i, f"{v:,}", ha="center",
va="center", fontsize=10, color="white",
fontweight="bold")
axes[1].set_yticks([])
axes[1].set_xticks([])
for spine in axes[1].spines.values():
spine.set_visible(False)
axes[1].set_title("Total", fontsize=11,
fontweight="bold", pad=8)
axes[0].set_title(
"Diamond Count by Clarity × Cut (LogNorm)",
fontsize=14, fontweight="bold", pad=15)
plt.tight_layout()
plt.show()
pic
Рецепт 9: Heatmap + side bar + LogNorm
-
Категория 4: Scatter и Bubble

10. Bubble chart с median crosshairs
Scatter plot, где размер пузырька = третья переменная, а перекрестие медианы (axvline + axhline) делит поле на четыре квадранта. Crosshairs определяют «выше/ниже медианы» по обоим измерениям — это скоринг в один взгляд. Студент из топ-4.
До:
ax.scatter(agg["avg_carat"], agg["avg_price"],
s=agg["bubble_size"], alpha=0.5)
После:

Показать код

import matplotlib.ticker as mticker
# Агрегация по cut + color
agg = (diamonds.groupby(["cut", "color"])
.agg(avg_price=("price", "mean"),
avg_carat=("carat", "mean"),
count=("price", "count"))
.reset_index())
agg["bubble_size"] = agg["count"] / agg["count"].max() * 600
fig, ax = plt.subplots(figsize=(13, 7))
cuts = agg["cut"].unique()
cut_colors = dict(zip(sorted(cuts), CAT_COLORS[:len(cuts)]))
for cut in sorted(cuts):
subset = agg[agg["cut"] == cut]
ax.scatter(
subset["avg_carat"], subset["avg_price"],
s=subset["bubble_size"], c=cut_colors[cut],
alpha=0.7, edgecolors="#1F2937",
linewidths=0.6, label=cut,
)
# Median crosshairs
med_carat = agg["avg_carat"].median()
med_price = agg["avg_price"].median()
ax.axvline(med_carat, color="#6B7280", ls="--", lw=1.2, alpha=0.7)
ax.axhline(med_price, color="#6B7280", ls="--", lw=1.2, alpha=0.7)
# Квадранты
for x, y, label in [
(med_carat*0.55, med_price*1.6, "Low carat\nHigh price"),
(med_carat*1.55, med_price*1.6, "High carat\nHigh price"),
(med_carat*0.55, med_price*0.4, "Low carat\nLow price"),
(med_carat*1.55, med_price*0.4, "High carat\nLow price"),
]:
ax.text(x, y, label, ha="center", fontsize=10,
color="#888", style="italic")
ax.set_title(
"Diamond Groups: Avg Price vs Avg Carat\n"
"(Bubble size = number of diamonds)",
fontsize=16, fontweight="bold", pad=15)
ax.set_xlabel("Average Carat", fontsize=13)
ax.set_ylabel("Average Price ($)", fontsize=13)
ax.yaxis.set_major_formatter(
mticker.FuncFormatter(lambda x, _: f"${x:,.0f}"))
ax.legend(title="Cut", bbox_to_anchor=(1.02, 1),
loc="upper left", fontsize=10)
ax.grid(alpha=0.2, ls="--")
plt.tight_layout()
plt.show()
pic
Рецепт 10: Bubble chart с crosshairs

11. Scatter + polynomial trend + аннотации
Scatter с полиномиальной линией тренда (np.polyfit + np.polyval) и аннотациями выбросов. Полином показывает форму зависимости лучше прямой, а аннотации привязывают точки к реальным событиям. Студент из топ-3.
До:
ax.scatter(sample["carat"], sample["price"], alpha=0.3, s=10)
После:

Показать код

sample = diamonds[["carat", "price", "cut"]].sample(
3000, random_state=42)
fig, ax = plt.subplots(figsize=(12, 7))
cuts = ["Ideal", "Premium", "Very Good", "Good", "Fair"]
colors = [BLUE, RED, GREEN, ORANGE, PURPLE]
for cut, color in zip(cuts, colors):
subset = sample[sample["cut"] == cut]
ax.scatter(subset["carat"], subset["price"],
c=color, s=15, alpha=0.4,
edgecolors="none", label=cut)
# Полиномиальный тренд (степень 3)
x = sample["carat"].values
y = sample["price"].values
z = np.polyfit(x, y, 3)
x_line = np.linspace(x.min(), x.max(), 200)
y_line = np.polyval(z, x_line)
ax.plot(x_line, y_line, color="#333", lw=2.5, ls="--",
alpha=0.6, label="Polynomial trend (deg=3)", zorder=5)
# Аннотация: самый дорогой
top = sample.nlargest(1, "price").iloc[0]
ax.annotate(
f"${top['price']:,}\n{top['carat']:.1f} ct, {top['cut']}",
xy=(top["carat"], top["price"]),
xytext=(-100, -30), textcoords="offset points",
fontsize=9, color="#C0392B",
arrowprops=dict(arrowstyle="->", color="#C0392B", lw=1.2),
bbox=dict(boxstyle="round,pad=0.3",
facecolor="#FADBD8", alpha=0.9),
)
# Аннотация: дешёвый крупный
cheap_big = sample[(sample["carat"] > 2.5) &
(sample["price"] < 5000)]
if len(cheap_big) > 0:
pt = cheap_big.iloc[0]
ax.annotate(
f"Unusual: {pt['carat']:.1f} ct\n"
f"for ${pt['price']:,}",
xy=(pt["carat"], pt["price"]),
xytext=(30, 20), textcoords="offset points",
fontsize=9, color="#7D3C98",
arrowprops=dict(arrowstyle="->",
color="#7D3C98", lw=1.2),
bbox=dict(boxstyle="round,pad=0.3",
facecolor="#E8DAEF", alpha=0.9),
)
ax.set_title("Diamond Price vs Carat with Polynomial Trend",
fontsize=16, fontweight="bold", pad=15)
ax.set_xlabel("Carat", fontsize=13)
ax.set_ylabel("Price ($)", fontsize=13)
ax.legend(loc="upper left", fontsize=9, framealpha=0.9)
plt.tight_layout()
plt.show()
pic
Рецепт 11: Scatter + polynomial trend
-
Категория 5: Многомерность

12. Radar chart (полярная диаграмма)
Полярная диаграмма для сравнения нескольких категорий по многим осям. Radar chart позволяет сравнить 5–10 параметров одновременно — это невозможно на scatter или heatmap. Студент из топ-5 использовал его для профилей сортов вина. Ключевой момент — это нормализация (x — min) / (max — min) перед отрисовкой.
До:
# Line plot с несколькими линиями
means.T.plot()
После:

Показать код

features = ["carat", "depth", "table", "price", "x", "y"]
labels = ["Carat", "Depth", "Table", "Price",
"Length (x)", "Width (y)"]
cuts = ["Ideal", "Premium", "Good"]
colors = [BLUE, RED, GREEN]
# Нормализация
df_norm = diamonds[features + ["cut"]].copy()
for col in features:
mn, mx = df_norm[col].min(), df_norm[col].max()
df_norm[col] = (df_norm[col] - mn) / (mx - mn)
means = df_norm.groupby("cut")[features].mean()
N = len(features)
angles = np.linspace(0, 2*np.pi, N,
endpoint=False).tolist()
angles += angles[:1] # замыкание
fig, ax = plt.subplots(figsize=(9, 9),
subplot_kw=dict(polar=True))
fig.patch.set_facecolor("white")
ax.set_facecolor("#FAFAFA")
ax.set_rlabel_position(30)
ax.set_yticks([0.2, 0.4, 0.6, 0.8])
ax.set_yticklabels(["0.2", "0.4", "0.6", "0.8"],
fontsize=8, color="#888")
ax.set_ylim(0, 1)
ax.grid(color="#DDDDDD", linewidth=0.6)
ax.spines["polar"].set_color("#CCCCCC")
for i, cut in enumerate(cuts):
values = means.loc[cut].tolist()
values += values[:1]
ax.fill(angles, values, color=colors, alpha=0.12)
ax.plot(angles, values, color=colors, linewidth=2.5,
label=cut, marker="o", markersize=5,
markerfacecolor="white",
markeredgecolor=colors, markeredgewidth=1.8)
ax.set_xticks(angles[:-1])
ax.set_xticklabels(labels, fontsize=10, fontweight="bold")
for label, angle in zip(ax.get_xticklabels(), angles[:-1]):
if angle in (0, np.pi):
label.set_horizontalalignment("center")
elif 0 < angle < np.pi:
label.set_horizontalalignment("left")
else:
label.set_horizontalalignment("right")
ax.set_title(
"Diamond Cut Profiles\n(Normalized Feature Means)",
fontsize=16, fontweight="bold", pad=30)
ax.legend(loc="upper right",
bbox_to_anchor=(1.25, 1.1),
fontsize=11, framealpha=0.9)
plt.tight_layout()
plt.show()
pic
Рецепт 12: Radar chart

13. Parallel coordinates + CI bands
Параллельные координаты с доверительными интервалами через fill_between. fill_betweenвокруг каждой линии показывает неопределенность — размытый профиль вместо тонкой линии. Студент из топ-5 использовал это для сравнения классов вина по всем признакам датасета.
До:
for clarity in clarity_order:
vals = [agg.loc[clarity, f"{f}_mean"] for f in features]
ax.plot(range(len(features)), vals, marker="o", label=clarity)
После:

Показать код

features = ["carat", "depth", "table", "price", "x"]
clarity_order = ["IF", "VVS1", "VS2", "SI2", "I1"]
agg = diamonds.groupby("clarity")[features].agg(["mean", "sem"])
agg.columns = [f"{f}_{s}" for f, s in agg.columns]
agg = agg.loc[clarity_order]
# Нормализация
for feat in features:
mn, mx = diamonds[feat].min(), diamonds[feat].max()
agg[f"{feat}_norm"] = ((agg[f"{feat}_mean"] - mn)
/ (mx - mn))
agg[f"{feat}_sem_norm"] = agg[f"{feat}_sem"] / (mx - mn)
fig, ax = plt.subplots(figsize=(13, 6))
show_colors = CAT_COLORS[:len(clarity_order)]
for i, clarity in enumerate(clarity_order):
means = [agg.loc[clarity, f"{f}_norm"]
for f in features]
sems = [agg.loc[clarity, f"{f}_sem_norm"]
for f in features]
x_pos = range(len(features))
ax.plot(x_pos, means, marker="o", lw=2.5, ms=7,
color=show_colors, label=clarity, zorder=3)
ax.fill_between(
x_pos,
[m - 1.96*s for m, s in zip(means, sems)],
[m + 1.96*s for m, s in zip(means, sems)],
color=show_colors, alpha=0.12)
ax.set_xticks(range(len(features)))
ax.set_xticklabels(
["Carat", "Depth", "Table", "Price", "Length (x)"],
fontsize=11)
ax.set_ylabel("Normalized Value", fontsize=13)
ax.set_title("Diamond Feature Profiles by Clarity (95% CI)",
fontsize=16, fontweight="bold", pad=15)
ax.legend(title="Clarity", fontsize=10,
title_fontsize=11, loc="upper right")
plt.tight_layout()
plt.show()
pic
Рецепт 13: Parallel coordinates + CI
-
Категория 6: Стилизация

14. Dark theme с кастомной палитрой
Темная тема для matplotlib — кастомный фон, неоновые цвета, мягкая сетка. Темный фон акцентирует данные, визуал выглядит дороже и профессиональнее. Студенты из топ-6 и топ-10 использовали этот стиль. Для этого нужно явно установить facecolor для figure и axes + цветной акцент.
До:
ax.bar(cut_price.index, cut_price.values, color="steelblue")
После:

Показать код

cut_price = diamonds.groupby("cut")["price"].mean().sort_values()
DARK_BG = "#1a1a2e"
ACCENT = "#5eead4"
TEXT_COLOR = "#e2e8f0"
GRID_COLOR = "#475569"
fig, ax = plt.subplots(figsize=(12, 6), facecolor=DARK_BG)
ax.set_facecolor(DARK_BG)
bars = ax.barh(cut_price.index, cut_price.values / 1000,
color=ACCENT, alpha=0.85, edgecolor="none",
height=0.6)
for bar, val in zip(bars, cut_price.values):
ax.text(bar.get_width() + 0.15,
bar.get_y() + bar.get_height() / 2,
f"${val:,.0f}", va="center", fontsize=12,
color=ACCENT, fontweight="bold")
ax.set_xlabel("Average Price (thousands $)",
color=TEXT_COLOR, fontsize=13)
ax.set_ylabel("")
ax.set_title("Average Diamond Price by Cut Quality",
color="#f8fafc", fontsize=16,
fontweight="bold", pad=15)
ax.tick_params(axis="both", colors="#cbd5e1", labelsize=11)
ax.spines["bottom"].set_color(GRID_COLOR)
ax.spines["left"].set_color(GRID_COLOR)
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
ax.xaxis.grid(True, alpha=0.15, color="#94a3b8")
ax.set_axisbelow(True)
plt.tight_layout()
plt.savefig("dark_theme.png", facecolor=DARK_BG)
plt.show()
pic
Рецепт 14: Dark theme

15. Cyberpunk (mplcyberpunk, glow effect)
Киберпанк-тема с glow-эффектом и gradient fill через библиотеку mplcyberpunk. Glow effect создает эффект «неонового свечения» линий, а add_gradient_fill добавляет градиентную заливку. Бонусный рецепт из топ-7 — для презентаций и обложек.
pip install mplcyberpunk
До:
ax.hist(prices, bins=50, color="steelblue")
После:

Показать код

import mplcyberpunk
prices = diamonds["price"]
plt.style.use("cyberpunk")
fig, ax = plt.subplots(figsize=(12, 6))
ax.hist(prices, bins=80, color="#00ffff", alpha=0.8)
# Glow + gradient
mplcyberpunk.make_lines_glow()
mplcyberpunk.add_gradient_fill(alpha_gradientglow=0.5)
ax.set_xlabel("Price ($)", fontsize=13, color="white")
ax.set_ylabel("Count", fontsize=13, color="white")
ax.set_title(
"Diamond Price Distribution — Cyberpunk Edition",
fontsize=16, fontweight="bold",
color="white", pad=15)
ax.tick_params(colors="#aaaaaa")
plt.tight_layout()
plt.show()
# Сброс стиля
plt.style.use("default")
pic
Рецепт 15: Cyberpunk

Четыре вывода, которые повторялись в топ-работах:
  • Контекст важнее данных. Победители не показывают распределение данных. Они добавляют фоновые зоны (axvspan), аннотации событий, inline-проценты. График, который рассказывает историю, всегда побеждает график, который просто показывает данные.
  • Адаптивность в деталях. Перекраска bins по условию, выбор цвета текста по яркости фона, медианы с path_effects — именно эти микрорешения позволяют читать стандартный график без усилий.
  • Уплотнение без перегрузки. Inset axes, twin axis, side bar — приемы, которые добавляют второй слой информации, не раздувая размер основной figure графика.
  • Стиль — это важный инструмент. Кастомные палитры, единый rcParams, осознанное использование dark theme — даже простой bar chart выигрывает от продуманного стиля.
Все рецепты из статьи воспроизводимы: данные — встроенный seaborn.load_dataset("diamonds"), каждый блок кода запускается автономно. Копируйте и адаптируйте под свои данные.

Попробуйте сами
Графики из этой статьи - результат домашних заданий студентов МТС Школы Аналитиков Данных. Готовим аналитиков и ML-разработчиков, которые не просто строят дашборды, а решают бизнес-задачи: от A/B-тестирования до ML-пайплайнов. Уже осенью откроем набор на новый поток. Приходите!-Источник
 
Loading...
Error