Módulo 5: Accuracy And Deterministic Anomaly Detection

Proyecto: la auditoría de accuracy de S04

Descripción

Este proyecto cierra el módulo. Tienes el diagnóstico completo (lecciones 1 a 3), la línea base construida sobre la semana canónica (lección 4), la función de detección probada sobre datos de juguete (lección 5), el resultado real sobre S04 (lección 6), y la calibración de su umbral (lección 7). Falta un solo paso: un script de cierre que junte check_price_baseline() con validate_referential_integrity() del módulo 3, y reporte, con evidencia ejecutada, el estado completo de las seis dimensiones de calidad de datos después de este módulo — exactamente el mismo tipo de auditoría honesta que ya cerraron los proyectos de los módulos 3 y 4.

Conexión con el módulo. Este proyecto no introduce ningún concepto nuevo — es la síntesis de las lecciones 2 a 7, aplicada de punta a punta al mismo incidente que diagnosticaron los módulos 1 a 4. Cierra el hilo de este módulo exactamente donde el módulo 6 lo retoma: la única dimensión que sigue sin ningún check ejecutable es freshness, a nivel de archivo, no de fila — el trabajo central del próximo módulo.

Una analogía: el informe de auditoría, con una nueva sección agregada

El módulo 3 ya construyó la analogía del informe de auditoría completo, con una sección por área revisada y una lista explícita de lo que todavía no se revisó. Este proyecto es esa misma auditoría, con una sección nueva: donde el informe del módulo 3 terminaba diciendo "pendientes: accuracy, freshness", este proyecto agrega la sección de accuracy al informe, con su propio hallazgo, su propia evidencia, y su propia fila marcada — y actualiza la lista de pendientes a un solo ítem restante.

El material que necesitas

Necesitas, en la misma carpeta de trabajo de este módulo:

modulo_5_accuracy/
├── kiosko.duckdb              (orders_s04 del modulo 2, orders de la leccion 4 de este modulo)
└── accuracy_audit.py          (este proyecto)

Si tu kiosko.duckdb no tiene la tabla orders (la semana canónica, cuarenta filas) todavía, repite el paso 1 de la lección 4 de este módulo antes de seguir — este proyecto no vuelve a explicar ese paso, lo da por hecho. Este proyecto también carga dim_product de nuevo (el mismo catálogo de cuatro productos del módulo 3), para poder recordar, en un solo reporte, el resultado de validate_referential_integrity() junto al de check_price_baseline().

La solución de referencia, verificada

# accuracy_audit.py -- proyecto de cierre del modulo 5
import duckdb
import polars as pl

con = duckdb.connect("kiosko.duckdb")

con.execute("""
    CREATE OR REPLACE TABLE dim_product (
        product_id VARCHAR,
        product_name VARCHAR,
        category VARCHAR,
        unit_cost DOUBLE
    )
""")
con.execute("""
    INSERT INTO dim_product VALUES
        ('P001', 'Bottled Water 600ml', 'beverages', 0.40),
        ('P002', 'Energy Bar', 'snacks', 0.60),
        ('P003', 'Instant Coffee Sachet', 'beverages', 0.35),
        ('P004', 'Phone Charger Cable', 'electronics', 2.10)
""")


def build_reference_prices(con: duckdb.DuckDBPyConnection) -> dict[str, float]:
    """Calcula el precio de referencia por producto sobre la semana canonica limpia (S01-S03)."""
    week_df = con.sql("SELECT * FROM orders").pl()
    baseline = (
        week_df.group_by("product_id")
        .agg(pl.col("unit_price").mean().alias("reference_price"))
        .sort("product_id")
    )
    return dict(zip(baseline["product_id"].to_list(), baseline["reference_price"].to_list()))


def check_price_baseline(
    df: pl.DataFrame, reference_prices: dict[str, float], tolerance: float = 0.5
) -> pl.DataFrame:
    """Filas de df cuyo unit_price se desvia de la linea base mas alla de `tolerance`."""
    return (
        df.with_columns(
            pl.col("product_id").replace_strict(reference_prices, default=None).alias("reference_price")
        )
        .filter(pl.col("unit_price").is_not_null() & pl.col("reference_price").is_not_null())
        .with_columns(
            ((pl.col("unit_price") - pl.col("reference_price")).abs() / pl.col("reference_price"))
            .alias("deviation")
        )
        .filter(pl.col("deviation") > tolerance)
    )


def validate_referential_integrity(orders_df: pl.DataFrame, dim_product_df: pl.DataFrame) -> pl.DataFrame:
    """Filas de orders_df cuyo product_id NO existe en dim_product_df (modulo 3, sin ningun cambio)."""
    return orders_df.join(dim_product_df, on="product_id", how="anti")


DIMENSIONS_STILL_OPEN = ["freshness"]


def main() -> None:
    print("=== Kiosko: auditoria de accuracy de S04 ===\n")

    reference_prices = build_reference_prices(con)
    print(f"reference_prices (semana canonica S01-S03): {reference_prices}\n")

    df = con.sql("SELECT * FROM orders_s04").pl()
    dim_product_df = con.sql("SELECT * FROM dim_product").pl()
    print(f"orders_s04: {df.height} filas | dim_product: {dim_product_df.height} filas\n")

    print("=== 1. check_price_baseline(df, reference_prices, tolerance=0.5) ===")
    price_anomalies = check_price_baseline(df, reference_prices, tolerance=0.5)
    print(price_anomalies.select(["order_id", "product_id", "unit_price", "reference_price", "deviation"]))

    print("\n=== 2. validate_referential_integrity(df, dim_product_df) (modulo 3, recordatorio) ===")
    orphans = validate_referential_integrity(df, dim_product_df)
    print(orphans.select(["order_id", "product_id"]))

    flagged_accuracy = set(price_anomalies["order_id"].to_list())
    flagged_consistency = set(orphans["order_id"].to_list())
    flagged_completeness = set(df.filter(pl.col("unit_price").is_null())["order_id"].to_list())
    flagged_validity = set(df.filter(pl.col("quantity") <= 0)["order_id"].to_list())

    dup_counts = df.group_by("order_id").agg(pl.len().alias("n"))
    duplicated_ids = set(dup_counts.filter(pl.col("n") > 1)["order_id"].to_list())
    # se marca la 2a aparicion fisica, el mismo criterio que validate_orders desde foundations
    flagged_uniqueness_rows = (
        df.with_row_index()
        .filter(pl.col("order_id").is_in(list(duplicated_ids)))
        .sort("index")
        .group_by("order_id")
        .tail(1)
    )
    flagged_uniqueness = set(flagged_uniqueness_rows["order_id"].to_list())

    dimensions_covered = {
        "completeness": flagged_completeness,
        "uniqueness": flagged_uniqueness,
        "validity": flagged_validity,
        "consistency": flagged_consistency,
        "accuracy": flagged_accuracy,
    }

    print("\n=== 3. Resumen por dimension (filas de fila, sin contar freshness) ===")
    for dim, ids in dimensions_covered.items():
        print(f"  {dim:<14}{sorted(ids)}")

    n_dimensions = sum(1 for ids in dimensions_covered.values() if ids)
    print(f"\nDimensiones de fila cubiertas hasta el modulo 5: {n_dimensions} de 6")
    print(f"Pendientes -- {', '.join(DIMENSIONS_STILL_OPEN)}")

    physical_problem_rows = df.with_row_index().filter(
        pl.col("order_id").is_in(list(flagged_accuracy | flagged_consistency | flagged_completeness | flagged_validity))
        | pl.col("order_id").is_in(list(duplicated_ids))
    )
    print(f"\nFilas fisicas con al menos un problema conocido: {physical_problem_rows.height} de {df.height}")
    print(f"Filas fisicas genuinamente limpias: {df.height - physical_problem_rows.height} de {df.height}")


if __name__ == "__main__":
    main()

Qué esperar (verificado corriendo python3 accuracy_audit.py real, con kiosko.duckdb conteniendo orders_s04, orders y dim_product, polars==1.43.2, duckdb==1.5.5):

=== Kiosko: auditoria de accuracy de S04 ===

reference_prices (semana canonica S01-S03): {'P001': 0.55, 'P002': 1.2, 'P003': 0.75, 'P004': 4.5}

orders_s04: 12 filas | dim_product: 4 filas

=== 1. check_price_baseline(df, reference_prices, tolerance=0.5) ===
shape: (1, 5)
┌──────────┬────────────┬────────────┬─────────────────┬───────────┐
│ order_id ┆ product_id ┆ unit_price ┆ reference_price ┆ deviation │
│ ---      ┆ ---        ┆ ---        ┆ ---             ┆ ---       │
│ str      ┆ str        ┆ f64        ┆ f64             ┆ f64       │
╞══════════╪════════════╪════════════╪═════════════════╪═══════════╡
│ ORD-9509 ┆ P002       ┆ 60.0       ┆ 1.2             ┆ 49.0      │
└──────────┴────────────┴────────────┴─────────────────┴───────────┘

=== 2. validate_referential_integrity(df, dim_product_df) (modulo 3, recordatorio) ===
shape: (1, 2)
┌──────────┬────────────┐
│ order_id ┆ product_id │
│ ---      ┆ ---        │
│ str      ┆ str        │
╞══════════╪════════════╡
│ ORD-9508 ┆ P099       │
└──────────┴────────────┘

=== 3. Resumen por dimension (filas de fila, sin contar freshness) ===
  completeness  ['ORD-9503']
  uniqueness    ['ORD-9502']
  validity      ['ORD-9507']
  consistency   ['ORD-9508']
  accuracy      ['ORD-9509']

Dimensiones de fila cubiertas hasta el modulo 5: 5 de 6
Pendientes -- freshness

Filas fisicas con al menos un problema conocido: 6 de 12
Filas fisicas genuinamente limpias: 6 de 12

Lee el resultado completo con el mismo cuidado que ya entrenaste en los proyectos anteriores. Las secciones 1 y 2 confirman, en un solo reporte, los dos hallazgos que hasta ahora vivían en módulos distintos: ORD-9509 por accuracy, ORD-9508 por consistency. La sección 3 es el resumen que cierra el diagnóstico completo del módulo 1: cinco dimensiones de fila, cada una con exactamente una fila marcada, ninguna dimensión con más de una. Y las dos últimas líneas contienen el número que ancla toda esta guía desde su diseño: seis de las doce filas físicas de orders_2026-08-14.csv tienen al menos un problema conocido, y seis son genuinamente limpias — la estructura exacta de "seis limpias, seis rotas, una por cada dimensión" que el diseño de esta guía fijó desde el principio, ahora confirmada con evidencia ejecutada, dimensión por dimensión, en vez de asumida.

Diagrama: de dónde venías, a dónde llegaste

flowchart LR
    A["Modulo 1:\ndiagnostico -- 4 herramientas,\nORD-9509 sin marca"] --> B["Leccion 2:\npor que accuracy es\nla dimension mas dificil"]
    B --> C["Leccion 3:\nORD-9509 disecada --\n4 verificaciones OK, ninguna es accuracy"]
    C --> D["Leccion 4:\nreference_prices desde\nla semana canonica"]
    D --> E["Leccion 5:\ncheck_price_baseline()\nprobada en datos de juguete"]
    E --> F["Leccion 6:\nORD-9509 ATRAPADA\ndeviation=49.0"]
    F --> G["Leccion 7:\ntolerance calibrado --\nni 50 ni 0.05"]
    G --> H["Este proyecto:\n5 de 6 dimensiones,\n6 de 12 filas con problema"]
    H --> I["Modulo 6:\nfreshness, volumen,\nlinaje"]

Cerrando la promesa del módulo, punto por punto

Lo que la lección 1 del módulo prometióEvidencia de que este módulo lo entregó
Explicar por qué accuracy es la dimensión más difícil de testearLecciones 1-2: comparación de las seis dimensiones, ORD-9509 pasando las cuatro herramientas anteriores con evidencia ejecutada
Diseccionar por qué una fila válida puede estar malLección 3: ORD-9509, verificación por verificación, cuatro "Sí" consecutivos
Construir una línea base desde la semana canónica limpiaLección 4: reference_prices = {'P001': 0.55, 'P002': 1.2, 'P003': 0.75, 'P004': 4.5}, con evidencia de varianza cero
Detección de anomalías con reglas y umbrales, sin Machine LearningLección 5: check_price_baseline(), probada en datos de juguete, con la frontera de ML trazada desde la lección 1
Atrapar el bug de dólares-a-centavos que el módulo 1 dejó pasar limpioLección 6: ORD-9509 marcada, deviation=49.0, con las cuatro herramientas anteriores confirmadas sin marca
Explorar qué pasa con umbrales mal calibradosLección 7: tolerance=50 (falso negativo, ORD-9509 se escapa) y tolerance=0.05 (falso positivo, promoción marcada)
Cerrar con un reporte honesto de qué queda pendienteEste proyecto: 5 de 6 dimensiones, DIMENSIONS_STILL_OPEN = ['freshness']

Con este proyecto, check_price_baseline() y reference_prices dejan de ser el trabajo aislado de un solo módulo — son, a partir de aquí, la quinta pieza de un sistema de calidad de datos que ya cubre cinco de las seis dimensiones que definió el módulo 1. El módulo 6 no vuelve a tocar ninguna fila individual: freshness y volumen son propiedades del archivo completo, y son, con precisión, el último ítem pendiente de esta lista.

Errores comunes

Pensar que "5 de 6 dimensiones" significa que S04 está "casi lista" para producción. Qué pasa: alguien, satisfecho con el progreso —de cero dimensiones cubiertas en el módulo 1 a cinco al cierre de este proyecto—, concluye que falta poco trabajo antes de que Kiosko pueda confiar completamente en los datos de S04. Por qué pasa: 5 de 6 se siente, numéricamente, como un progreso casi completo. Cómo detectarlo: revisa qué es, exactamente, la dimensión que falta — freshness, ya confirmada como violada desde el módulo 1, lección 5 (57 horas de retraso sobre la llegada esperada, 33 horas sobre el SLA de 24 horas). No es una dimensión "menor" que las otras cinco, es una violación ya conocida y ya confirmada, simplemente sin un check formal todavía. Cómo corregirlo: trata el conteo de dimensiones como una medida de cobertura de herramientas, no de estado real del archivoS04 sigue teniendo un archivo que llegó tarde, sin importar cuántas dimensiones de fila ya tengan su propio check.

Recalcular dimensions_covered a mano, en vez de reutilizar los conjuntos que ya construye el script. Qué pasa: alguien, al extender este proyecto con un reporte adicional, vuelve a filtrar df desde cero para cada dimensión, en vez de reutilizar flagged_completeness, flagged_validity, etc., ya calculados. Por qué pasa: es fácil olvidar qué variables ya existen cuando un script crece. Cómo detectarlo: si tu extensión del script tiene más de una línea que filtra df.filter(pl.col("quantity") <= 0) o equivalente, ya estás duplicando lógica que el script original ya calculó. Cómo corregirlo: el diccionario dimensions_covered de este proyecto ya centraliza los cinco conjuntos de order_id marcados — cualquier reporte adicional debería construirse a partir de él, no recalculando cada filtro desde cero.

Olvidar por qué uniqueness en este proyecto reporta un solo order_id, no dos filas físicas. Qué pasa: alguien, comparando el resultado de este proyecto contra el del módulo 2 (que reportaba ORD-9502 dos veces, una por cada aparición física), se confunde al ver que este reporte lo cuenta una sola vez en la sección 3. Por qué pasa: los dos proyectos usan una unidad de conteo distinta a propósito. Cómo detectarlo: revisa la línea de flagged_uniqueness en el script — agrupa por order_id y toma la última fila física (group_by("order_id").tail(1)), el mismo criterio de "se marca la segunda aparición" que ya estableció validate_orders() desde foundations. Cómo corregirlo: cuando compares reportes de distintos módulos de esta guía, verifica siempre si están contando order_id distintos o filas físicas — no son la misma unidad, y esta guía usa ambas en distintos contextos, siempre explicando cuál está usando en cada caso.

Ejercicios

Ejercicio 1 — Corre el proyecto completo tú mismo, desde cero. En una carpeta nueva, con kiosko.duckdb conteniendo orders_s04 (módulo 2) y orders (lección 4 de este módulo), corre python3 accuracy_audit.py. Confirma que ves exactamente 5 de 6 dimensiones y 6 de 12 filas físicas con algún problema.

Ver solución

Si orders_s04 tiene las doce filas exactas del módulo 2 y orders tiene las cuarenta filas exactas de la semana canónica (sin ninguna mezcla entre las dos tablas), la salida debería reproducir exactamente la de este proyecto: reference_prices con los cuatro valores conocidos, ORD-9509 marcada por accuracy con deviation=49.0, ORD-9508 marcada por consistency, 5 dimensiones cubiertas, 6 filas físicas con problema. Si tu resultado difiere, revisa primero que orders no tenga ninguna fila de S04 mezclada — el mismo chequeo que ya sugirió el ejercicio 1 de la lección 6.

Ejercicio 2 — Extiende el reporte para mostrar, explícitamente, las seis filas físicas genuinamente limpias. Usando physical_problem_rows ya calculado en main(), agrega un bloque que imprima los order_id de las filas que no tienen ningún problema conocido — las seis filas limpias de S04.

Ver solución
clean_rows = df.with_row_index().filter(~pl.col("index").is_in(physical_problem_rows["index"].to_list()))
print(f"\nFilas fisicas genuinamente limpias ({clean_rows.height}):")
for row in clean_rows.select(["order_id", "product_id", "unit_price"]).iter_rows(named=True):
    print(f"  {row['order_id']} | product_id={row['product_id']} | unit_price={row['unit_price']}")

Salida esperada:

Filas fisicas genuinamente limpias (6):
  ORD-9501 | product_id=P001 | unit_price=0.55
  ORD-9504 | product_id=P004 | unit_price=4.5
  ORD-9505 | product_id=P001 | unit_price=0.55
  ORD-9506 | product_id=P003 | unit_price=0.75
  ORD-9510 | product_id=P004 | unit_price=4.5
  ORD-9511 | product_id=P002 | unit_price=1.2

Seis filas — y fíjate en un detalle que vale la pena confirmar con cuidado: ninguna de las dos apariciones de ORD-9502 aparece en esta lista, ni siquiera la primera, que por sí sola es una fila perfectamente válida. La razón está en cómo physical_problem_rows filtra: usa pl.col("order_id").is_in(list(duplicated_ids)), una condición sobre el nombre del order_id, no sobre su índice físico — así que cualquier fila cuyo order_id sea "ORD-9502" cae en el filtro, sus dos apariciones incluidas, aunque la primera nunca haya sido, por sí sola, motivo de rechazo. Esto es distinto del criterio de flagged_uniqueness (usado en la sección 3 del reporte), que sí distingue cuál aparición específica se marca. Este ejercicio es una buena demostración de por qué "filas físicas con algún problema" y "apariciones marcadas por uniqueness" son preguntas relacionadas pero no idénticas — y de por qué vale la pena, como hizo este ejercicio, verificar el resultado real en vez de asumirlo por analogía con otro reporte de esta guía.

Ejercicio 3 — Argumenta si check_price_baseline() debería correr antes o después de validate_referential_integrity() en un pipeline real. En 2-3 frases, considerando que check_price_baseline() excluye silenciosamente las filas con product_id desconocido (ya visto en la lección 5), argumenta si el orden de ejecución entre las dos funciones importa para el resultado final.

Ver solución

El orden no cambia el resultado final de ninguna de las dos funciones — cada una opera sobre una copia lógica de df sin modificar el DataFrame original, así que corren de forma independiente sin importar la secuencia. Lo que sí cambiaría, en un pipeline real con recursos limitados, es la eficiencia: si validate_referential_integrity() ya identificó que ORD-9508 tiene un product_id inexistente, ejecutar check_price_baseline() sobre esa misma fila es trabajo parcialmente redundante, porque la función ya la va a excluir por su cuenta (al no encontrar una entrada en reference_prices). En un sistema con volúmenes de datos mucho mayores a los de Kiosko, correr primero validate_referential_integrity() y filtrar las filas huérfanas antes de pasarlas a check_price_baseline() podría ahorrar cómputo — una optimización razonable, pero no necesaria a la escala de esta guía, y que este proyecto elige no implementar para mantener las dos funciones completamente independientes y fáciles de razonar por separado.

Resumen y siguiente paso: el cierre de este módulo

Con este proyecto cierras el módulo 5. Aprendiste, con evidencia acumulada de cuatro módulos anteriores, por qué accuracy es la dimensión más difícil de testear de las seis; diseccionaste ORD-9509 verificación por verificación, confirmando que una fila puede pasar completeness, uniqueness, validity y consistency y seguir estando mal; construiste reference_prices sobre la única porción de datos de Kiosko ya confirmada confiable, nunca sobre el archivo bajo sospecha; escribiste check_price_baseline(), probada primero en datos de juguete, y la corriste sobre el incidente real —ORD-9509, atrapada, con deviation=49.0—; y calibraste su umbral, viendo con evidencia qué pasa en ambos extremos, demasiado laxo y demasiado estricto. Y, en el camino, mantuviste la frontera con Machine Learning firme en cada lección: toda regla de este módulo es explicable en una sola frase.

Con este proyecto, cinco de las seis dimensiones de calidad de datos de S04 ya tienen su propio check ejecutable: completeness, uniqueness y validity (módulos 1-2), consistency (módulo 3), y ahora accuracy (este módulo). Queda una sola dimensión sin resolver, y no es una fila más — es una propiedad del archivo completo, ya confirmada como violada desde la primera lección de esta guía.

Hacia dónde sigues. El módulo 6Freshness, volumen y linaje— cierra el diagnóstico completo de las seis dimensiones: check_freshness(df, run_at=PIPELINE_RUN_AT, sla_hours=24), corrido sobre el mismo archivo, va a fallar —la primera vez en esta guía que un check de nivel de archivo se ejecuta de verdad, no solo se calcula a mano como en el módulo 1—; check_volume(df, min_rows=5, max_rows=20) va a pasar, el contraste deliberado de que no todo en S04 está roto. Y, más allá de las seis dimensiones, ese módulo traza por primera vez el linaje de Kiosko: de dónde viene cada columna del warehouse que las ocho guías anteriores de este ecosistema construyeron.

Recursos

  • Polars — documentación oficial completa (group_by, agg, replace_strict, join, filter, with_row_index — el conjunto completo de expresiones usadas en este módulo). docs.pola.rs. En inglés.
  • Módulo 1, lección 5, de esta misma guía ("Conoce a S04: la cuarta tienda de Kiosko") — fuente del cálculo de freshness que retoma el módulo 6. src/guides/data-reliability-and-governance-guide/workbook/module-01-when-green-does-not-mean-correct/es/05-meet-s04-kioskos-fourth-store.md. En español.
  • Módulo 3, proyecto (lección 8), de esta misma guía — la base de comparación exacta de este proyecto (DIMENSIONS_STILL_OPEN, el mismo patrón de reporte honesto). src/guides/data-reliability-and-governance-guide/workbook/module-03-consistency-and-referential-checks/es/08-project-s04s-full-consistency-report.md. En español.
  • DISEÑO de esta guía — el mapa completo de los ocho módulos, incluido el módulo 6 que sigue. src/guides/data-reliability-and-governance-guide/DISENO.md. En español.