2. Leer un perfil

Ya tenemos un perfil. ¿Qué significa cada fila de hallazgos(perfil)? ¿Cuándo un ok es una buena noticia? ¿Qué hacemos con una clave que no se cumple?

En esta guía:

Al final hay un glosario con las palabras del paquete.

Un perfil para leer

Seguimos con datos_operativos y la clave de la guía 1.

library(lupa)
perfil <- perfilar(datos_operativos, clave = "id_evento")
#> Warning: La clave declarada no es única: entre 13 filas con la clave completa, 1 repite su valor
#> (2 filas participan en colisiones). La clave cumple la ausencia de nulos: no tiene
#> valores ausentes.
h <- hallazgos(perfil)

El aviso es el mismo de la guía 1: EVT001 se repite. Lo retomamos al hablar de claves.

Las 3 severidades

severidad es un factor ordenado, ok < sospechoso < error: se puede contar y comparar. h$severidad >= "sospechoso" deja los hallazgos que piden atención.

levels(h$severidad)
#> [1] "ok"         "sospechoso" "error"
table(h$severidad)
#> 
#>         ok sospechoso      error 
#>          6         11          6
nrow(h[h$severidad >= "sospechoso", ])
#> [1] 17

Los ok de este perfil son lecturas de la estructura, no buenas noticias:

h[h$severidad == "ok", c("columna", "tipo_hallazgo", "n_afectados")]
#>           columna               tipo_hallazgo n_afectados
#> 1     id_registro       posible_identificador           1
#> 15       id_copia       posible_identificador           1
#> 16      id_evento       posible_identificador           1
#> 17 codigo_usuario casi_duplicados_vocabulario           0
#> 18   fecha_evento casi_duplicados_vocabulario           0
#> 23       contacto       dato_personal_posible           1

El mismo tipo de hallazgo puede salir con 2 severidades. Los ausentes disfrazados de texto son error; el de monto, sospechoso:

h[h$tipo_hallazgo == "faltantes_disfrazados", c("columna", "severidad", "evidencia")]
#>           columna  severidad             evidencia
#> 3  codigo_usuario      error               S/D (1)
#> 5    fecha_evento      error              NULL (1)
#> 9           canal      error <blanco> (1); S/D (1)
#> 12          monto sospechoso               -99 (1)

"S/D", "NULL" y la celda vacía son marcas de ausencia sin ambigüedad. El -99 está en la lista de centinelas numéricos que lupa usa por omisión. Pero en otra columna podría ser un dato real: es una conjetura del paquete. Si lo declaramos en sentinelas_numericos, la conjetura pasa a criterio y la severidad sube.

declarado <- perfilar(datos_operativos["monto"], sentinelas_numericos = -99)
hd <- hallazgos(declarado)
hd[hd$tipo_hallazgo == "faltantes_disfrazados", c("columna", "severidad", "evidencia")]
#>   columna severidad evidencia
#> 1   monto     error   -99 (1)

Declararlo cambia además los números: el -99 sale del resumen.

resumen <- c("minimo", "mediana", "n_valores_excluidos_resumen")
rbind(
  por_omision = columnas(perfil)[columnas(perfil)$columna == "monto", resumen],
  declarado = columnas(declarado)[, resumen]
)
#>             minimo mediana n_valores_excluidos_resumen
#> por_omision    -99    1490                           0
#> declarado      -50    1545                           1

⚠️ Por qué sólo error frena

De los 3 niveles, error es el único candidato para alimentar una barrera automática, un proceso que rechaza una entrega. Lo es sólo después de que aceptemos su criterio y verifiquemos su alcance. Un sospechoso pide una persona que mire.

Pero buscar errores no alcanza. Lo que no se pudo evaluar queda fuera de la escala, en cobertura(). Un guion que decide solo tiene que mirar las 2 cosas: cero errores con diagnósticos sin ejecutar no es un perfil limpio.

c(errores = sum(h$severidad == "error"), no_evaluados = nrow(cobertura(perfil)))
#>      errores no_evaluados 
#>            6            9

🔎 Qué trae cada hallazgo

Cada fila de hallazgos() tiene los mismos campos.

campo qué dice
columna la columna; NA si el hallazgo es de la tabla entera
tipo_hallazgo la clase de señal, de un vocabulario fijo
severidad ok, sospechoso o error
descripcion la señal, en una oración
evidencia lo que se midió para afirmarla
sugerencia qué se podría hacer; la decisión es nuestra
n_evaluados, n_afectados cuántas unidades se miraron y en cuántas está la señal
unidad_conteo en qué se cuentan las 2 anteriores: filas, columnas, formatos, valores distintos
estado_reparacion en los hallazgos de codificación, si el texto se puede reparar; en los demás, NA
trazabilidad en qué filas está: sus índices y, si declaramos clave, sus valores; si el hallazgo no trae filas, estado lo dice (no_aplica o no_disponible)

El vocabulario completo de tipo_hallazgo, con lo que mide cada uno, está en el artículo Alcance y cobertura.

Veamos uno entero: las mayúsculas de canal, con los textos largos fuera de la tabla.

mayus <- h[h$tipo_hallazgo == "mayusculas_inconsistentes", ]
for (campo in c("descripcion", "evidencia", "sugerencia")) {
  writeLines(strwrap(paste0(campo, ": ", mayus[[campo]]), 88, exdent = 2))
}
#> descripcion: Conviven valores que sólo se diferencian por mayúsculas y minúsculas.
#> evidencia: "web"; "Web"
#> sugerencia: Definir y aplicar una convención de capitalización para la columna.
mayus[, c("n_evaluados", "n_afectados", "unidad_conteo")]
#>    n_evaluados n_afectados  unidad_conteo
#> 11           8           2 valor_distinto
mayus$trazabilidad[[1]]$claves$id_evento
#> [1] "EVT003" "EVT001" "EVT006" "EVT010" "EVT012" "EVT001"

La unidad importa. n_afectados cuenta valores distintos: "web" y "Web", 2 de los 8 que tiene la columna. La trazabilidad, en cambio, enumera todas las filas que contienen esos valores, porque lupa no sabe cuál de las 2 formas es la correcta. Por eso la traza tiene 6 filas: primero la variante y después la forma más frecuente.

Alta cardinalidad

alta_cardinalidad aparece en estas columnas del perfil:

h[h$tipo_hallazgo == "alta_cardinalidad", c("columna", "severidad", "evidencia")]
#>           columna  severidad                        evidencia
#> 2  codigo_usuario sospechoso Tasa de valores distintos: 0.846
#> 4    fecha_evento sospechoso Tasa de valores distintos: 0.923
#> 14       contacto sospechoso Tasa de valores distintos: 0.923

Una columna de texto con muchos valores distintos admite 3 lecturas: texto libre, identificador o categoría mal normalizada. «Muchos» es, por omisión, más de la mitad (umbral_alta_cardinalidad). El hallazgo no afirma que sea un defecto: ofrece las lecturas para que decida quien conoce la columna.

En este perfil, ninguna de esas columnas tiene un problema de cardinalidad:

Lo sabemos porque conocemos las columnas. Los datos solos no lo dicen: el largo de los valores no distingue un catálogo de un texto libre. Por eso el hallazgo se informa siempre.

El perfil también señala que contacto parece contener correos (dato_personal_posible). Su evidencia sale entera. Enmascarar los posibles datos personales es un complemento que se pide con proteger_datos_personales = TRUE; con él, esa evidencia saldría como [evidencia protegida]. Publicar o no los valores de una base es decisión de quien la usa. Qué se enmascara y con qué alcance está en el artículo Alcance y cobertura.

Leer cualquier perfil

Cada vía de perfilado devuelve una forma distinta:

perfil$general$filas existe en el primero y es NULL en el segundo, donde el conteo vive en otro lugar. Un NULL que no avisa es la peor forma de fallar en un guion: lo que sigue calcula sobre nada.

Los accesores le ponen un solo nombre a lo mismo. Andan igual sobre los resultados de perfilar(), analizar(), perfilar_dbi() y perfilar_coleccion():

n_filas(perfil)
#> [1] 13
nrow(hallazgos(perfil))
#> [1] 23
nrow(cobertura(perfil))
#> [1] 9
campos <- c("columna", "tipo_inferido", "n_faltantes_totales", "n_distintos")
columnas(perfil)[1:4, campos]
#>          columna tipo_inferido n_faltantes_totales n_distintos
#> 1    id_registro         doble                   0          11
#> 2 codigo_usuario         texto                   1          11
#> 3   fecha_evento         fecha                   1          12
#> 4          canal         texto                   2           8

columnas() trae, además del nombre de cada columna, 117 campos analíticos; su significado, por familias, está en ?perfilar.

Los accesores no inventan lo que no hay. sql_perfil() lista las consultas que se enviaron al motor. Sobre un perfil en memoria devuelve NULL, porque una tabla vacía sugeriría que se consultó y no se encontró nada.

is.null(sql_perfil(perfil))
#> [1] TRUE

Del mismo modo, un perfil de una base sin filas leídas devuelve una tabla de hallazgos vacía con su aviso. Cómo se leen los 2 bloques de perfilar_dbi() está en 10. Perfilar una base.

Las claves

Declarar y comprobar

clave dice qué columnas identifican una fila en el sistema de origen. Con ella, lupa hace 2 cosas:

perfil$meta$clave$unicidad$estado
#> [1] "refutada"
perfil$meta$clave$ausencia_nulos$estado
#> [1] "verificada"
repetida <- h[h$tipo_hallazgo == "clave_no_unica", ]
repetida$trazabilidad[[1]]$localizador
#> [1] "clave_declarada"
repetida$trazabilidad[[1]]$claves
#>   id_evento
#> 1    EVT001
#> 2    EVT001
writeLines(strwrap(repetida$sugerencia, 88))
#> Revisar si sobra una columna en la clave, si hay duplicados de carga o si la clave real
#> es otra. La trazabilidad sigue localizando por ella, pero un valor puede señalar más de
#> una fila.

clave_no_unica es un error con las filas que repiten. Acá las 2 filas con EVT001 son la misma fila cargada 2 veces; la sugerencia enumera las otras causas posibles.

id_registro también parece una clave, y su repetición es de otra clase. Declarada como clave, clave_no_unica señala más filas que filas_duplicadas:

por_registro <- perfilar(datos_operativos, clave = "id_registro")
#> Warning: La clave declarada no es única: entre 13 filas con la clave completa, 2 repiten su valor
#> (4 filas participan en colisiones). La clave cumple la ausencia de nulos: no tiene
#> valores ausentes.
hr <- hallazgos(por_registro)
hr[hr$tipo_hallazgo %in% c("clave_no_unica", "filas_duplicadas"),
   c("tipo_hallazgo", "n_afectados")]
#>       tipo_hallazgo n_afectados
#> 20   clave_no_unica           4
#> 21 filas_duplicadas           2
datos_operativos[datos_operativos$id_registro == 10,
                 c("id_registro", "monto", "id_evento")]
#>    id_registro monto id_evento
#> 10          10  1700    EVT010
#> 11          10   -50    EVT012

Las filas 1 y 12 son copias exactas, y las 2 señales las cuentan. Las 2 filas con id_registro 10, en cambio, no son copias: dicen montos distintos para el mismo registro. Sólo la clave declarada las muestra.

Ausentes en la clave

Que una clave no se repita y que no tenga ausentes son preguntas distintas. En una base de datos, 2 ausentes (NULL) no son iguales entre sí. Por eso la unicidad se mira sólo entre las filas con la clave completa, y los ausentes van a otro hallazgo, clave_con_ausentes.

tramites <- data.frame(
  id_tramite = c("T1", "T2", NA, NA, "T5"),
  monto = c(10, 20, 30, 40, 50)
)
p_tramites <- perfilar(tramites, clave = "id_tramite")
hallazgos(p_tramites)[, c("columna", "tipo_hallazgo", "severidad", "n_afectados")]
#>      columna         tipo_hallazgo  severidad n_afectados
#> 1 id_tramite posible_identificador         ok           1
#> 2 id_tramite             faltantes sospechoso           2
#> 3       <NA>    clave_con_ausentes      error           2
p_tramites$meta$clave$unicidad$estado
#> [1] "verificada"
p_tramites$meta$clave$ausencia_nulos$estado
#> [1] "refutada"

Entre las 3 filas completas la clave no se repite (verificada). Aun así, no cumple NOT NULL (refutada), porque 2 filas no tienen clave. perfilar() lo avisa además con una advertencia, que acá no mostramos.

Si ninguna fila tiene la clave completa, no hay nada que comparar. Decir que la clave es única sería cierto sobre un conjunto vacío, y engañoso: por eso ese caso tiene su propio estado.

vacia <- data.frame(id_tramite = rep(NA_character_, 3), monto = c(1, 2, 3))
perfilar(vacia, clave = "id_tramite")$meta$clave$unicidad$estado
#> [1] "sin_casos_evaluables"

Sugerir y elegir una clave

Si no sabemos cuál es la clave, sugerir_clave() ordena las columnas candidatas por 3 señales, que publica por separado:

nrow(sugerir_clave(datos_operativos))
#> [1] 0
candidatas <- sugerir_clave(datos_operativos, umbral_casi = 0.9)
candidatas[, c("columna", "identifica", "sin_faltantes", "tasa_distintos",
               "parecido_nombre")]
#>        columna identifica sin_faltantes tasa_distintos parecido_nombre
#> 1    id_evento      FALSE          TRUE      0.9230769               2
#> 2 fecha_evento      FALSE          TRUE      0.9230769               0
#> 3        monto      FALSE          TRUE      0.9230769               0
#> 4     contacto      FALSE          TRUE      0.9230769               0
writeLines(candidatas$motivo[1])
#> no identifica: 92.3% de valores distintos, el resto repite; su nombre es el de una clave

Con la fila duplicada, ninguna columna identifica las 13 filas. Ninguna llega tampoco al 95 % de valores distintos que pide umbral_casi por omisión, así que la primera llamada no devuelve nada. Con un umbral menor aparecen las que casi identifican, que suelen ser claves con duplicados de carga. id_evento va primera por su nombre. monto también aparece: un importe que casi no se repite. Ordenar no es decidir; esa diferencia no está en los datos.

elegir_clave() muestra esas candidatas numeradas, con su motivo. Ofrece además escribir otra, o varias separadas por coma para una clave compuesta. Si un nombre real contiene una coma, esa escritura es ambigua: conviene pasar el vector de nombres directamente a clave, por ejemplo clave = c("a,b"). elegir_clave() devuelve la elegida, lista para perfilar():

clave <- elegir_clave(datos_operativos)
perfil <- perfilar(datos_operativos, clave = clave)

En un guion que corre solo no pregunta: devuelve NULL y avisa qué habría ofrecido. En una base de datos, la clave primaria declarada en el catálogo del motor se lee en vez de sugerirse. Qué garantiza cada motor está en el artículo Motores y escala.

Contradicciones entre columnas

A veces cada columna está bien y la fila se contradice. El año de la fecha de ingreso, el año fiscal y el año del archivo pueden ser plausibles por separado y no coincidir. Ninguna columna sola lo muestra, así que el perfil no lo ve.

El grupo lo declaramos nosotros con senal_redundante(). lupa no lo adivina: 2 columnas de año pueden ser el de nacimiento y el de ingreso, y no tienen por qué coincidir. transformacion lleva la fecha a su año para compararla con las otras 2.

expedientes <- data.frame(
  expediente = sprintf("EXP-%02d", 1:6),
  fecha_ingreso = as.Date(c(
    "2023-03-10", "2023-11-02", "2024-01-15", "2023-12-30", "2024-02-01", NA
  )),
  anio_fiscal = c(2023L, 2023L, 2024L, 2024L, 2024L, 2024L),
  anio_archivo = c(2023L, 2023L, 2024L, 2023L, 2024L, 2024L)
)
anio <- senal_redundante(
  c("fecha_ingreso", "anio_fiscal", "anio_archivo"),
  transformacion = list(fecha_ingreso = function(x) as.integer(format(x, "%Y"))),
  nombre = "año del expediente"
)
discordancias <- detectar_discordancias(expedientes, anio)
discordancias[, c("senal", "n_filas", "n_evaluadas", "n_discordantes")]
#>                senal n_filas n_evaluadas n_discordantes
#> 1 año del expediente       6           5              1
writeLines(strwrap(discordancias$evidencia, 88))
#> fila 4: fecha_ingreso=2023; anio_fiscal=2024; anio_archivo=2023; universo: 5 de 6 filas
#> con todas las columnas presentes

La fila 4 entró el 30 de diciembre de 2023, figura en el año fiscal 2024 y se archivó en 2023. La fila 6 no tiene fecha: no cuenta como discordante ni como concordante, y sale del universo evaluado (n_evaluadas). Ausencia no es desacuerdo. Para tolerar diferencias, ventana fija cuánto pueden separarse los valores numéricos (con años, ventana = 1 acepta 1 año).

Glosario

Las palabras que usan el paquete y sus guías, en orden alfabético.

La tabla que traduce las granularidades al vocabulario de las bases de datos:

granularidades()[, c("nivel", "granularidad", "relacional")]
#>    nivel           granularidad           relacional
#> 1      1      instanciaAtributo                celda
#> 2      2               atributo              columna
#> 3      3      conjuntoAtributos conjunto de columnas
#> 4      4       instanciaEntidad                tupla
#> 5      5                entidad                tabla
#> 6      6      conjuntoEntidades   conjunto de tablas
#> 7      7              coleccion        base de datos
#> 8      8    conjuntoColecciones                 <NA>
#> 9      9           organizacion                 <NA>
#> 10    10 conjuntoOrganizaciones                 <NA>

Las métricas, los modelos y los marcos se usan en 7. Del hallazgo a la métrica y 8. Marcos y modelos de calidad.

Resumen

🧭 Paso siguiente

3. Explorar columnas con analizar() (vignette("explorar-columnas")) recorre lo que el perfil no muestra en una tabla: patrones, tipos, distribuciones, asociaciones y tiempo.