Saltar al contenido

Indice / Cierre / 10.2

Proyecto integrador: del DW sucio al reporte

Un almacén de datos de 500 ventas, dos dimensiones y siete pasos. Al final aparece el hallazgo más caro del curso: un informe correcto al que le faltaba el 84 % del total, sin un solo error ni advertencia.

El caso

Un esquema estrella: una tabla de hechos y dos dimensiones
ArchivoFilasGranoClave
dw_hecho_ventas.csv500una ventaventa_id
dw_dim_cliente.csv40un clientecliente_id
dw_dim_producto.csv30un productoproducto_id

El pedido: un informe de ventas por categoría de producto y un panel de margen por segmento y mes. Con la tabla de rechazos, como pide el curso.

dw_hecho_ventas.csv (19.2 kB)

Pasos 1 y 2: leer y diagnosticar

EjemploPaso 1 y 2 - leer y diagnosticar, sin corregir nada
import pandas as pd

NULOS = ["NaN", "N/A", "null", "-"]

hechos = pd.read_csv("dw_hecho_ventas.csv", na_values=NULOS)
clientes = pd.read_csv("dw_dim_cliente.csv", na_values=NULOS)
productos = pd.read_csv("dw_dim_producto.csv", na_values=NULOS)

print("hechos   :", hechos.shape, list(hechos.columns))
print("clientes :", clientes.shape, list(clientes.columns))
print("productos:", productos.shape, list(productos.columns))

print()
print("dtypes de los hechos:")
for columna, tipo in hechos.dtypes.items():
    print(f"   {columna:<13} {tipo}")
print("   fecha entro como texto: read_csv no convierte fechas solo.")

print()
print("ausentes por columna:")
ausentes = hechos.isna().sum()
for columna, cuantos in ausentes.items():
    if cuantos:
        print(f"   {columna:<13} {cuantos}")
print("   total de celdas ausentes:", int(ausentes.sum()))

print()
print("integridad referencial (capa 4):")
sin_cliente = ~hechos["cliente_id"].isin(clientes["cliente_id"])
sin_producto = ~hechos["producto_id"].isin(productos["producto_id"])
print("   ventas con cliente_id inexistente :", int(sin_cliente.sum()))
print("   ventas con producto_id inexistente:", int(sin_producto.sum()))
print("   clientes sin ninguna venta        :",
      int((~clientes["cliente_id"].isin(hechos["cliente_id"])).sum()))

print()
print("claves y grano:")
print("   venta_id unico :", bool(hechos["venta_id"].is_unique))
print("   grano          : una fila = una venta")
print("   clientes distintos en los hechos:", hechos["cliente_id"].nunique())
print("   productos distintos en los hechos:", hechos["producto_id"].nunique())

print()
print("rangos, que es donde aparece lo que no avisa:")
for columna in ("cantidad", "ingreso", "costo"):
    serie = hechos[columna]
    print(f"   {columna:<9} min {serie.min():>12.2f}   max {serie.max():>14.2f}"
          f"   negativos: {int((serie < 0).sum())}")

print()
print("Nada de esto se corrige todavia. Es la radiografia: siete columnas,")
print("500 filas, y ya sabemos que hay ausentes, negativos, un atipico de")
print("siete cifras y una fecha que va a dar problema.")
hechos   : (500, 7) ['venta_id', 'fecha', 'producto_id', 'cliente_id', 'cantidad', 'ingreso', 'costo']
clientes : (40, 4) ['cliente_id', 'codigo', 'nombre', 'segmento']
productos: (30, 4) ['producto_id', 'sku', 'nombre', 'categoria']

dtypes de los hechos:
   venta_id      int64
   fecha         str
   producto_id   int64
   cliente_id    int64
   cantidad      float64
   ingreso       float64
   costo         float64
   fecha entro como texto: read_csv no convierte fechas solo.

ausentes por columna:
   fecha         1
   cantidad      1
   costo         2
   total de celdas ausentes: 4

integridad referencial (capa 4):
   ventas con cliente_id inexistente : 1
   ventas con producto_id inexistente: 1
   clientes sin ninguna venta        : 0

claves y grano:
   venta_id unico : True
   grano          : una fila = una venta
   clientes distintos en los hechos: 41
   productos distintos en los hechos: 31

rangos, que es donde aparece lo que no avisa:
   cantidad  min        -6.00   max          24.00   negativos: 1
   ingreso   min      -320.00   max     9999999.99   negativos: 1
   costo     min        27.55   max       10226.35   negativos: 0

Nada de esto se corrige todavia. Es la radiografia: siete columnas,
500 filas, y ya sabemos que hay ausentes, negativos, un atipico de
siete cifras y una fecha que va a dar problema.

Antes de tocar una celda: qué hay, de qué tipo, qué falta, y si las referencias apuntan a algo que existe. Los hallazgos:

QuéCuántoCapa
fecha entró como texto500 filas-
Celdas ausentes43
Cantidad e ingreso negativos1 + 13
Un ingreso de 9.999.999,9913 (atípico)
cliente_id y producto_id inexistentes1 + 14
La señal que anticipa el problema

Los hechos referencian 41 clientes distintos y 31 productos, pero las dimensiones tienen 40 y 30. Uno de más en cada una: son los dos huérfanos.

Es el mismo conteo de la lección 2.2 (cursos_en_inscripciones daba 9 con 8 cursos), y se hace antes de cruzar nada.

Paso 3: el pipeline

EjemploPaso 3 - el pipeline, con la tabla de rechazos
import pandas as pd

NULOS = ["NaN", "N/A", "null", "-"]
MOTIVOS = []          # se llena con (venta_id, columna, motivo, valor)


def anotar(sub, columna, motivo):
    """Registra las filas rechazadas y devuelve las que sobreviven."""
    for _, fila in sub[sub["_malo"]].iterrows():
        MOTIVOS.append({"venta_id": int(fila["venta_id"]), "columna": columna,
                        "motivo": motivo, "valor": fila[columna]})
    return sub[~sub["_malo"]].drop(columns="_malo")


def marcar(datos, columna, condicion, motivo):
    copia = datos.copy()
    copia["_malo"] = condicion
    return anotar(copia, columna, motivo)


hechos = pd.read_csv("dw_hecho_ventas.csv", na_values=NULOS)
clientes = pd.read_csv("dw_dim_cliente.csv", na_values=NULOS)
productos = pd.read_csv("dw_dim_producto.csv", na_values=NULOS)
entrada = len(hechos)

# El orden es el de las capas: primero lo que falta, despues lo que no
# puede valer eso, y al final lo que necesita otra tabla.
limpio = hechos.copy()
limpio["fecha"] = pd.to_datetime(limpio["fecha"], errors="coerce")

limpio = marcar(limpio, "fecha", limpio["fecha"].isna(), "fecha_invalida")
for columna in ("cantidad", "ingreso", "costo"):
    limpio = marcar(limpio, columna, limpio[columna].isna(), "ausente")
for columna in ("cantidad", "ingreso"):
    limpio = marcar(limpio, columna, limpio[columna] < 0,
                    "negativo_incoherente")
limpio = marcar(limpio, "cliente_id",
                ~limpio["cliente_id"].isin(clientes["cliente_id"]), "huerfano")
limpio = marcar(limpio, "producto_id",
                ~limpio["producto_id"].isin(productos["producto_id"]),
                "huerfano")

# El atipico se MARCA, no se descarta: es una venta posible.
q1, q3 = limpio["ingreso"].quantile([0.25, 0.75])
iqr = q3 - q1
techo = q3 + 1.5 * iqr
limpio = limpio.assign(es_atipico=limpio["ingreso"] > techo)

rechazos = pd.DataFrame(MOTIVOS)

print("entrada  :", entrada)
print("validas  :", len(limpio))
print("rechazadas:", len(rechazos))
assert len(limpio) + len(rechazos) == entrada
print("invariante: validas + rechazadas == entrada  OK")

print()
print("rechazos por motivo:")
resumen = (rechazos.groupby(["motivo", "columna"], as_index=False)
           .size().sort_values(["motivo", "columna"]))
for _, fila in resumen.iterrows():
    print(f"   {fila['motivo']:<22} {fila['columna']:<12} {fila['size']:>3}")

print()
print("el detalle completo, que son pocas filas:")
print(rechazos.sort_values("venta_id").to_string(index=False))

print()
print(f"atipicos marcados (ingreso > {techo:,.2f}):",
      int(limpio["es_atipico"].sum()))
print("   siguen en la tabla, con una columna que los identifica.")
print("   Descartarlos seria otra politica, y habria que declararla.")
entrada  : 500
validas  : 491
rechazadas: 9
invariante: validas + rechazadas == entrada  OK

rechazos por motivo:
   ausente                cantidad       1
   ausente                costo          2
   fecha_invalida         fecha          2
   huerfano               cliente_id     1
   huerfano               producto_id    1
   negativo_incoherente   cantidad       1
   negativo_incoherente   ingreso        1

el detalle completo, que son pocas filas:
 venta_id     columna               motivo  valor
       91    cantidad negativo_incoherente   -6.0
      128    cantidad              ausente    NaN
      177     ingreso negativo_incoherente -320.0
      213       costo              ausente    NaN
      266       costo              ausente    NaN
      305 producto_id             huerfano    777
      351  cliente_id             huerfano    888
      402       fecha       fecha_invalida    NaT
      455       fecha       fecha_invalida    NaT

atipicos marcados (ingreso > 12,323.03): 15
   siguen en la tabla, con una columna que los identifica.
   Descartarlos seria otra politica, y habria que declararla.

500 filas de entrada, 491 válidas, 9 rechazadas, con su motivo del vocabulario cerrado y su assert. El atípico se marca, no se descarta: es una venta posible, y descartarla sería una política distinta que habría que declarar.

El orden de las comprobaciones es el de las capas

Primero lo que falta (capa 3), después lo que no puede valer eso (capa 3), y al final lo que necesita otra tabla (capa 4). Cada fila se rechaza una sola vez, con el primer defecto que aparece, y por eso los motivos suman exactamente 9.

Paso 4: enriquecer, y lo que se cayó del informe

EjemploPaso 4 - enriquecer, y la venta mas grande que desaparece
import pandas as pd

NULOS = ["NaN", "N/A", "null", "-"]

hechos = pd.read_csv("dw_hecho_ventas.csv", na_values=NULOS)
clientes = pd.read_csv("dw_dim_cliente.csv", na_values=NULOS)
productos = pd.read_csv("dw_dim_producto.csv", na_values=NULOS)

limpio = hechos.copy()
limpio["fecha"] = pd.to_datetime(limpio["fecha"], errors="coerce")
limpio = limpio[limpio["fecha"].notna()]
limpio = limpio.dropna(subset=["cantidad", "ingreso", "costo"])
limpio = limpio[(limpio["cantidad"] >= 0) & (limpio["ingreso"] >= 0)]
limpio = limpio[limpio["cliente_id"].isin(clientes["cliente_id"])]
limpio = limpio[limpio["producto_id"].isin(productos["producto_id"])]
print("filas saneadas:", len(limpio))

# validate="m:1" es la red: si una dimension tuviera la clave repetida,
# el merge duplicaria filas y esto lo convierte en un error.
enriquecido = (limpio
               .merge(clientes[["cliente_id", "segmento"]], on="cliente_id",
                      how="left", validate="m:1")
               .merge(productos[["producto_id", "categoria"]],
                      on="producto_id", how="left", validate="m:1")
               .assign(margen=lambda d: d["ingreso"] - d["costo"],
                       mes=lambda d: d["fecha"].dt.to_period("M").astype(str)))
assert len(enriquecido) == len(limpio)
print("filas despues de los dos merge:", len(enriquecido), " <- las mismas")

ingreso_total = enriquecido["ingreso"].sum()
print("ingreso total del universo saneado:", f"{ingreso_total:,.2f}")

# --- El resumen ingenuo
ingenuo = (enriquecido.groupby("categoria", as_index=False)
           .agg(ventas=("venta_id", "count"), ingreso=("ingreso", "sum")))
print()
print("resumen por categoria (groupby por defecto):")
print(ingenuo.round(2).to_string(index=False))
print()
print("suma del resumen :", f"{ingenuo['ingreso'].sum():>16,.2f}")
print("ingreso total    :", f"{ingreso_total:>16,.2f}")
print("FALTAN           :", f"{ingreso_total - ingenuo['ingreso'].sum():>16,.2f}")

print()
print("=" * 66)
print("Faltan 10 millones, que es el 84% del total, y nada aviso.")
print("=" * 66)
faltantes = enriquecido[enriquecido["categoria"].isna()]
print("filas con categoria ausente:", len(faltantes))
print(faltantes[["venta_id", "producto_id", "cliente_id", "ingreso",
                 "segmento", "categoria"]].to_string(index=False))

print()
print("El producto 6 existe en la dimension, pero su categoria esta vacia.")
print("El merge trajo NaN, y groupby DESCARTA las claves nulas por defecto.")
print("La venta mas grande del archivo se cayo del informe sin una queja.")
print()
print("Y el cliente 8 tiene el segmento vacio, asi que la misma venta")
print("tambien desaparece del panel por segmento. Dos veces, por dos")
print("dimensiones distintas.")

print()
print("EL ARREGLO, y es una palabra:")
arreglado = (enriquecido.groupby("categoria", as_index=False, dropna=False)
             .agg(ventas=("venta_id", "count"), ingreso=("ingreso", "sum")))
print("   groupby(..., dropna=False)")
print(arreglado.round(2).to_string(index=False))
print()
print("suma del resumen :", f"{arreglado['ingreso'].sum():>16,.2f}")
print("¿cierra ahora?", abs(arreglado["ingreso"].sum() - ingreso_total) < 0.01)

print()
print("La otra forma, que suele leerse mejor en un informe: darle nombre")
print("al hueco ANTES de agrupar.")
con_nombre = enriquecido.assign(
    categoria=enriquecido["categoria"].fillna("(sin categoria)"),
    segmento=enriquecido["segmento"].fillna("(sin segmento)"))
otro = (con_nombre.groupby("categoria", as_index=False)
        .agg(ingreso=("ingreso", "sum")))
print("   fillna('(sin categoria)') y despues groupby normal")
print("   ¿cierra?", abs(otro["ingreso"].sum() - ingreso_total) < 0.01)
print("   Un '(sin categoria)' con 10 millones al lado se ve; un NaN")
print("   descartado, no.")
filas saneadas: 491
filas despues de los dos merge: 491  <- las mismas
ingreso total del universo saneado: 11,985,251.83

resumen por categoria (groupby por defecto):
     categoria  ventas   ingreso
Almacenamiento     136 581644.42
         Audio      17  64554.15
       Equipos     107 465089.34
     Pantallas      57 225766.76
   Perifericos     120 446871.41
         Redes      29 125860.30

suma del resumen :     1,909,786.38
ingreso total    :    11,985,251.83
FALTAN           :    10,075,465.45

==================================================================
Faltan 10 millones, que es el 84% del total, y nada aviso.
==================================================================
filas con categoria ausente: 25
 venta_id  producto_id  cliente_id    ingreso    segmento categoria
       23            6           7    3050.11    Gobierno       NaN
       44            6          34    3017.38    Gobierno       NaN
       89            6          13    1294.02    Gobierno       NaN
      120            6          20     465.08 Corporativo       NaN
      146            6          13    1915.18    Gobierno       NaN
      168            6           4     901.54 Corporativo       NaN
      186            6          13    3522.34    Gobierno       NaN
      272            6          14   12907.33    Gobierno       NaN
      274            6          11    9012.87      Retail       NaN
      290            6          29    4199.47   Educacion       NaN
      301            6          30    5116.45      Retail       NaN
      313            6          38    1295.86      Retail       NaN
      314            6          33      39.82 Corporativo       NaN
      319            6          26    2447.45    Gobierno       NaN
      339            6          25     648.86   Educacion       NaN
      358            6          17    3926.95   Educacion       NaN
      360            6          18     526.20      Retail       NaN
      364            6          26    4588.11    Gobierno       NaN
      376            6          34    3849.26    Gobierno       NaN
      382            6          28    1282.68 Corporativo       NaN
      438            6           6    2178.43    Gobierno       NaN
      456            6           7    4578.91    Gobierno       NaN
      479            6          36    1775.32   Educacion       NaN
      480            6           8 9999999.99         NaN       NaN
      486            6          29    2925.84   Educacion       NaN

El producto 6 existe en la dimension, pero su categoria esta vacia.
El merge trajo NaN, y groupby DESCARTA las claves nulas por defecto.
La venta mas grande del archivo se cayo del informe sin una queja.

Y el cliente 8 tiene el segmento vacio, asi que la misma venta
tambien desaparece del panel por segmento. Dos veces, por dos
dimensiones distintas.

EL ARREGLO, y es una palabra:
   groupby(..., dropna=False)
     categoria  ventas     ingreso
Almacenamiento     136   581644.42
         Audio      17    64554.15
       Equipos     107   465089.34
     Pantallas      57   225766.76
   Perifericos     120   446871.41
         Redes      29   125860.30
           NaN      25 10075465.45

suma del resumen :    11,985,251.83
¿cierra ahora? True

La otra forma, que suele leerse mejor en un informe: darle nombre
al hueco ANTES de agrupar.
   fillna('(sin categoria)') y despues groupby normal
   ¿cierra? True
   Un '(sin categoria)' con 10 millones al lado se ve; un NaN
   descartado, no.
El resumen sumaba 1.909.786 y el total era 11.985.252

Faltaba el 84 %. Ni un error, ni una advertencia, ni una fila menos en el DataFrame: 25 ventas simplemente no aparecían en ninguna categoría.

La causa: el producto 6 existe en la dimensión, pero su categoria está vacía. El merge trajo NaN, y groupby descarta las claves nulas por defecto. Entre esas 25 ventas estaba la de 9.999.999,99, que es la más grande del archivo.

Y ocurre dos veces: el cliente 8 tiene el segmento vacío, así que la misma venta también desaparece del panel por segmento, por una dimensión distinta.

Dos arreglos, los dos de una línea
CómoQué quedaCuándo
groupby(..., dropna=False) Una fila con la clave en NaN Cuando el resultado es para procesar
.fillna("(sin categoria)") antes de agrupar Una fila con un nombre legible Cuando el resultado lo lee una persona

Los dos cierran. La segunda se lee mejor en un informe: un «(sin categoría)» con diez millones al lado se ve; un NaN descartado, no.

Paso 5: el reporte, y los assert

EjemploPaso 5 - el reporte, y los assert que lo sostienen
import tempfile
from pathlib import Path

import pandas as pd

NULOS = ["NaN", "N/A", "null", "-"]

hechos = pd.read_csv("dw_hecho_ventas.csv", na_values=NULOS)
clientes = pd.read_csv("dw_dim_cliente.csv", na_values=NULOS)
productos = pd.read_csv("dw_dim_producto.csv", na_values=NULOS)
entrada = len(hechos)

limpio = hechos.copy()
limpio["fecha"] = pd.to_datetime(limpio["fecha"], errors="coerce")
limpio = limpio[limpio["fecha"].notna()]
limpio = limpio.dropna(subset=["cantidad", "ingreso", "costo"])
limpio = limpio[(limpio["cantidad"] >= 0) & (limpio["ingreso"] >= 0)]
limpio = limpio[limpio["cliente_id"].isin(clientes["cliente_id"])]
limpio = limpio[limpio["producto_id"].isin(productos["producto_id"])]

# El hueco de la dimension recibe un nombre ANTES de agrupar, o las 25
# ventas del producto sin categoria se caen del informe (paso 4).
enriquecido = (limpio
               .merge(clientes[["cliente_id", "segmento"]], on="cliente_id",
                      how="left", validate="m:1")
               .merge(productos[["producto_id", "categoria"]],
                      on="producto_id", how="left", validate="m:1")
               .assign(
                   segmento=lambda d: d["segmento"].fillna("(sin segmento)"),
                   categoria=lambda d: d["categoria"].fillna("(sin categoria)"),
                   margen=lambda d: d["ingreso"] - d["costo"],
                   mes=lambda d: d["fecha"].dt.to_period("M").astype(str)))

por_categoria = (enriquecido
                 .groupby("categoria", as_index=False)
                 .agg(ventas=("venta_id", "count"),
                      unidades=("cantidad", "sum"),
                      ingreso=("ingreso", "sum"),
                      margen=("margen", "sum"))
                 .assign(margen_pct=lambda d:
                         (100 * d["margen"] / d["ingreso"]).round(1))
                 .sort_values(["margen", "categoria"], ascending=[False, True])
                 .reset_index(drop=True))
for columna in ("unidades", "ingreso", "margen"):
    por_categoria[columna] = por_categoria[columna].round(2)

# ---- LOS ASSERT: lo que tiene que valer si el pipeline no se rompio.
rechazadas = entrada - len(enriquecido)
assert len(enriquecido) + rechazadas == entrada
assert por_categoria["ventas"].sum() == len(enriquecido)
assert abs(por_categoria["ingreso"].sum()
           - enriquecido["ingreso"].sum()) < 0.01
assert por_categoria["categoria"].is_unique
assert not por_categoria.isna().any().any()
assert (por_categoria["margen"] <= por_categoria["ingreso"]).all()

print("INFORME DE VENTAS")
print("=" * 60)
print(f"{'filas de entrada':<24}: {entrada}")
print(f"{'filas validas':<24}: {len(enriquecido)} "
      f"({100 * len(enriquecido) / entrada:.1f}%)")
print(f"{'filas rechazadas':<24}: {rechazadas}")
print(f"{'ingreso total':<24}: {enriquecido['ingreso'].sum():,.2f}")
print(f"{'margen total':<24}: {enriquecido['margen'].sum():,.2f}")
print(f"{'periodo':<24}: {enriquecido['mes'].min()} a "
      f"{enriquecido['mes'].max()}")
print()
print("por categoria")
print("-" * 60)
print(por_categoria.to_string(index=False))

print()
print("Los cuatro assert que sostienen el informe:")
print("   validas + rechazadas == entrada")
print("   la suma de ventas por categoria == filas validas")
print("   la suma de ingreso por categoria == ingreso total")
print("   ninguna celda del resumen esta vacia")
print()
print("El TERCERO es el que hubiera atrapado el problema del paso 4:")
print("con groupby por defecto, la suma del resumen daba 1.909.786 y el")
print("total 11.985.252. Una linea de codigo contra 10 millones perdidos.")

carpeta = Path(tempfile.mkdtemp())
por_categoria.to_csv(carpeta / "informe_categoria.csv", index=False,
                     encoding="utf-8", lineterminator="\n")
print()
print("escrito:", (carpeta / "informe_categoria.csv").name,
      f"({len(por_categoria)} filas)")
print()
print("Y esa es la unidad 8 entera: leer, diagnosticar, sanear con tabla de")
print("rechazos, enriquecer, resumir, comprobar y escribir. Siete pasos que")
print("son las siete unidades anteriores puestas una atras de la otra.")
INFORME DE VENTAS
============================================================
filas de entrada        : 500
filas validas           : 491 (98.2%)
filas rechazadas        : 9
ingreso total           : 11,985,251.83
margen total            : 10,675,524.41
periodo                 : 2026-01 a 2026-08

por categoria
------------------------------------------------------------
      categoria  ventas  unidades     ingreso      margen  margen_pct
(sin categoria)      25     264.0 10075465.45 10028620.08        99.5
 Almacenamiento     136    1736.0   581644.42   197253.59        33.9
        Equipos     107    1442.0   465089.34   161797.41        34.8
    Perifericos     120    1426.0   446871.41   144564.93        32.4
      Pantallas      57     667.0   225766.76    78540.75        34.8
          Redes      29     332.0   125860.30    45083.64        35.8
          Audio      17     217.0    64554.15    19664.01        30.5

Los cuatro assert que sostienen el informe:
   validas + rechazadas == entrada
   la suma de ventas por categoria == filas validas
   la suma de ingreso por categoria == ingreso total
   ninguna celda del resumen esta vacia

El TERCERO es el que hubiera atrapado el problema del paso 4:
con groupby por defecto, la suma del resumen daba 1.909.786 y el
total 11.985.252. Una linea de codigo contra 10 millones perdidos.

escrito: informe_categoria.csv (7 filas)

Y esa es la unidad 8 entera: leer, diagnosticar, sanear con tabla de
rechazos, enriquecer, resumir, comprobar y escribir. Siete pasos que
son las siete unidades anteriores puestas una atras de la otra.
Cuatro líneas que valen más que toda la revisión visual
assert len(validas) + rechazadas == entrada
assert resumen["ventas"].sum() == len(validas)
assert abs(resumen["ingreso"].sum() - validas["ingreso"].sum()) < 0.01
assert not resumen.isna().any().any()

El tercero es el que atrapa el problema del paso 4. Con groupby por defecto habría fallado inmediatamente: 1.909.786 contra 11.985.252.

Una línea de código contra diez millones perdidos, y sin ella el informe se publica igual de contento.

Los siete pasos, que son las siete unidades

#PasoDe dónde viene
1Leer, con na_values declarados1.3 · 5.4 · 7.1
2Diagnosticar sin corregir: tipos, ausentes, rangos, claves2.2 · 2.3 · 5.1
3Sanear, con tabla de rechazos y motivo1.5 · 1.6 · 5.7
4Enriquecer con las dimensiones, con validate=6.2 · 6.3
5Resumir: groupby, pivot_table, orden total4.8 · 6.4 · 6.5
6Comprobar con assert de invariantes1.6 · 5.7
7Escribir: index=False, redondeo por columna, UTF-87.4

Las tres cosas que este proyecto enseña y ninguna lección sola podía

  1. El defecto más caro no está en los datos que procesás: está en la tabla con la que los cruzás. Las 500 filas de hechos estaban razonablemente bien. Lo que faltaba era una celda de una dimensión de 30 filas.
  2. Un informe al que le falta el 84 % se ve exactamente igual que uno completo. Seis categorías, números plausibles, márgenes coherentes. Solo la comprobación del total lo delata.
  3. Los assert se escriben cuando el pipeline funciona, no cuando falla. Ahí es cuando uno sabe qué tiene que valer. Después, cuando algo se rompe - una dimensión nueva, un mes con otro formato -, están puestos.
Sobre los números que no coinciden entre lecciones

El techo del IQR de ingreso da 12.243,21 en el ejercicio EJ-3.4 y 12.323,03 en el paso 3 de este proyecto. No es un error: allá se calcula sobre las 500 filas de entrada y acá sobre las 491 saneadas.

Los dos son correctos, y la diferencia es exactamente el punto de la lección 3.7: el criterio depende de sobre qué se calcula, y por eso se declara.

Para seguir

El mismo esquema admite varias vueltas más, todas con herramientas que ya viste:

Y la pregunta que conviene hacerse en cada una: ¿qué invariante tendría que valer si esto salió bien? Esa pregunta, contestada en una línea de código, es lo único que separa un resultado publicable de uno que hay que creer de palabra.

Ponete a prueba

1. En el diagnóstico, los hechos referencian 41 clientes distintos y la dimensión tiene 40 filas. ¿Qué significa?Nivel 1 · Recordar
2. En el paso 3, la venta de 9.999.999,99 se marca en vez de descartarse. ¿Por qué?Nivel 1 · Recordar
3. El resumen por categoría sumaba 1.909.786 y el ingreso total era 11.985.252. ¿Dónde estaba el defecto?Nivel 2 · Predecir la salida
4. ¿Cuál de los cuatro assert del informe habría atrapado ese problema?Nivel 2 · Predecir la salida
5. Vas a publicar el informe para que lo lea una persona. ¿Cómo manejás el hueco de la dimensión?Nivel 3 · Elegir entre dos casi correctas
6. El techo del IQR de ingreso da 12.243,21 en EJ-3.4 y 12.323,03 en este proyecto. ¿Cuál está mal?Nivel 3 · Elegir entre dos casi correctas
7. Un compañero corre el mismo pipeline y su informe tiene seis categorías, todas con márgenes entre 30 % y 36 %, y un total de 1.909.786. Todo se ve coherente. ¿Cómo le mostrás en una línea que le falta algo?Nivel 4 · Encontrar el defecto