Урок курса
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
- Это примерно в 7 раз выше медианы. Интуиция подсказывает «выброс», но контекст говорит другое: крупный магазин в праздничный день с акцией вполне может дать такую выручку. Аналогично с
Customers: основная масса — 405–837 покупателей в день, но максимум достигает 7 - Это реальный трафик, а не ошибка в данных. Выброс влияет на два ключевых параметра:
- Среднее — смещается в сторону экстремального значения.
- Дисперсия (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
}Без регистрации. Гостевая проверка не сохраняет прогресс. Для сохранения перейдите к уроку.
Отправьте решение, чтобы увидеть результаты тестов.
Задача 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Без регистрации. Гостевая проверка не сохраняет прогресс. Для сохранения перейдите к уроку.
Отправьте решение, чтобы увидеть результаты тестов.
Задача 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")],
}Без регистрации. Гостевая проверка не сохраняет прогресс. Для сохранения перейдите к уроку.
Отправьте решение, чтобы увидеть результаты тестов.
Продолжить с сохранением прогресса
Перейдите к уроку в учебном кабинете.
