EDApandasPythonанализ данныхвизуализация

EDA в Python: разведочный анализ данных пошагово

2026-07-10 12 мин

Коротко: Разведочный анализ данных (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()
Признакcountmeanstdmin50%max
price47980342115602-501990999999
quantity482132.13.801500
age4112034.712.1033214

Здесь видно почти всё: отрицательная цена (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

Дальше — решение по каждой колонке, и оно зависит от смысла, а не от техники:

Теперь дубли. Важно различать полные дубли строк и дубли по бизнес-ключу:

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"). Читаем матрицу так:

Категориальные признаки корреляцией не измеришь — для них смотрят частоты и разрез целевой переменной по категориям:

# распределение категорий
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». Из нашего разбора набирается такой список:

Хорошая гипотеза всегда конкретна и проверяема: указывает признаки, направление связи и способ проверки. Разницу между корреляцией и настоящим эффектом проверяют статистикой — как считать значимость на Python, разбирал материал про A/B-тесты на scipy.stats. Отработать формулировку гипотез под конкретный кейс помогает и AI-наставник, который разберёт вашу логику как интервьюер.

Именно этот навык — «увидел аномалию, сформулировал гипотезу, придумал проверку» — проверяют на реальных собеседованиях аналитика, а не умение вызвать describe().

Какие ошибки в EDA чаще всего портят анализ?

EDA обманчиво прост: методов немного, и кажется, что достаточно вызвать describe() и построить пару графиков. На деле большинство кривых выводов рождается не из-за сложной статистики, а из-за одних и тех же базовых промахов.

Отдельная и коварная ошибка — принять локальную аномалию за тренд и побежать с выводом к продукту. Если какая-то метрика внезапно просела или скакнула, 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)

Дальше шаблон удобно разбить на секции ноутбука в фиксированном порядке:

Такой ноутбук открывается на новом проекте, меняется путь к файлу и имя целевой колонки — и за 15 минут у вас полный профиль данных. Держите его рядом со своей шпаргалкой по pandas, чтобы не вспоминать синтаксис методов.

Лучший способ довести эти шаги до автоматизма — прогонять их руками на разных датасетах. В нашем Python-тренажёре 402 задачи на pandas, numpy и scipy с проверкой прямо в браузере: первые 5 доступны бесплатно, остальные открываются в Pro. Разберите десяток задач на группировки, пропуски и распределения — и EDA перестанет быть чек-листом, который надо гуглить, и станет мышечной памятью.

Короткий итог: EDA — это не разовый ритуал, а повторяемый пайплайн из семи шагов от info() до списка гипотез. Оформите его один раз как функцию и секции ноутбука — и каждый новый датасет будет отдавать свои проблемы и инсайты за минуты, а не за день отладки.

Отработай EDA на реальных данных
В тренажёре 402 Python-задачи на pandas, numpy и scipy — с проверкой в браузере. Первые 5 бесплатно, дальше Pro.
Открыть Python-тренажёр →