Перейти к содержанию
Публикация AiManual

Дедупликация списка поставщиков на Python: почему детерминированные этапы важнее similarity score

Две детерминированные стадии на Python снимают 76% дубликатов поставщиков без единого similarity score. Показываем рабочий код нормализации хоста, сведение к re

Коротко

Что будет в материале

  1. 01

    Почему similarity score не спасает от дубликатов доменов

  2. 02

    Детерминированная нормализация: первый этап, который убирает 76% дубликатов

  3. 03

    Сведение к registered domain через tldextract и Public Suffix List

  4. 04

    RapidFuzz process.cdist: скоринг оставшихся кандидатов

Список поставщиков на 10 000+ строк, где один и тот же сайт лежит под четырьмя разными написаниями, чистится не порогом похожести, а двумя скучными детерминированными стадиями. Нормализация хоста и сведение к registered domain через Public Suffix List сняли 76% дубликатов без единого similarity score. Дальше в дело идёт скоринг: fuzzy matcher оценил 33,7 миллиона пар за две секунды, и всё это работало на Python 3.12 с RapidFuzz 3.14.5 и tldextract 5.3.2 на одном vCPU облачной песочницы (разбор пайплайна и исходные цифры).

Главный вывод непривычен для тех, кто привык крутить порог fuzzy-матчинга: безопасного порога на коротких строках вроде доменов не существует. Диапазон score 88-97 содержит и настоящие дубликаты, и разные компании с похожими именами. Автоматическое слияние на любом значении внутри этого диапазона удалит часть живых поставщиков вместе с историей закупок.

Что делать вместо поиска магического порога: превратить similarity score в ранжированную очередь на ручную проверку, размер которой подбирается под доступные часы, а все слияния делаются обратимыми. Ниже пайплайн целиком: нормализация, registered domain, process.cdist, блокировка, ревью, аудит и замер качества.

Почему similarity score не спасает от дубликатов доменов

Метрика похожести сравнивает строки, а не организации. Хост из 15-20 символов несёт слишком мало информации, чтобы отличить опечатку от реально другого юрлица: обе ситуации дают score в диапазоне 88-97, и никакое значение не режет этот диапазон по смыслу.

Формальная теория тут не нова. Entity resolution получила статистическое описание в работе Fellegi и Sunter 1969 года: поля сравниваются взвешенно, а вес признака зависит от его различающей силы. Короткий домен несёт мало различающей информации, поэтому любые расстояния на нём шумят. Проблема не в библиотеке и не в выборе метрики, а в количестве информации внутри строки.

Пример: solartravelmag.com в четырёх написаниях

Один и тот же сайт приезжает в список так:

  • https://www.solartravelmag.com/blog?utm_source=newsletter
  • solartravelmag.com
  • blog.solartravelmag.com
  • SolarTravelMag.COM

Четыре строки, один поставщик. Каждая пара внутри этого квартета даёт score около 90-100, и здесь высокий балл честно отражает реальность. Проблема в том, что ровно такие же баллы получают пары вроде solartravelmag.com и solartravelmag.de или solartravelmag.com и solartravelmag.org, за которыми стоят другие компании. Метрика не подсказывает, какой из двух случаев перед вами, а порог, который отделит один от другого, придётся выдумать.

Разбор того, почему Damerau-Levenshtein, Jaro-Winkler и q-gram Jaccard одинаково плохо работают на коротких идентификаторах, есть в отдельном материале про fuzzy matching в даталейке.

Ловушки: домены, отличающиеся на одну правку

Самый неприятный класс записей - домены, которые отличаются от существующего бренда на одну правку и принадлежат другому поставщику. В том же синтетическом наборе таких ловушек было 180, а опечаток при вводе на одну клавишу - 505: одинаковый класс ошибок с противоположным правильным решением.

Живой пример: реальный бренд quantumleap.io и домен quantumleep.io другой компании. Разница в одну букву, score выше 90, а слияние удалит отдельного поставщика со всей его историей. Ловушка и опечатка неразличимы на уровне строки: и там, и там одна правка. Различает их только внешний контекст, которого в файле нет. Это и делает авто-мерж опасным при любой настройке порога.

Детерминированная нормализация: первый этап, который убирает 76% дубликатов

Первый этап в разобранном пайплайне намеренно примитивен: lowercase, отсечение схемы, обрезка пути и строки запроса, удаление ведущего www. Никаких моделей, словарей и эвристик, которые надо тюнить. Работает бесплатно, объясняется за минуту и даёт одинаковый результат на первом прогоне и на десятом.

Именно эта стадия вместе со сведением к registered domain сняла 76% дубликатов. В синтетическом наборе ей соответствовали 2 429 поверхностных вариантов (схемы, www-префиксы, случайный регистр, пути, трекинг-параметры), плюс 888 строк с поддоменами вроде blog. и m. и 529 пар страновых доменов вроде brand.com рядом с brand.de (состав набора в разборе). Поверхностные варианты закрываются нормализацией, поддомены снимаются вторым этапом, а страновые домены остаются человеку.

Как нормализовать хост на Python: код и порядок операций

Порядок операций важен, потому что каждая следующая зависит от результата предыдущей: сначала регистр (иначе WWW. и www. не совпадут), затем схема и хост, затем путь с параметрами, в конце ведущий www.

from urllib.parse import urlparse

def normalize_host(raw: str) -> str:
    host = raw.strip().lower()          # 1. регистр
    if "://" not in host:
        host = "http://" + host
    parsed = urlparse(host)             # 2. схема, путь, query
    host = parsed.netloc or parsed.path
    host = host.split("@")[-1]          # убираем user:pass
    host = host.split(":")[0]           # убираем порт
    if host.startswith("www."):         # 3. ведущий www
        host = host[4:]
    return host.rstrip(".")

Что важно не сломать: rstrip(".") вместо удаления всех точек, иначе пострадают домены, где точка значима. Регистр приводится до разбора URL, а www снимается только ведущий: www2.example.com и example.com это разные хосты, и склеивать их молча нельзя.

Функция возвращает хост, а не домен. На выходе у вас всё ещё blog.solartravelmag.com, и это правильно: нормализация не должна угадывать, где кончается бренд и начинается публичный суффикс.

Сведение к registered domain через tldextract и Public Suffix List

Второй детерминированный этап отвечает на вопрос, где в хосте заканчивается registered domain. Библиотека tldextract (версия 5.3.2 в разобранном пайплайне) делает это по Public Suffix List: списку публичных суффиксов, который знает, что .co.uk, .com.br и .pvt.k12.ma.us это суффиксы, а не бренды.

Без такого списка наивное правило «взять две последние метки» ломается на brand.co.uk и example.com.br. С ним blog.solartravelmag.com, m.solartravelmag.com и solartravelmag.com сходятся в одну строку, и 888 строк с поддоменами из набора перестают существовать как отдельные записи.

Пример кода: tldextract для registered domain

import tldextract

# suffix_list_urls=() отключает загрузку свежего PSL из сети,
# используется встроенный снимок списка
extract = tldextract.TLDExtract(suffix_list_urls=())

extract("blog.solartravelmag.com").registered_domain  # 'solartravelmag.com'
extract("brand.co.uk").registered_domain              # 'brand.co.uk'
extract("brand.de").registered_domain                 # 'brand.de'

Страновые домены остаются снаружи: brand.com и brand.de дадут два разных registered domain, хотя поставщик может быть один и тот же. Сводить их автоматически нельзя, потому что обратная ситуация встречается не реже: один бренд в разных странах это разные юрлица, договоры и условия оплаты. В наборе таких пар было 529, и каждая требует решения человека. Рабочее правило: держать их отдельным списком кандидатов и не сливать без подтверждения.

RapidFuzz process.cdist: скоринг оставшихся кандидатов

После двух детерминированных этапов остаётся то, что правилами не решается: опечатки, сокращения, транслитерации, перестановки слов. Здесь подключается RapidFuzz и его process.cdist, который считает матрицу оценок между двумя списками строк в один вызов.

from rapidfuzz import process, fuzz

hosts = [...]  # нормализованные registered domain'ы

scores = process.cdist(
    hosts, hosts,
    scorer=fuzz.token_set_ratio,
    score_cutoff=88,
    workers=-1,
)

33,7 миллиона пар за две секунды на одном vCPU означают, что скоринг перестаёт быть узким местом. Узким местом становится человек на другом конце очереди: сколько пар он способен осмысленно разобрать за час работы.

Метрику выбирают под природу строки. Для доменов осмысленны token-based варианты вроде token_set_ratio: они устойчивее к перестановкам и лишним токенам, чем посимвольные расстояния. Настраивать метрику имеет смысл, а искать в её значениях безопасный порог нет: ловушки из набора дают такой же высокий score, как настоящие дубликаты.

Блокировка по префиксу и суффиксу: как сократить число пар

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

def blocking_keys(host: str):
    label = host.split(".")[0]
    return (label[:4], label[-3:])  # префикс и суффикс основного лейбла

buckets = {}
for host in hosts:
    for key in blocking_keys(host):
        buckets.setdefault(key, set()).add(host)

candidates = []
for group in buckets.values():
    group = sorted(group)
    for i, a in enumerate(group):
        for b in group[i + 1:]:
            candidates.append((a, b))

Сколько именно пар срезает блокировка, зависит от данных, и это надо замерять, а не оценивать на глаз. Плата за экономию - потеря пар: solartravelmag.com и xolartravelmag.com с опечаткой в первом символе попадут в разные бакеты и не будут сравнимы никогда. Поэтому recall блокировки проверяется на размеченной выборке, а не «выглядит разумно».

Ранжированная очередь на ручную проверку вместо авто-мержа

Реальный результат работы матчера - ранжированный список пар для проверки, а не готовый набор слияний. Это меняет постановку задачи: вместо «какой порог поставить» вы решаете «сколько пар в неделю готовы просмотреть».

Размер очереди считается арифметикой. Если ревьюер разбирает 100 пар в час, а на задачу есть 5 часов в неделю, очередь на этот период - 500 пар. Сортировка по score задаёт порядок, при котором самые вероятные дубликаты идут первыми; хвост очереди откладывается, а не удаляется.

scoreпарачто видно сразучто решает человек
95quantumleap.io / quantumleep.ioодна букваловушка или опечатка
93brand.com / brand.deразные страныодно юрлицо или два
91solar-travel-mag.com / solartravelmag.comдефисыпроверить сайт и реквизиты
89nordwind.io / nordwind.coдругая зоначасто разные компании
88paintlab.io / paintlab.coсокращениесмотреть контакты

Ключевое свойство очереди в том, что она не выносит решений. Низкий score означает «проверить позже», а не «разные компании»: именно в хвосте могут оказаться опечатки с переставленными буквами. Верх очереди это не автоматический merge, а «смотреть первым делом».

Как сделать мержи обратимыми

Исходные строки не перезаписываются никогда. Слияние оформляется отдельной записью, и тогда откат это удаление одной строки, а не восстановление из бэкапа.

# таблица merge_log
# canonical_id | merged_id | score | reviewer | decided_at | reason | reverted_at

canonical = {row["id"]: row for row in source_rows}
for decision in merge_log:
    if decision["reverted_at"] is None:
        canonical.pop(decision["merged_id"], None)

В логе видно, кто и почему принял решение: при разборе ошибок это полезнее самого score. Отдельный вопрос - конфликтующие атрибуты. Если у двух записей различаются реквизиты, валюта или адрес, при слиянии придётся выбрать одно значение, и это решение тоже стоит писать в лог, а не оставлять «как получилось».

Проверка качества: recall, precision и размеченная выборка

У пайплайна две разные метрики, и путать их нельзя. Recall относится к блокировке: какую долю настоящих дубликатов она довела до сравнения. Precision относится к очереди: какую долю верхних пар ревьюер подтвердил.

Авто-мержей по построению нет, поэтому precision автоматических слияний здесь нечего считать: все слияния делает человек, а система лишь расставляет приоритеты. Зато precision очереди даёт рабочий сигнал: если из первых 100 пар подтвердились 70, ревьюер тратит треть времени на заведомый шум, и нижнюю границу очереди стоит поднять.

Как замерить recall блокировки

Берёте 200-500 пар, размеченных вручную, прогоняете через блокировку и считаете, сколько известных дубликатов осталось среди кандидатов. Арифметика простая: из 47 пар, помеченных как дубликаты, блокировка сохранила 39, значит recall = 0,83. Пропущенные 8 пар разбираются отдельно: если среди них опечатки в первом символе, ключ блокировки слишком узкий и его пора ослабить.

Все цифры точности из разобранного пайплайна получены на синтетическом наборе с готовой разметкой. Набор содержал 11 531 строку, описывающую 7 180 реальных поставщиков, и ground truth в нём есть по построению (методика и размер выборки). В реальном списке разметки нет, и её придётся создавать: 200-500 пар, размеченных вручную, дают основу для замера recall и precision. Как строить golden-выборку и считать метрики без маркетинговых процентов, разобрано на примере оценки точности детекции ПДн.

Ограничения подхода и типичные ошибки

Что пайплайн не делает:

  • не снимает опечатки и транслитерации: quantumleep.io останется отдельной строкой, пока человек не подтвердит слияние;
  • не разводит страновые домены: brand.com и brand.de требуют решения о юрлице, и автоматика тут опаснее ручного просмотра;
  • не даёт безопасного порога: диапазон 88-97 смешивает дубликаты и разные компании, поэтому авто-мерж исключён по определению;
  • не гарантирует полноту: блокировка по ключу может выбросить пару с опечаткой в начале строки, и без замера recall это останется незамеченным;
  • не проверен на реальных данных: все precision-оценки сделаны на синтетическом наборе с ground truth метками, а не на живом списке поставщиков.

Про масштаб: 11 531 строка это небольшая задача, где матрица оценок и один vCPU укладываются в секунды. На миллионах строк понадобится другой класс решений, где сравниваются не все пары подряд, а кандидаты, отобранные через MinHash и LSH. Как это устроено на 1,4 ТБ кода, разобрано в материале про дедупликацию данных для LLM.

И трезвая деталь про источники данных: автор разбора приводит пример маркетплейса, который открыто заявляет, что его указанные цены соответствуют реальности примерно в 90% случаев. Шум в справочниках это норма, поэтому идеальной разметки не будет ни при каком пайплайне. Обратимые мержи и ранжированная очередь значат здесь больше, чем идеально подобранный порог.

Подписаться на канал