Урок курса

Exploratory Data Analysis: распределения и аномалии

Data Scientist: от данных до деплоя моделей

Exploratory Data Analysis: распределения и аномалии

После того как данные загружены и датафрейм собран, первый вопрос звучит просто: «А что вообще внутри?» Прежде чем строить графики или обучать модели, нужно получить числовое резюме по каждому признаку — это и есть описательная статистика.

1. Описательная статистика: первый взгляд на данные

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

  • Среднее (mean) — центр тяжести распределения. Чувствительно к выбросам.
  • Медиана (50%) — середина отсортированного ряда. Устойчива к экстремальным значениям.
  • Стандартное отклонение (std) — типичное отклонение от среднего.
  • Квантили (25%, 75%) — границы, которые отсекают нижнюю и верхнюю четверть данных.
  • Минимум и максимум — крайние точки; именно здесь чаще всего прячутся ошибки и выбросы. Посмотрим, как это выглядит на rossmann_train.csv. Вывод df.describe() для ключевых признаков:

import pandas as pd

df = pd.read_csv('rossmann_train.csv')
df.describe()

По данным из сводки описательной статистики видно следующее:

  • Sales: среднее ≈ 5774, медиана ≈ 5744 — они близки, что говорит об относительной симметрии. Но максимум — 41 551, при среднем около 5774. Это почти в 7 раз выше среднего: явный сигнал о наличии правого хвоста и потенциальных выбросов.
  • Customers: среднее ≈ 633, медиана ≈ 609, максимум — 7388. Похожая картина: основной массив компактный, но хвост тянется далеко вправо.
  • Open: среднее ≈ 0.83 при min=0 и max=1 — это бинарный признак, где 17% строк приходится на закрытые магазины. Когда магазин закрыт, Sales и Customers равны нулю — это важно учесть при анализе распределений. Отдельно стоит запустить df.info(): он показывает типы данных и количество непустых значений по каждому столбцу — быстрая проверка, что никакие столбцы не потерялись при загрузке.

df.info()

Ещё один быстрый шаг — df.isnull().sum() и df.nunique(). Первый считает пропуски по столбцам, второй — количество уникальных значений. Например, DayOfWeek имеет ровно 7 уникальных значений — это ожидаемо. Если бы там оказалось, скажем, 8 — это был бы сигнал об ошибке кодирования. Что важно понять: описательная статистика не заменяет визуализацию, но она задаёт вопросы, на которые визуализация потом отвечает. Заметили большой разрыв между медианой и максимумом у Sales? Следующий шаг — посмотреть на форму распределения.

2. Форма распределения: что скрывается за числами

Описательная статистика отвечает на вопрос «где» и «насколько широко» — но не отвечает на вопрос «как именно» распределены значения. Два признака могут иметь одинаковое среднее и стандартное отклонение, но совершенно разную форму. Форма распределения характеризуется несколькими вещами:

  • Симметрия — значения равномерно расположены вокруг центра, как у нормального распределения.
  • Скошенность (skew) — хвост тянется в одну сторону. Правый хвост означает, что большинство значений небольшие, но есть редкие очень крупные.
  • Моды — количество пиков. Унимодальное распределение имеет один пик, бимодальное — два, что часто сигнализирует о двух разных группах в данных.
  • Хвосты — насколько далеко уходят крайние значения от центра. В rossmann_train.csv это хорошо видно на примере Sales. Разница между 25-м перцентилем (3 727) и 75-м (7 856) составляет около 4 100. При этом максимум — 41 551, то есть правый хвост простирается почти в 9 раз дальше верхнего квартиля. Это классическое правостороннее распределение: большинство магазинов дают выручку в диапазоне 3 700–7 900, но есть единицы с продажами на порядок выше. Посмотреть форму числом можно через skew() и kurt():

import pandas as pd

df = pd.read_csv('rossmann_train.csv')

print(df[['Sales', 'Customers']].skew())
print(df[['Sales', 'Customers']].kurt())

Значение skew > 0 означает правый хвост, skew < 0 — левый. Для Sales и Customers ожидаем заметный положительный skew, что подтверждает визуальное наблюдение из describe(). Почему форма важна на практике? Линейные модели предполагают определённые свойства остатков; если целевая переменная сильно скошена, логарифмическое преобразование Sales перед обучением может существенно улучшить качество. Кроме того, бимодальность часто указывает на смешение подгрупп — например, магазины разного типа или рабочие и выходные дни. В rossmann_train.csv дни, когда магазин закрыт (Open=0), дают Sales=0, что формирует отдельный пик в нуле и делает распределение Sales без фильтрации смесью двух режимов. Поэтому перед анализом формы распределения полезно сначала отфильтровать строки с Open=1:


df_open = df[df['Open'] == 1]
print(df_open[['Sales', 'Customers']].skew())

Это даст честную картину распределения выручки в рабочие дни, без артефакта от нулей.

3. Когда среднее врёт: ловушка скошенного распределения

Среднее — удобная метрика, но у неё есть слабое место: оно чувствительно к крайним значениям. Один магазин с продажами 41 551 тянет среднее вверх, хотя 75% магазинов зарабатывают меньше 7 856. Посмотрим на конкретные числа из rossmann_train.csv:

  • Sales: mean ≈ 5 774, median ≈ 5 744 — разрыв небольшой, но max = 41 551 создаёт правый хвост.
  • Customers: mean ≈ 633, median ≈ 609, max = 7 388 — схожая картина. Пока разрыв между mean и median умеренный — всё выглядит прилично. Но стоит включить в выборку дни с Open=0, где Sales=0, и среднее резко просядет, а медиана останется примерно на месте. Именно так проявляется смещение среднего при смеси режимов. Проверить это легко:

import pandas as pd

df = pd.read_csv('rossmann_train.csv')

print('Все строки:')
print(df['Sales'].agg(['mean', 'median']))

print('\nТолько открытые магазины:')
df_open = df[df['Open'] == 1]
print(df_open['Sales'].agg(['mean', 'median']))

В выводе будет видно, что среднее на полной выборке заметно ниже, чем на отфильтрованной — за счёт нулей от закрытых дней. Медиана реагирует на этот эффект слабее. Вторая ловушка — автоматическое удаление выбросов. Когда видишь Sales = 41 551, первый импульс — выкинуть эту строку как аномалию. Но это может быть реальная акция или крупный магазин в праздник. Удалив такие строки, вы получите модель, которая систематически недооценивает продажи в пиковые периоды. Практическое правило: прежде чем интерпретировать mean или удалять экстремальные значения, посмотри на форму распределения и разберись в контексте точки. Выброс и ошибка — разные вещи, и только предметный анализ позволяет их различить.

4. Выброс: аномалия или редкое событие?

Выброс — это значение, которое сильно отличается от основной массы данных. Но «сильно отличается» — не приговор и не повод немедленно удалять строку. В rossmann_train.csv наглядный пример: Sales имеет медиану около 5 744, а максимум — 41

  1. Это примерно в 7 раз выше медианы. Интуиция подсказывает «выброс», но контекст говорит другое: крупный магазин в праздничный день с акцией вполне может дать такую выручку. Аналогично с Customers: основная масса — 405–837 покупателей в день, но максимум достигает 7
  2. Это реальный трафик, а не ошибка в данных. Выброс влияет на два ключевых параметра:
  • Среднее — смещается в сторону экстремального значения.
  • Дисперсия (std) — увеличивается, создавая ложное впечатление о высокой вариабельности всей выборки. Посмотреть, насколько выброс искажает картину, можно напрямую:

import pandas as pd

df = pd.read_csv('rossmann_train.csv')
df_open = df[df['Open'] == 1]

### Сравниваем std с выбросами и без верхнего хвоста
threshold = df_open['Sales'].quantile(0.99)
df_trimmed = df_open[df_open['Sales'] <= threshold]

print('С выбросами:', df_open['Sales'].agg(['mean', 'std']).round(1))
print('Без верхнего 1%:', df_trimmed['Sales'].agg(['mean', 'std']).round(1))

Здесь мы не удаляем выбросы — мы смотрим, как верхний 1% значений Sales влияет на среднее и стандартное отклонение. Разница в std покажет, насколько хвост «раздувает» оценку разброса. Ключевое разграничение, которое нужно держать в голове: выброс бывает двух природ. Первый — ошибка данных: магазин с Sales = 0 при Open = 1, или отрицательное количество покупателей. Такие строки нужно проверять и, скорее всего, исправлять или исключать. Второй — редкое, но реальное событие: рекордный день продаж, крупный магазин в туристическом районе, праздничный всплеск. Удалить такие строки — значит намеренно обучить модель не видеть пиковые нагрузки. Практически это означает: обнаружение выброса — это начало анализа, а не его конец. Следующий шаг — понять, к какой из двух категорий он относится, и только после этого принимать решение.

5. Пропущенные значения: три разных механизма

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

  • MCAR (Missing Completely At Random) — пропуск никак не связан ни с самим пропущенным значением, ни с другими признаками. Например, часть записей потерялась из-за случайного сбоя при выгрузке. Такие пропуски можно удалить или заполнить без риска внести смещение.
  • MAR (Missing At Random) — пропуск зависит от других наблюдаемых признаков, но не от самого пропущенного значения. Например, данные о промо-активности отсутствуют чаще для магазинов определённого типа, но не потому, что промо там было или не было. Здесь удаление строк уже может дать смещённую выборку.
  • MNAR (Missing Not At Random) — пропуск зависит именно от самого пропущенного значения. Например, магазины с очень низкими продажами могут не передавать данные — тогда пропуск в Sales связан с его реальным значением. Это самый опасный случай: никакая импутация не устраняет смещение без дополнительных предположений. В rossmann_train.csv по сводке пропусков все ключевые столбцы — Store, DayOfWeek, Date, Sales, Customers, Open — заполнены полностью, пропусков нет. Это упрощает анализ, но не означает, что данные идеальны: нули в Sales при Open=1 — это тоже форма аномалии, которую стоит проверять отдельно. Проверить количество и долю пропусков по каждому столбцу элементарно:

import pandas as pd

df = pd.read_csv('rossmann_train.csv')

missing = df.isnull().sum()
missing_pct = df.isnull().mean() * 100

pd.DataFrame({'missing_count': missing, 'missing_pct': missing_pct.round(2)})

Если датасет расширенный и содержит, например, дополнительные характеристики магазинов (StoreType, Assortment, CompetitionDistance), там пропуски уже встречаются — и паттерн их распределения становится информативным: CompetitionDistance отсутствует у части магазинов, и это, скорее всего, MAR или MNAR, а не случайный сбой. Почему механизм пропуска важен до того, как вы выбрали стратегию заполнения? Потому что одна и та же операция — скажем, заполнение медианой — даёт разные последствия в зависимости от природы пропуска. При MCAR это нейтрально. При MNAR это маскирует систематический паттерн и портит обучение модели. Разбираться с механизмом нужно до кода, не после.

Визуализация паттерна пропусков: missingno в деле

Когда df.isnull().sum() показывает нули по всем столбцам, как в rossmann_train.csv, кажется, что тема пропусков закрыта. Но на практике вы почти всегда будете работать с датасетами, где пропуски есть — и тогда простого счётчика недостаточно. Важен не только объём, но и паттерн: где именно в таблице появляются пропуски и есть ли между ними связь. Библиотека missingno создана именно для этого. Её основная функция msno.matrix() строит матрицу присутствия данных: каждая строка датафрейма — горизонтальная полоска, каждый столбец — вертикальная колонка. Заполненные ячейки отображаются тёмным, пропущенные — белым.


import pandas as pd
import missingno as msno
import matplotlib.pyplot as plt

df = pd.read_csv('rossmann_train.csv')
msno.matrix(df)
plt.show()

Для rossmann_train.csv матрица будет полностью тёмной — пропусков нет. Но посмотрите, что происходит, если добавить к нему таблицу с характеристиками магазинов (store.csv), где присутствуют CompetitionDistance, Promo2SinceWeek, PromoInterval:


store = pd.read_csv('store.csv')
msno.matrix(store)
plt.show()

Здесь уже видна структура: CompetitionDistance пропущен у нескольких магазинов, а Promo2SinceWeek и PromoInterval отсутствуют ровно там, где магазин не участвует в Promo2. Это не случайный сбой — это MNAR: пропуск в Promo2SinceWeek напрямую связан со значением признака Promo2=0. Матрица делает этот паттерн видимым за секунды. Ещё один инструмент из того же пакета — msno.heatmap(). Он показывает корреляцию между пропусками разных столбцов:


msno.heatmap(store)
plt.show()

Если два столбца пропускают значения в одних и тех же строках, корреляция будет близка к 1. Высокая корреляция пропусков — почти всегда признак MAR или MNAR, а не случайности. Практическое правило: запускайте msno.matrix() до того, как принимаете решение об импутации. Картинка сразу покажет, рассыпаны ли пропуски равномерно (MCAR) или сгруппированы в определённых строках и столбцах. Это меняет всё: при MCAR можно заполнять медианой без особых последствий, при MNAR — любая импутация без учёта причины пропуска будет маскировать проблему, а не решать её. В следующем уроке, когда будем выбирать стратегию обработки пропусков, этот визуальный анализ станет отправной точкой.

Задача 1

Какой график лучше всего подходит для одновременного отображения медианы, квартилей и выбросов одного числового признака?

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 2

В датасете медицинских записей пропущены значения давления у пациентов, которые отказались от измерения из-за страха плохого результата. Какой механизм пропусков это описывает?

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 3

Какое из утверждений о выбросах является антипаттерном при EDA?

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 4

Найдите выбросы методом межквартильного размаха

В payload['df'] приходит DataFrame с числовым столбцом value. Найдите в нём выбросы и верните их описание одним словарём.

Границы строятся от квартилей, где Q1 и Q3 — 25-й и 75-й перцентили:

IQR = Q3 - Q1
нижняя граница = Q1 - 1.5 * IQR
верхняя граница = Q3 + 1.5 * IQR

Значение считается выбросом, если оно строго меньше нижней границы или строго больше верхней.

Верните словарь:

{
    "outliers_indices": [...],   # индексы строк с выбросами
    "lower_bound": float,        # нижняя граница
    "upper_bound": float,        # верхняя граница
    "outliers_ratio": float,     # доля выбросов от общего числа строк
}

Если выбросов нет, список пустой, а доля равна нулю.

Квантили считайте через pandas.Series.quantile() с интерполяцией по умолчанию: от метода зависят границы, а с ними и результат проверки.

Пример

value: [1, 2, 3, 4, 5, 100]

Q1 = 2.25, Q3 = 4.75, IQR = 2.5
нижняя граница = 2.25 - 1.5 * 2.5 = -1.5
верхняя граница = 4.75 + 1.5 * 2.5 = 8.5

Единственный выброс — 100, строка с индексом 5.

{
    "outliers_indices": [5],
    "lower_bound": -1.5,
    "upper_bound": 8.5,
    "outliers_ratio": 0.166667
}
РешениеPython

Без регистрации. Гостевая проверка не сохраняет прогресс. Для сохранения перейдите к уроку.

Отправьте решение, чтобы увидеть результаты тестов.

Задача 5

Опишите распределение числового признака

В payload['df'] приходит DataFrame с числовым столбцом feature. Посчитайте по нему описательные статистики и три производных признака распределения.

Верните словарь:

{
    "mean": float,                  # среднее
    "median": float,                # медиана
    "std": float,                   # стандартное отклонение
    "min": float,                   # минимум
    "max": float,                   # максимум
    "q25": float,                   # 25-й перцентиль
    "q75": float,                   # 75-й перцентиль
    "skewness": float,              # скошенность
    "kurtosis": float,              # эксцесс
    "is_symmetric": bool,           # True, если |skewness| < 0.2
    "has_outliers": bool,           # True, если max > q75 * 3
    "mean_median_diff_pct": float,  # (mean - median) / median * 100
}

Считайте методами pandas: quantile() с интерполяцией по умолчанию, std() с ddof=1, skew() и kurt(). У scipy.stats.skew() и kurtosis() по умолчанию смещённые оценки и другие числа — им нужен bias=False.

Важно. Не округляйте результат — сравнение идёт с точностью до шести знаков, числа в примере показаны округлёнными только для читаемости. Флаги is_symmetric и has_outliers верните как обычный Python-bool: сравнение pandas-величин даёт numpy.bool_, который проверку не пройдёт — оберните результат в bool(...). Ключей в словаре должно быть ровно двенадцать, лишние не допускаются.

Пример

feature: [1, 2, 2, 3, 4, 100]

mean   = 18.67          q25      = 2.0
median = 2.5            q75      = 3.75
std    = 39.86          skewness = 2.45
min    = 1              kurtosis = 5.99
max    = 100

is_symmetric         = False    # 2.45 не меньше 0.2
has_outliers         = True     # 100 больше 3.75 * 3
mean_median_diff_pct = 646.67
РешениеPython

Без регистрации. Гостевая проверка не сохраняет прогресс. Для сохранения перейдите к уроку.

Отправьте решение, чтобы увидеть результаты тестов.

Задача 6

Найдите пропуски и связи между ними

В payload['df'] приходит DataFrame с несколькими признаками, в которых есть пропуски. Посчитайте долю пропусков по столбцам и найдите столбцы, чьи пропуски появляются вместе.

Верните словарь:

{
    "missing_ratio": {...},      # {имя столбца: доля пропусков от 0 до 1}, по всем столбцам
    "high_missing_cols": [...],  # столбцы, где доля пропусков строго больше 0.2
    "high_corr_pairs": [...],    # пары (col1, col2) с корреляцией масок пропусков строго больше 0.5
}

Маска пропусков — булева серия isnull(). Корреляцию считайте только по столбцам, где есть хотя бы один пропуск; если таких столбцов меньше двух, high_corr_pairs пустой. Внутри пары имена идут в лексикографическом порядке, col1 < col2.

Границы строгие. Столбец с долей пропусков ровно 0.2 в high_missing_cols не попадает, пара с корреляцией ровно 0.5 — в high_corr_pairs тоже. В заготовке уже лежит хелпер _compute_corr_pairs(masks_df, threshold=0.5), он считает пары и сортирует имена внутри пары.

Пример

DataFrame с тремя столбцами:
  A — 10% пропусков
  B — 25% пропусков
  C — 30% пропусков
корреляция масок пропусков B и C равна 0.6

{
    "missing_ratio": {"A": 0.1, "B": 0.25, "C": 0.3},
    "high_missing_cols": ["B", "C"],
    "high_corr_pairs": [("B", "C")],
}
РешениеPython

Без регистрации. Гостевая проверка не сохраняет прогресс. Для сохранения перейдите к уроку.

Отправьте решение, чтобы увидеть результаты тестов.

Продолжить с сохранением прогресса

Перейдите к уроку в учебном кабинете.

Перейти к интерактивному уроку