Indice / pandas: calidad y limpieza / 5.7
Pipeline reproducible
Las seis lecciones anteriores ensenaron a arreglar una cosa cada una. Esta las junta en algo que se puede volver a correr manana, sobre el archivo del mes que viene, y que avisa fuerte cuando los datos cambian.
Para que sirve
Una limpieza hecha a mano funciona una vez. El problema aparece despues: el archivo se actualiza, alguien pregunta por que hay 30 filas y no 35, y vos tenes diez celdas de cuaderno que ya no sabes en que orden se corrieron.
Un pipeline es la misma limpieza escrita de forma que se pueda contestar tres preguntas sin depender de tu memoria:
- ¿Se puede volver a correr? Dos veces sobre el mismo archivo tiene que dar exactamente lo mismo.
- ¿Que se descarto y por que? Con nombre y motivo, no con un numero.
- ¿Como me entero si manana los datos vienen distintos? El programa tiene que parar, no seguir y escribir un archivo que alguien va a creer.
ELI5Explicado facil
Imaginate una receta de cocina. Podes cocinar de memoria y te sale bien… esa vez. Si alguien te pregunta cuanta sal le pusiste, no sabes.
El pipeline es escribir la receta con los pasos numerados y poner una balanza al final: si el bizcochuelo pesa la mitad de lo que deberia, la balanza te avisa antes de servirlo. La receta sola te deja repetir; la balanza te avisa cuando algo cambio.
El ejemplo minimo
Tres funciones que no tocan lo que reciben, encadenadas con pipe:
import pandas as pd
crudo = pd.DataFrame({
"codigo": ["P1", "P2", "P3"],
"categoria": [" audio ", "CABLES", "Audio"],
"cantidad": [10, -2, 5],
})
def normalizar_texto(df):
return df.assign(categoria=df["categoria"].str.strip().str.capitalize())
def descartar_negativos(df):
return df[df["cantidad"] >= 0]
def agregar_bandera(df):
return df.assign(mucho_stock=(df["cantidad"] > 8).astype("int64"))
limpio = (crudo
.pipe(normalizar_texto)
.pipe(descartar_negativos)
.pipe(agregar_bandera))
print(limpio.to_string(index=False))
print()
print("El original quedo intacto:")
print(crudo.to_string(index=False))
codigo categoria cantidad mucho_stock
P1 Audio 10 1
P3 Audio 5 0
El original quedo intacto:
codigo categoria cantidad
P1 audio 10
P2 CABLES -2
P3 Audio 5
Fijate en lo ultimo que imprime: el DataFrame original quedo igual. Cada funcion devuelve una tabla nueva, no modifica la que le llego. Eso es lo que se llama una funcion pura, y es toda la idea de la leccion.
pipe no hace magia: es azucar
df.pipe(f) es exactamente f(df), y
df.pipe(f).pipe(g) es g(f(df)). Lo unico que aporta es que
se lee en el orden en que pasan las cosas, de arriba abajo, en vez
de de adentro hacia afuera. Con tres etapas da igual; con siete, no.
Por que no alcanza con escribirlo seguido
import pandas as pd
def limpiar_mal(df):
"""Toca el DataFrame que recibe. No devuelve una copia: devuelve el mismo."""
df["categoria"] = df["categoria"].str.strip().str.capitalize()
df["cantidad"] = df["cantidad"].clip(lower=0)
return df
crudo = pd.DataFrame({
"codigo": ["P1", "P2", "P3"],
"categoria": [" audio ", "CABLES", "Audio"],
"cantidad": [10, -2, 5],
})
primera = limpiar_mal(crudo)
print("Lo que se pidio limpiar:")
print(primera.to_string(index=False))
print()
print("Y el 'crudo', que nadie toco:")
print(crudo.to_string(index=False))
print("Es el mismo objeto:", primera is crudo)
print()
print("El -2 ya no esta en ningun lado: se perdio el dato original.")
print("Volver a correr la limpieza no falla, pero ya no parte de lo mismo.")
Lo que se pidio limpiar:
codigo categoria cantidad
P1 Audio 10
P2 Cables 0
P3 Audio 5
Y el 'crudo', que nadie toco:
codigo categoria cantidad
P1 Audio 10
P2 Cables 0
P3 Audio 5
Es el mismo objeto: True
El -2 ya no esta en ningun lado: se perdio el dato original.
Volver a correr la limpieza no falla, pero ya no parte de lo mismo.
Las dos tablas salen identicas porque son el mismo objeto: la
funcion modifico el DataFrame que le pasaron. Y el -2 original ya no
existe en ningun lado, asi que nadie puede revisar si esa fila estaba bien
descartada.
Este es el error que mas cuesta ver, porque no da error. El programa anda, el archivo sale, los numeros parecen razonables. Lo que se rompio es la posibilidad de auditar lo que hiciste.
Lo mismo, paso a paso
Este es el pipeline completo sobre el inventario, etapa por etapa. Mira la
columna motivo: se va llenando, pero ninguna fila desaparece
hasta el ultimo paso.
- leerEl archivo entra entero: 35 filas, ninguna revisada todavia. Las tres columnas de la derecha son las que el pipeline va a agregar, y arrancan vacias.
pd.read_csv("inventario_productos.csv").assign(motivo=pd.NA) - marcar_ausentesPrimera etapa. Las filas a las que les falta la cantidad o el precio reciben el motivo ausente. Fijate que no se borro ninguna: solo se les anoto algo en una columna.
df["cantidad"].isna() | df["precio_unitario"].isna() - marcar_negativosSegunda etapa, sobre la misma tabla. Una cantidad negativa y un precio negativo son imposibles. El motivo se anota solo donde todavia no habia ninguno: gana el primero que lo encontro.
(df["cantidad"] < 0) | (df["precio_unitario"] <= 0) - marcar_atipicosTercera etapa, y la unica que no rechaza nada. El atipico se marca con una bandera y se queda: descartarlo seria decidir por el que lea el informe. La fila con 999999 unidades es rara, no imposible.
(df["cantidad"] > 10000).astype("int64") - valorizarCuarta etapa: la columna derivada. Solo tiene valor donde hay cantidad y precio, asi que las filas con motivo quedan vacias solas, sin ningun if.
(df["cantidad"] * df["precio_unitario"]).round(2) - partir en dosRecien al final se parte: 30 validos y 5 rechazados, que suman exactamente las 35 de entrada. Como nada se tiro en el camino, la conciliacion no puede fallar: es una consecuencia de como esta escrito el pipeline, no algo que haya que cuidar.
validos = df[df["motivo"].isna()]
Si cada etapa sacara sus filas, cada una tendria que devolver dos cosas - lo que sigue y lo que aparto - y la cadena se corta. En cambio, si cada etapa solo anota un motivo en una columna, todas reciben una tabla y devuelven una tabla. Se parte una sola vez, al final. Y ademas la conciliacion sale gratis: como nadie tiro nada, validos + rechazados es necesariamente el total.
Sobre datos de verdad
import pandas as pd
# El truco que hace que todo el pipeline sea encadenable: no se saca ninguna
# fila hasta el final. Se le anota el motivo en una columna, y recien al final
# se parte en dos. Asi cada paso recibe y devuelve una tabla, y nada se pierde.
def leer(ruta):
return pd.read_csv(ruta).assign(motivo=pd.NA)
def normalizar(df):
return df.assign(
producto=df["producto"].str.strip(),
categoria=df["categoria"].str.strip().str.capitalize(),
)
def rechazar(df, mascara, motivo):
"""Anota el motivo solo donde todavia no habia ninguno: gana el primero."""
return df.assign(motivo=df["motivo"].mask(mascara & df["motivo"].isna(), motivo))
def marcar_ausentes(df):
falta = df["cantidad"].isna() | df["precio_unitario"].isna()
return rechazar(df, falta, "ausente")
def marcar_negativos(df):
imposible = (df["cantidad"] < 0) | (df["precio_unitario"] <= 0)
return rechazar(df, imposible, "negativo_incoherente")
def marcar_atipicos(df):
return df.assign(atipico=(df["cantidad"] > 10000).astype("int64"))
def valorizar(df):
return df.assign(
valor_total=(df["cantidad"] * df["precio_unitario"]).round(2))
revisado = (leer("inventario_productos.csv")
.pipe(normalizar)
.pipe(marcar_ausentes)
.pipe(marcar_negativos)
.pipe(marcar_atipicos)
.pipe(valorizar))
validos = revisado[revisado["motivo"].isna()].drop(columns="motivo")
rechazados = revisado[revisado["motivo"].notna()]
print("entrada :", len(revisado))
print("validos :", len(validos))
print("rechazados :", len(rechazados))
print("atipicos marcados:", int(validos["atipico"].sum()))
print()
print(validos.head(4).to_string(index=False))
entrada : 35
validos : 30
rechazados : 5
atipicos marcados: 1
codigo producto categoria cantidad precio_unitario atipico valor_total
P001 Teclado mecánico Perifericos 112.0 4.53 0 507.36
P002 Ratón inalámbrico Perifericos 82.0 781.78 0 64105.96
P003 Monitor 24 pulgadas Pantallas 108.0 872.23 0 94200.84
P005 Webcam HD Perifericos 26.0 713.42 0 18548.92
Cinco funciones de tres lineas cada una. Ninguna sabe de las otras, todas se pueden probar sueltas, y el orden en que se aplican esta escrito en un solo lugar que se lee de corrido.
df["motivo"].mask(mascara & df["motivo"].isna(), motivo) hace que
gane el primero que encontro el problema. Sin esa condicion, una
fila con dos defectos aparece dos veces en el informe, y entonces
len(validos) + len(rechazados) ya no da el total. Es la misma regla que
usan los enunciados de las practicas: una fila rechazada, un motivo, y el
orden de las etapas es el que decide cual.
Los asserts
Un assert no es una prueba: es una promesa escrita en el
codigo, comprobada en cada corrida. Cuesta una linea y convierte un error
silencioso en uno ruidoso.
import pandas as pd
crudo = pd.read_csv("inventario_productos.csv")
falta = crudo["cantidad"].isna() | crudo["precio_unitario"].isna()
imposible = (crudo["cantidad"] < 0) | (crudo["precio_unitario"] <= 0)
validos = crudo[~(falta | imposible)]
rechazados = crudo[falta | imposible]
# Cada assert es una promesa del pipeline. Si alguna se rompe, el programa para
# aca y no escribe un archivo que despues alguien va a creer.
assert len(validos) + len(rechazados) == len(crudo), "no cierra el conteo"
assert validos["codigo"].is_unique, "hay codigos repetidos en la salida"
assert (validos["cantidad"] >= 0).all(), "quedo una cantidad negativa"
assert (validos["precio_unitario"] > 0).all(), "quedo un precio no positivo"
assert validos[["cantidad", "precio_unitario"]].notna().all().all(), "quedaron ausentes"
print("Las 5 invariantes se cumplen.")
print(f"{len(validos)} validos + {len(rechazados)} rechazados = {len(crudo)}")
# Y asi se ve cuando una promesa es falsa. Este assert esta mal a proposito:
# quien lo escribio dio por hecho que los atipicos se descartaban, y la politica
# que elegimos es marcarlos y conservarlos.
print()
try:
assert (validos["cantidad"] <= 10000).all(), "quedo una cantidad fuera de escala"
print("no deberia llegar aca")
except AssertionError as error:
print("AssertionError:", error)
print()
print("El assert no esta roto: contradice la politica.")
print("Un assert es una decision escrita, y este afirma lo contrario")
print("de lo que el pipeline decidio hacer con los atipicos.")
Las 5 invariantes se cumplen.
30 validos + 5 rechazados = 35
AssertionError: quedo una cantidad fuera de escala
El assert no esta roto: contradice la politica.
Un assert es una decision escrita, y este afirma lo contrario
de lo que el pipeline decidio hacer con los atipicos.
El ultimo caso es el interesante. Ese assert no esta roto: afirma que
no quedan cantidades fuera de escala, y la politica que elegimos fue
marcarlas y conservarlas. Cuando un assert falla hay dos posibilidades, y
distinguirlas es el trabajo: o los datos cambiaron, o la promesa estaba mal escrita
porque quien la escribio entendio otra cosa.
assert desaparece con python -O
Ejecutar con la bandera -O elimina todos los
assert del programa. Por eso sirven para afirmar invariantes
tuyas - cosas que el pipeline promete y que solo un bug puede romper
- y no para validar datos de entrada. Rechazar una fila con el
precio negativo es logica del programa y va con un if; afirmar que
despues de rechazarlas no queda ninguna es un assert.
Dejar constancia
import pandas as pd
crudo = pd.read_csv("inventario_productos.csv")
falta = crudo["cantidad"].isna() | crudo["precio_unitario"].isna()
imposible = (crudo["cantidad"] < 0) | (crudo["precio_unitario"] <= 0)
rechazados = pd.concat([
crudo[falta].assign(motivo="ausente"),
crudo[imposible & ~falta].assign(motivo="negativo_incoherente"),
])
validos = crudo[~(falta | imposible)].copy()
validos["atipico"] = (validos["cantidad"] > 10000).astype("int64")
validos["valor_total"] = (validos["cantidad"] * validos["precio_unitario"]).round(2)
print("rechazos por motivo:")
print(rechazados["motivo"].value_counts().to_string())
print()
print(rechazados[["codigo", "producto", "cantidad", "precio_unitario",
"motivo"]].to_string(index=False))
print()
print("conciliacion:", len(validos), "+", len(rechazados), "=", len(crudo))
total = validos["valor_total"].sum()
sin_atipico = validos.loc[validos["atipico"] == 0, "valor_total"].sum()
print()
print(f"valor del inventario : {total:>15,.2f}")
print(f"sin la fila marcada atipica : {sin_atipico:>15,.2f}")
print(f"la fila marcada explica el : {100 * (1 - sin_atipico / total):.2f} % del total")
print()
print("Por eso se marca y se informa en vez de descartar en silencio:")
print("el numero de arriba es indefendible, y el de abajo hay que poder explicarlo.")
rechazos por motivo:
motivo
ausente 3
negativo_incoherente 2
codigo producto cantidad precio_unitario motivo
P004 Monitor 27 pulgadas NaN 432.95 ausente
P011 Placa de video 44.0 NaN ausente
P030 Docking station 113.0 NaN ausente
P007 Micrófono USB -5.0 301.36 negativo_incoherente
P015 Cable USB-C 94.0 -12.30 negativo_incoherente
conciliacion: 30 + 5 = 35
valor del inventario : 327,667,075.24
sin la fila marcada atipica : 1,177,401.73
la fila marcada explica el : 99.64 % del total
Por eso se marca y se informa en vez de descartar en silencio:
el numero de arriba es indefendible, y el de abajo hay que poder explicarlo.
Tres cosas que este informe hace y que un conteo suelto no:
- Concilia. 30 + 5 = 35. Si esa suma no cierra, hay filas
contadas dos veces o perdidas, y no hay que buscarlo a mano: lo dice el
assert. - Nombra. Cada rechazo sale con su codigo, su producto y el valor que lo mando afuera. El que revisa puede ir al archivo y verlo.
- Avisa de lo que quedo adentro. Una sola fila marcada como atipica explica el 99,64 % del valor del inventario. No se descarto - puede ser cierta - pero cualquier total que la incluya sin aclararlo es indefendible.
Donde se rompe
Causa: alguna etapa modifica lo que recibe, o el pipeline acumula en una lista o un diccionario definido afuera de las funciones.
Arreglo: que cada etapa reciba una tabla y devuelva una tabla nueva. Si necesitas juntar los rechazos, juntalos en una columna del mismo DataFrame, no en una variable global.
Causa: se asigno sobre una porcion de otro DataFrame
(df[df.a > 1]["b"] = 0). En pandas 3 eso emite un
ChainedAssignmentError como advertencia y
no modifica nada: el programa sigue con el dato viejo.
Arreglo: assign devuelve una tabla nueva y no tiene este
problema. Si necesitas asignar en el lugar, un .copy() explicito al
entrar en la funcion y loc para escribir.
Causa: el assert se escribio sin la coma y el texto. En
seis meses vas a leer AssertionError y el numero de linea, y nada
mas.
Arreglo: assert condicion, "que fue lo que se rompio".
El mensaje se escribe para el que va a leer el error, que probablemente seas
vos sin acordarte de nada.
Causa: una fila con dos defectos se aparto dos veces, una por cada etapa que la encontro.
Arreglo: el motivo se anota solo si no habia otro. Y despues el
assert de conciliacion, que es justamente el que atrapa esto.
Causa: etapas que hacen tres cosas cada una y se llaman
procesar o limpiar_2.
Arreglo: una etapa, una decision, y el nombre de la funcion dice cual:
marcar_ausentes, marcar_negativos,
valorizar. La cadena de pipe tiene que leerse como el
indice del informe.
Aca hay que elegir
Las tres politicas de la unidad, ahora como decision de diseno. La eleccion no la hacen los datos: la hace el enunciado, y hay que dejarla escrita.
- Descartar e informar - cuando el valor es imposible y no hay forma de recuperarlo. Sale del archivo de salida y entra en el de rechazos con su motivo. Es lo que hicimos con los negativos y los ausentes.
- Imputar - cuando falta el dato pero hay una estimacion defendible (la mediana, el ultimo valor conocido). Se conserva la fila y se agrega una columna que diga que ese valor fue imputado; si no, en tres meses nadie distingue lo medido de lo inventado.
- Marcar y conservar - cuando el valor es raro pero posible. Es lo que hicimos con el atipico, y por eso el informe puede decir que una fila explica el 99,64 % del total.
Una regla que ahorra discusiones: si el pipeline saca una fila, tiene que poder decir cual y por que. Un descarte que no deja rastro es indistinguible de un bug.
Recetario
| Expresion | Que hace | Cuando |
|---|---|---|
df.pipe(f) | Lo mismo que f(df), pero se lee en orden | Encadenar etapas |
df.assign(c=...) | Devuelve una copia con la columna nueva | Agregar o pisar una columna sin mutar |
df.assign(**{...}) | Varias columnas de una | Cuando el nombre no es un identificador valido |
df.copy() | Copia independiente | Al entrar en una funcion que va a escribir con loc |
s.mask(cond, valor) | Reemplaza donde la condicion es verdadera | Anotar un motivo sin pisar el anterior |
s.where(cond, valor) | Reemplaza donde es falsa | La otra mitad, cuidado con confundirlas |
df.rename(columns={...}) | Renombra devolviendo copia | Primera etapa, para no arrastrar nombres feos |
assert cond, "mensaje" | Para el programa si la promesa es falsa | Invariantes propias, nunca validacion de entrada |
df["k"].is_unique | bool | Assert clasico despues de deduplicar |
len(a) + len(b) == len(entrada) | Conciliacion | El assert que atrapa mas bugs por linea |
df["motivo"].value_counts() | Rechazos por motivo | El informe, en una linea |