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

Тонкое скрещивание 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 на больших объёмах:

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

Комментарии (2)


  1. sbars
    31.05.2025 22:11

    Обидно было для экономии памяти перейти от string на category и при merge наблюдать, как category превращается в string, пожирая сэкономленную память. Пришлось писать правильный merge, а заодно правильные groupby и pivot_table - observed=False по-умолчанию, это перебор (в том числи и в прямом смысле :) ).


  1. economist75
    31.05.2025 22:11

    В анализе постоянно приходится делать string-category-string преобразования, выигрыш слишком велик чтобы им пренебречь.