Что может пойти не так с merge() — дубли, потери, ошибки типов

от автора

Привет, Хабр!

Тонкое скрещивание DataFrame-ов в pandas по-прежнему остаётся источником прелестных багов: случайных дубликатов, потерянных строк и неуловимых NaN-ов. В статье рассмотрим эти проблемы.

Строковые ключи с пробелами и Unicode-невидимками

Симптом

df_left  = pd.DataFrame({'id': ['A', 'B', ' C', 'D\u200b'], 'val': range(4)}) df_right = pd.DataFrame({'id': ['A', 'B', 'C', 'D'],      'note': ['ok']*4})  pd.merge(df_left, df_right, on='id', how='inner')

Получаем всего две строки вместо четырёх. Виновники — ведущий пробел и невидимый ZERO WIDTH SPACE.

Как чинится

strip-очистка:

strip = (     lambda s: (s.astype('string')                  .str.normalize('NFKC')   # унифицируем юникод                  .str.replace(r'\s+', ' ', regex=True)                  .str.strip()) )  df_left['id']  = strip(df_left['id']) df_right['id'] = strip(df_right['id']) 

Валидация ключей:

assert df_left['id'].isna().sum() == 0, 'Left id has NaN after strip' assert df_left['id'].duplicated().sum() == 0, 'Left id not unique' 

Тест-мердж с indicator=True:

(     pd.merge(df_left, df_right, on='id', how='outer', indicator=True)       .query("_merge != 'both'") )

Если список непересечений ненулевой — значит очистка неполная. indicator экономит часы логов.

many-to-many -> лавина дублей

Взрыв на песочнице

sales  = pd.DataFrame({'shop': [1,1,2,2], 'day': ['2025-05-01']*4, 'sku': [1,2,1,2], 'qty': [5,6,7,8]}) prices = pd.DataFrame({'sku': [1,1,2,2], 'region': ['EU','US']*2, 'price': [10,11,12,13]})  fat = sales.merge(prices, on='sku', how='inner') print(fat.shape)  # (16, 6) – BOOM

Четыре строки превратились в шестнадцать по правилу декартова произведения. Это классическая ловушка many-to-many.

validate — первый барьер

sales.merge(prices, on='sku', validate='m:m') # ValueError: Merge keys are not unique in either left or right dataset

Да, параметр существует с 2018-го, но до сих пор редко встречается в чужом коде. m:m не запрещает операцию — он лишь проверяет, что обе стороны действительно many-to-many; если одна сторона вдруг стала уникальной — это повод проверить логику.

Как жить с many-to-many

  1. Явно группируем дубликаты до merge.

  2. После слияния проверяем агрегаты (например, что сумма qty не удвоилась).

  3. На SQL-манер можно использовать anti-join чтобы найти строки, которым нет пары — начиная с pandas 3.0 это делается прямо в merge(how='left_anti').

NaN в ключах

Pandas трактует NaN как «не равно ничему, даже себе», поэтому:

left  = pd.DataFrame({'id': [1, 2, np.nan], 'x': [10, 20, 30]}) right = pd.DataFrame({'id': [1, np.nan], 'y': [100, 999]})  left.merge(right, on='id', how='inner')   # строка с NaN выпадет left.merge(right, on='id', how='outer')   # появятся два NaN-ключа

Если NaN логически означает «другая сущность», замените его sentinel-значением (-1 или '__missing__') перед merge — и сразу после верните обратно. Только не забудьте зафиксировать в тесте.

Диагностика: _merge

indicator=True добавляет столбец merge с тремя категориями (leftonly, right_only, both).

Обычный приём:

probe = (     left.merge(right, on='id', how='outer', indicator=True)          .pipe(lambda df: df[df._merge != 'both']) )  if not probe.empty:     raise RuntimeError(f'Найдены несовпадения:\n{probe.head()}')

Так ошибку ловит не QA, а юнит-тест.

Когда merge() не нужен

Первый сигнал, что пора положить merge() на полку — у вас нет логической операции «соединить по ключу». Если задача сводится к пришить один датафрейм к другому без сопоставления строк, значит нужно pd.concat(). Конкатенация просто выкладывает фреймы кирпичиками: по умолчанию вдоль оси 0 (строки) и сохраняет столбцы, даже если наборы колонок не совпадают. Типовой приём в ETL-пайплайне:

dfs = [pd.read_parquet(p) for p in paths]          # партиции за разные даты whole = pd.concat(dfs, ignore_index=True, sort=False)

ignore_index=True перегенерирует числовой индекс — без этого на больших массивах ловите дубликаты. sort=False экономит вам пару секунд и немного RAM, откладывая перестановку столбцов.

Когда ключом служит время, а вам нужно привязать показания датчика к ближайшему (или предыдущему) рекорду в другом фрейме, сцена ваша — merge_asof(). Этот полубрат merge() делает линейный nearest-join по отсортированным меткам. В банках на нём склеивают тики с рыночными котировками, в IIoT — телеметрию с расписанием парка станков:

aligned = pd.merge_asof(     trades.sort_values('timestamp'),     quotes.sort_values('timestamp'),     on='timestamp',     direction='backward',  # берём ближайшую котировку в прошлом     tolerance=pd.Timedelta('2s')  # и не дальше, чем 2 секунды )

Главное требование — оба DataFrame должны быть отсортированы по ключу; за это отвечает сам разработчик, иначе получите MergeError.

Если ваша цель — точечный патч значений без изменения «формы» таблицы, используйте DataFrame.update(). Метод идёт по совпадающим индексам и столбцам, заменяя только те ячейки, где в патче не NaN. Это безопасная альтернатива тяжелённому merge() плюс fillna():

base   = pd.DataFrame({'id': [1, 2, 3], 'price': [100, 200, 300]}) patch  = pd.DataFrame({'id': [2],       'price': [180]}).set_index('id')  base.set_index('id', inplace=True) base.update(patch)      # только строка id=2 будет переписана base.reset_index(inplace=True)

update() не вернёт нового объекта — он работает in-place, поэтому используйте копию, если нужно сохранить исходный набор.

Паттерн «до продакшена»

def safe_merge(left: pd.DataFrame,                right: pd.DataFrame,                *,                on: str,                how: str = 'inner',                validate: str | None = None,                strict: bool = True) -> pd.DataFrame:     """Обёртка над pandas.merge с валидацией и логами"""     _left  = left.copy()     _right = right.copy()      # 1. Приведём ключ к строковому string-dtype     for df in (_left, _right):         df[on] = (             df[on].astype('string')                   .str.normalize('NFKC')                   .str.strip()         )      # 2. Базовая валидация     if strict:         dup_left  = _left[on].duplicated().any()         dup_right = _right[on].duplicated().any()         if dup_left or dup_right:             raise ValueError(f'Duplicates in {"left" if dup_left else "right"} key column {on}')      # 3. Слияние     res = _left.merge(_right,                       on=on,                       how=how,                       validate=validate,                       indicator=True)      # 4. Проверка на потери     lost = res.query('_merge != "both"')     if strict and not lost.empty:         raise RuntimeError(f'Merge produced orphan rows:\n{lost.head()}')      return res.drop(columns='_merge')

Смысл обёртки — не дать прокатиться багу дальше CI. Она делает сразу несколько вещей:

  1. Приведение ключа к string и нормализация Unicode
    Даже если вход был int или object, всё приводится к string-типу, нормализуется (NFKC убирает визуально похожие символы) и strip-ится.

  2. Базовая валидация на дубликаты
    Если одна из сторон содержит неуникальные ключи, merge() может вести себя как декартово произведение — что не всегда ожидаемо. При strict=True такие ситуации блокируются на этапе выполнения с явным ValueError.

  3. Встроенный indicator=True и проверка на orphan-строки
    indicator добавляет столбец merge, по которому сразу можно понять, какие строки остались без пары. После merge мы фильтруем все строки, где merge != "both", и если они есть — кидаем RuntimeError с их дампом.


Делитесь своим опытом работы с merge в комментариях.

Если вы работаете с данными, вы наверняка сталкивались с тем, что «обычный merge» — лишь вершина айсберга. Под капотом — чистка, валидация, агрегирование и грамотная архитектура хранилища. Если статья оказалась вам близка по духу — возможно, будет полезно углубиться ещё в несколько тем, где качество соединения и подготовки данных решает всё.

Вот три открытых урока, которые расширят взгляд на аналитику данных — от кластеризации до построения DWH и ML на больших объёмах:

Больше актуальных навыков по работе с данными вы можете получить в рамках практических онлайн-курсов от экспертов отрасли.


ссылка на оригинал статьи https://habr.com/ru/articles/913736/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *