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
| Archivo | Filas | Grano | Clave |
|---|---|---|---|
dw_hecho_ventas.csv | 500 | una venta | venta_id |
dw_dim_cliente.csv | 40 | un cliente | cliente_id |
dw_dim_producto.csv | 30 | un producto | producto_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
Pasos 1 y 2: leer y diagnosticar
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ánto | Capa |
|---|---|---|
fecha entró como texto | 500 filas | - |
| Celdas ausentes | 4 | 3 |
| Cantidad e ingreso negativos | 1 + 1 | 3 |
| Un ingreso de 9.999.999,99 | 1 | 3 (atípico) |
cliente_id y producto_id inexistentes | 1 + 1 | 4 |
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
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.
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
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.
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.
| Cómo | Qué queda | Cuá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
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.
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
| # | Paso | De dónde viene |
|---|---|---|
| 1 | Leer, con na_values declarados | 1.3 · 5.4 · 7.1 |
| 2 | Diagnosticar sin corregir: tipos, ausentes, rangos, claves | 2.2 · 2.3 · 5.1 |
| 3 | Sanear, con tabla de rechazos y motivo | 1.5 · 1.6 · 5.7 |
| 4 | Enriquecer con las dimensiones, con validate= | 6.2 · 6.3 |
| 5 | Resumir: groupby, pivot_table, orden total | 4.8 · 6.4 · 6.5 |
| 6 | Comprobar con assert de invariantes | 1.6 · 5.7 |
| 7 | Escribir: index=False, redondeo por columna, UTF-8 | 7.4 |
Las tres cosas que este proyecto enseña y ninguna lección sola podía
- 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.
- 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.
- Los
assertse 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.
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:
- El panel de segmento × mes con
pivot_tableyfill_value=0(lección 6.5), cuidando que los meses sin ventas aparezcan. - La serie diaria con
resample("D")(lección 6.6), que incluye los días sin ventas - cosa quegroupbyno hace. - El libro de Excel con una hoja por sección (lección 7.3), o el
to_sqlpara dejarlo consultable (lección 7.5).
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.