Коротко: Разведочный анализ данных (EDA) в Python — это набор шагов, которые вы прогоняете на любом новом датасете, чтобы понять его структуру, качество и внутренние зависимости до построения моделей и дашбордов. Минимальный пайплайн: загрузить данные, посмотреть info() и describe(), найти пропуски и дубли, изучить распределения и выбросы, посчитать корреляции, разобрать категориальные признаки и выписать список гипотез. Ниже — единый рабочий ноутбук-шаблон на pandas, который переиспользуется на любом наборе данных.
Что такое разведочный анализ данных и зачем он нужен?
Разведочный анализ данных (Exploratory Data Analysis, EDA) — это первичное «прощупывание» датасета до того, как вы начнёте считать метрики, строить модели или готовить отчёт. Термин ввёл статистик Джон Тьюки ещё в 1970-х: идея в том, чтобы сначала посмотреть на данные глазами и через простые графики, а не сразу гнать их в сложную статистику.
На практике EDA отвечает на четыре вопроса:
- Что вообще в данных? Сколько строк и колонок, какие типы, что означает каждый признак.
- Насколько данные грязные? Пропуски, дубли, аномальные значения, кривые типы (число лежит как строка).
- Как распределены значения? Симметрия, хвосты, выбросы, дисбаланс классов.
- Что с чем связано? Корреляции числовых признаков, зависимости между категориями и целевой переменной.
Пропустить этот этап — значит построить воронку на дублирующихся заказах или посчитать средний чек, который на 40% состоит из тестовых транзакций по 999999 рублей. Аккуратный EDA экономит часы отладки на следующих этапах и часто сам по себе рождает половину инсайтов. Если вы только осваиваете pandas, начните с базового курса pandas с нуля — дальше пойдёт легче.
С чего начать EDA: загрузка, info и describe?
Возьмём типичный грязный датасет — выгрузку заказов интернет-магазина. Первые три команды дают 80% первичного понимания.
import pandas as pd
import numpy as np
df = pd.read_csv("orders.csv")
print(df.shape) # (48213, 10) — строки и колонки
print(df.head(3)) # глазами смотрим на реальные значения
df.info() # типы, non-null, память
shape сразу говорит масштаб. head() показывает, что реально лежит в ячейках — это важнее любой документации, потому что реальность всегда грязнее описания. info() — самый недооценённый метод: он выводит типы и число заполненных значений по каждой колонке.
Column Non-Null Count Dtype
order_id 48213 non-null int64
user_id 48213 non-null int64
order_date 48213 non-null object <- дата лежит строкой
category 46901 non-null object <- 1312 пропусков
price 48213 non-null object <- цена как строка, подозрительно
quantity 48213 non-null int64
city 45002 non-null object
payment_method 48213 non-null object
age 41120 non-null float64
is_returned 48213 non-null int64
Два красных флага сразу: order_date и price имеют тип object, хотя должны быть датой и числом. Это классика — где-то в колонке цены затесались значения вроде "1 200" или "нет данных", из-за которых pandas не смог привести всё к числу.
Дальше — describe() по числовым колонкам:
df["price"] = pd.to_numeric(df["price"].str.replace(" ", ""), errors="coerce")
df["order_date"] = pd.to_datetime(df["order_date"], errors="coerce")
df.describe()
| Признак | count | mean | std | min | 50% | max |
|---|---|---|---|---|---|---|
| price | 47980 | 3421 | 15602 | -50 | 1990 | 999999 |
| quantity | 48213 | 2.1 | 3.8 | 0 | 1 | 500 |
| age | 41120 | 34.7 | 12.1 | 0 | 33 | 214 |
Здесь видно почти всё: отрицательная цена (min = -50), явный мусорный максимум (999999), возраст 0 и 214 лет, а std цены в 4 раза больше среднего — верный признак тяжёлого правого хвоста. Про describe() подробнее разбирал отдельный разбор — первый взгляд на данные: info, describe, index.
Как найти пропуски и дубли в данных?
Пропуски и дубли — две самые частые причины кривых цифр в отчётах. Начинаем с карты пропусков:
na = df.isna().sum().sort_values(ascending=False)
na_pct = (df.isna().mean() * 100).round(1)
missing = pd.DataFrame({"count": na, "pct": na_pct})
print(missing[missing["count"] > 0])
count pct
age 7093 14.7
city 3211 6.7
category 1312 2.7
price 233 0.5
Дальше — решение по каждой колонке, и оно зависит от смысла, а не от техники:
- age (14.7%) — много, но не критично. Для числового признака часто заполняют медианой по сегменту, но сначала стоит проверить, не пропущен ли возраст систематически (например, только у гостевых заказов) — это уже гипотеза.
- category (2.7%) — заполняем отдельной категорией
"unknown", чтобы не терять строки при группировке. - price (0.5%) — это те 233 строки, где
to_numericвернулNaNиз-за мусора. Их обычно проще удалить или разобрать вручную.
Теперь дубли. Важно различать полные дубли строк и дубли по бизнес-ключу:
print("Полные дубли строк:", df.duplicated().sum())
print("Дубли по order_id:", df.duplicated(subset=["order_id"]).sum())
# смотрим на конкретные дубли ключа
dups = df[df.duplicated(subset=["order_id"], keep=False)]
dups.sort_values("order_id").head(6)
Полных дублей может не быть вовсе, а вот дубли по order_id — почти всегда есть: один заказ выгрузился дважды из-за джойна или ретрая. Такие строки нельзя слепо суммировать. Убираем, оставляя последнюю версию записи:
df = df.drop_duplicates(subset=["order_id"], keep="last")
Механику fillna, dropna и работу с дублями детально разбирал разбор пропуски и дубли в pandas. Если в проекте несколько источников, дедупликацию удобно делать ещё на уровне SQL — потренироваться можно в SQL-тренажёре.
Как посмотреть распределения и поймать выбросы?
Числа из describe() врут, если смотреть только на среднее. Распределение нужно видеть. Минимальный набор — гистограмма и boxplot по каждому числовому признаку.
import matplotlib.pyplot as plt
num_cols = ["price", "quantity", "age"]
df[num_cols].hist(bins=50, figsize=(12, 4), layout=(1, 3))
plt.tight_layout()
plt.show()
df.boxplot(column="price", figsize=(6, 4))
plt.show()
Гистограмма цены почти наверняка покажет длинный правый хвост: 90% заказов до 5000 рублей и редкие значения под 100000+. Для таких распределений среднее бесполезно, а медиана и перцентили честны. Про это отдельно писал разбор медиана и перцентили vs среднее.
Выбросы формально ищут двумя способами. Первый — правило IQR (межквартильного размаха):
q1 = df["price"].quantile(0.25)
q3 = df["price"].quantile(0.75)
iqr = q3 - q1
low = q1 - 1.5 * iqr
high = q3 + 1.5 * iqr
outliers = df[(df["price"] < low) | (df["price"] > high)]
print(f"Выбросов по IQR: {len(outliers)} ({len(outliers)/len(df)*100:.1f}%)")
Формула порогов простым текстом: нижняя граница = Q1 − 1.5 × IQR, верхняя = Q3 + 1.5 × IQR, где IQR = Q3 − Q1. Всё, что за границами, — кандидаты в выбросы.
Второй способ — z-оценка: z = (значение − среднее) / стандартное отклонение. Значения с модулем z больше 3 считают аномальными. IQR устойчивее для скошенных данных, z-score — для примерно нормальных.
Ключевая мысль: выброс не значит «удалить». Отрицательная цена −50 — это баг (возврат затесался в заказы), его убираем. А заказ на 200000 рублей — возможно, реальная оптовая покупка, и это самый ценный сегмент. Решение всегда через призму бизнеса, а не автоматом.
Как исследовать корреляции и категориальные признаки?
Корреляции показывают, какие числовые признаки движутся вместе. Считаем матрицу и рисуем heatmap:
corr = df[["price", "quantity", "age", "is_returned"]].corr()
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(6, 5))
im = ax.imshow(corr, cmap="coolwarm", vmin=-1, vmax=1)
ax.set_xticks(range(len(corr))); ax.set_xticklabels(corr.columns, rotation=45)
ax.set_yticks(range(len(corr))); ax.set_yticklabels(corr.columns)
for i in range(len(corr)):
for j in range(len(corr)):
ax.text(j, i, f"{corr.iloc[i, j]:.2f}", ha="center", va="center")
fig.colorbar(im)
plt.tight_layout(); plt.show()
По умолчанию corr() считает коэффициент Пирсона — он ловит линейную связь и чувствителен к выбросам. Для скошенных данных надёжнее ранговый Спирмен: df.corr(method="spearman"). Читаем матрицу так:
- Значения около +1 или −1 — сильная связь, около 0 — её нет.
- Помните главное правило: корреляция не равна причинности. Если
is_returnedкоррелирует сquantity, это повод для гипотезы, а не готовый вывод.
Категориальные признаки корреляцией не измеришь — для них смотрят частоты и разрез целевой переменной по категориям:
# распределение категорий
print(df["payment_method"].value_counts(normalize=True).round(3))
# доля возвратов по способу оплаты
ret_by_pay = df.groupby("payment_method")["is_returned"].agg(["mean", "count"])
print(ret_by_pay.sort_values("mean", ascending=False))
payment_method mean count
cash_on_delivery 0.184 9021 <- возвраты почти в 3 раза выше
sbp 0.071 18330
card 0.063 20862
Разрез сразу даёт кандидата в инсайт: наложенный платёж возвращают заметно чаще. Похожая механика лежит в основе метрик удержания — например, Retention D7 и Churn Rate как раз считаются такими разрезами по когортам и сегментам.
Как собрать список гипотез из результатов EDA?
EDA заканчивается не графиками, а списком проверяемых гипотез. Каждая наблюдённая аномалия или зависимость превращается в формулировку «предполагаю, что X влияет на Y». Из нашего разбора набирается такой список:
- Возраст пропущен неслучайно. Гипотеза: возраст отсутствует в основном у заказов без регистрации. Проверка: доля пропусков
ageв разрезе наличияuser_id. - Наложенный платёж повышает возвраты. Гипотеза:
cash_on_deliveryдаёт возвраты выше среднего даже с поправкой на категорию товара. Проверка: сравнение долей + тест значимости. - Крупные заказы — отдельный сегмент. Гипотеза: заказы дороже 95-го перцентиля дают непропорционально большую долю выручки. Проверка: концентрация выручки по децилям чека.
- Города с малым числом заказов зашумляют статистику. Гипотеза: топ-5 городов дают 70%+ заказов, остальные лучше свернуть в
other.
Хорошая гипотеза всегда конкретна и проверяема: указывает признаки, направление связи и способ проверки. Разницу между корреляцией и настоящим эффектом проверяют статистикой — как считать значимость на Python, разбирал материал про A/B-тесты на scipy.stats. Отработать формулировку гипотез под конкретный кейс помогает и AI-наставник, который разберёт вашу логику как интервьюер.
Именно этот навык — «увидел аномалию, сформулировал гипотезу, придумал проверку» — проверяют на реальных собеседованиях аналитика, а не умение вызвать describe().
Какие ошибки в EDA чаще всего портят анализ?
EDA обманчиво прост: методов немного, и кажется, что достаточно вызвать describe() и построить пару графиков. На деле большинство кривых выводов рождается не из-за сложной статистики, а из-за одних и тех же базовых промахов.
- Смотреть только на среднее. Среднее по скошенному распределению врёт: один заказ на миллион перетягивает
mean, а медиана остаётся честной. Всегда сверяйтеmeanиmedian— если они сильно расходятся, у признака тяжёлый хвост. - Чистить выбросы автоматом. Массовое удаление всего за границами IQR выбрасывает вместе с багами реальный премиум-сегмент. Сначала посмотрите на конкретные строки-выбросы глазами, потом решайте, что с ними делать.
- Игнорировать типы после загрузки. Дата или число, лежащие как
object, тихо ломают сортировку, арифметику и группировки. Приведение типов — обязательный первый шаг, а не необязательная деталь. - Считать корреляцию причинностью. Матрица
corr()показывает совместное движение признаков, а не влияние одного на другой. Любая сильная связь — это гипотеза для проверки, а не готовый вывод для презентации. - Не фиксировать шаги чистки. EDA, сделанный в консоли и нигде не сохранённый, невоспроизводим: через неделю вы не вспомните, почему удалили 233 строки. Каждое решение о чистке оформляйте отдельной ячейкой с коротким комментарием.
Отдельная и коварная ошибка — принять локальную аномалию за тренд и побежать с выводом к продукту. Если какая-то метрика внезапно просела или скакнула, EDA лишь показывает, где именно, но не почему; дальше нужна структурная диагностика по сегментам, срезам и источникам. Как раскладывать такое падение по слоям, разбирал отдельный материал про диагностику упавшей метрики — это естественное продолжение разведочного анализа, когда сам факт проблемы уже виден в данных.
Как оформить переиспользуемый ноутбук-шаблон?
Смысл шаблона в том, чтобы не писать один и тот же код заново для каждого датасета. Оформите EDA как функцию, принимающую DataFrame и возвращающую отчёт о качестве.
import pandas as pd
import numpy as np
def eda_report(df: pd.DataFrame) -> pd.DataFrame:
"""Быстрый профиль датасета: типы, пропуски, уникальность, выбросы."""
rows = []
for col in df.columns:
s = df[col]
info = {
"column": col,
"dtype": str(s.dtype),
"na_pct": round(s.isna().mean() * 100, 1),
"nunique": s.nunique(),
"sample": s.dropna().iloc[0] if s.notna().any() else None,
}
if pd.api.types.is_numeric_dtype(s):
q1, q3 = s.quantile(0.25), s.quantile(0.75)
iqr = q3 - q1
mask = (s < q1 - 1.5 * iqr) | (s > q3 + 1.5 * iqr)
info["outliers_pct"] = round(mask.mean() * 100, 1)
info["median"] = round(s.median(), 2)
rows.append(info)
return pd.DataFrame(rows)
# использование на любом датасете
df = pd.read_csv("any_dataset.csv")
report = eda_report(df)
print(report)
Дальше шаблон удобно разбить на секции ноутбука в фиксированном порядке:
- Загрузка и типы —
read_csv, приведение дат и чисел,info(). - Профиль качества — вызов
eda_report(df): пропуски, уникальность, выбросы одной таблицей. - Чистка — дедупликация по ключу, обработка пропусков, отсечение багов (отрицательные цены, возраст 0).
- Распределения — гистограммы и boxplot по числовым,
value_countsпо категориальным. - Зависимости — корреляционная матрица + разрезы целевой переменной по категориям.
- Гипотезы — markdown-ячейка со списком проверяемых утверждений и планом проверки.
Такой ноутбук открывается на новом проекте, меняется путь к файлу и имя целевой колонки — и за 15 минут у вас полный профиль данных. Держите его рядом со своей шпаргалкой по pandas, чтобы не вспоминать синтаксис методов.
Лучший способ довести эти шаги до автоматизма — прогонять их руками на разных датасетах. В нашем Python-тренажёре 402 задачи на pandas, numpy и scipy с проверкой прямо в браузере: первые 5 доступны бесплатно, остальные открываются в Pro. Разберите десяток задач на группировки, пропуски и распределения — и EDA перестанет быть чек-листом, который надо гуглить, и станет мышечной памятью.
Короткий итог: EDA — это не разовый ритуал, а повторяемый пайплайн из семи шагов от info() до списка гипотез. Оформите его один раз как функцию и секции ноутбука — и каждый новый датасет будет отдавать свои проблемы и инсайты за минуты, а не за день отладки.