Saltar al contenido

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:

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:

EjemploUn pipeline es una cadena de funciones que no tocan lo que reciben
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

EjemploLa misma limpieza escrita "como sale", y por que no se puede repetir
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.

Paso a pasoEl pipeline etapa por etapa: nada se descarta hasta el final
  1. 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)
  2. 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()
  3. 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)
  4. 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")
  5. 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)
  6. 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()]
El truco que hace que todo esto sea encadenable

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

EjemploEl pipeline completo sobre el inventario real
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.

El motivo se anota solo donde no habia ninguno

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.

EjemploLos asserts: lo que el pipeline promete, comprobado en cada corrida
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

EjemploEl informe de rechazos, y el atipico que se lleva puesto el total
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:

  1. 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.
  2. 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.
  3. 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

(sin error) corri el programa dos veces y dio distinto

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.

SettingWithCopyWarning / ChainedAssignmentError

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.

AssertionError: (sin mensaje)

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.

(sin error) los rechazados suman mas que la entrada

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.

(sin error) el pipeline anda pero nadie entiende que hace

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

Que hace el pipeline con una fila que no cumple

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

Pipeline reproducible
ExpresionQue haceCuando
df.pipe(f)Lo mismo que f(df), pero se lee en ordenEncadenar etapas
df.assign(c=...)Devuelve una copia con la columna nuevaAgregar o pisar una columna sin mutar
df.assign(**{...})Varias columnas de unaCuando el nombre no es un identificador valido
df.copy()Copia independienteAl entrar en una funcion que va a escribir con loc
s.mask(cond, valor)Reemplaza donde la condicion es verdaderaAnotar un motivo sin pisar el anterior
s.where(cond, valor)Reemplaza donde es falsaLa otra mitad, cuidado con confundirlas
df.rename(columns={...})Renombra devolviendo copiaPrimera etapa, para no arrastrar nombres feos
assert cond, "mensaje"Para el programa si la promesa es falsaInvariantes propias, nunca validacion de entrada
df["k"].is_uniqueboolAssert clasico despues de deduplicar
len(a) + len(b) == len(entrada)ConciliacionEl assert que atrapa mas bugs por linea
df["motivo"].value_counts()Rechazos por motivoEl informe, en una linea

Ponete a prueba

1. ¿Que es df.pipe(f)?Nivel 1 · Recordar
2. ¿Que quiere decir que una etapa del pipeline sea una funcion pura?Nivel 1 · Recordar
3. Este codigo corre sin error. ¿Que imprime la ultima linea?
def limpiar(df):
    df["cantidad"] = df["cantidad"].clip(lower=0)
    return df

crudo = pd.DataFrame({"cantidad": [10, -2, 5]})
limpio = limpiar(crudo)
print(crudo["cantidad"].tolist())
Nivel 2 · Predecir la salida
4. ¿Por que el pipeline anota un motivo en una columna en vez de ir sacando las filas en cada etapa?Nivel 2 · Predecir la salida
5. Una fila tiene el precio ausente y la cantidad negativa. El pipeline usa df["motivo"].mask(mascara & df["motivo"].isna(), motivo). ¿Que motivo le queda?Nivel 3 · Elegir entre dos casi correctas
6. ¿Cual de estas comprobaciones va como assert y cual como if?Nivel 3 · Elegir entre dos casi correctas
7. El pipeline corre limpio sobre 35 filas: 30 validas, 5 rechazadas, todos los asserts pasan. Pero el informe dice que el inventario vale 327.667.075,24 cuando el negocio esperaba algo cercano a 1.200.000. ¿Que pasa?Nivel 4 · Encontrar el defecto

Practicas de esta leccion