Каждый раз, когда вы делаете 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()
Рецепт 1: Histogram + KDE + axvspan
Рецепт 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()
Рецепт 2: Dual KDE с медианами
Рецепт 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()
Рецепт 3: KDE + inset scatter
Рецепт 3: KDE + inset scatter

4. Histogram с программной перекраской bins

Гистограмма, где каждый столбец можно перекрасить по условию. Студент из топ-4 использовал это для выделения «зоны стресса» в распределении доходностей. Ключевой прием — patches[i].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[i] >= threshold:
        patches[i].set_facecolor(RED)
        patches[i].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()
Рецепт 4: Histogram с перекраской bins
Рецепт 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()
Рецепт 5: Line + CI + recession zones
Рецепт 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()
Рецепт 6: Twin-axis + аннотации
Рецепт 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()
Рецепт 7: Stacked bar + inline проценты
Рецепт 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()
Рецепт 8: Heatmap с кластеризацией
Рецепт 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[i] * 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()
Рецепт 9: Heatmap + side bar + LogNorm
Рецепт 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()
Рецепт 10: Bubble chart с crosshairs
Рецепт 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()
Рецепт 11: Scatter + polynomial trend
Рецепт 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[i], alpha=0.12)
    ax.plot(angles, values, color=colors[i], linewidth=2.5,
            label=cut, marker="o", markersize=5,
            markerfacecolor="white",
            markeredgecolor=colors[i], 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()
Рецепт 12: Radar chart
Рецепт 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[i], 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[i], 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()
Рецепт 13: Parallel coordinates + CI
Рецепт 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()
Рецепт 14: Dark theme
Рецепт 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")
Рецепт 15: Cyberpunk
Рецепт 15: Cyberpunk

Четыре вывода, которые повторялись в топ-работах:

  1. Контекст важнее данных. Победители не показывают распределение данных. Они добавляют фоновые зоны (axvspan), аннотации событий, inline-проценты. График, который рассказывает историю, всегда побеждает график, который просто показывает данные.

  2. Адаптивность в деталях. Перекраска bins по условию, выбор цвета текста по яркости фона, медианы с path_effects — именно эти микрорешения позволяют читать стандартный график без усилий.

  3. Уплотнение без перегрузки. Inset axes, twin axis, side bar — приемы, которые добавляют второй слой информации, не раздувая размер основной figure графика.

  4. Стиль — это важный инструмент. Кастомные палитры, единый rcParams, осознанное использование dark theme — даже простой bar chart выигрывает от продуманного стиля.

Все рецепты из статьи воспроизводимы: данные — встроенный seaborn.load_dataset("diamonds"), каждый блок кода запускается автономно. Копируйте и адаптируйте под свои данные.

Попробуйте сами

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