Módulo 5: Accuracy And Deterministic Anomaly Detection

Atrapando el bug de dólares a centavos

Descripción

Esta es la lección donde ORD-9509 deja de ser invisible. Sin ningún cambio en check_price_baseline() ni en reference_prices —las mismas dos piezas exactas que construyeron las lecciones 4 y 5—, esta lección las corre sobre las doce filas reales de orders_2026-08-14.csv, y por primera vez en cuatro módulos, algo marca esa fila.

Conexión con el módulo. Esta lección cierra el arco narrativo que abrió la lección 1: cuatro herramientas, cuatro veces ORD-9509 sin ninguna marca. Esta es la quinta herramienta, y la primera que sí la atrapa — con evidencia ejecutada, no con una promesa.

Una analogía: el guardia de seguridad, ahora en la puerta real

Las lecciones anteriores probaron al guardia de seguridad de la lección 5 en un simulacro — tres tarjetas de juguete, una de ellas claramente falsa, para confirmar que el mecanismo de comparación funciona antes de ponerlo a trabajar de verdad. Esta lección lo pone en la puerta real, con el tráfico real del día: las doce filas de S04, con sus seis problemas conocidos y sus seis filas genuinamente limpias, mezcladas exactamente como llegaron.

Ejemplo trabajado: check_price_baseline(), corrida sobre orders_s04

Paso 1 — las mismas dos piezas, sin ningún cambio

# catch_dollars_to_cents.py
import duckdb
import polars as pl


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)
    )

Cero líneas nuevas de lógica — es, literalmente, el mismo código de las lecciones 4 y 5, copiado sin modificaciones. Eso es intencional, y vale la pena notarlo antes de seguir: si esta lección necesitara reescribir algo para que funcionara sobre datos reales, el trabajo de las lecciones anteriores habría sido, en el mejor de los casos, un borrador. No lo fue.

Paso 2 — correr sobre orders_s04

# catch_dollars_to_cents.py -- continuacion
con = duckdb.connect("kiosko.duckdb")

reference_prices = build_reference_prices(con)
print(f"reference_prices: {reference_prices}\n")

df = con.sql("SELECT * FROM orders_s04").pl()
print(f"orders_s04.shape: {df.shape}\n")

anomalies = check_price_baseline(df, reference_prices, tolerance=0.5)
print(f"check_price_baseline(df, reference_prices, tolerance=0.5) -> anomalies.shape: {anomalies.shape}")
print(anomalies.select(["order_id", "product_id", "unit_price", "reference_price", "deviation"]))

Qué esperar. Al correr python3 catch_dollars_to_cents.py en la carpeta donde ya tienes kiosko.duckdb con orders (semana canónica, lección 4) y orders_s04 (módulo 2) cargadas, la salida es exactamente esta:

reference_prices: {'P001': 0.55, 'P002': 1.2, 'P003': 0.75, 'P004': 4.5}

orders_s04.shape: (12, 6)

check_price_baseline(df, reference_prices, tolerance=0.5) -> anomalies.shape: (1, 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      │
└──────────┴────────────┴────────────┴─────────────────┴───────────┘

Léelo despacio, porque son cuatro módulos de tensión narrativa resolviéndose en una sola fila de tabla. ORD-9509, product_id=P002, unit_price=60.0, comparado contra reference_price=1.2 — el precio real de una Energy Bar en la semana canónica completa de Kiosko—, con deviation=49.0. Esa desviación significa que 60.00 está 49 veces (4900%) por encima del precio de referencia — dicho de otra forma, 60.00 / 1.20 = 50, ORD-9509 cobró cincuenta veces el precio normal del producto. Con tolerance=0.5 (50%), cualquier desviación por encima de ese umbral se marca — y 49.0 está a años luz de 0.5. Ninguna otra fila del archivo aparece en este resultado: exactamente una fila, exactamente la que cuatro módulos anteriores dejaron pasar.

El diagnóstico completo, cerrado: las seis dimensiones de S04, todas cubiertas por fila

Con este resultado, vale la pena volver, por última vez, a las doce filas completas de orders_2026-08-14.csv y confirmar, dimensión por dimensión, que cada una de las seis filas rotas ya tiene su herramienta:

#FilaDimensiónHerramienta que la atrapaMódulo
1ORD-9503 (unit_price vacío)Completenessvalidate_orders() / OrdersSchema1 / 2
2-3ORD-9502 (x2)Uniquenessvalidate_orders() / OrdersSchema1 / 2
4ORD-9507 (quantity=-1)Validityvalidate_orders() / OrdersSchema1 / 2
5ORD-9508 (product_id=P099)Consistencyvalidate_referential_integrity()3
6ORD-9509 (unit_price=60.00)Accuracycheck_price_baseline()5 (esta lección)

Cinco filas físicas, cinco dimensiones, cinco herramientas distintas — y una sexta dimensión, freshness, que ya se confirmó como violada desde el módulo 1, lección 5, sin necesitar ninguna fila individual marcada, porque es una propiedad del archivo completo. Con esta lección, todas las dimensiones de fila que las doce líneas de S04 violan ya tienen, cada una, su propio mecanismo de detección, construido con su propia responsabilidad, sin que ninguna herramienta intente cubrir el trabajo de otra.

Diagrama: el cierre del arco narrativo completo

flowchart TD
    A["orders_2026-08-14.csv\n12 filas"] --> B["Modulo 1: validate_orders()\nORD-9509: valid, sin marca"]
    A --> C["Modulo 2: OrdersSchema\nORD-9509: passing, sin marca"]
    A --> D["Modulo 3: validate_referential_integrity()\nORD-9509: P002 existe, sin marca"]
    A --> E["Modulo 4: contract_to_pandera_schema()\nORD-9509: sin problema conocido"]
    A --> F["Modulo 5: check_price_baseline()\nORD-9509: ANOMALA (deviation=49.0)"]

    B -.->|"4 herramientas,\ncero marcas"| G["El bug de\ndolares-a-centavos,\ninvisible"]
    C -.-> G
    D -.-> G
    E -.-> G
    F ==>|"la 5a herramienta,\ncon la referencia correcta"| H["El bug,\natrapado"]

Profundización: por qué esta lección no necesitó ningún caso especial para ORD-9509

Vale la pena notar algo que podría pasar desapercibido: check_price_baseline() no tiene ninguna línea de código que mencione ORD-9509, S04, ni 60.00. La función es completamente genérica —recibe cualquier DataFrame con columnas product_id y unit_price, y cualquier diccionario de precios de referencia—, y ORD-9509 se marca no porque la función la conozca, sino porque el número que trae, comparado contra el número que ya se sabe correcto, produce una desviación que supera el umbral configurado. Esto contrasta, de forma deliberada, con la tentación que ya nombró el módulo 1, lección 7, de "arreglar" un problema agregando una lista de casos especiales codificados a mano (if product_id == "P099": ...). Una regla general, aplicada correctamente, atrapa el caso específico sin necesitar saber que ese caso específico existe de antemano — la misma propiedad que ya tenía validate_referential_integrity() en el módulo 3, ahora confirmada de nuevo con una herramienta completamente distinta.

Errores comunes

Sorprenderse de que solo una fila se marque, esperando ver más. Qué pasa: alguien, después de ver que validate_orders() marcó tres filas y OrdersSchema marcó cuatro, espera que check_price_baseline() también marque varias filas, y se desconcierta al ver solo una. Por qué pasa: el patrón de "varias filas marcadas" ya se volvió familiar en los módulos anteriores. Cómo detectarlo: revisa la tabla de "El diagnóstico completo" de esta lección — accuracy, a diferencia de completeness/uniqueness/validity (que juntas cubren cuatro filas físicas), es la única dimensión que el diseño del incidente de S04 asigna a una sola fila. Cómo corregirlo: el número de filas marcadas por cada herramienta depende de cuántos problemas reales de esa dimensión tiene el archivo, no de alguna propiedad general de la herramienta — check_price_baseline() marcaría tantas filas como precios anómalos reales encontrara, ni más ni menos, y en este archivo específico ese número es uno.

Pensar que deviation=49.0 significa "49% de desviación". Qué pasa: alguien lee deviation=49.0 y lo interpreta como un porcentaje pequeño, confundiéndolo con 0.49 (49%). Por qué pasa: la mayoría de los porcentajes con los que se trabaja a diario están entre 0% y 100%, así que un número mayor a 1.0 en un contexto de "desviación" puede sentirse, a primera vista, como un error de escala. Cómo detectarlo: recuerda la fórmula exacta de la lección 5 — deviation es una fracción, no un porcentaje ya multiplicado por 100; 49.0 significa 4900%, cuarenta y nueve veces el precio de referencia sumado sobre sí mismo. Cómo corregirlo: si necesitas mostrar la desviación como un porcentaje más legible para un reporte, multiplica por 100 explícitamente (deviation * 100) y agrega el símbolo % en el texto — nunca asumas que el número crudo de la columna deviation ya está en esa escala.

Concluir que, con ORD-9509 atrapada, el archivo de S04 ya está "limpio". Qué pasa: alguien, satisfecho con el resultado de esta lección, trata el archivo de S04 como si ya no tuviera ningún problema pendiente. Por qué pasa: después de cinco módulos, cada dimensión de fila del incidente ya tiene su propia herramienta — se siente como un cierre completo. Cómo detectarlo: revisa la tabla completa del módulo 1 —seis problemas, uno de ellos es freshness, a nivel de archivo, no de fila—; ninguna herramienta de este módulo ni de los anteriores corrigió ni resolvió esa violación, solo la nombró. Cómo corregirlo: esta lección cierra el diagnóstico de las cinco dimensiones de fila, no las seis dimensiones completas del incidente. El módulo 6 de esta guía es, precisamente, donde freshness (y volumen) se convierten en un check real y ejecutable — el trabajo de esta guía sigue.

Ejercicios

Ejercicio 1 — Corre el script completo tú mismo, y confirma la fila exacta. Con kiosko.duckdb conteniendo tanto orders (la semana canónica, lección 4) como orders_s04 (módulo 2), corre python3 catch_dollars_to_cents.py. Confirma que anomalies.shape es (1, 5) y que la única fila es ORD-9509.

Ver solución

Si tu kiosko.duckdb tiene ambas tablas exactamente como las dejaron el módulo 2 (orders_s04, doce filas) y la lección 4 de este módulo (orders, cuarenta filas de la semana canónica), la salida debería reproducir exactamente la de esta lección: reference_prices con los cuatro valores conocidos, anomalies.shape: (1, 5), y ORD-9509 como única fila, con deviation=49.0. Si tu resultado difiere, revisa primero que orders no tenga ninguna fila de S04 mezclada por accidente — recuerda la profundización de la lección 4 sobre por qué mezclar las dos tablas produciría un reference_prices distorsionado.

Ejercicio 2 — Calcula cuánto dinero de más habría cobrado Kiosko si ORD-9509 no se hubiera atrapado. Usando quantity=1 y unit_price=60.00 de ORD-9509, contra el reference_price=1.20 de P002, calcula la diferencia en dinero real (no en desviación relativa) entre lo que se cobró y lo que debería haberse cobrado.

Ver solución
overcharge = (60.00 - 1.20) * 1  # (unit_price - reference_price) * quantity
print(f"Diferencia: {overcharge}")

Salida esperada:

Diferencia: 58.8

Cincuenta y ocho dólares con ochenta centavos de más, en una sola orden de una sola unidad. Este ejercicio conecta la desviación relativa (49.0, o 4900%) con su impacto financiero directo — útil para recordar por qué esta guía trata el bug de dólares-a-centavos con la seriedad que le da la advertencia de mercado citada en el diseño de esta guía: en un volumen de órdenes real, un error sistemático de esta magnitud, multiplicado por cientos o miles de transacciones, representa un impacto financiero significativo, no un detalle cosmético de reporte.

Ejercicio 3 — Argumenta por qué esta lección, a diferencia de las anteriores, no necesitó ningún ejemplo de juguete antes de tocar datos reales. En 2-3 frases, explica por qué esta lección fue directo a orders_s04, sin repetir el paso intermedio de datos de juguete que sí usó la lección 5.

Ver solución

La lección 5 ya cumplió ese propósito — probar check_price_baseline() contra un caso controlado donde el resultado esperado se conocía de antemano, confirmando que el mecanismo funciona correctamente antes de exponerlo a datos reales. Repetir ese mismo paso en esta lección sería redundante: el objetivo de esta lección no es validar la función de nuevo, es aplicar una herramienta ya validada al caso real que motivó todo el módulo. Este es el mismo patrón exacto que ya siguió el módulo 3 (validate_referential_integrity() probada en la lección 4 con datos de juguete, aplicada a S04 real recién en la lección 6) — cada herramienta nueva de esta guía se prueba primero en un entorno controlado, y solo después se despliega sobre el incidente real.

Resumen y siguiente paso

En esta lección corriste check_price_baseline() —sin ningún cambio respecto a las lecciones 4 y 5— sobre las doce filas reales de orders_2026-08-14.csv, y confirmaste, con evidencia ejecutada, el resultado que este módulo completo prometió desde su primera línea: ORD-9509, con unit_price=60.00, se marca como anómala, con deviation=49.0 contra un reference_price=1.2 — cincuenta veces el precio normal de una Energy Bar. Con esta lección, las cinco dimensiones de fila del incidente de S04 —completeness, uniqueness, validity, consistency, accuracy— ya tienen, cada una, su propia herramienta de detección, construida con su propia responsabilidad.

Antes de avanzar deberías poder: reproducir el resultado exacto de esta lección, incluida la desviación numérica precisa; explicar por qué check_price_baseline() no necesitó ningún caso especial para ORD-9509; y completar, de memoria, la tabla de las cinco dimensiones de fila y sus cinco herramientas correspondientes.

Tienes el bug atrapado, con evidencia. Pero una pregunta queda abierta: ¿qué tan bien elegido está tolerance=0.5? La lección 7 explora, con datos reales, qué pasa cuando ese umbral está mal calibrado — demasiado estricto, o demasiado laxo.

Recursos

  • Módulo 1, lección 5, de esta misma guía ("Conoce a S04: la cuarta tienda de Kiosko") — fuente del cálculo original de freshness, la sexta dimensión que sigue pendiente después de esta lección. 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.
  • Polars — documentación oficial completa (group_by, agg, replace_strict, filter, with_columns — el conjunto completo de expresiones usadas en este módulo). docs.pola.rs. En inglés.
  • src/paths/data-engineering-ecosystem/VALIDACION.md — la auditoría de mercado que cita explícitamente el bug de dólares-a-centavos como un incidente real reportado por practicantes. Documento interno del repo, no URL pública.
  • DISEÑO de esta guía — el resultado exacto que esta lección confirma con evidencia ejecutada. src/guides/data-reliability-and-governance-guide/DISENO.md. En español.