Indice / pandas: calidad y limpieza / 5.5
Duplicados
Encontrar los duplicados es fácil. Lo difícil es decidir qué cuenta como duplicado en este archivo, y cuál de las copias se queda.
Para qué sirve
Una fila de más infla todos los totales, y no se nota: el archivo tiene el tamaño que esperabas, las columnas están bien, ningún error salta. Simplemente un cliente aparece dos veces y facturó el doble.
Hay dos preguntas, y la segunda es la que se subestima. La primera es qué es un duplicado acá: ¿la fila entera repetida, o dos filas que comparten la clave? La segunda es cuál de las dos se queda, y ahí casi siempre hay una respuesta correcta y varias que parecen funcionar.
ELI5Explicado facil
Tenés dos fichas del mismo cliente. Una dice que su teléfono es 4444-5555 y es de enero; la otra dice 4444-9999 y es de agosto. No son fichas idénticas: son la misma persona anotada dos veces, y una está más al día que la otra.
Si te quedás con cualquiera, la mitad de las veces vas a llamar al número viejo. Y lo peor: vas a tener la cantidad correcta de clientes igual, así que nadie se va a dar cuenta revisando el total.
El ejemplo mínimo
import pandas as pd
pedidos = pd.DataFrame({
"cliente": ["Ana", "Bruno", "Ana", "Ana"],
"monto": [100, 250, 100, 300],
})
print(pedidos)
print()
print("duplicated() por fila entera:")
print(pedidos.duplicated().to_list(), "->", int(pedidos.duplicated().sum()), "duplicada")
print()
print("keep='last' marca la otra:")
print(pedidos.duplicated(keep="last").to_list())
print()
print("keep=False marca TODAS las del grupo repetido:")
print(pedidos.duplicated(keep=False).to_list())
print()
print("Por cliente, sin mirar el monto:")
print(pedidos.duplicated(subset=["cliente"]).to_list())
cliente monto
0 Ana 100
1 Bruno 250
2 Ana 100
3 Ana 300
duplicated() por fila entera:
[False, False, True, False] -> 1 duplicada
keep='last' marca la otra:
[True, False, False, False]
keep=False marca TODAS las del grupo repetido:
[True, False, True, False]
Por cliente, sin mirar el monto:
[False, False, True, True]
keep
"first" (por defecto) marca todas menos la primera.
"last" marca todas menos la última. False marca
todas las del grupo - que es el que sirve para
mirar qué está repetido antes de decidir.
Sobre datos de verdad
Exacto contra semántico
import pandas as pd
libros = pd.read_csv("libros_catalogo.csv")
print("filas totales :", len(libros))
print("filas identicas enteras:", int(libros.duplicated().sum()))
print("isbn repetidos :", int(libros.duplicated(subset=["isbn"]).sum()))
print()
repetido = libros[libros.duplicated(subset=["isbn"], keep=False)]
print("Las filas que comparten isbn:")
print(repetido.to_string(index=False))
print()
print("No son identicas: comparten la clave y difieren en lo demas.")
print("Por eso duplicated() sin subset no las encuentra.")
filas totales : 50
filas identicas enteras: 0
isbn repetidos : 1
Las filas que comparten isbn:
isbn titulo autor anio precio
978-1000-001 Algoritmos vol. 1 Ximena Molina 2003.0 57.24
978-1000-001 Sistemas operativos vol. 4 Tamara Peña 2003.0 64.43
No son identicas: comparten la clave y difieren en lo demas.
Por eso duplicated() sin subset no las encuentra.
Ninguna fila es idéntica a otra, pero dos comparten el isbn, que es
la clave del catálogo. Son dos libros distintos con el mismo código:
un error de carga que duplicated() a secas no encuentra, porque compara
la fila entera y estas difieren en el título, el autor y el precio.
La conclusión es incómoda y hay que aceptarla: pandas no sabe qué es un duplicado en tu archivo. Eso lo tenés que decidir vos, mirando cuál es la clave.
Cuál se queda
import pandas as pd
catalogo = pd.read_csv("catalogo_sistema_a.csv")
repetidos = catalogo[catalogo.duplicated(subset=["sku"], keep=False)]
print("Filas con sku repetido:")
print(repetidos.to_string(index=False))
print()
print("keep='first' se queda con la que aparece primero en el archivo:")
print(catalogo.drop_duplicates(subset=["sku"], keep="first")
.query("sku in ['SKU002', 'SKU007']")[["sku", "precio", "actualizado"]]
.to_string(index=False))
print()
print("Pero lo que queremos es la mas reciente. Hay que ordenar primero:")
por_fecha = catalogo.sort_values("actualizado", kind="stable")
print(por_fecha.drop_duplicates(subset=["sku"], keep="last")
.query("sku in ['SKU002', 'SKU007']")[["sku", "precio", "actualizado"]]
.to_string(index=False))
Filas con sku repetido:
sku nombre precio stock actualizado
SKU002 Artículo 002 695.67 84.0 2026-08-06
SKU007 Artículo 007 85.34 16.0 2026-07-15
SKU002 Artículo 002 31.50 12.0 2026-08-20
SKU007 Artículo 007 88.00 3.0 2026-08-22
keep='first' se queda con la que aparece primero en el archivo:
sku precio actualizado
SKU002 695.67 2026-08-06
SKU007 85.34 2026-07-15
Pero lo que queremos es la mas reciente. Hay que ordenar primero:
sku precio actualizado
SKU002 31.5 2026-08-20
SKU007 88.0 2026-08-22
Acá está el punto de la lección. keep="first" se quedó con la fila
que aparece primero en el archivo, que resultó ser la versión vieja. El orden del
archivo no significa nada: si querés la más reciente, tenés que ordenar
primero y recién después deduplicar.
Lo mismo, paso a paso
- Cinco filas del catalogo. A simple vista no hay nada raro: todas tienen sus datos completos.
catalogo = pd.read_csv("catalogo_sistema_a.csv") - duplicated() sin subset no encuentra nada: las filas comparten el sku pero difieren en el precio y en la fecha, asi que no son identicas.
catalogo.duplicated().sum() # 0 - Con subset=['sku'] aparecen: SKU002 y SKU007 estan dos veces cada uno. Es un duplicado semantico, no exacto.
catalogo.duplicated(subset=["sku"], keep=False) - drop_duplicates con keep=first se queda con la que aparece primero en el archivo. Para SKU007 esa es la de julio: la vieja.
catalogo.drop_duplicates(subset=["sku"], keep="first") - El orden del archivo no significa nada. Si queres la mas reciente tenes que decirlo: primero ordenar por la fecha.
catalogo.sort_values("actualizado", kind="stable") - Y recien ahi deduplicar con keep='last'. Ahora se conservan las de agosto, que es lo que queriamos.
.drop_duplicates(subset=["sku"], keep="last") - Mira los precios de SKU002: 695.67 contra 31.5. Elegir mal la fila no da error ni cambia la cantidad de filas, solo cambia los numeros. Es el tipo de error que sobrevive a cualquier revision apurada.
# 62 filas -> 60 filas, y dos precios distintos
Los empates y los ausentes
import pandas as pd
catalogo = pd.read_csv("catalogo_sistema_a.csv")
fecha = pd.to_datetime(catalogo["actualizado"], errors="coerce")
print("filas sin fecha de actualizacion:", int(fecha.isna().sum()))
ordenado = catalogo.assign(_f=fecha).sort_values(
"_f", na_position="first", kind="stable")
unico = ordenado.drop_duplicates(subset=["sku"], keep="last").drop(columns="_f")
print("antes :", len(catalogo), "filas |", catalogo["sku"].nunique(), "sku distintos")
print("despues:", len(unico), "filas |", unico["sku"].nunique(), "sku distintos")
print()
print("Dos decisiones que hay que dejar por escrito:")
print(" na_position='first' -> una fila sin fecha nunca le gana a una con fecha")
print(" kind='stable' -> si empatan, gana la que estaba mas abajo")
print("Sin declararlas, dos personas correctas obtienen archivos distintos.")
filas sin fecha de actualizacion: 0
antes : 62 filas | 60 sku distintos
despues: 60 filas | 60 sku distintos
Dos decisiones que hay que dejar por escrito:
na_position='first' -> una fila sin fecha nunca le gana a una con fecha
kind='stable' -> si empatan, gana la que estaba mas abajo
Sin declararlas, dos personas correctas obtienen archivos distintos.
na_position decide si una fila sin fecha puede ganarle a una
con fecha. Y kind="stable" decide qué pasa cuando dos filas empatan.
Ninguna de las dos da error si las dejás por defecto: simplemente producen otro
archivo. Si el enunciado no las fija, dos personas que resuelven bien el ejercicio
obtienen resultados distintos.
Dejar constancia
import pandas as pd
catalogo = pd.read_csv("catalogo_sistema_a.csv")
antes = len(catalogo)
fecha = pd.to_datetime(catalogo["actualizado"], errors="coerce")
unico = (catalogo.assign(_f=fecha)
.sort_values("_f", na_position="first", kind="stable")
.drop_duplicates(subset=["sku"], keep="last")
.drop(columns="_f")
.sort_values("sku", kind="stable"))
quitadas = antes - len(unico)
print(f"entraron {antes} filas, salieron {len(unico)}, se quitaron {quitadas}")
print("sku que perdieron una fila:",
sorted(catalogo.loc[catalogo.duplicated(subset=['sku'], keep=False), 'sku']
.unique()))
assert unico["sku"].is_unique, "quedaron sku repetidos"
print()
print("El assert es barato y convierte un error silencioso en uno ruidoso.")
entraron 62 filas, salieron 60, se quitaron 2
sku que perdieron una fila: ['SKU002', 'SKU007']
El assert es barato y convierte un error silencioso en uno ruidoso.
Dónde se rompe
Causa: sin subset compara la fila entera, y tus duplicados
comparten la clave pero difieren en el resto.
Arreglo: definí la clave y pasala:
duplicated(subset=["isbn"]).
Causa: te quedaste con la fila equivocada de cada par. La cantidad de filas es la correcta, así que el error no se ve en ningún conteo.
Arreglo: ordenar por el criterio que define «la buena» antes de deduplicar, y dejarlo escrito.
Causa: pusiste en subset una columna que difiere entre las
filas repetidas - típicamente la fecha. Con eso, las filas dejan de ser
duplicadas.
Arreglo: en subset va solo la clave, nunca las columnas que
esperás que cambien.
Causa: deduplicaste por una columna y verificaste por otra, o la clave
real es compuesta (por ejemplo estudiante_id + curso_id).
Arreglo: un assert df["clave"].is_unique después de
deduplicar. Cuesta una línea y convierte el error silencioso en uno ruidoso.
Acá hay que elegir
No hay un default correcto. Los tres criterios habituales:
- La más reciente - cuando hay una columna de fecha confiable. Es el caso más común y el más fácil de justificar.
- La más completa - la que tiene menos ausentes. Útil cuando las copias se complementan, pero hay que definir cómo se cuenta «más completa».
- Combinar - tomar de cada copia lo que la otra no tiene. Es lo mejor y lo más caro; se justifica cuando perder datos es peor que el trabajo extra.
Elijas el que elijas: escribilo, y anotá cuántas filas se fueron.
Recetario
| Expresión | Qué hace | Cuándo |
|---|---|---|
df.duplicated() | Marca repeticiones de fila entera | Duplicados exactos |
df.duplicated(subset=["k"]) | Marca claves repetidas | Duplicados semánticos |
df.duplicated(keep=False) | Marca todas las del grupo | Para mirar antes de decidir |
df[df.duplicated(subset=["k"], keep=False)] | Las filas en conflicto | Inspeccionarlas de a pares |
df.drop_duplicates(subset=["k"]) | Deja una por clave | Cuando da igual cuál |
df.sort_values("f").drop_duplicates(subset=["k"], keep="last") | Deja la más reciente | El caso habitual |
sort_values(..., na_position="first") | Los ausentes al principio | Para que nunca ganen |
sort_values(..., kind="stable") | Los empates conservan su orden | Resultado reproducible |
df["k"].is_unique | bool | Como assert, después de deduplicar |
df["k"].value_counts().head() | Las claves más repetidas | Ver cuán grave es antes de tocar |