Привет, Хабр!
Тонкое скрещивание 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-слоя до потребностей бизнеса.
Больше актуальных навыков по работе с данными вы можете получить в рамках практических онлайн-курсов от экспертов отрасли.
Комментарии (2)
economist75
31.05.2025 22:11В анализе постоянно приходится делать string-category-string преобразования, выигрыш слишком велик чтобы им пренебречь.
sbars
Обидно было для экономии памяти перейти от string на category и при merge наблюдать, как category превращается в string, пожирая сэкономленную память. Пришлось писать правильный merge, а заодно правильные groupby и pivot_table - observed=False по-умолчанию, это перебор (в том числи и в прямом смысле :) ).