lección 7
Proyecto: limpiar un dataset real
Los cuatro problemas a la vez: por qué el orden de ataque importa, y cómo una limpieza impecable puede llevarse por delante medio mes.
⏱ 60 min
En 2010, dos economistas de Harvard publicaron un resultado que se convirtió en el argumento técnico de la política económica de media Europa: cuando la deuda pública de un país pasa del 90% de su producto interior bruto, el crecimiento medio se vuelve negativo, un −0,1%. Tres años después, un estudiante de doctorado en Massachusetts llamado Thomas Herndon intentó reproducir la cifra para un trabajo de clase y no le salió. Pidió el fichero original, y al abrirlo encontró que la fórmula que calculaba esa media cubría 44 filas de las 49 que tenía la tabla. Cinco países quedaban fuera de la cuenta: Australia, Austria, Bélgica, Canadá y Dinamarca. Con los cinco dentro, y corregidas otras dos decisiones discutibles, el crecimiento medio de ese grupo no era −0,1%: era +2,2%.
Un rango que no llegaba hasta el final. Eso es todo lo que había. Y fíjate en la propiedad que tenían en común los cinco países que se quedaron fuera: eran los cinco primeros por orden alfabético. No faltaban cinco países al azar, faltaba un trozo contiguo de la tabla, que es la forma que tiene una pérdida de datos cuando la causa es mecánica y no aleatoria.
Nada estaba vacío, nada daba un error y la media se calculó sin una sola queja. El resultado era una cifra perfectamente formada, publicable y equivocada. Así que este proyecto no consiste en aplicar cuatro técnicas seguidas: ya sabes tratar los huecos, las copias, los valores imposibles y los tipos, de uno en uno. Consiste en las dos cosas que solo aparecen cuando los cuatro problemas conviven: que el orden en que se tocan no es indiferente, y que una limpieza puede salir impecable y estar mal.
## La tabla del encargo
El encargo es de una frase: dar la facturación del mes y el pedido medio por canal de venta. La tabla llega como llegan casi todas, en un export que hace otro equipo: 32 líneas, seis columnas y cuatro canales —la tienda física, la web, el teléfono y un marketplace, es decir, una plataforma de terceros donde la empresa también vende—. La primera pregunta no es qué cifra dar, sino qué trae esa tabla dentro.
1import io2import pandas as pd34CSV = """pedido_id;fecha;canal;cliente;importe;unidades5P-1001;03/03/2024;web;Ana López;120,50;26P-1002;04/03/2024;web;Luis Soto;89,90;17P-1003;05/03/2024;tienda;Marta Ruiz;340,00;48P-1004;05/03/2024;web;Ana López;1.234,56;129P-1005;06/03/2024;telefono;Grupo Vela;12.400,00;15010P-1006;07/03/2024;web;Jorge Vidal;45,20;111P-1007;08/03/2024;tienda;Elena Mora;210,00;312P-1008;2024-03-08;marketplace;Sara Gil;610,00;613P-1009;09/03/2024;web;Pablo Nieto;;214P-1010;10/03/2024;tienda;Diego Paz;155,00;215P-1011;2024-03-11;marketplace;Nuria Cano;545,00;516P-1012;11/03/2024;web;Marta Ruiz;98,00;117P-1013;12/03/2024;telefono;Grupo Vela;890,00;1018P-1014;12/03/2024;web;Sara Gil;99.999,00;119P-1015;13/03/2024;tienda;Ana López;47,90;120P-1016;2024-03-14;marketplace;Hugo Bravo;730,00;721P-1017;14/03/2024;web;Elena Mora;430,25;522P-1018;15/03/2024;telefono;Casa Miralles;1.100,00;1423P-1019;16/03/2024;tienda;Luis Soto;;324P-1020;2024-03-17;marketplace;Sara Gil;480,00;425P-1021;17/03/2024;web;Jorge Vidal;62,80;126P-1022;18/03/2024;tienda;Nuria Cano;125,00;227P-1023;2024-03-19;marketplace;Iván Pardo;920,00;928P-1024;19/03/2024;telefono;Grupo Vela;240,00;329P-1025;20/03/2024;web;Diego Paz;210,00;230P-1026;2024-03-21;marketplace;Hugo Bravo;815,00;831P-1027;21/03/2024;tienda;Pablo Nieto;68,00;132P-1028;22/03/2024;web;Ana López;340,00;433P-1029;22/03/2024;telefono;Casa Miralles;360,00;434P-1030;23/03/2024;tienda;Marta Ruiz;95,00;135P-1005;06/03/2024;telefono;Grupo Vela;12.400,00;15036P-1023;2024-03-19;marketplace;Iván Pardo;920,00;937"""3839ventas = pd.read_csv(io.StringIO(CSV), sep=";")4041print("Filas:", len(ventas), "| pedidos distintos:", ventas["pedido_id"].nunique())42print("Huecos por columna:")43for col, n in ventas.isna().sum().items():44 print(" ", col, int(n))45print("Tipos:")46for col, t in ventas.dtypes.items():47 print(" ", col, str(t))
El diagnóstico de tres líneas que se hace antes de calcular nada.
Con ese diagnóstico delante, la tentación es empezar por lo que más se ve. Los dos huecos están ahí, señalados, y arreglarlos es una línea. Es exactamente la decisión que arruina el resto del trabajo, y para entender por qué hay que mirar cómo se tapan estos cuatro problemas entre sí.
Ninguno de los cuatro problemas se deja medir mientras otro está sin arreglar. Los dos huecos que acabas de contar no son los que tienes: son los que se ven. Los que de verdad van a decidir el resultado los vas a crear tú dentro de un minuto, al convertir los tipos. Un recuento hecho sobre una tabla sucia es una foto de la suciedad, no un inventario del trabajo.
## El orden no es cuestión de gusto
Los cuatro pasos tienen dependencias reales, del mismo tipo que las de una obra: no puedes pintar antes de enlucir, no porque quede peor, sino porque lo que pintes se va a tapar. Aquí es igual de mecánico, y el dibujo de esas dependencias cabe en un diagrama.
Las dos flechas rojas no son igual de peligrosas, y merece la pena ver la diferencia. La larga termina en un error: el programa se para y te obliga a hacer las cosas en orden. La corta no termina en nada; funciona, devuelve números y los números tienen la pinta correcta. Un atajo que falla te enseña el orden. Un atajo que funciona te lo esconde.
## Tipos primero, porque lo demás no se puede ni intentar
Aquí no hay una discusión de eficiencia. Mientras el importe sea texto, la columna no tiene máximo, no tiene media y no tiene percentiles, así que el paso de los valores extremos no está mal hecho: está sin hacer, y no hay manera de empezarlo.
1# ventas es la tabla de arriba, leida sin declarar ningun tipo.23print("describe() del importe tal como llego:")4for clave, valor in ventas["importe"].describe().items():5 print(" ", clave, valor)67try:8 ventas["importe"] > 10009except TypeError as e:10 print("Buscar los mayores de 1000:", e)
Las dos pruebas que demuestran que el paso de los extremos está bloqueado.
Ese describe() con count, unique, top y freq en una columna que debería ser numérica es una de las señales más rápidas que existen. Si en un describe() no ves percentiles, esa columna es texto, y todo lo que calcules con ella a partir de ahí va a contestar otra pregunta.
## Copias antes que huecos, y es el que más se hace al revés
Este es el atajo de la flecha corta, y hace daño por dos caminos a la vez. El primero es que el valor con el que rellenas viene contaminado: si calculas la media de la columna con las copias dentro, cada fila repetida vota dos veces, así que la media que obtienes no es la media de los pedidos del mes, es la media de un mes en el que algunos pedidos ocurrieron dos veces.
El segundo es más tonto y también cuesta: rellenas filas que vas a borrar. Si una de las filas con hueco resulta ser una copia, le has puesto un valor inventado a un hecho que no existe, y ese valor inventado ha entrado en la media que usaste para rellenar las demás.
1# CSV es la tabla de arriba. Primer paso hecho: los tipos se declaran al2# leer, asi que el importe llega como float y los dos huecos del fichero3# siguen siendo huecos en vez de convertirse en cualquier otra cosa.4ventas = pd.read_csv(io.StringIO(CSV), sep=";", dtype={"pedido_id": str},5 decimal=",", thousands=".")67print("Copias del mismo pedido:", int(ventas.duplicated(subset="pedido_id").sum()))89sin_copias = ventas.drop_duplicates(subset="pedido_id", keep="first")10print("Filas:", len(ventas), "->", len(sin_copias))1112print("Media CON las copias :", round(float(ventas["importe"].mean()), 2))13print("Media SIN las copias :", round(float(sin_copias["importe"].mean()), 2))14print("Mediana CON las copias:", round(float(ventas["importe"].median()), 2))15print("Mediana SIN las copias:", round(float(sin_copias["importe"].median()), 2))
Cuatro candidatos a ser el valor con el que se rellenan dos huecos.
Esa es la interacción que no se ve cuando cada problema se estudia por separado. El orden correcto te ahorra 151,71 € de contaminación, y elegir la media en lugar de la mediana te cuesta 4.044,33 € en cada hueco que rellenes. Las dos cosas hay que hacerlas bien, y la segunda pesa veintiséis veces más que la primera. La mediana no es una preferencia estética: es lo único razonable mientras los valores imposibles siguen dentro de la columna, que es precisamente el caso en este punto del trabajo, porque los extremos van al final.
Y hay una segunda razón para atacar las copias antes, que no tiene nada que ver con las medias. Cuando quitas una fila repetida estás eligiendo cuál de las dos se queda, y esa elección no siempre es inocente.
1import pandas as pd23copias = pd.DataFrame({4 "pedido_id": ["P-1013", "P-1013", "P-1024"],5 "canal": ["telefono", "telefono", "telefono"],6 "importe": [890.0, 890.0, 240.0],7 "unidades": [None, 10.0, 3.0],8})910for etiqueta, tabla in [11 ("keep primera", copias.drop_duplicates(subset="pedido_id", keep="first")),12 ("keep ultima ", copias.drop_duplicates(subset="pedido_id", keep="last")),13]:14 print(etiqueta, "-> huecos:", int(tabla.isna().sum().sum()),15 "| filas tras dropna:", len(tabla.dropna()))1617# Sin depender del orden del fichero: primero las copias mas completas.18llenas = copias.notna().sum(axis=1)19mejor = (copias.assign(_llenas=llenas)20 .sort_values("_llenas", ascending=False)21 .drop_duplicates(subset="pedido_id", keep="first")22 .drop(columns="_llenas"))23print("por completitud -> huecos:", int(mejor.isna().sum().sum()),24 "| filas tras dropna:", len(mejor.dropna()))
Tres formas de quitar la misma copia, con tres resultados distintos.
## Y los extremos al final
El último puesto de los extremos tiene dos motivos, y el segundo es el que sorprende. El primero ya está dicho: hasta que la columna es numérica no hay nada que buscar. El segundo es que mientras haya copias, un valor extremo puede no ser un valor extremo: la misma venta contada cinco veces no produce un importe raro, produce un cliente que aparenta haber comprado cinco veces más de lo que compró, y cualquier análisis por cliente lo marcará como el caso anómalo del mes. Ahí no hay ningún dato que corregir; hay una carga que se repitió.
Con los tipos puestos y las copias fuera, en cambio, el criterio que separa el error del dato grande de verdad funciona a la primera. En esta tabla es el precio por unidad: el pedido de 12.400 € son 150 unidades, o sea 82,67 € cada una, que es un pedido mayorista normal; el de 99.999 € es una unidad, y ningún artículo de esta empresa cuesta eso. El mismo cociente separa los dos casos sin discutir nada.
- 01.Tipos. Declarar al leer qué es cada columna. Desbloquea todo lo demás, porque hasta aquí no hay números, hay texto con pinta de número.
- 02.Copias. Quitar las filas que repiten el mismo hecho, eligiendo la copia más completa y no la primera que pase. Desbloquea cualquier cuenta, porque hasta aquí una suma cuenta hechos que no ocurrieron.
- 03.Huecos. Decidir uno por uno qué significa cada hueco y qué se hace con él, contando también los que ha creado la conversión del primer paso. Desbloquea el reparto, porque hasta aquí no sabes cuántas filas hay de verdad.
- 04.Extremos. Separar lo imposible de lo grande. Va al final porque necesita números, necesita que cada fila sea un hecho y necesita saber cuántos datos faltan.
- 05.Y un quinto paso que no es de limpieza: el control. Comparar la tabla que entregas con la que entró, por grupos. Es el único paso que puede decirte que los cuatro anteriores han salido mal.
## La limpieza sale impecable
Con el orden decidido, la limpieza entera son quince líneas. Aquí están, en los cuatro pasos y sin trampa ninguna: cada línea es la que recomienda cualquier manual y la que escribiría cualquiera con este tema recién estudiado.
1import io2import pandas as pd34# 1 · TIPOS. Se declaran al leer: el importe con la coma decimal y el punto5# de los miles a la espanola, y el pedido_id como texto.6ventas = pd.read_csv(io.StringIO(CSV), sep=";", dtype={"pedido_id": str},7 decimal=",", thousands=".")8# Las fechas vienen en formato espanol, dia/mes/ano.9ventas["fecha"] = pd.to_datetime(ventas["fecha"], format="%d/%m/%Y", errors="coerce")1011# 2 · COPIAS.12sin_copias = ventas.drop_duplicates(subset="pedido_id", keep="first").copy()1314# 3 · HUECOS.15limpio = sin_copias.dropna()1617# 4 · EXTREMOS: lo imposible se aparta, no se borra sin mas.18por_unidad = limpio["importe"] / limpio["unidades"]19apartados = limpio[por_unidad > 1000]20final = limpio.drop(apartados.index)2122print("Filas:", len(ventas), "->", len(final))23print("Huecos:", int(final.isna().sum().sum()),24 "| copias:", int(final.duplicated(subset="pedido_id").sum()))25print("Apartados por imposibles:", len(apartados))26print("Total del mes:", round(float(final["importe"].sum()), 2), "EUR")27print("Pedido medio:", round(float(final["importe"].mean()), 2),28 "| mediana:", round(float(final["importe"].median()), 2))
Los cuatro pasos en el orden correcto. El resultado no tiene nada que objetar.
Ese es el momento del que trata esta lección. No hay un error de sintaxis que encontrar, ni una función usada del revés, ni un orden equivocado. Hay una tabla limpia que ya no representa al negocio, y ninguna de las cifras que acabas de imprimir puede avisarte, porque todas se calculan sobre lo que quedó.
## La pregunta que lo descubre no es cuántas filas quedan
De 32 a 21 filas es una pérdida del 34%, y la primera reacción es que suena mucho pero se explica: dos copias, dos pedidos sin importe, un imposible. Cinco filas justificadas. Faltan seis por explicar, y ahí está todo el asunto. La pregunta que hay que hacer no es cuántas filas se han ido, es quiénes.
1# El control: la misma columna de canal, antes y despues de limpiar.2antes = sin_copias["canal"].value_counts(normalize=True) * 1003despues = final["canal"].value_counts(normalize=True) * 10045for canal in sorted(antes.index):6 a = float(antes[canal])7 d = float(despues.get(canal, 0.0))8 print(f" {canal}: {a:.1f}% -> {d:.1f}%")910euros = float(sin_copias.loc[sin_copias["canal"] == "marketplace", "importe"].sum())11print("Ventas de marketplace que entraron:", round(euros, 2), "EUR")
Seis líneas. Son las que separan una tabla limpia de una tabla limpia y fiable.
El mecanismo, ahora que se sabe dónde mirar, es de una línea. Los pedidos de marketplace llegan de una plataforma de terceros que escribe la fecha en otro formato: 2024-03-08 en lugar de 08/03/2024. La conversión de fechas declaraba el formato español con errors igual a coerce, así que esas seis filas —siete, contando la copia— no se convirtieron en una fecha equivocada: se convirtieron en NaT, el hueco de las fechas. Y el dropna del paso siguiente hace lo que se le ha pedido: borra las filas a las que les falta algo.
Fíjate en la forma que tiene esa pérdida, porque es exactamente la del error con el que empieza esta lección. No faltan seis pedidos repartidos por la tabla: falta un bloque entero de filas que comparten una propiedad, igual que allí faltaban los cinco países que empezaban por las primeras letras del alfabeto. Una pérdida causada por un mecanismo siempre tiene forma de grupo, porque un mecanismo se aplica a lo que cumple una condición, y por eso se caza mirando grupos y no totales. Si las seis filas se hubieran ido al azar, el reparto por canal no se habría movido y el daño habría sido mucho menor: seis pedidos menos de una tabla de treinta, repartidos, mueven el total y no cambian lo que la tabla dice del negocio.
Nadie mintió en ninguna línea. El coerce es la opción razonable cuando exploras, el dropna es la instrucción estándar para dejar una tabla completa, y las dos están en el orden correcto. Lo que falló es que entre las dos hay una pregunta sin hacer: el coerce creó siete huecos nuevos y el dropna los borró sin que nadie contara cuántos eran ni de quién.
Y conviene saber qué buscar cuando el control salta, porque una pérdida con forma de grupo se produce casi siempre por uno de tres mecanismos. El primero es el de esta tabla: una conversión que solo falla en un grupo porque solo ese grupo trae otro formato, y que deja huecos que se borran más adelante. El segundo es un filtro cuya condición está relacionada con el grupo sin que nadie lo pretendiera: quitar los importes de más de mil euros no elimina errores, elimina mayoristas. Y el tercero es un cruce que no empareja porque la clave de ese grupo viene escrita de otra forma, con un cero de delante que se perdió o con espacios sobrantes; ese es el más difícil de ver, porque el grupo no desaparece de la tabla: se queda con las columnas nuevas vacías y suma cero. Si el control salta, la causa está en uno de esos tres sitios antes que en ningún otro.
Los huecos que borras no son solo los que venían en el fichero: también son los que has creado tú al convertir. En esta tabla los del fichero eran 2 y los de la conversión 7, así que el 78% del daño lo hizo la limpieza. Después de cualquier conversión con errors igual a coerce, la línea siguiente cuenta cuántos huecos hay ahora y de qué filas son. Sin esa línea, el paso de los huecos borra a ciegas el trabajo del paso anterior.
## Cómo se arregla, y cómo no se vuelve a colar
El arreglo es el que pide una columna con dos formatos mezclados: dos pasadas. La primera convierte lo que encaja con el formato español y deja el resto en NaT; la segunda coge solo esos NaT, los convierte con el otro formato, y rellena. Si después de las dos pasadas queda algún NaT, entonces sí hay un problema de verdad que hay que mirar a mano.
1import io2import pandas as pd34ventas = pd.read_csv(io.StringIO(CSV), sep=";", dtype={"pedido_id": str},5 decimal=",", thousands=".")67# Pasada 1: el formato de casi todo el fichero.8fecha = pd.to_datetime(ventas["fecha"], format="%d/%m/%Y", errors="coerce")9print("Sin entender tras la primera pasada:", int(fecha.isna().sum()))1011# Pasada 2: solo las que la primera no entendio, con el otro formato.12resto = pd.to_datetime(ventas.loc[fecha.isna(), "fecha"],13 format="%Y-%m-%d", errors="coerce")14ventas["fecha"] = fecha.fillna(resto)15print("Sin entender tras la segunda:", int(ventas["fecha"].isna().sum()))1617sin_copias = ventas.drop_duplicates(subset="pedido_id", keep="first").copy()18apartados = sin_copias[sin_copias["importe"] / sin_copias["unidades"] > 1000]19bueno = sin_copias.drop(apartados.index)2021conocidos = int(bueno["importe"].notna().sum())22total = float(bueno["importe"].sum())23mk = float(bueno.loc[bueno["canal"] == "marketplace", "importe"].sum())2425print("Filas:", len(bueno), "| importes conocidos:", conocidos)26print("Total del mes:", round(total, 2), "EUR sobre", conocidos, "pedidos")27print("De marketplace:", round(mk, 2), "EUR =", round(mk / total * 100, 1), "%")
Las dos pasadas, y el total que sale cuando no falta nadie.
La parte que hay que llevarse no es el truco de las dos pasadas: es que la limpieza no se valida mirando el resultado. Un resultado limpio no tiene ninguna propiedad que lo distinga de un resultado limpio y sesgado, porque los dos tienen cero huecos y cero copias. Lo único que distingue uno de otro es la comparación con lo que entró.
Ese dibujo es el control convertido en imagen, y conviene tenerlo como forma mental: cada fila que sale de la tabla tiene que poder colocarse en un bloque con nombre. Las que no encajan en ninguno son el hallazgo. Un 34% de pérdida no es una cifra buena ni mala por sí sola; lo que la califica es si la puedes descomponer entera.
- Compara proporciones, no recuentos. Que un canal pase de 6 pedidos a 5 puede ser normal; que pase del 20% al 0% no lo es nunca. Y las proporciones tienen una ventaja: avisan también cuando la tabla crece.
- La columna por la que comparas es la que segmenta el negocio, y casi siempre son las mismas: canal, región, tipo de cliente, mes, y el sistema del que viene el dato. No hace falta elegir bien: se pueden recorrer todas las columnas de pocas categorías en un bucle de cuatro líneas.
- Un grupo que se desploma es un sesgo; un grupo que se mueve dos puntos es ruido. El umbral no es sagrado, pero conviene tener uno escrito y mirar todo lo que lo pase, en vez de decidir caso por caso mirando si el resultado interesa.
- Si un grupo desaparece del todo, no discutas si importa: busca la causa. Un cero exacto no lo produce nunca la casualidad, siempre lo produce un mecanismo, y ese mecanismo suele estar borrando también otras cosas que todavía no has visto.
La comparación se guarda en una función de seis líneas y se llama al final de cualquier limpieza, siempre, aunque la limpieza sea de dos líneas. La rutina vale más que el criterio: nadie se acuerda de sospechar el día que va con prisa, y ese es justamente el día en que la tabla llega con un formato nuevo. Y esa función vive en el mismo fichero que la limpieza, no en el cuaderno de hoy: una comprobación que hay que volver a escribir cada vez es una comprobación que dentro de dos meses no se hace.
## El registro de la limpieza
Queda una cosa por hacer, y es la que convierte «he limpiado la tabla» en algo que otra persona puede comprobar. Un registro: cuántas filas entraron, cuántas salieron, y por qué cada una. Cuesta cinco líneas y resuelve tres problemas distintos a la vez.
1# Cada cifra sale de una resta entre dos tablas, nunca de un numero escrito2# a mano: asi el registro sigue siendo verdad el mes que viene.3print("entran :", len(ventas), "filas")4print("- copias del mismo pedido:", len(ventas) - len(sin_copias),5 "(una carga que se repitio)")6print("- fechas no entendidas :", int(ventas["fecha"].isna().sum()),7 "(dos formatos declarados al leer)")8print("- importes imposibles :", len(apartados),9 "(99999 EUR en 1 unidad, apartado y anotado)")10print("quedan :", len(bueno), "filas y", conocidos, "importes")11print("total del mes :", round(total, 2), "EUR sobre", conocidos, "pedidos")12print("sin importe :", int(bueno["importe"].isna().sum()),13 "pedidos, que NO se rellenan")
El registro se calcula, no se escribe a mano. Un registro escrito a mano se queda viejo.
## Cómo se cuenta esto
- Nunca digas «he limpiado la tabla». Di lo que has quitado y cuánto: «entraron 32 filas y quedan 29; dos eran una carga repetida y una es un importe imposible que he apartado».
- Da el total con su denominador. «22.762,11 € sobre 27 pedidos, y hay 2 de los que no sabemos el importe» es una frase completa. La misma cifra sin la segunda mitad no lo es.
- Cuenta el control aunque salga bien, porque es lo que da derecho a la cifra: «el reparto por canal se mantiene después de limpiar, así que la limpieza no ha borrado a nadie en particular». El día que salga mal, esa frase ya está en el vocabulario de todos.
- Y si el control sale mal, dilo antes de la cifra, no después. «Falta el 18% de las ventas y son todas de un canal» es una noticia; la misma frase detrás de un total ya presentado es una rectificación, y cuesta mucho más.
Los cuatro problemas de una tabla se aprenden de uno en uno y se arreglan todos juntos, y lo que hace falta para arreglarlos juntos no es más sintaxis: es un orden que respeta las dependencias y una comprobación final que no se fía del resultado. El hábito de comparar el antes y el después por grupos no se queda en la limpieza. Es exactamente el mismo reflejo que hace falta cuando una cuenta sale mejor de lo que esperabas: preguntar sobre quién está calculada y qué grupo se ha ido por el camino sin que nadie lo decidiera. Una tabla limpia no es una tabla en la que todo cuadra: es una tabla de la que sabes qué falta y por qué.
## comentarios
Reporta erratas, ayuda a otros o comparte tu opinión. Sé constructivo.
Inicia sesión para comentar y responder.
cargando comentarios...
Regístrate para guardar tu progreso.