
Дубли в pandas: как выбрать ключ и сохранить причину удаления строки


Когда в таблице появляются повторы, соблазн понятен: вызвать drop_duplicates() и получить «чистый» DataFrame. Но для рабочих данных это часто слишком грубо. Полное совпадение по всем столбцам встречается реже, чем повтор одного и того же бизнес-события с разными техническими полями: временем загрузки, статусом, источником файла. Если удалить строки без условия, можно потерять нужную версию записи или, наоборот, оставить устаревшую.
Узкий и практический вопрос здесь такой: как выбрать столбцы, по которым запись считается повтором, и как сохранить понятную причину, почему конкретная строка была исключена. Ниже разберем один учебный кейс целиком: от выбора бизнес-ключа до журнала исключенных строк и отдельной очереди на ручную проверку.
Что считать повтором в вашей таблице
В pandas строка считается дублем относительно тех столбцов, которые вы передали в subset. Если subset не указан, метод сравнивает все колонки сразу. Технически это корректно, но аналитически не всегда полезно: два повтора одного счета могут отличаться только полем source_file или временем загрузки, и тогда полное сравнение их не поймает.
Поэтому сначала полезно разделить столбцы на две группы. Первая группа отвечает на вопрос «это одно и то же бизнес-событие?». Вторая — «какая версия этой записи для нас предпочтительнее?». В реальных таблицах эти группы часто не совпадают. Например, номер счета и клиент могут определять событие, а статус и время загрузки — помогать выбрать, какую строку оставить.
Если этот шаг пропустить, очистка дублей превращается в угадывание. Вы либо не найдете повторы, потому что строки чуть отличаются, либо удалите слишком много, потому что равенство по всем полям ничего не говорит о смысле записи.
Бизнес-ключ и полное совпадение: на одном примере
Учебный пример. Среда: Python 3.11, pandas, Jupyter или блокнот Anaconda. Представим выгрузку счетов из нескольких файлов. Один и тот же счет мог прийти повторно, а иногда — прийти с обновленным статусом. Для нас бизнес-ключом будут client_id и invoice_no. Полное совпадение по всем столбцам здесь не подходит, потому что технические поля различаются.
import pandas as pd
records = [
{"row_id": 1, "client_id": "C01", "invoice_no": "A100", "invoice_date": "2024-06-01", "amount": 1200, "status": "new", "loaded_at": "2024-06-05 09:00", "source_file": "batch_1.csv"},
{"row_id": 2, "client_id": "C01", "invoice_no": "A100", "invoice_date": "2024-06-01", "amount": 1200, "status": "paid", "loaded_at": "2024-06-05 12:00", "source_file": "batch_2.csv"},
{"row_id": 3, "client_id": "C01", "invoice_no": "A100", "invoice_date": "2024-06-01", "amount": 1200, "status": "paid", "loaded_at": "2024-06-05 12:00", "source_file": "batch_2_copy.csv"},
{"row_id": 4, "client_id": "C02", "invoice_no": "B200", "invoice_date": "2024-06-02", "amount": 800, "status": "new", "loaded_at": "2024-06-05 09:10", "source_file": "batch_1.csv"},
{"row_id": 5, "client_id": "C02", "invoice_no": "B200", "invoice_date": "2024-06-02", "amount": 900, "status": "new", "loaded_at": "2024-06-05 09:20", "source_file": "batch_2.csv"},
{"row_id": 6, "client_id": "C03", "invoice_no": "C300", "invoice_date": "2024-06-03", "amount": 500, "status": "new", "loaded_at": "2024-06-05 10:00", "source_file": "batch_3.csv"},
{"row_id": 7, "client_id": "C03", "invoice_no": "C300", "invoice_date": "2024-06-03", "amount": 500, "status": "new", "loaded_at": "2024-06-05 10:00", "source_file": "batch_3_copy.csv"},
{"row_id": 8, "client_id": "C04", "invoice_no": "D400", "invoice_date": "2024-06-04", "amount": 700, "status": "new", "loaded_at": "2024-06-05 11:00", "source_file": "batch_4.csv"},
]
df = pd.DataFrame(records)
df["invoice_date"] = pd.to_datetime(df["invoice_date"])
df["loaded_at"] = pd.to_datetime(df["loaded_at"])
key_cols = ["client_id", "invoice_no"]
df["is_key_duplicate"] = df.duplicated(subset=key_cols, keep=False)Если искать дубли по всем столбцам, группа A100 почти не сократится: там отличается статус и имя файла. Но с точки зрения учета это один и тот же счет. Напротив, у пары B200 совпадает ключ, но отличается сумма. Это уже не тот случай, где строку стоит удалять автоматически. Значит, нам нужен не просто фильтр дублей, а правило разветвления: что можно схлопнуть автоматически, а что нужно отдать на разбор.
Как заранее зафиксировать правило выбора строки
Хорошее правило выбора строки должно быть коротким и проверяемым. В нашем примере оно такое: если бизнес-ключ один и тот же, сначала предпочитаем более высокий статус, потом более позднюю загрузку, а если и они одинаковы — берем строку с меньшим row_id как технический тай-брейк. Это важно: без последнего пункта результат может зависеть от случайного порядка строк.
Отдельно надо решить, какие поля внутри одного ключа обязаны совпадать. Для счета обычно подозрительно, если внутри одной пары client_id + invoice_no различаются сумма или дата счета. Такие случаи лучше не чистить автоматически. Их правильнее пометить как конфликтные группы.
status_priority = {"paid": 2, "new": 1, "cancelled": 0}
df["status_rank"] = df["status"].map(status_priority).fillna(-1).astype(int)
group_check = (
df.groupby(key_cols, dropna=False)
.agg(
rows_in_key=("row_id", "size"),
amount_nunique=("amount", "nunique"),
date_nunique=("invoice_date", "nunique")
)
.reset_index()
)
group_check["needs_review"] = (
(group_check["rows_in_key"] > 1) &
((group_check["amount_nunique"] > 1) | (group_check["date_nunique"] > 1))
)
df = df.merge(group_check[key_cols + ["rows_in_key", "needs_review"]], on=key_cols, how="left")
review_df = df[df["needs_review"]].copy()
work_df = df[~df["needs_review"]].copy()
work_df = work_df.sort_values(
key_cols + ["status_rank", "loaded_at", "row_id"],
ascending=[True, True, False, False, True]
)
work_df["keep_row"] = ~work_df.duplicated(subset=key_cols, keep="first")
keepers = work_df[work_df["keep_row"]].copy()В этом учебном наборе правило дает предсказуемый результат. Для A100 остается строка 2: статус paid выше, чем new. Строка 3 выглядит как техническая копия той же предпочтительной версии. Для C300 из двух одинаковых по смыслу строк остается 6-я, потому что она выигрывает тай-брейк по row_id. А пара B200 уходит в ручной разбор: суммы 800 и 900 внутри одного ключа не стоит схлопывать без дополнительного контекста.
Как собрать журнал исключенных строк с причиной
Если сразу вызвать drop_duplicates, вы потеряете объяснение, что именно исчезло. Для контрольной таблицы удобнее сначала выделить предпочтительные строки, а затем собрать все проигравшие записи в отдельный журнал. В нем должны быть как минимум: исходный row_id, сохраненный kept_row_id, бизнес-ключ и причина исключения.
excluded = work_df[(work_df["rows_in_key"] > 1) & ~work_df["keep_row"]].copy()
winner_info = keepers[key_cols + ["row_id", "status_rank", "loaded_at"]].rename(
columns={
"row_id": "kept_row_id",
"status_rank": "kept_status_rank",
"loaded_at": "kept_loaded_at"
}
)
excluded = excluded.merge(winner_info, on=key_cols, how="left")
def get_reason(row):
if row["status_rank"] < row["kept_status_rank"]:
return "ниже приоритет статуса для того же бизнес-ключа"
if row["loaded_at"] < row["kept_loaded_at"]:
return "оставлена более поздняя загрузка"
return "техническая копия предпочтительной записи"
excluded["duplicate_reason"] = excluded.apply(get_reason, axis=1)
control_log = excluded[["row_id", "kept_row_id", "client_id", "invoice_no", "duplicate_reason"]]Проверяемый итог учебного кейса такой: из 8 строк автоматически сохраняются 3 строки в keepers — это 2, 6 и 8. В ручной разбор уходят 2 строки — 4 и 5. В журнал исключений попадают 3 строки — 1, 3 и 7. Сумма 3 + 2 + 3 дает исходные 8 строк, значит ни одна запись не потерялась между этапами.
| Группа | Что произошло | Результат |
|---|---|---|
| C01 + A100 | Повтор одного счета, статусы и файлы различаются | Оставлена строка 2, строки 1 и 3 записаны в журнал |
| C02 + B200 | Внутри ключа различается amount | Автоудаления нет, строки 4 и 5 отправлены на проверку |
| C03 + C300 | Техническая копия одной и той же записи | Оставлена строка 6, строка 7 записана в журнал |
| C04 + D400 | Уникальная запись | Остается без изменений |
Такой журнал полезен не только для отладки. По нему можно объяснить коллегам, почему строка исчезла из витрины, и отдельно посчитать типы проблем: устаревшие версии, технические копии, конфликтные ключи.
Как проверить результат перед публикацией таблицы
После очистки стоит проверить не только количество строк, но и логику решения. Во-первых, каждая исходная строка должна оказаться ровно в одном из трех наборов: keepers, control_log или review_df. Во-вторых, по каждому бизнес-ключу, который не ушел на ручной разбор, должна остаться ровно одна строка. В-третьих, причины исключения должны быть сформулированы так, чтобы их можно было понять без чтения кода.
Есть и практический критерий качества: сможете ли вы через неделю ответить, почему была удалена строка 7, не пересобирая весь расчет заново. Если ответ хранится только в сортировке внутри ноутбука, процесс хрупкий. Если он лежит в контрольной таблице отдельным полем, решение можно проверить и переиспользовать.
- Не ищите дубли по всем столбцам, пока не назвали бизнес-ключ.
- Отделяйте авторазрешимые повторы от конфликтных групп, где меняются важные поля.
- Фиксируйте правило выбора строки заранее: приоритет статуса, время загрузки, тай-брейк.
- Собирайте журнал исключенных строк вместо молчаливого удаления.
- Проверяйте, что все исходные строки разложились по итоговым наборам без потерь.
Если хотите потренировать такой подход в учебной среде, в карточке курса «Погружение в мир AI: наука о данных и машинное обучение» на Азбуке курсов и в подтвержденной программе школы есть темы по работе с DataFrame, чтению CSV/XLSX/SQL и подготовке данных в Pandas, включая удаление дубликатов, обработку пропусков и группировку. Для задачи с журналом дублей полезна именно связка этих тем, а не одно действие drop_duplicates().





















