
Каждый раз, когда вы делаете EDA, вы стоите перед выбором: нарисовать быстрый df.plot() — или потратить 10–20 минут на оформление, которое скажет что-то важное о ваших данных. В нашем курсе в Школе аналитиков данных МТС мы проверили этот выбор экспериментально: 44 студента сделали 220 EDA-графиков, мы получили 6000 попарных сравнений и проанализировали через CrowdBT (кстати, уже второй раз!). Результат: победители используют не больше данных, а больше контекста. Фоновые зоны, медианы, адаптивная перекраска, inset-axes — именно эти приемы отличают скучный график от графика, который меняет решения.
В статье — cookbook из 15 рецептов с кодом «до» и «после» на Python. Данные — встроенный seaborn.load_dataset("diamonds"), копируйте, запускайте, вдохновляйтесь.
Содержание
Категория 1. Распределения
Категория 2. Временные ряды
Категория 3. Корреляция и матрицы
Категория 4. Scatter и Bubble
Категория 5. Многомерность
Категория 6. Стилизация
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()

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()

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()

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()

Категория 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()

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()

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()

Категория 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()

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()

Категория 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()

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()

Категория 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()

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()

Категория 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()

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")

Четыре вывода, которые повторялись в топ-работах:
Контекст важнее данных. Победители не показывают распределение данных. Они добавляют фоновые зоны (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-пайплайнов. Уже осенью откроем набор на новый поток. Приходите!