Saltar al contenido
← Todos los proyectos

Proyecto #17 · Datos y gobernanza

Calidad de datos y catálogo

Reglas de calidad con semáforo, diccionario de datos automático y clasificación de PII.

📦 Datos: DWH del proyecto 16 Reglas con severidadStaging vs. curadaData catalogEnmascaramiento PII

Resumen ejecutivo

  • 12 reglas declarativas en 6 dimensiones, con severidad y umbral, evaluadas en las dos capas del DWH.
  • Staging (fuente): 6 verdes, 1 amarillas y 5 rojas — índice 52/100. Capa curada: 11 verdes, 0 rojas — índice 98/100.
  • Lo que no se puede corregir no se borra: se manda a cuarentena con su motivo (proyecto #16), y lo que queda en amarillo en la capa curada (correos faltantes) se reporta al dueño del dato en la fuente.
  • Catálogo automático de 27 columnas; 7 sensibles (PII directa, financiera o cuasi-identificador) con enmascaramiento por rol para analistas.
Índice staging52 / 100
Índice curada98 / 100
Rojas staging → curada5 → 0
Columnas PII7

Resultados

Semáforo en la capa curada

  • Amarillo C01 · email no nulo (advertencia) 1.20 % (umbral 0.5 %)
  • Verde C02 · estado no nulo (crítica) 0.00 % (umbral 0.0 %)
  • Verde U01 · id_cliente único (crítica) 0.00 % (umbral 0.0 %)
  • Verde R01 · edad entre 18 y 90 (crítica) 0.00 % (umbral 0.0 %)
  • Verde R02 · score de buró entre 300 y 850 (crítica) 0.00 % (umbral 0.0 %)
  • Verde R03 · monto original positivo (crítica) 0.00 % (umbral 0.0 %)
  • Verde R04 · plazo en {24, 36, 48, 60} (crítica) 0.00 % (umbral 0.0 %)
  • Verde I01 · todo crédito tiene cliente (crítica) 0.00 % (umbral 0.0 %)
  • Verde I02 · toda póliza tiene crédito (crítica) 0.00 % (umbral 0.0 %)
  • Verde K01 · días de atraso en múltiplos de 30 (crítica) 0.00 % (umbral 0.0 %)
  • Verde K02 · saldo no negativo (crítica) 0.00 % (umbral 0.0 %)
  • Verde K03 · si hay atraso no hay pago registrado (advertencia) 0.00 % (umbral 0.1 %)

Semáforo en staging (fuente)

  • Amarillo C01 · email no nulo 1.21 %
  • Verde C02 · estado no nulo 0.00 %
  • Rojo U01 · id_cliente único 0.37 %
  • Rojo R01 · edad entre 18 y 90 0.40 %
  • Verde R02 · score de buró entre 300 y 850 0.00 %
  • Rojo R03 · monto original positivo 0.07 %
  • Verde R04 · plazo en {24, 36, 48, 60} 0.00 %
  • Rojo I01 · todo crédito tiene cliente 0.11 %
  • Verde I02 · toda póliza tiene crédito 0.00 %
  • Rojo K01 · días de atraso en múltiplos de 30 0.01 %
  • Verde K02 · saldo no negativo 0.00 %
  • Verde K03 · si hay atraso no hay pago registrado 0.01 %

Índice de calidad por capa (0–100)

Verde = 1, amarillo = 0.5, rojo = 0; críticas pesan doble

Staging 52
Curada 98

Metodología y validación

Reglas de calidad como SQL declarativo con dimensión, severidad, umbral y semáforo; catálogo y clasificación PII generados del esquema.

✔ Cómo se validó

  • Las mismas reglas sobre staging y capa curada para medir el efecto de la limpieza.
  • Perfilado de cuantiles para detectar valores imposibles.
  • Los defectos sembrados en el generador (#16) funcionan como pruebas de que cada regla detecta lo que debe.

⚙ Supuestos

  • Umbrales definidos con el dueño de cada dato (aquí, supuestos razonables).

⚠ Limitaciones

  • La clasificación PII por nombre de columna es una primera pasada; se complementa con revisión manual.

Reglas: staging vs. capa curada

ReglaDescripciónSeveridadStaging %StagingCurada %Curada
C01email no nuloAdvertencia1.21Amarillo1.2Amarillo
C02estado no nuloCrítica0Verde0Verde
U01id_cliente únicoCrítica0.374Rojo0Verde
R01edad entre 18 y 90Crítica0.399Rojo0Verde
R02score de buró entre 300 y 850Crítica0Verde0Verde
R03monto original positivoCrítica0.073Rojo0Verde
R04plazo en {24, 36, 48, 60}Crítica0Verde0Verde
I01todo crédito tiene clienteCrítica0.109Rojo0Verde
I02toda póliza tiene créditoCrítica0Verde0Verde
K01días de atraso en múltiplos de 30Crítica0.012Rojo0Verde
K02saldo no negativoCrítica0Verde0Verde
K03si hay atraso no hay pago registradoAdvertencia0.01Verde0Verde

Perfilado de variables clave

VariableMínimoMedianaMáximo% nulos
edad (staging)-546710
edad (curada)2146710.4
monto_original (staging)-1,493,000364,0004,287,0000
monto_original (curada)86,000364,0004,287,0000

Vista enmascarada para el rol analista

id_clientenombreemailtelefonoestadoedad
1J. L. F.c***@correo-ficticio.mx******6214Estado de México46
2L. R. D.c***@correo-ficticio.mx******5912Guanajuato26
3J. G. L.c***@correo-ficticio.mx******9970Jalisco57
4J. R. V.c***@correo-ficticio.mx******7052Jalisco48
5F. L. G.c***@correo-ficticio.mx******0321Yucatán34

Diccionario de datos y clasificación PII

TablaColumnaTipo% nulosValores distintos (aprox.)Clasificación
dim_clienteid_clienteBIGINT0.07,649No sensible
dim_clientenombreVARCHAR0.03,522Nombre (PII directa)
dim_clienteemailVARCHAR1.28,314Correo (PII directa)
dim_clientetelefonoVARCHAR0.08,234Teléfono (PII directa)
dim_clienteestadoVARCHAR0.013Cuasi-identificador
dim_clienteedadBIGINT0.450Cuasi-identificador
dim_clienteingreso_mensualDOUBLE0.0983Financiero sensible
dim_clientescore_buroDOUBLE0.0378Financiero sensible
dim_productoid_productoBIGINT0.04No sensible
dim_productoproductoVARCHAR0.04No sensible
dim_productotipo_productoVARCHAR0.04No sensible
dim_productotasa_anualDOUBLE0.04No sensible

Pruébalo: Python en tu navegador

Una versión compacta del análisis, ejecutada con Pyodide (CPython en WebAssembly). Edita el código y vuelve a ejecutarlo; si defines la variable grafica, se dibuja abajo.

demo.py · editable
La primera ejecución descarga Python + NumPy/pandas (~15 MB, una sola vez).

Código completo

El script exacto que generó los resultados de esta página (python run_all.py).

p17_calidad_datos.py179 líneas
"""#17 Framework de calidad de datos y catálogo automático sobre el DWH del proyecto #16.

Reglas declarativas (completitud, unicidad, rango, catálogo, integridad referencial y
consistencia) ejecutadas como SQL, con semáforo, diccionario de datos generado y clasificación
automática de columnas sensibles (PII). Es gobernanza aplicada: cada regla es un control documentado.
"""
import re

import duckdb
import numpy as np
import pandas as pd

from common import bars, export, table
from p16_dwh_snowflake import DB, run as construir
from validacion import model_card

# (id, tabla, dimensión, descripción, SQL que cuenta filas que VIOLAN la regla, umbral % tolerado)
REGLAS = [
    ("C01", "dim_cliente", "Completitud", "email no nulo", "email IS NULL", 0.5),
    ("C02", "dim_cliente", "Completitud", "estado no nulo", "estado IS NULL", 0.0),
    ("U01", "dim_cliente", "Unicidad", "id_cliente único",
     "id_cliente IN (SELECT id_cliente FROM dim_cliente GROUP BY 1 HAVING COUNT(*) > 1)", 0.0),
    ("R01", "dim_cliente", "Rango", "edad entre 18 y 90", "edad NOT BETWEEN 18 AND 90", 0.0),
    ("R02", "dim_cliente", "Rango", "score de buró entre 300 y 850", "score_buro NOT BETWEEN 300 AND 850", 0.0),
    ("R03", "fact_credito", "Rango", "monto original positivo", "monto_original <= 0", 0.0),
    ("R04", "fact_credito", "Catálogo", "plazo en {24, 36, 48, 60}", "plazo_meses NOT IN (24, 36, 48, 60)", 0.0),
    ("I01", "fact_credito", "Integridad", "todo crédito tiene cliente",
     "id_cliente NOT IN (SELECT id_cliente FROM dim_cliente)", 0.0),
    ("I02", "fact_poliza", "Integridad", "toda póliza tiene crédito",
     "id_credito NOT IN (SELECT id_credito FROM fact_credito)", 0.0),
    ("K01", "fact_pago", "Catálogo", "días de atraso en múltiplos de 30", "dias_atraso % 30 <> 0", 0.0),
    ("K02", "fact_pago", "Consistencia", "saldo no negativo", "saldo_insoluto < 0", 0.0),
    ("K03", "fact_pago", "Consistencia", "si hay atraso no hay pago registrado",
     "dias_atraso > 0 AND pago_realizado > 0", 0.1),
]

PII = {  # patrón en nombre de columna → categoría de dato personal
    r"nombre": "Nombre (PII directa)", r"email|correo": "Correo (PII directa)",
    r"telefono|celular": "Teléfono (PII directa)", r"rfc|curp": "Identificador fiscal (PII directa)",
    r"ingreso|score": "Financiero sensible", r"edad|estado": "Cuasi-identificador",
}


def semaforo(pct, umbral):
    """Verde dentro del umbral; amarillo hasta 5× el umbral; rojo si es de tolerancia cero o lo rebasa."""
    if pct <= umbral:
        return "Verde"
    return "Amarillo" if pct <= umbral * 5 else "Rojo"


CAPA_CRUDA = {"dim_cliente": "stg_cliente", "fact_credito": "stg_credito", "fact_pago": "stg_pago",
              "fact_poliza": "stg_poliza"}


def evaluar(con, capa: str) -> pd.DataFrame:
    filas = []
    for rid, tabla, dim, desc, cond, umbral in REGLAS:
        t = CAPA_CRUDA.get(tabla, tabla) if capa == "Staging" else tabla
        c = cond
        if capa == "Staging":
            for cur, raw in CAPA_CRUDA.items():
                c = c.replace(cur, raw)
        total = con.execute(f"SELECT COUNT(*) FROM {t}").fetchone()[0]
        malas = con.execute(f"SELECT COUNT(*) FROM {t} WHERE {c}").fetchone()[0]
        pct = malas / total * 100
        filas.append([rid, t, dim, desc, "Crítica" if umbral == 0 else "Advertencia", total, malas,
                      round(pct, 3), umbral, semaforo(pct, umbral)])
    return pd.DataFrame(filas, columns=["Regla", "Tabla", "Dimensión", "Descripción", "Severidad", "Filas",
                                        "Violaciones", "% violación", "Umbral %", "Semáforo"])


def indice(res: pd.DataFrame) -> float:
    """Índice 0–100: verde = 1, amarillo = 0.5, rojo = 0; las reglas críticas pesan el doble."""
    puntos = res["Semáforo"].map({"Verde": 1, "Amarillo": 0.5, "Rojo": 0})
    peso = res["Severidad"].map({"Crítica": 2, "Advertencia": 1})
    return float((puntos * peso).sum() / peso.sum() * 100)


def enmascarar(col: str, v) -> str:
    """Enmascaramiento por rol: el analista ve la forma del dato, no el dato."""
    if v is None or (isinstance(v, float) and np.isnan(v)):
        return "—"
    v = str(v)
    if "email" in col:
        u, _, d = v.partition("@")
        return f"{u[0]}***@{d}"
    if "telefono" in col:
        return "******" + v[-4:]
    if "nombre" in col:
        return " ".join(p[0] + "." for p in v.split())
    return v


def run():
    if not DB.exists():
        construir()
    con = duckdb.connect(str(DB), read_only=True)
    crudo, res = evaluar(con, "Staging"), evaluar(con, "Curada")
    ind_crudo, ind_cur = indice(crudo), indice(res)

    # Diccionario de datos automático + clasificación PII (capa curada)
    dicc = []
    for t in ["dim_cliente", "dim_producto", "fact_credito", "fact_pago", "fact_poliza"]:
        info = con.execute(f"DESCRIBE {t}").df()
        stats = con.execute(f"SUMMARIZE {t}").df().set_index("column_name")
        for _, c in info.iterrows():
            cat = next((v for k, v in PII.items() if re.search(k, c.column_name)), "No sensible")
            dicc.append([t, c.column_name, c.column_type, f"{stats.loc[c.column_name, 'null_percentage']}",
                         int(stats.loc[c.column_name, "approx_unique"]), cat])
    dicc = pd.DataFrame(dicc, columns=["Tabla", "Columna", "Tipo", "% nulos", "Valores distintos (aprox.)",
                                       "Clasificación"])
    muestra = con.execute("SELECT id_cliente, nombre, email, telefono, estado, edad FROM dim_cliente LIMIT 5").df()
    mascara = muestra.apply(lambda col: col.map(lambda v: enmascarar(col.name, v)))
    # Perfilado: cuantiles de las variables numéricas clave (detecta valores imposibles de un vistazo)
    perfil = con.execute("""
        SELECT 'edad (staging)' variable, MIN(edad) minimo, QUANTILE_CONT(edad, 0.5) mediana, MAX(edad) maximo,
               AVG(CASE WHEN edad IS NULL THEN 1 ELSE 0 END) * 100 pct_nulos FROM stg_cliente
        UNION ALL SELECT 'edad (curada)', MIN(edad), QUANTILE_CONT(edad, 0.5), MAX(edad),
               AVG(CASE WHEN edad IS NULL THEN 1 ELSE 0 END) * 100 FROM dim_cliente
        UNION ALL SELECT 'monto_original (staging)', MIN(monto_original), QUANTILE_CONT(monto_original, 0.5),
               MAX(monto_original), 0 FROM stg_credito
        UNION ALL SELECT 'monto_original (curada)', MIN(monto_original), QUANTILE_CONT(monto_original, 0.5),
               MAX(monto_original), 0 FROM fact_credito""").df().round(2)
    perfil.columns = ["Variable", "Mínimo", "Mediana", "Máximo", "% nulos"]
    con.close()

    conteo = res["Semáforo"].value_counts().reindex(["Verde", "Amarillo", "Rojo"], fill_value=0)
    conteo_c = crudo["Semáforo"].value_counts().reindex(["Verde", "Amarillo", "Rojo"], fill_value=0)
    pii = dicc[dicc.Clasificación != "No sensible"]
    comp = pd.DataFrame({"Regla": res.Regla, "Descripción": res.Descripción, "Severidad": res.Severidad,
                         "Staging %": crudo["% violación"], "Staging": crudo.Semáforo,
                         "Curada %": res["% violación"], "Curada": res.Semáforo})
    result = {
        "title": "Calidad de datos y catálogo (gobernanza)",
        "summary": [
            f"{len(res)} reglas declarativas en 6 dimensiones, con severidad y umbral, evaluadas en las dos capas del DWH.",
            f"Staging (fuente): {conteo_c['Verde']} verdes, {conteo_c['Amarillo']} amarillas y {conteo_c['Rojo']} rojas — "
            f"índice {ind_crudo:.0f}/100. Capa curada: {conteo['Verde']} verdes, {conteo['Rojo']} rojas — índice {ind_cur:.0f}/100.",
            "Lo que no se puede corregir no se borra: se manda a cuarentena con su motivo (proyecto #16), y lo que "
            "queda en amarillo en la capa curada (correos faltantes) se reporta al dueño del dato en la fuente.",
            f"Catálogo automático de {len(dicc)} columnas; {len(pii)} sensibles (PII directa, financiera o "
            "cuasi-identificador) con enmascaramiento por rol para analistas.",
        ],
        "metrics": [
            {"label": "Índice staging", "value": f"{ind_crudo:.0f} / 100"},
            {"label": "Índice curada", "value": f"{ind_cur:.0f} / 100"},
            {"label": "Rojas staging → curada", "value": f"{conteo_c['Rojo']} → {conteo['Rojo']}"},
            {"label": "Columnas PII", "value": str(len(pii))},
        ],
        "charts": [
            {"id": "semaforo", "type": "status", "title": "Semáforo en la capa curada",
             "items": [{"label": f"{r['Regla']} · {r['Descripción']} ({r['Severidad'].lower()})", "status": r["Semáforo"],
                        "value": f"{r['% violación']:.2f} % (umbral {r['Umbral %']} %)"} for _, r in res.iterrows()]},
            {"id": "semaforo_crudo", "type": "status", "title": "Semáforo en staging (fuente)",
             "items": [{"label": f"{r['Regla']} · {r['Descripción']}", "status": r["Semáforo"],
                        "value": f"{r['% violación']:.2f} %"} for _, r in crudo.iterrows()]},
            bars("indice", "Índice de calidad por capa (0–100)", ["Staging", "Curada"], [round(ind_crudo, 1), round(ind_cur, 1)],
                 fmt="num", subtitle="Verde = 1, amarillo = 0.5, rojo = 0; críticas pesan doble"),
        ],
        "tables": [table("Reglas: staging vs. capa curada", comp),
                   table("Perfilado de variables clave", perfil),
                   table("Vista enmascarada para el rol analista", mascara),
                   table("Diccionario de datos y clasificación PII", dicc)],
        "card": model_card(
            "Reglas de calidad como SQL declarativo con dimensión, severidad, umbral y semáforo; catálogo y "
            "clasificación PII generados del esquema.",
            ["Las mismas reglas sobre staging y capa curada para medir el efecto de la limpieza.",
             "Perfilado de cuantiles para detectar valores imposibles.",
             "Los defectos sembrados en el generador (#16) funcionan como pruebas de que cada regla detecta lo que debe."],
            ["Umbrales definidos con el dueño de cada dato (aquí, supuestos razonables)."],
            ["La clasificación PII por nombre de columna es una primera pasada; se complementa con revisión manual."]),
    }
    export("p17-calidad-datos", result)
    return result


if __name__ == "__main__":
    run()