Привет, Хабр!
Тонкое скрещивание 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
-
Явно группируем дубликаты до merge.
-
После слияния проверяем агрегаты (например, что сумма
qtyне удвоилась). -
На 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. Она делает сразу несколько вещей:
-
Приведение ключа к
stringи нормализация Unicode
Даже если вход былintилиobject, всё приводится кstring-типу, нормализуется (NFKCубирает визуально похожие символы) иstrip-ится. -
Базовая валидация на дубликаты
Если одна из сторон содержит неуникальные ключи,merge()может вести себя как декартово произведение — что не всегда ожидаемо. Приstrict=Trueтакие ситуации блокируются на этапе выполнения с явнымValueError. -
Встроенный
indicator=Trueи проверка на orphan-строкиindicatorдобавляет столбецmerge, по которому сразу можно понять, какие строки остались без пары. Послеmergeмы фильтруем все строки, гдеmerge != "both", и если они есть — кидаемRuntimeErrorс их дампом.
Делитесь своим опытом работы с merge в комментариях.
Если вы работаете с данными, вы наверняка сталкивались с тем, что «обычный merge» — лишь вершина айсберга. Под капотом — чистка, валидация, агрегирование и грамотная архитектура хранилища. Если статья оказалась вам близка по духу — возможно, будет полезно углубиться ещё в несколько тем, где качество соединения и подготовки данных решает всё.
Вот три открытых урока, которые расширят взгляд на аналитику данных — от кластеризации до построения DWH и ML на больших объёмах:
-
2 июня в 18:00
Популярные методы кластеризации
Что умеют k-means и DBSCAN, где их используют и как настроить всё руками. -
11 июня в 20:00
Spark ML
Инструменты масштабируемого машинного обучения: пайплайны, трансформеры, индустриальные практики. -
1 июля в 20:00
Архитектура DWH по рецепту: метод борща в действии
Как проектировать аналитическую платформу — от логики core-слоя до потребностей бизнеса.
Больше актуальных навыков по работе с данными вы можете получить в рамках практических онлайн-курсов от экспертов отрасли.
ссылка на оригинал статьи https://habr.com/ru/articles/913736/
Добавить комментарий