Indice / pandas: combinar y resumir / 6.3
merge avanzado
Cuatro herramientas que convierten un merge de
«espero que haya salido bien» en uno que informa qué pasó y
falla cuando algo no cierra.
Para qué sirve
La lección anterior dejó tres preguntas abiertas, y las tres tienen respuesta de una línea:
- ¿Qué filas encontraron pareja y cuáles no? →
indicator=True - ¿Qué hay en una tabla y no en la otra? → anti-join
- ¿Cómo evito que un duplicado multiplique el archivo? →
validate=
Y queda una cuarta que no es técnica: cuando las dos fuentes dicen cosas distintas del mismo producto, cuál gana. Esa la decide el enunciado, no pandas.
ELI5Explicado facil
Juntaste las dos listas de invitados y ahora tenés que contestar tres preguntas distintas: ¿quién estaba en las dos?, ¿quién estaba en una sola? y ¿me aseguré de que nadie figure dos veces?
Y una cuarta, la incómoda: si una lista dice que Ana come sin sal y la otra dice que no, alguien tiene que decidir. Ninguna de las dos listas lo va a resolver sola.
indicator=True
import pandas as pd
def unico(nombre):
t = pd.read_csv(nombre)
return (t.sort_values("actualizado", kind="stable")
.drop_duplicates(subset=["sku"], keep="last"))
a = unico("catalogo_sistema_a.csv")
b = unico("catalogo_sistema_b.csv")
todo = a.merge(b, on="sku", how="outer", suffixes=("_a", "_b"),
indicator=True)
print("outer:", len(todo), "filas")
print()
print(todo["_merge"].value_counts().to_string())
print()
print("Esa columna es lo que convierte a un outer en un diagnostico:")
print("sin ella, las filas con precio_b vacio pueden ser 'no esta en B'")
print("o 'esta en B pero sin precio', y son cosas muy distintas.")
print()
print(todo.loc[todo["_merge"] != "both",
["sku", "precio_a", "precio_b", "_merge"]]
.head(4).to_string(index=False))
outer: 70 filas
_merge
both 46
left_only 14
right_only 10
Esa columna es lo que convierte a un outer en un diagnostico:
sin ella, las filas con precio_b vacio pueden ser 'no esta en B'
o 'esta en B pero sin precio', y son cosas muy distintas.
sku precio_a precio_b _merge
SKU001 205.25 NaN left_only
SKU002 31.50 NaN left_only
SKU003 31.89 NaN left_only
SKU004 NaN NaN left_only
La columna _merge convierte el outer en un diagnóstico:
46 productos en las dos fuentes, 14 solo en A, 10 solo en B. Sin ella, una fila con
precio_b vacío puede significar «no está en B» o «está en B
pero sin precio», y son cosas muy distintas.
_merge es una columna categórica con tres valores:
"both", "left_only" y "right_only". Se filtra
como cualquier otra, y se saca antes de escribir el archivo con
drop(columns="_merge").
Anti-join
import pandas as pd
def unico(nombre):
t = pd.read_csv(nombre)
return (t.sort_values("actualizado", kind="stable")
.drop_duplicates(subset=["sku"], keep="last"))
a = unico("catalogo_sistema_a.csv")
b = unico("catalogo_sistema_b.csv")
# El anti-join no es un how: es un left (u outer) con indicator, filtrado.
solo_a = (a.merge(b, on="sku", how="left", indicator=True)
.query('_merge == "left_only"'))
print("productos que estan solo en A:", len(solo_a))
print(solo_a[["sku", "nombre_x", "precio_x"]].head(4).to_string(index=False))
print()
solo_b = (b.merge(a, on="sku", how="left", indicator=True)
.query('_merge == "left_only"'))
print("productos que estan solo en B:", len(solo_b))
print()
print("Es lo mismo que se podria hacer con conjuntos:")
print("len(set(a.sku) - set(b.sku)) =", len(set(a["sku"]) - set(b["sku"])))
print("pero el merge te devuelve las FILAS enteras, no solo las claves.")
productos que estan solo en A: 14
sku nombre_x precio_x
SKU008 Artículo 008 635.69
SKU012 Artículo 012 445.66
SKU011 Artículo 011 767.47
SKU004 Artículo 004 NaN
productos que estan solo en B: 10
Es lo mismo que se podria hacer con conjuntos:
len(set(a.sku) - set(b.sku)) = 14
pero el merge te devuelve las FILAS enteras, no solo las claves.
El anti-join no es un how: es un left
con indicator, filtrado por left_only. Se podría hacer con
conjuntos, pero eso devuelve solo las claves; el merge te devuelve las filas
enteras, que es lo que hace falta para un informe.
validate: que falle en vez de multiplicar
import pandas as pd
a = pd.read_csv("catalogo_sistema_a.csv")
b = pd.read_csv("catalogo_sistema_b.csv")
try:
a.merge(b, on="sku", how="inner", validate="1:1")
except pd.errors.MergeError as error:
print("validate='1:1' ->", type(error).__name__)
print(error)
print()
print("El mensaje dice DONDE esta el problema, que es lo que uno quiere.")
def unico(t):
return (t.sort_values("actualizado", kind="stable")
.drop_duplicates(subset=["sku"], keep="last"))
ok = unico(a).merge(unico(b), on="sku", how="inner", validate="1:1",
suffixes=("_a", "_b"))
print()
print("deduplicando primero, validate='1:1' pasa:", len(ok), "filas")
print()
# El otro caso frecuente: muchas ventas contra un catalogo.
ventas = pd.read_csv("dw_hecho_ventas.csv")
productos = pd.read_csv("dw_dim_producto.csv")
cruce = ventas.merge(productos, on="producto_id", how="left", validate="m:1")
print("ventas x productos con validate='m:1':", len(cruce), "filas")
print("m:1 afirma que el catalogo tiene una fila por producto.")
print("Si manana alguien duplica un producto, este merge para en vez de")
print("duplicar 500 ventas.")
validate='1:1' -> MergeError
Merge keys are not unique in either left or right dataset; not a one-to-one merge.
Duplicates in left:
sku
SKU002
SKU007 ...
Duplicates in right:
sku
SKU020 ...
El mensaje dice DONDE esta el problema, que es lo que uno quiere.
deduplicando primero, validate='1:1' pasa: 46 filas
ventas x productos con validate='m:1': 500 filas
m:1 afirma que el catalogo tiene una fila por producto.
Si manana alguien duplica un producto, este merge para en vez de
duplicar 500 ventas.
merge que multiplica filas no da error
Si la clave se repite en las dos tablas, cada aparición se cruza con cada aparición. Con dos duplicados son dos filas de más y nadie lo nota; con veinte, el archivo se multiplica y los totales dejan de tener sentido.
validate="1:1" afirma que la clave es única en las dos.
validate="m:1" afirma que lo es en la derecha - el caso de «muchas
ventas contra un catálogo», que es el más común. Y el mensaje de error
dice qué claves están repetidas, que es exactamente lo que uno
querría saber.
| Valor | Afirma que la clave es única en | Caso típico |
|---|---|---|
"1:1" | las dos tablas | Unificar dos catálogos |
"m:1" | la derecha | Hechos contra dimensión: ventas × productos |
"1:m" | la izquierda | El espejo, poco usado |
"m:m" | ninguna | Explícitamente: sé que va a multiplicar |
Sufijos
import pandas as pd
def unico(nombre):
t = pd.read_csv(nombre)
return (t.sort_values("actualizado", kind="stable")
.drop_duplicates(subset=["sku"], keep="last"))
a = unico("catalogo_sistema_a.csv")
b = unico("catalogo_sistema_b.csv")
print("columnas de A:", list(a.columns))
print("columnas de B:", list(b.columns))
print("comparten todo menos la clave, asi que hay 4 choques de nombre.")
sin_sufijos = a.merge(b, on="sku", how="inner")
print()
print("sin declarar sufijos, pandas pone _x y _y:")
print([c for c in sin_sufijos.columns if c.endswith(("_x", "_y"))])
con_sufijos = a.merge(b, on="sku", how="inner", suffixes=("_a", "_b"))
print()
print("declarandolos:")
print([c for c in con_sufijos.columns if c.endswith(("_a", "_b"))])
print()
print("_x y _y no significan nada dentro de seis meses. _a y _b tampoco")
print("mucho, pero al menos se corresponden con los nombres de los archivos.")
print("Lo mejor es que digan de que sistema viene cada dato.")
columnas de A: ['sku', 'nombre', 'precio', 'stock', 'actualizado']
columnas de B: ['sku', 'nombre', 'precio', 'stock', 'actualizado']
comparten todo menos la clave, asi que hay 4 choques de nombre.
sin declarar sufijos, pandas pone _x y _y:
['nombre_x', 'precio_x', 'stock_x', 'actualizado_x', 'nombre_y', 'precio_y', 'stock_y', 'actualizado_y']
declarandolos:
['nombre_a', 'precio_a', 'stock_a', 'actualizado_a', 'nombre_b', 'precio_b', 'stock_b', 'actualizado_b']
_x y _y no significan nada dentro de seis meses. _a y _b tampoco
mucho, pero al menos se corresponden con los nombres de los archivos.
Lo mejor es que digan de que sistema viene cada dato.
Cuando las dos tablas tienen columnas homónimas, pandas agrega _x y
_y. Funciona y no significa nada: dentro de seis meses nadie sabe cuál
era la de la izquierda. suffixes=("_a", "_b") es una palabra más y hace
el archivo legible.
Unificar de verdad
import pandas as pd
def unico(nombre):
t = pd.read_csv(nombre)
return (t.sort_values("actualizado", kind="stable")
.drop_duplicates(subset=["sku"], keep="last"))
a = unico("catalogo_sistema_a.csv")
b = unico("catalogo_sistema_b.csv")
todo = a.merge(b, on="sku", how="outer", suffixes=("_a", "_b"),
indicator=True)
print("unificado:", len(todo), "filas")
comunes = todo[todo["_merge"] == "both"]
# La guarda de notna importa: NaN != valor da True, y un dato que falta en una
# fuente NO es una contradiccion entre las dos.
def difieren(columna):
a_col, b_col = comunes[f"{columna}_a"], comunes[f"{columna}_b"]
return a_col.ne(b_col) & a_col.notna() & b_col.notna()
def falta_en_una(columna):
return comunes[f"{columna}_a"].isna() ^ comunes[f"{columna}_b"].isna()
print()
print("de los", len(comunes), "productos que estan en las dos fuentes:")
print(" se contradicen en el precio :", int(difieren("precio").sum()))
print(" se contradicen en el nombre :", int(difieren("nombre").sum()))
print(" a una le falta el nombre :", int(falta_en_una("nombre").sum()))
print(" alguna de las tres cosas :",
int((difieren("precio") | difieren("nombre")
| falta_en_una("nombre")).sum()))
print()
print("Sin la guarda de notna, 'nombre distinto' daria",
int(comunes["nombre_a"].ne(comunes["nombre_b"]).sum()),
"en vez de", int(difieren("nombre").sum()) )
print("porque cuenta como contradiccion la fila donde B no tiene nombre.")
print()
print(comunes.loc[difieren("precio"), ["sku", "precio_a", "precio_b"]]
.head(4).to_string(index=False))
print()
# combine_first llena los huecos de la primera con la segunda. Es la forma
# corta de decir "gana A, y si A no tiene, uso B".
precio = todo["precio_a"].combine_first(todo["precio_b"])
print("precio unificado con 'gana A':", int(precio.notna().sum()),
"de", len(todo), "con valor")
print()
print("Cual gana NO lo deciden los datos. Aca elegimos A por escrito;")
print("elegir B, o el mas reciente, o promediarlos, son politicas distintas")
print("y todas producen un archivo distinto.")
unificado: 70 filas
de los 46 productos que estan en las dos fuentes:
se contradicen en el precio : 11
se contradicen en el nombre : 9
a una le falta el nombre : 1
alguna de las tres cosas : 18
Sin la guarda de notna, 'nombre distinto' daria 10 en vez de 9
porque cuenta como contradiccion la fila donde B no tiene nombre.
sku precio_a precio_b
SKU015 146.43 164.00
SKU017 373.88 -19.99
SKU020 256.96 145.00
SKU025 171.32 191.88
precio unificado con 'gana A': 69 de 70 con valor
Cual gana NO lo deciden los datos. Aca elegimos A por escrito;
elegir B, o el mas reciente, o promediarlos, son politicas distintas
y todas producen un archivo distinto.
De los 46 productos que están en las dos fuentes, 18 no dicen lo
mismo: 9 se contradicen en el nombre, 11 en el precio, 3 en las dos cosas, y
en 1 el problema es otro - B directamente no tiene el nombre. El
merge las trae todas y no resuelve ninguna.
Fijate en el conteo del ejemplo: sin la guarda de notna(), «nombre
distinto» daría 10 en vez de 9, porque
NaN != "Artículo 033" es True. Un dato que falta en una
fuente no es una contradicción entre las dos, y confundirlos hace
que el informe pida una decisión donde solo había que copiar un valor.
No hay una respuesta técnica. Hay políticas, y cada una produce un archivo distinto:
- Gana una fuente (
combine_first) - simple y defendible si hay una razón: «A es el sistema de facturación y manda». - Gana el más reciente - usa la columna
actualizadode cada lado. Es la política más común y la que más se parece a la verdad. - Informar y no elegir - producir el archivo de
discrepancias y que decida una persona. Es lo correcto cuando la diferencia es
grande: un producto pasa de
373.88a-19.99, y eso no es una actualización, es un dato roto.
Elijas la que elijas, el número que hay que publicar es 18: la cantidad de productos donde las dos fuentes no coinciden. Un archivo unificado que no dice eso está escondiendo el problema.
Dónde se rompe
Causa: validate="1:1" y la clave se repite. Esto es
el sistema funcionando: el error es la respuesta correcta.
Arreglo: mirá qué claves nombra el mensaje y decidí la política de
deduplicación (lección 5.5). No saques el validate.
Causa: falta el validate y hubo multiplicación.
Arreglo: assert len(u) == len(set(a.k) | set(b.k)) después
de un outer. Si no cierra, hay duplicados.
Causa: no se declararon los sufijos y hay nombres repetidos.
Arreglo: suffixes=("_a", "_b"), o mejor, nombres que digan
de qué sistema viene el dato.
Causa: son dos cosas distintas: la fila sí encontró pareja, y el dato faltaba en el archivo de origen.
Arreglo: ninguno - es la información que indicator
existe para darte. Sin esa columna las dos situaciones serían indistinguibles.
Causa: NaN != NaN da True. Dos filas donde
ninguna fuente tiene el dato aparecen como «distintas».
Arreglo: comparar solo donde las dos tienen valor, o usar
.ne(otra) & a.notna() & b.notna().
Recetario
| Expresión | Qué hace | Cuándo |
|---|---|---|
merge(..., indicator=True) | Agrega _merge con both/left_only/right_only | Siempre que uses outer o left |
.query('_merge == "left_only"') | Anti-join | Lo que está en una sola tabla |
merge(..., validate="1:1") | Falla si la clave se repite en alguna | Unificar dos catálogos |
merge(..., validate="m:1") | Falla si se repite en la derecha | Hechos contra dimensión |
suffixes=("_a", "_b") | Nombra las columnas homónimas | Siempre que haya choques |
a.combine_first(b) | Toma a, y donde falta usa b | «Gana A» |
x.ne(y) & x.notna() & y.notna() | Distintos, sin contar los ausentes | Informe de discrepancias |
drop(columns="_merge") | Saca la columna auxiliar | Antes de escribir |
assert len(u) == len(set(a.k) | set(b.k)) | El outer no multiplicó | Después de unificar |