Поиск дубликатов в данных на Python: разбор подходов
Дубликаты в датасете портят агрегаты и раздувают базу. Разбираем, чем set отличается от pandas.duplicated, как ловить нечёткие совпадения и где каждый подход ломается на реальных данных.

Есть выгрузка заказов на 2,3 млн строк из CRM. Отдел аналитики жалуется: сумма выручки в отчёте на 4% выше, чем в бухгалтерии. Причина банальна — часть заказов задвоилась при синхронизации, и GROUP BY честно просуммировал каждый дважды. Задача: найти дубликаты и решить, какие удалять. Ниже — как это делают на Python, чем платит каждый подход и где он ломается на грязных данных.
Работаем в связке Python 3.11 и pandas 2.x. Для нечёткого сравнения понадобится rapidfuzz — он быстрее устаревшего fuzzywuzzy. Все примеры воспроизводимы на любом CSV с колонками вроде email, телефона и имени.
Что вообще считать дубликатом
Прежде чем писать код, договоритесь о критерии. "Дубликат" — это не одно понятие, а минимум три разных задачи с разной ценой ошибки:
- Полное совпадение строки. Все поля идентичны. Самый простой и самый безопасный случай.
- Совпадение по ключу. Одинаковый email или телефон при разных остальных полях. Здесь уже нужно решать, какую запись оставить.
- Нечёткое совпадение. "ООО Ромашка" и "Ромашка, ООО", "ivanov@mail.ru" и "Ivanov@Mail.ru". Формально строки разные, по смыслу — один объект.
Ошибиться дорого в обе стороны. Пропустите дубликат — завысите метрики. Склеите две разные записи как дубликат — потеряете реального клиента. Поэтому нечёткий поиск почти никогда не удаляет автоматически, а помечает пары на ручную проверку.
Подход 1: точные дубликаты через pandas
Для полного совпадения строк не нужно ничего изобретать. duplicated() возвращает булеву маску, drop_duplicates() сразу чистит.
import pandas as pd
# pandas 2.x
orders = pd.read_csv("orders.csv")
# Сколько полностью совпадающих строк
dup_mask = orders.duplicated(keep=False) # True для всех членов группы дублей
print(f"Строк-дублей: {dup_mask.sum()}")
# Смотрим сами дубли, отсортировав рядом
doubles = orders[dup_mask].sort_values(list(orders.columns))
print(doubles.head(20))
# Удаляем, оставляя первое вхождение
clean = orders.drop_duplicates(keep="first")
Параметр keep решает многое: "first" и "last" оставляют одну запись из группы, False помечает или удаляет все. Для аудита берите keep=False — вы увидите обе стороны каждой пары, а не только "лишнюю".
Чем платите: duplicated() сравнивает строки как есть. Лишний пробел, разный регистр email, "+7" против "8" в телефоне — и записи считаются разными. На реальной выгрузке точное сравнение находит хорошо если половину дублей.
Подход 2: дубликаты по ключу с нормализацией
Здесь начинается настоящая работа. Прежде чем сравнивать, поля надо привести к единому виду. Для email — нижний регистр и обрезка пробелов, для телефона — только цифры и единый формат кода страны.
import pandas as pd
import re
def norm_email(value):
if pd.isna(value):
return None
return str(value).strip().lower()
def norm_phone(value):
if pd.isna(value):
return None
digits = re.sub(r"\D", "", str(value))
# приводим ведущую 8 к 7 для российских номеров
if len(digits) == 11 and digits.startswith("8"):
digits = "7" + digits[1:]
return digits or None
orders["email_norm"] = orders["email"].map(norm_email)
orders["phone_norm"] = orders["phone"].map(norm_phone)
# Дубли по нормализованному email
key_dupes = orders[orders.duplicated(subset=["email_norm"], keep=False)]
key_dupes = key_dupes[key_dupes["email_norm"].notna()]
print(key_dupes.sort_values("email_norm").head(20))
Обратите внимание на фильтр notna(). Пустой email или телефон — не дубликат, а отсутствие данных. Если этого не сделать, все записи без email схлопнутся в одну гигантскую "группу дублей" и вы удалите половину базы. Это классическая ошибка, которая проходит тесты на маленьком датасете и взрывается на проде.
Нормализация ключа важнее алгоритма сравнения. Плохой ключ на хорошем алгоритме даёт мусор; хороший ключ ловит 90% дублей обычным duplicated.
Когда в группе несколько записей, встаёт вопрос — какую оставить. Логика зависит от данных: обычно берут запись с самой поздней датой обновления или с наибольшим числом заполненных полей.
# Оставляем самую свежую запись по каждому email
orders_sorted = orders.sort_values("updated_at", ascending=False)
clean_by_key = orders_sorted.drop_duplicates(subset=["email_norm"], keep="first")
Подход 3: нечёткое сравнение через rapidfuzz
Когда ключа нет или он тоже грязный (имена, названия компаний, адреса), точное сравнение бессильно. Тогда считают меру похожести строк. Библиотека rapidfuzz даёт метрики Левенштейна на C, поэтому работает в разы быстрее чистого Python.
from rapidfuzz import fuzz, process
companies = [
"ООО Ромашка",
"Ромашка ООО",
"ООО Василёк",
"ооо ромашка ",
]
# Сравнение одной пары, 0..100
print(fuzz.token_sort_ratio("ООО Ромашка", "Ромашка ООО")) # высокий балл
# Поиск похожих для каждого элемента выше порога
for i, name in enumerate(companies):
matches = process.extract(
name, companies, scorer=fuzz.token_sort_ratio, limit=3
)
for match_name, score, idx in matches:
if idx != i and score >= 85:
print(f"{name!r} ~ {match_name!r} ({score})")
token_sort_ratio сначала сортирует слова, поэтому "ООО Ромашка" и "Ромашка ООО" совпадут. Порог 85 — не догма: подбирайте по своим данным, глядя на ложные срабатывания.
Главная проблема нечёткого поиска — сложность. Сравнение всех пар — это O(n²). На 2 млн записей это 2×10¹² операций, счёт не закончится никогда. Поэтому process.extract в лоб по всей таблице применять нельзя.
Блокировка: как не сравнивать всех со всеми
Стандартный приём — блокинг. Сначала грубо разбиваете записи на группы по дешёвому признаку (первая буква, город, первые цифры телефона), а нечёткое сравнение делаете только внутри группы. Это снижает число сравнений на порядки.
# Грубый блок: первый символ нормализованного названия
orders["block"] = orders["name_norm"].str[0]
for block_key, group in orders.groupby("block"):
names = group["name_norm"].tolist()
# внутри группы сравнений на порядки меньше
# ... запуск process.extract только по names
Цена блокинга: если два дубля попали в разные блоки (например, из-за опечатки в первой букве), вы их не найдёте. Компромисс между полнотой и скоростью выбираете вы.
Что выбрать под задачу
| Ситуация | Подход | Цена |
|---|---|---|
| Полностью идентичные строки | drop_duplicates() | Пропускает всё, что отличается регистром и пробелами |
| Есть стабильный ключ (email, ИНН) | Нормализация + duplicated(subset) | Нужно решить, какую запись оставлять |
| Ключ грязный или его нет | rapidfuzz + блокинг | O(n²), ложные срабатывания, ручная проверка |
| Десятки млн строк, ключ есть | Дедупликация в SQL/БД, не в pandas | Логика уезжает из Python |
Правило простое: поднимайтесь по сложности только когда предыдущий уровень не справился. Сначала нормализуйте и попробуйте точное совпадение по ключу. Нечёткий поиск — последняя мера, потому что он единственный требует человека в цикле.
Где это ломается
- Пустые значения как ключ. Разобрали выше — фильтруйте
NaNперед группировкой, иначе схлопнете все записи без данных. - Память на нечётком поиске.
process.cdistстроит матрицу похожести n×n. На нескольких тысячах записей это уже сотни МБ. Держите размер блока под контролем. - Транзитивность склейки. A похож на B, B похож на C, но A и C — разные. При автоматической склейке пар вы можете нечаянно объединить A и C в одну сущность. Для честной группировки нужен алгоритм связных компонент (например, через
networkx), а не простое попарное слияние. - Кодировки и невидимые символы. Неразрывный пробел U+00A0 выглядит как обычный, но ломает точное сравнение. Прогоняйте строки через нормализацию Unicode при чтении.
Что делать дальше
Соберите пайплайн из трёх слоёв: нормализация полей на входе, точная дедупликация по ключу, и только для остатка — нечёткое сравнение с блокингом и выгрузкой пар на ручную проверку. Логируйте, сколько записей удалено на каждом шаге — это ваш контроль, что чистка не съела лишнее.
Дальше стоит посмотреть в сторону recordlinkage — библиотеки, которая собирает блокинг, сравнение и классификацию пар в один интерфейс. А объединение связанных дублей в кластеры (то самое A-B-C) — отдельная тема; здесь мы остановились на поиске и пометке пар.
Поиск источников: Реальные статьи и книги со ссылками, библиография по ГОСТ
Спросить за 15 ₽Источники: pandas.DataFrame.duplicated — официальная документация, RapidFuzz — документация
Частые вопросы
У меня pandas 1.x, код сработает?
Чем rapidfuzz лучше fuzzywuzzy?
Почему нечёткий поиск удалил нужные записи?
Как найти дубликаты не по одному полю, а по нескольким сразу?
На миллионах строк нечёткий поиск не заканчивается, что делать?
Как узнать, что чистка не удалила лишнего?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.