Все материалы
Продуктовая аналитикачеклистстарт

Очистка данных в pandas: пропуски, дубликаты и типы

Практический порядок очистки таблицы в pandas: найти дубликаты, обработать пропуски, привести типы и не удалить важные бизнес-сценарии.

КейсПрактика31 июля 2026 г.обновлено 15 сентября 2026 г.10 мин

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

Коротко

Надёжная очистка начинается с контракта: какое зерно таблицы, какие поля обязательны, какие значения допустимы и что считается дубликатом. После каждого изменения сохраняйте количество строк и контрольные суммы, чтобы не потерять данные молча.

  • Не удаляй строки до того, как описал причину удаления.
  • Проверяйте дубли по бизнес-ключу, а не по всей строке.
  • Пропуски в категориальном поле и в сумме денег требуют разных решений.
  • Приводите типы явно и считайте число значений, которые не удалось преобразовать.

Опишите контракт учебной таблицы

Пусть в orders одна строка — один заказ. order_id обязателен и уникален, created_at должен быть датой, revenue — неотрицательным числом, а channel может быть неизвестным, но не пустой строкой. Такой контракт превращает спор о данных в набор проверок.

Пример контракта для orders
ПолеПравилоЧто делать при нарушении
order_idне NULL, уникаленостановить расчёт и проверить источник
created_atвалидная дата в известной timezoneвынести строки в отчёт ошибок
revenueчисло >= 0проверить возвраты и валюту, не ставить ноль автоматически
channelкатегория или unknownнормализовать регистр и пустые значения

Типы: строка с числом ещё не число

CSV часто отдаёт сумму как текст: "1 250", "1250,50" или "n/a". pd.to_numeric помогает привести значения, но после errors="coerce" нужно отдельно посмотреть, сколько строк превратилось в NaN. Иначе плохая запись растворится в среднем.

pythonПривести сумму и дату с контролем ошибок
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 с допустимой точностью.

pythonНайти и сохранить повторные заказы
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, чтобы он не исчез из группировки. Ноль подходит только тогда, когда отсутствие записи действительно означает отсутствие значения: например, скидка не применялась. Для неизвестной выручки ноль создаст ложное снижение среднего.

pythonОбработать пропуски по смыслу поля
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 и контрольные суммы. Тогда очистка становится проверяемой частью анализа, а не невидимой подготовкой перед графиком.

Baseline до очистки
ПроверкаЗачем
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%, отчёт нельзя публиковать без проверки.

pythonРазделить clean и quarantine с причиной
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 встречался и как готовая категория. Для каждой загрузки записывайте число таких строк, источник и период, затем сравнивайте доли. Так журнал становится способом обнаружить изменение данных, а не только подтвердить, что очистка завершилась без исключения.

pythonДве проблемные строки, три нарушения и сверка известных сумм
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 только потому, что метрике нужны уникальные пользователи: это можно сделать на этапе агрегации, не разрушая источник.

Уточните зерно до drop_duplicates

Если не можете словами сказать, что является ключом строки, ещё рано удалять дубли. Сначала разберитесь со схемой и владельцем источника.

Нормализованный ключ ещё не доказывает, что заказы одинаковые

Пусть источник прислал 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 недостаточно. Дальнейшая дедупликация потребует версии и содержимого заказа; этот фрагмент намеренно останавливается на обнаружении кандидатов, сохраняя все три исходные строки.

pythonКандидаты на дубли сохраняют магазин и исходное написание ключа
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 с причиной.
  • Удалять дубли по явному ключу и правилу приоритета.
  • Сверить показатели и распределения до и после.
Продолжить чтение
Вся библиотека