Saltar al contenido

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:

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

Ejemploindicator=True: de que lado vino cada fila
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.

Se activa una vez y se usa después

_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

EjemploAnti-join: lo que esta en una tabla y no en la otra
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

Ejemplovalidate: que el merge falle en vez de multiplicar en silencio
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.
Un 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.

Los cuatro validate
ValorAfirma que la clave es única enCaso típico
"1:1"las dos tablasUnificar dos catálogos
"m:1"la derechaHechos contra dimensión: ventas × productos
"1:m"la izquierdaEl espejo, poco usado
"m:m"ningunaExplícitamente: sé que va a multiplicar

Sufijos

EjemploSufijos: cuando las dos tablas tienen columnas con el mismo nombre
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

EjemploUnificar dos fuentes: outer y despues resolver los conflictos
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.

Cuál fuente gana

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 actualizado de 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.88 a -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

MergeError: Merge keys are not unique in either left or right dataset

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.

(sin error) el archivo unificado tiene más filas que la unión de las claves

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.

(sin error) las columnas quedaron con _x e _y

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.

(sin error) _merge dice both y una columna está vacía igual

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.

(sin error) al comparar dos columnas, los ausentes salen como discrepancia

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

merge avanzado
ExpresiónQué haceCuándo
merge(..., indicator=True)Agrega _merge con both/left_only/right_onlySiempre que uses outer o left
.query('_merge == "left_only"')Anti-joinLo que está en una sola tabla
merge(..., validate="1:1")Falla si la clave se repite en algunaUnificar dos catálogos
merge(..., validate="m:1")Falla si se repite en la derechaHechos contra dimensión
suffixes=("_a", "_b")Nombra las columnas homónimasSiempre 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 ausentesInforme de discrepancias
drop(columns="_merge")Saca la columna auxiliarAntes de escribir
assert len(u) == len(set(a.k) | set(b.k))El outer no multiplicóDespués de unificar

Ponete a prueba

1. ¿Que agrega indicator=True a un merge?Nivel 1 · Recordar
2. ¿Que es un anti-join en pandas?Nivel 1 · Recordar
3. merge(..., validate="1:1") levanta MergeError. ¿Que conviene hacer?Nivel 2 · Predecir la salida
4. Cruzas 500 ventas contra un catalogo de 30 productos. ¿Que validate corresponde?Nivel 2 · Predecir la salida
5. Un outer con indicator da 46 both, 14 left_only y 10 right_only. ¿Cuantas filas tiene el resultado y que significa?Nivel 3 · Elegir entre dos casi correctas
6. Comparas precio_a con precio_b para armar un informe de discrepancias. ¿Que cuidado hay que tener?Nivel 3 · Elegir entre dos casi correctas
7. Este codigo unifica dos catalogos y el archivo sale con 71 filas cuando la union de los sku da 70. ¿Por que?
a = pd.read_csv("catalogo_sistema_a.csv")
b = pd.read_csv("catalogo_sistema_b.csv")
u = a.merge(b, on="sku", how="outer", suffixes=("_a", "_b"))
u.to_csv("unificado.csv", index=False)
Nivel 4 · Encontrar el defecto

Prácticas de esta lección