Очистка данных в pandas: пропуски, дубликаты и типы
Практический порядок очистки таблицы в pandas: найти дубликаты, обработать пропуски, привести типы и не удалить важные бизнес-сценарии.
Содержание статьи
Очистка данных — это не «сделать таблицу красивой». Нужно понять, какие строки являются дублями, что означает пропуск и какой тип должен быть у каждого поля. Если удалить всё подозрительное без правил, можно убрать реальные возвраты, повторные события или пользователей с неполным профилем.
Коротко
Надёжная очистка начинается с контракта: какое зерно таблицы, какие поля обязательны, какие значения допустимы и что считается дубликатом. После каждого изменения сохраняйте количество строк и контрольные суммы, чтобы не потерять данные молча.
- Не удаляй строки до того, как описал причину удаления.
- Проверяйте дубли по бизнес-ключу, а не по всей строке.
- Пропуски в категориальном поле и в сумме денег требуют разных решений.
- Приводите типы явно и считайте число значений, которые не удалось преобразовать.
Опишите контракт учебной таблицы
Пусть в orders одна строка — один заказ. order_id обязателен и уникален, created_at должен быть датой, revenue — неотрицательным числом, а channel может быть неизвестным, но не пустой строкой. Такой контракт превращает спор о данных в набор проверок.
| Поле | Правило | Что делать при нарушении |
|---|---|---|
| order_id | не NULL, уникален | остановить расчёт и проверить источник |
| created_at | валидная дата в известной timezone | вынести строки в отчёт ошибок |
| revenue | число >= 0 | проверить возвраты и валюту, не ставить ноль автоматически |
| channel | категория или unknown | нормализовать регистр и пустые значения |
Типы: строка с числом ещё не число
CSV часто отдаёт сумму как текст: "1 250", "1250,50" или "n/a". pd.to_numeric помогает привести значения, но после errors="coerce" нужно отдельно посмотреть, сколько строк превратилось в NaN. Иначе плохая запись растворится в среднем.
before = orders['revenue'].isna().sum()
orders['revenue'] = (
orders['revenue'].astype('string')
.str.replace(' ', '', regex=False)
.str.replace(',', '.', regex=False)
.pipe(pd.to_numeric, errors='coerce')
)
after = orders['revenue'].isna().sum()
print('new missing revenue:', after - before)
orders['created_at'] = pd.to_datetime(orders['created_at'], errors='coerce', utc=True)Дубликаты: сначала понять ключ
drop_duplicates() без subset сравнивает всю строку. Для заказа это может быть неправильно: одна и та же покупка могла получить обновлённый статус или комментарий. Если источник гарантирует одну строку на заказ, проверяйте order_id; если это поток событий, ключом может быть user_id, event_name и occurred_at с допустимой точностью.
duplicates = orders.loc[orders.duplicated('order_id', keep=False)]
print('duplicate rows:', len(duplicates))
orders = (
orders.sort_values('updated_at')
.drop_duplicates('order_id', keep='last')
)
assert orders['order_id'].is_uniqueВ таблице статусов повторный order_id может быть историей изменений. В таблице фактов оплаты повтор может означать дубль загрузки. Решение зависит от зерна и бизнес-смысла, а не от самого названия метода.
Пропуски: unknown, zero и отдельный статус
Категориальный пропуск часто лучше превратить в явную группу unknown, чтобы он не исчез из группировки. Ноль подходит только тогда, когда отсутствие записи действительно означает отсутствие значения: например, скидка не применялась. Для неизвестной выручки ноль создаст ложное снижение среднего.
orders['channel'] = orders['channel'].replace('', pd.NA).fillna('unknown')
orders['discount'] = orders['discount'].fillna(0)
invalid_revenue = orders['revenue'].isna() | orders['revenue'].lt(0)
quarantine = orders.loc[invalid_revenue].copy()
clean_orders = orders.loc[~invalid_revenue].copy()Чеклист перед расчётом
Очищенная таблица должна быть не просто без пропусков. В финальном отчёте сохраните количество отброшенных строк, причины и контрольные числа. Так другой аналитик поймёт, почему выручка после очистки отличается от файла источника.
- Сколько строк было в источнике и сколько осталось?
- Сколько дублей найдено и по какому ключу?
- Сколько значений не удалось привести к нужному типу?
- Какие пропуски стали
unknown, а какие — нулём? - Изменились ли сумма, количество заказов и диапазон дат?
Сначала профилируйте источник, потом исправляйте
Очистка — это не набор универсальных вызовов dropna и drop_duplicates. Сначала зафиксируйте исходное состояние: число строк, обязательные колонки, диапазон дат, уникальные ключи и долю пропусков. Без baseline невозможно понять, какое решение изменило результат и сколько данных было потеряно.
Профиль смотрите по смысловым группам. Пропуски в стране могут быть проблемой регистрации, а пропуски в discount — нормальным отсутствием скидки. Дубли в таблице событий могут быть повторной доставкой сообщения, а дубли в таблице заказов — ошибкой загрузки. Один и тот же метод для всех колонок уничтожает этот контекст.
Очищенный набор должен сопровождаться отчётом об изменениях. Храните количество строк до и после, причины quarantine и контрольные суммы. Тогда очистка становится проверяемой частью анализа, а не невидимой подготовкой перед графиком.
| Проверка | Зачем |
|---|---|
| rows и columns | увидеть изменение размера и схемы |
| min/max даты | поймать неверный период или парсинг |
| nunique ключа | понять ожидаемую уникальность |
| missing share | отделить системную проблему от единичных строк |
| sum контрольного поля | сверить цену очистки |
Quarantine лучше тихого удаления
Если строка не проходит контракт, не обязательно удалять её навсегда. Отложите её в quarantine с причиной: invalid_date, missing_user_id, negative_revenue или duplicate_order. Основной расчёт работает на clean-слое, а владелец данных получает список проблемных случаев. Это сохраняет возможность расследования.
Разные ошибки требуют разных действий. Непарсящуюся дату нельзя превращать в начало эпохи. Отсутствующий channel можно показать как unknown, если неизвестность является допустимым состоянием. Отрицательная выручка может быть возвратом, а может быть поломкой источника. Название причины должно отражать решение, а не только технический симптом.
Quarantine не должен стать кладбищем строк. Считайте его долю по дням и источникам, поставьте порог, после которого pipeline останавливается. Если сегодня в quarantine ушло 0.1%, это одно обсуждение; если после релиза 25%, отчёт нельзя публиковать без проверки.
invalid_date = orders['created_at'].isna()
invalid_user = orders['user_id'].isna()
invalid_revenue = orders['revenue'].lt(0)
bad = invalid_date | invalid_user | invalid_revenue
quarantine = orders.loc[bad].copy()
# Маски берутся по тем же строкам, что попали в quarantine: иначе длины не совпадут.
quarantine['reason'] = np.select(
[invalid_date[bad], invalid_user[bad], invalid_revenue[bad]],
['invalid_date', 'missing_user_id', 'negative_revenue'],
default='other',
)
clean = orders.loc[~bad].copy()Причины пересекаются, поэтому журнал должен сверяться по строкам
В одной строке orders могут одновременно отсутствовать дата и сумма. Если журнал показывает две ошибки даты и две ошибки выручки, это ещё не четыре удалённые строки. Сумма причин считает нарушения, а размер quarantine — исходные записи. Для сверки присвой каждой строке устойчивый row_id внутри снимка выгрузки и сохраните полный набор флагов. Тогда можно посчитать и распространённость каждой проблемы, и число затронутых заказов без двойного счёта.
Ниже маленький пример с четырьмя заказами. У второго нет даты и revenue, у третьего сумма отрицательная, остальные проходят контракт. Получится одна ошибка даты, две ошибки суммы, но только две строки quarantine. Это специально заданные данные: они проверяют пересечение причин. Условия хранятся в DataFrame с тем же индексом, что orders; после выбора плохих строк выбираются и соответствующие флаги. Их размер и порядок остаются согласованными.
Если системе нужен один основной reason, опишите приоритет отдельно от полной диагностики. Например, ошибка ключа может запрещать дальнейшее сопоставление, но это не делает неизвестную сумму в той же строке несуществующей. Храните и primary_reason, и флаги всех нарушений. Для последовательных стадий различайте «изменено значение», «отложена строка» и «удалена предыдущая версия»: нормализация регистра не должна увеличивать счётчик потерянных заказов.
Суммы сверяйте с учётом их смысла. В учебном наборе известная выручка равна 1 680 ₽: 1 200 − 20 + 500. В clean остаётся 1 700 ₽, в quarantine известна сумма −20 ₽ и ещё одна сумма неизвестна. Равенство известных частей выполняется, но оно не доказывает полноту денег. Контракт этого упражнения запрещает отрицательную revenue; в платёжном журнале −20 могло бы быть законным возвратом и потребовало бы другой обработки.
Отчёт по пропускам сохраните до заполнения channel значением unknown. Сам исходный флаг channel_missing может подсказать, что проблема сосредоточена в новом источнике или версии приложения. После замены текста он не восстанавливается однозначно, если unknown встречался и как готовая категория. Для каждой загрузки записывайте число таких строк, источник и период, затем сравнивайте доли. Так журнал становится способом обнаружить изменение данных, а не только подтвердить, что очистка завершилась без исключения.
import pandas as pd
orders = pd.DataFrame({
'order_id': [101, 102, 103, 104],
'created_at': pd.to_datetime(['2026-07-01', None, '2026-07-02', '2026-07-03']),
'revenue': [1200.0, None, -20.0, 500.0],
}).rename_axis('row_id')
flags = pd.DataFrame({
'invalid_date': orders['created_at'].isna(),
'invalid_revenue': orders['revenue'].isna() | orders['revenue'].lt(0),
}, index=orders.index)
bad = flags.any(axis=1)
quarantine = orders.loc[bad].join(flags.loc[bad])
clean = orders.loc[~bad].copy()
assert len(orders) == len(clean) + len(quarantine)
assert flags.sum().to_dict() == {'invalid_date': 1, 'invalid_revenue': 2}
assert len(quarantine) == 2
assert orders['revenue'].sum() == clean['revenue'].sum() + quarantine['revenue'].sum()
assert quarantine['revenue'].isna().sum() == 1Дубликат определяется ключом и временем
Полный дубль строки — только один из вариантов. В заказах ключом может быть order_id, в событиях — комбинация user_id, event_name и occurred_at, а в таблице статусов — ключ плюс версия обновления. Сначала опишите, сколько строк должно быть на ключ. Затем решите, какую запись оставить: последнюю по updated_at, первую подтверждённую или все как историю.
Порядок удаления имеет значение. Если оставить первую строку без сортировки, результат будет зависеть от порядка выгрузки. Сначала отсортируйте по полю, которое выражает бизнес-приоритет, затем используйте drop_duplicates. Сохраните отброшенные строки или хотя бы их число и период.
Дедупликация событий и дедупликация фактов — разные задачи. Повторное событие может быть техническим дублем, а два платежа на одну сумму — двумя реальными операциями. Не удаляй по user_id только потому, что метрике нужны уникальные пользователи: это можно сделать на этапе агрегации, не разрушая источник.
Если не можете словами сказать, что является ключом строки, ещё рано удалять дубли. Сначала разберитесь со схемой и владельцем источника.
Нормализованный ключ ещё не доказывает, что заказы одинаковые
Пусть источник прислал order_id = " A-101 " и order_id = "a-101". Убрать внешние пробелы может быть допустимо по контракту экспорта, но перевод ключа в нижний регистр уже требует знания правил идентификатора. Для channel регистр часто косметический, для order_id он может отличать реальные записи. Не применяйте одну строковую очистку ко всем колонкам только потому, что они имеют одинаковый dtype. Ключ определяет сущность, категория описывает её свойство.
Сохраните raw_order_id и постройте candidate_order_id отдельно. Затем найдите группы, где несколько разных исходных ключей превратились в один кандидат. Это отчёт о коллизиях нормализации, а не готовый список дублей. Внутри каждой группы сравните источник, время создания и покупателя. Если данные пришли из двух магазинов, оба могут законно использовать A-101: ключом итоговой таблицы станет пара source и order_id. Удаление одного заказа по совпавшему коду потеряет реальную продажу.
Даже после подтверждения идентичности остаётся вопрос версий. Две строки одного заказа могут содержать paid и refunded. Прежде чем оставить последнюю, разберите updated_at как время с согласованной зоной. Сортировка текстовых дат другого формата может выбрать неверную версию. Если время одинаково, нужен дополнительный приоритет, например source_revision. При противоречащих значениях и отсутствии порядка не выбирайте победителя по случайному месту в файле — сохраните конфликт для разбора.
Порядок очистки теперь следует из зависимостей. Сначала сохраните сырой снимок и происхождение строки, потом разберите типы, затем проверьте кандидатов ключей и выберите версии. Только после этого нормализуйте отображаемые категории и рассчитывайте агрегаты. Удаление дублей до проверки источника может объединить разные магазины, а заполнение пропусков до сравнения версий — скрыть, какая запись действительно содержала значение. У каждого шага должна быть возможность объяснить предыдущую строку.
В контрольном наборе ниже два магазина используют A-101 независимо, а у первого магазина есть повторная запись с внешними пробелами. После удаления пробелов число уникальных составных ключей равно двум, хотя различных исходных строковых ключей тоже два по совсем другой причине. Проверка показывает, почему одного nunique недостаточно. Дальнейшая дедупликация потребует версии и содержимого заказа; этот фрагмент намеренно останавливается на обнаружении кандидатов, сохраняя все три исходные строки.
import pandas as pd
orders = pd.DataFrame({
'source': ['shop_1', 'shop_1', 'shop_2'],
'order_id': [' A-101 ', 'A-101', 'A-101'],
})
orders['raw_order_id'] = orders['order_id']
orders['candidate_order_id'] = orders['order_id'].str.strip()
key = ['source', 'candidate_order_id']
candidates = orders.loc[orders.duplicated(key, keep=False)]
assert len(candidates) == 2
assert len(orders.drop_duplicates(key)) == 2
assert len(orders) == 3 # источник пока не сокращалиДо и после очистки должны объяснять разницу
Сравните итоговые показатели на raw и clean: строки, уникальные заказы, уникальные пользователи, сумма выручки и окно дат. Для каждого расхождения должна быть причина. Если clean revenue выше raw, возможно, вы убрали отрицательные возвраты; это не обязательно ошибка, но отчёт должен назвать показатель как gross или net.
Не сравнивайте только среднее. Удаление десяти крупных заказов может почти не изменить количество строк, но сильно изменить p95 и LTV. Сохраните несколько квантилей и долю сегментов до и после. Так видно, не исчезла ли целая категория пользователей вместе с «плохими» строками.
Последний шаг — повторный профиль. Чистый набор должен соответствовать контракту, но неизвестные значения и quarantine должны быть видны в отчёте. Если после очистки всё идеально, это повод проверить, не отфильтровал ли код сам источник ошибки.
- Зафиксировать baseline до изменения.
- Разделить допустимые неизвестные и реальные ошибки.
- Сохранить quarantine с причиной.
- Удалять дубли по явному ключу и правилу приоритета.
- Сверить показатели и распределения до и после.
Материалы по теме

Как тестировать аналитические расчёты на Python и pandas
Практический гайд по тестам для аналитика: проверить метрики на маленьком датасете, поймать регрессию и защитить расчёт от тихих изменений.

Как ускорить pandas: память, типы и обработка больших файлов
Что делать, если pandas медленно работает или не помещает файл в память: категории, downcast, chunksize, Parquet и контроль размера данных.

Pandas apply и векторизация: как писать быстрее и понятнее
Когда использовать apply, почему векторные операции быстрее и как переписать медленный построчный расчёт в pandas.