EDA-шаблон с кодом: разведочный анализ по-продуктовому

Кратко

EDA — это не preprocessing перед моделью, а первый разговор с данными. Пока ты не знаешь форму распределения, механизм пропусков и сегменты с аномальным поведением, любая метрика, которую ты посчитаешь, будет догадкой. Ниже — шаблон, который я запускаю в каждом новом проекте: загрузка, чистка, распределения, взаимосвязи, время, гипотезы. Код воспроизводимый, с фиксированным seed, и привязан к продуктовым вопросам на каждом шаге.

Загрузка и схема данных

Первый шаг — не график, а понимание размерности и типов. Загружаю данные, фиксирую seed для воспроизводимости и сразу описываю переменные в отдельном словаре. Словарь экономит час, когда через неделю возвращаешься к датасету.

import warnings
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from scipy import stats

warnings.filterwarnings("ignore")
sns.set(style="whitegrid")
plt.rcParams["figure.figsize"] = (10, 6)

RNG = np.random.default_rng(42)  # детерминизм для синтетики и бутстрапа

# Загрузка. Для своих данных: pd.read_csv("data/events.csv", sep="\t", parse_dates=["ts"])
df = sns.load_dataset("titanic").copy()
print("Размерность:", df.shape)
df.head(10)

Сразу после загрузки — info() и describe(include="all"). Первый показывает типы и пропуски, второй — сводную статистику по всем колонкам, включая категориальные.

df.info()
df.describe(include="all").T

Параллельно составляю словарь переменных. Это может быть markdown-блок в ноутбуке или отдельный YAML-файл рядом с данными:

| Переменная   | Тип      | Описание                                  |
|--------------|----------|-------------------------------------------|
| user_id      | int      | Уникальный ID пользователя                 |
| event        | str      | Название события                          |
| ts           | datetime | Timestamp события (UTC)                   |
| session_id   | str      | ID сессии                                  |
| revenue      | float    | Выручка в USD, 0 для бесплатных событий   |
| platform     | str      | Платформа: web / ios / android            |

Продуктовый вопрос этого этапа: какие события мы вообще ожидаем увидеть, и совпадает ли фактический список событий с тем, что отправляется в продакшене. Если в выгрузке есть события, которых нет в каталоге, — либо баг импорта, либо новое событие, которое забыли задокументировать. В воспроизводимых пайплайнах я уже писал, почему каталог событий — это единый источник правды.

Чистка: пропуски, дубликаты, типы

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

print(f"Полных дубликатов: {df.duplicated().sum()}")
df = df.drop_duplicates().reset_index(drop=True)

Пропуски оцениваю в процентах от общего объёма и сортирую по убыванию. Цель — не просто найти, а понять механизм: MCAR (полностью случайные), MAR (зависят от других переменных) или MNAR (зависят от самого пропущенного значения). Это влияет на выбор стратегии.

missing = df.isnull().sum().sort_values(ascending=False)
missing_pct = 100 * missing / len(df)
missing_df = pd.DataFrame({"count": missing, "pct": missing_pct})
print(missing_df[missing_df["count"] > 0])

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

numeric_cols = df.select_dtypes(include=[np.number]).columns
categorical_cols = df.select_dtypes(include=["object", "category"]).columns

# Числовые — медианой
df[numeric_cols] = df[numeric_cols].fillna(df[numeric_cols].median())

# Категориальные — модой, если пропуски есть
for col in categorical_cols:
    if df[col].isnull().any():
        df[col] = df[col].fillna(df[col].mode()[0])

Типы привожу явно: даты — через parse_dates при загрузке, категориальные с малым числом значений — через astype("category") (экономит память и ускоряет groupby), числовые — через pd.to_numeric(..., errors="coerce"), чтобы невалидные значения стали NaN, а не ломали весь столбец.

df["embarked"] = df["embarked"].astype("category")
df["age"] = pd.to_numeric(df["age"], errors="coerce")

Продуктовый вопрос: не являются ли пропуски в ключевом поле (например, revenue для платящих пользователей) симптомом бага трекинга. Пропуск в age на Титанике — это MAR, а пропуск в revenue у события purchase — почти всегда баг.

Univariate: распределения и выбросы

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

df[numeric_cols].hist(bins=30, figsize=(12, 8))
plt.suptitle("Распределение числовых переменных")
plt.tight_layout()
plt.show()

Для отдельной ключевой метрики — гистограмма с KDE и boxplot рядом. Гистограмма показывает форму, boxplot — выбросы и квартили.

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5))

sns.histplot(df["fare"], bins=30, kde=True, ax=ax1, color="steelblue")
ax1.set_title("Распределение стоимости билета")
ax1.set_xlabel("Fare")
ax1.set_ylabel("Частота")

sns.boxplot(x=df["fare"], ax=ax2, color="steelblue")
ax2.set_title("Boxplot: выбросы в fare")

plt.tight_layout()
plt.show()

Категориальные переменные — через value_counts(normalize=True). Это сразу показывает дисбаланс классов и редкие категории, которые стоит объединить в «other».

for col in categorical_cols:
    print(f"\n{col} ({df[col].nunique()} уникальных)")
    print(df[col].value_counts(normalize=True).head(10))

Выбросы ищу через IQR. Но не удаляю автоматически — сначала проверяю, не являются ли они редкими, но важными событиями. Выброс в fare > 500 на Титанике — это реальная цена за люкс, а не ошибка ввода. А выброс в session_duration > 3600 секунд — почти всегда технический артефакт (вкладка открыта ночью).

def iqr_bounds(data, column, k=1.5):
    q1, q3 = data[column].quantile([0.25, 0.75])
    iqr = q3 - q1
    return q1 - k * iqr, q3 + k * iqr

lower, upper = iqr_bounds(df, "fare")
outliers = df[(df["fare"] < lower) | (df["fare"] > upper)]
print(f"Выбросов в fare: {len(outliers)} ({100 * len(outliers) / len(df):.1f}%)")
print(f"Границы: [{lower:.2f}, {upper:.2f}]")

Продуктовый вопрос: какова здоровая доля тяжёлых пользователей (power users). В retention-анализе хвост распределения — это часто самые лояльные клиенты, и удалять их как выбросы — значит потерять самый ценный сегмент.

Bivariate: корреляции и сегменты

Корреляционная матрица — это первый взгляд на взаимосвязи. Использую только числовые колонки, annot=True, center=0, чтобы цвета читались интуитивно.

corr = df[numeric_cols].corr()
plt.figure(figsize=(10, 8))
sns.heatmap(corr, annot=True, fmt=".2f", cmap="coolwarm", center=0, linewidths=0.5)
plt.title("Корреляционная матрица")
plt.tight_layout()
plt.show()

Корреляция Пирсона ловит только линейные связи. Для нелинейных связей и ранговых связей полезно посмотреть на Спирмена или Kendall. Если корреляция > 0.8 между двумя признаками — один из них, скорее всего, избыточен для модели.

# Спирмен для ранговых связей (устойчив к выбросам)
spearman = df[["age", "fare", "pclass"]].corr(method="spearman")
print(spearman)

Для связи категориальная ↔ числовая — boxplot по группам. Это сразу показывает, отличается ли распределение метрики между сегментами.

plt.figure(figsize=(10, 5))
sns.boxplot(x="class", y="fare", data=df, palette="viridis")
plt.title("Стоимость билета по классу")
plt.tight_layout()
plt.show()

Для попарного анализа нескольких переменных — pairplot, но осторожно: на больших датасетах работает медленно. Выбираю 4–5 ключевых переменных и раскрашиваю по целевой.

sns.pairplot(df[["survived", "age", "fare", "pclass"]].dropna(),
             hue="survived", palette="Set1", diag_kind="kde")
plt.suptitle("Попарные зависимости", y=1.02)
plt.show()

Продуктовый вопрос: какие сегменты показывают аномальную метрику. Мобильная конверсия на 40% ниже десктопной — это не корреляция, это гипотеза для отдельного расследования и, возможно, для A/B-теста.

Время и тренды

Если в данных есть timestamp, обязательно смотрю временные ряды. Агрегирую по дню, неделе, месяцу — в зависимости от горизонта данных. Проверяю сезонность (день недели, час), тренды и аномальные дни.

# Предполагаем, что есть колонка ts типа datetime
# df["ts"] = pd.to_datetime(df["ts"])
# df["date"] = df["ts"].dt.date
# daily = df.groupby("date").size().rename("events").reset_index()

# Демо: синтетический временной ряд
dates = pd.date_range("2026-01-01", periods=90, freq="D")
events = RNG.poisson(200, 90) + np.arange(90) * 1.5  # тренд + дневной шум
daily = pd.DataFrame({"date": dates, "events": events})

plt.figure(figsize=(12, 5))
plt.plot(daily["date"], daily["events"], color="steelblue")
plt.title("События по дням")
plt.xlabel("Дата")
plt.ylabel("Количество событий")
plt.tight_layout()
plt.show()

Для выявления аномальных дней — скользящее среднее и отклонение от него.

window = 7
daily["ma"] = daily["events"].rolling(window=window, center=True).mean()
daily["resid"] = daily["events"] - daily["ma"]
threshold = daily["resid"].std() * 3
anomalies = daily[daily["resid"].abs() > threshold]
print(f"Аномальных дней: {len(anomalies)}")
print(anomalies)

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

Продуктовые гипотезы

Гипотезы формулирую до того, как посмотрел на все графики — иначе это data dredging. Минимум 2–3 проверяемые гипотезы, каждая с нулевой и альтернативной формулировкой.

# H1: возраст выживших отличается от возраста погибших
# H0: средний возраст в группах равен
survived = df.loc[df["survived"] == 1, "age"].dropna()
died = df.loc[df["survived"] == 0, "age"].dropna()
t_stat, p_value = stats.ttest_ind(survived, died, equal_var=False)
print(f"t={t_stat:.3f}, p={p_value:.4f}")
if p_value < 0.05:
    print("Различия значимы на уровне 0.05")
else:
    print("Нет оснований отвергнуть H0")

Для категориальной ↔ категориальной — хи-квадрат. Проверяю, зависит ли доля выживших от класса или порта посадки.

from scipy.stats import chi2_contingency

table = pd.crosstab(df["pclass"], df["survived"])
chi2, p, dof, expected = chi2_contingency(table)
print(f"chi2={chi2:.3f}, p={p:.6f}")

Для продуктовых гипотез важно не только p-value, но и размер эффекта. Разница в 0.1 п.п. конверсии может быть статистически значимой на миллионе пользователей и практически бессмысленной. Поэтому рядом с p-value всегда считаю разницу средних или относительный эффект.

diff = survived.mean() - died.mean()
rel_effect = diff / died.mean()
print(f"Разница средних: {diff:.2f} лет")
print(f"Относительный эффект: {rel_effect:+.1%}")

Продуктовый вопрос: какие из найденных различий достаточно велики, чтобы ради них запускать A/B-тест или менять продукт. Статистическая значимость без практического — это academic window-dressing.

Чек-лист перед завершением

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

  • Данные загружены, размерность зафиксирована, словарь переменных составлен.
  • Типы приведены: даты — datetime, категориальные — category, числа — numeric.
  • Дубликаты посчитаны и обработаны (удалены или объяснены).
  • Пропуски найдены, механизм оценен (MCAR / MAR / MNAR), стратегия выбрана осознанно.
  • Распределения построены, форма оценена, выбросы найдены и объяснены (не просто удалены).
  • Категориальные переменные изучены, редкие категории объединены или отмечены.
  • Корреляции посчитаны, мультиколлинеарность отмечена.
  • Временной ряд проверен, аномальные дни объяснены.
  • Сегменты с аномальным поведением зафиксированы.
  • 2–3 гипотезы сформулированы и проверены, размер эффекта посчитан рядом с p-value.
  • Выводы и следующие шаги записаны в таблицу: наблюдение → действие → ответственный.

Выводы

  • EDA начинается с вопроса, а не с графика. Без бизнес-задачи или гипотезы разведка превращается в data dredging — красивые картинки без выводов.
  • Пропуски — это не шум, а сигнал. Понимать механизм (MCAR / MAR / MNAR) важнее, чем просто заполнить медианой; dropna() без проверки искажает выводы.
  • Выбросы — это не мусор. Сначала проверь, не редкое ли это, но важное событие (power user, премиум-клиент), и только потом решай, удалять или оставить.
  • Корреляция ≠ причинность. Heatmap показывает связь, не её направление. Причинность проверяется экспериментом, а не матрицей Пирсона.
  • Сегментация — половина ценности EDA. Агрегаты скрывают аномалии: мобильная конверсия на 40% ниже десктопной видна только в разрезе по платформе.
  • p-value без размера эффекта — это половина результата. Статистическая значимость на большом датасете есть почти всегда; вопрос в том, стоит ли ради этого менять продукт.

Смотрите также

← К статьям