En este documento aplicaré los principios de aseguramiento y control de la calidad sobre una base clínica observacional de pacientes hospitalizados por síndrome coronario agudo, con el propósito de depurarla de forma reproducible y de esta manera, poder construir una Tabla 1 descriptiva que resuma la muestra de manera clara y entendible, por lo cual, seguiré el flujo de análisis exploratorio que plantea la guía de la semana, adaptándolo al contexto cardiológico, con un enfoque de análisis exploratorio, guiándome y tomando como referencia el libro de estadística de Bruce, Bruce y Gedeck.
La ruta que recorreré es la siguiente:
Según la guía de contexto, el estudio del que proviene la base es observacional y multicéntrico; reunió información de pacientes adultos hospitalizados por síndrome coronario agudo entre 2018 y 2023 en tres instituciones de alta complejidad, con la mortalidad a 30 días como desenlace primario y el reingreso no programado a 30 días como desenlace secundario.
Esta orientación clínica ya me define qué variables son relevantes y, más adelante, cuál será el eje de estratificación de la Tabla 1.
if (!require("pacman")) install.packages("pacman")
pacman::p_load(
readxl, # importo la base en formato Excel
janitor, # me sirve para limpiar nombres, detectar duplicados y tabular frecuencias
tidyverse, # me sirve para manipular y graficar (dplyr, ggplot2, tidyr, forcats, etc.)
skimr, # me sirve para hacer resumen descriptivo rapido y panorama de faltantes
dlookr, # diagnostico de calidad: tipos, faltantes y atipicos
visdat, # me sirve para visualizar el patron de datos faltantes (vis_miss)
naniar, # explorar co-ocurrencia de faltantes (gg_miss_upset, gg_miss_var)
GGally, # matriz de dispersión (pair plot) multivariada
corrplot, # mapa de calor de correlacion y de V de Cramer
DescTools, # V de Cramer para asociacion entre variables categoricas
patchwork, # componer varias graficas en un mismo panel
scales, # formato de ejes y etiquetas
gtsummary, # construir la Tabla 1 descriptiva y estratificada
flextable,
knitr
)gtsummary::theme_gtsummary_compact()
gtsummary::theme_gtsummary_language(
language = "es",
decimal.mark = ",",
big.mark = "."
)
tema_flex <- function(ft) {
ft |>
flextable::theme_vanilla() |>
flextable::bg(bg = "#EEF3F8", part = "header") |>
flextable::color(color = "#1F3B57", part = "header") |>
flextable::bold(part = "header") |>
flextable::fontsize(size = 10, part = "all") |>
flextable::padding(padding = 4, part = "all") |>
flextable::align(align = "center", part = "all") |>
flextable::autofit()
}
# tamaño estándar para los números sobre las barras (geom_text) en mis gráficas
tam_etiqueta <- 4.2
# tema reutilizable para todas mis gráficas: letra grande, oscura y uniforme
tema_gg <- function(base = 14) {
ggplot2::theme_minimal(base_size = base) +
ggplot2::theme(
text = ggplot2::element_text(colour = "#1F3B57"),
plot.title = ggplot2::element_text(face = "bold", size = base + 3, colour = "#16314A"),
plot.subtitle = ggplot2::element_text(size = base - 2, colour = "#3B5570"),
axis.title = ggplot2::element_text(size = base - 1, colour = "#1F3B57"),
axis.text = ggplot2::element_text(size = base - 2, colour = "#1F3B57"),
strip.text = ggplot2::element_text(face = "bold", size = base - 2, colour = "#1F3B57"),
legend.title = ggplot2::element_text(size = base - 2, colour = "#1F3B57"),
legend.text = ggplot2::element_text(size = base - 2, colour = "#1F3B57"),
plot.title.position = "plot"
)
}Antes de iniciar dejo explícitas las cuatro fuentes que sostendrán mis decisiones a lo largo del trabajo:
Referencia 1 — Kraler S, Mueller C, Libby P, Bhatt DL (2025). “Acute coronary syndromes: mechanisms, challenges, and new opportunities”. European Heart Journal: Se trata de una revisión del estado del arte, donde la pregunta que abordaron es cómo avanzar hacia un manejo personalizado del síndrome coronario agudo que supere la interpretación binaria del electrocardiograma y los biomarcadores; el problema de fondo es que esta enfermedad es heterogénea en sus mecanismos, pues abarca ruptura de placa, erosión, nódulos calcificados, disección coronaria espontánea, espasmo y disfunción microvascular, y esa diversidad no queda capturada por la dicotomía clásica.
Por su naturaleza de revisión, su metodología no es un análisis estadístico primario sino una síntesis narrativa de la evidencia, en la que se discutieron herramientas de diagnóstico y pronóstico construidas con regresión y aprendizaje automático (puntajes como GRACE, CoDE-ACS, PRAISE y SEX-SHOCK) y métricas como el área bajo la curva, los cocientes de riesgo y los odds ratios de los estudios que revisaron.
La población que cubre el estudio, es el espectro amplio de pacientes con síndrome coronario agudo, con énfasis en las diferencias por sexo y en la enfermedad coronaria prematura. Entre sus resultados destacaron que la ruptura de placa explica cerca del 60% de los casos y la erosión hasta un tercio, que la troponina ultrasensible es el estándar de referencia para el diagnóstico de infarto, y que las mujeres con dicha patología se presentan a mayor edad y con más comorbilidades.
Considero que esta revisión, me ayudará a sustentar mis decisiones sobre qué variables clínicas y bioquímicas incluir y cómo interpretarlas, a saber, la troponina, la fracción de eyección, la función renal estimada por creatinina, el perfil lipídico y las comorbilidades, en tanto el artículo las establece como los ejes fisiopatológicos y pronósticos de la enfermedad.
Referencia 2 — Timmis A, Kazakiewicz D, Townsend N, Huculeci R, Aboyans V, Vardas P (2023). “Global epidemiology of acute coronary syndromes”. Nature Reviews Cardiology: Es una revisión epidemiológica de alcance global, donde la pregunta que respondieron, es: cuál es la carga de mortalidad por síndrome coronario agudo y cómo ha cambiado en veinte años según región e ingreso nacional?.
el problema que se plantearon en la investigación, fué que ésta epidemiología está limitada por la falta de datos comparables, lo que dificulta dirigir la prevención; asi mismo, la metodología que utilizarón,fué analizar, la base de mortalidad de la Organización Mundial de la Salud usando R, y presentaron las variables continuas como mediana con rango intercuartílico justamente por su robustez frente a atípicos, además detectaron valores extremos mediante diagramas de caja con bigotes a 1.5 veces el rango intercuartílico, calcularon tasas de mortalidad estandarizadas por edad y fueron explícitos en no asumir causalidad ni forzar significancia.
La población fueron los datos de mortalidad de 122 países, de los cuales 63 son de ingreso alto, 42 de ingreso medio-alto y 17 de ingreso medio-bajo, entre 2000 y 2020, estratificados por sexo y región.
Entre sus resultados, la mortalidad fue mayor en hombres que en mujeres en todas las regiones, y para 2020 las tasas más altas se desplazaron hacia las regiones de menor ingreso, con América Latina y el Caribe a la cabeza. Por tanto, este paper,lo usaré porque sostendrá dos cosas a la vez:
Referencia 3 — Van den Broeck J, Argeseanu Cunningham S, Eeckels R, Herbst K (2005). “Data Cleaning: Detecting, Diagnosing, and Editing Data Abnormalities”. PLoS Medicine: Es un artículo metodológico del tipo policy forum, donde se plantea el cómo organizar y ejecutar la limpieza de datos de forma eficiente y ética.
Se propone Van den Broeck J, con esta revisión, un proceso de tres fases cíclicas, tamizaje, diagnóstico y edición, apoyado en herramientas como las verificaciones de rango con límites duros y blandos, los controles de consistencia y el examen de las distribuciones, por lo cual, este documento, sostendrá el esqueleto completo de mi depuración, con las fases de tamizaje, diagnóstico y edición, para ordenar el manejo de duplicados, valores fuera de rango y faltantes, y la distinción entre límite duro y límite blando.
Referencia 4 — Bruce P, Bruce A, Gedeck P (2022). “Estadística práctica para ciencia de datos con R y Python”, 2ª edición. Marcombo: Es una obra de referencia metodológica, por lo que tendré en cuenta por ejemplo, el capítulo 1, para el Análisis exploratorio de datos, que desarrolla justo el vocabulario y las técnicas que aplicaré, como las estimaciones de localización (media, mediana, media recortada), las estimaciones de variabilidad (desviación estándar, percentiles, rango intercuartílico), la exploración de la distribución (histograma, diagrama de caja, gráfico de densidad), la exploración de datos binarios y categóricos (moda, gráficos de barras), la correlación y la exploración de dos o más variables.
Siguiendo el marco de limpieza de datos (3), antes de cualquier corrección debo cargar la base, verificar que se haya importado de forma fiel y reconocer su estructura, pues, esta etapa de reconocimiento es la antesala de la fase de tamizaje, ya que me permite saber con qué cuento, cuántos registros y variables tengo, de qué tipo es cada una y si ya asoma algún punto que deba examinar más adelante.
Importaré la base con la librería readxl y, como primera medida de orden recomendada por el marco de limpieza (3), estandarizaré los nombres de las columnas con janitor, luego guardaré el resultado en un objeto que nombraré datos_crudos, porque quiero preservar intacta la versión original y construir más adelante las versiones depuradas de manera trazable.
Demás verificaré enseguida las dimensiones de la base y observaré sus primeras filas para confirmar que la importación fue fiel.
datos_crudos <- readxl::read_excel("cardiology_dataset.xlsx") |>
janitor::clean_names() # estandarizo los nombres de columnas
dim(datos_crudos)#> [1] 2506 23
Análisis: La importación fue exitosa y la base quedó cargada con 2506 filas y 23 columnas, donde cada fila representa un registro de paciente y cada columna una variable del estudio.
Aquí aparece mi primer punto de atención: el contexto declara una muestra esperada de 2500 pacientes, de modo que observo 6 registros más de los previstos. Todavía no afirmo a qué se debe esa diferencia, pues podría tratarse de filas repetidas, de registros adicionales o de otro fenómeno; será en la fase de tamizaje donde lo examine con detalle.
Por ahora dejo señalada la discrepancia y, fiel al principio de trazabilidad del marco de limpieza (3), conservo la base cruda sin modificar para poder documentar cada decisión sobre una versión original intacta.
Para tener una mirada rápida y completa de la estructura emplearé la función glimpse, que me muestra en una sola vista el número de filas y columnas, el tipo de cada variable y sus primeros valores. Con ello puedo reconocer la naturaleza de cada columna y contrastarla con el diccionario de datos del estudio.
#> Rows: 2,506
#> Columns: 23
#> $ patient_id <dbl> 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, …
#> $ age <dbl> 71, 63, 73, 85, 62, 62, 86, 75, 59, 72, 59, 59, 68,…
#> $ sex <chr> "Male", "Male", "Female", "Female", "Female", "Fema…
#> $ bmi <dbl> 23.8, 30.8, 27.8, 20.0, 30.1, 25.1, 32.4, 28.0, 30.…
#> $ systolic_bp <dbl> 124, 117, 105, 119, 102, 172, 130, 129, 142, 149, 1…
#> $ diastolic_bp <dbl> 66, 83, 65, 93, 78, 92, 75, 83, 82, 80, 76, 84, 71,…
#> $ chol_total <dbl> 120, 188, 226, 229, 203, 236, 302, 268, 179, 142, 1…
#> $ ldl <dbl> 85, 131, 129, 99, 120, 166, 91, 61, 134, 117, 99, 1…
#> $ hdl <dbl> 15, 34, 37, 52, 53, 60, NA, 30, NA, NA, 31, NA, 41,…
#> $ triglycerides <dbl> 70, 125, 192, 170, 124, 150, 108, 148, 357, 258, 15…
#> $ ejection_fraction <dbl> 49.9, 67.5, 74.0, 66.7, 60.6, 62.6, 73.6, 62.6, 49.…
#> $ troponin <dbl> 0.045, 0.036, 0.089, 0.060, 0.060, 0.057, 0.079, 0.…
#> $ creatinine <dbl> 0.96, 0.95, 0.98, NA, 0.93, NA, 0.96, 0.77, NA, NA,…
#> $ diabetes <chr> "Yes", "No", "Yes", "No", "No", "Yes", "No", "Yes",…
#> $ hypertension <chr> "No", "No", "Yes", "Yes", "No", "Yes", "No", "Yes",…
#> $ heart_failure <chr> "No", "No", "No", "No", "No", "No", "Yes", "Yes", "…
#> $ smoking_status <chr> "Never", "Former", NA, NA, "Never", "Never", "Forme…
#> $ treat_statin <chr> "No", "Yes", "No", "Yes", "Yes", "Yes", "No", "Yes"…
#> $ treat_beta_blocker <chr> "Yes", "Yes", "Yes", "Yes", "Yes", "No", "Yes", "Ye…
#> $ treat_acei <chr> "No", "No", "No", "Yes", "No", "No", "No", "No", "Y…
#> $ length_of_stay <dbl> 4, 3, 6, 7, 6, 8, 5, 7, 6, 6, 7, 7, 7, 4, 6, 6, 6, …
#> $ mortality_30d <chr> "No", "Yes", "No", "Yes", "No", "Yes", "Yes", "No",…
#> $ readmission_30d <chr> "No", "No", "No", "Yes", "No", "Yes", "No", "No", "…
Análisis: El retrato de la estructura confirma las 23 variables y me permite reconocer que se organizan en bloques clínicos coherentes con el diccionario del estudio, a saber, el identificador del paciente, las variables demográficas (edad, sexo e índice de masa corporal), los signos vitales (presión arterial sistólica y diastólica), el perfil lipídico (colesterol total, LDL, HDL y triglicéridos), los marcadores de daño miocárdico y función renal (troponina y creatinina), la función ventricular (fracción de eyección), las comorbilidades (diabetes, hipertensión e insuficiencia cardiaca), el estado de tabaquismo, el tratamiento al egreso (estatina, betabloqueador e IECA o ARA-II), la estancia hospitalaria y los dos desenlaces (mortalidad y reingreso a 30 días).
Este conjunto corresponde a los ejes fisiopatológicos y pronósticos que la evidencia reconoce para el síndrome coronario agudo (1). En cuanto a los tipos, observo que las variables cuantitativas se importaron como numéricas, mientras que las cualitativas llegaron como texto, hecho que tendré presente porque en la fase de diagnóstico convertiré esas categóricas a factores para que R las trate como corresponde, tanto en el análisis descriptivo como en la Tabla 1.
Anoto además que patient_id, aunque numérico, es un identificador y no una variable cuantitativa, de manera que lo excluiré de los resúmenes numéricos para no calcular estadísticas sin sentido sobre un código.
Con la estructura reconocida y la base cruda preservada, quedo en condiciones de iniciar la fase de tamizaje (3).
Clasificaré las 23 variables de la base según su naturaleza (cualitativa, cuantitativa o identificador), su tipo (continua, discreta, dicotómica o politómica) y su escala de medición, y lo organizaré en una tabla.
La clase a la que pertenece cada variable no es un dato accesorio, sino la brújula que define cómo la describo, qué gráfica le corresponde y qué estadística tiene sentido aplicarle, por lo cual,siguiendo el marco de tipos de datos de Bruce (4), una variable continua pide histograma, caja de bigotes y medidas de localización y dispersión, mientras que una categórica pide frecuencias y barras.
Además, esta clasificación hace explícito el tratamiento que ya anticipé al ver la estructura, esto es, la conversión de las categóricas a factores, paso necesario antes del análisis descriptivo y de la Tabla 1.
# clasificación que construyo a partir del diccionario de datos del estudio
clasificacion <- tibble::tribble(
~variable, ~etiqueta, ~naturaleza, ~tipo, ~escala, ~rol,
"patient_id", "Identificador del paciente", "Identificador", "Nominal", "Nominal", "Llave única; se excluye de los resúmenes",
"age", "Edad (años)", "Cuantitativa", "Continua", "Razón", "Demográfica",
"sex", "Sexo biológico", "Cualitativa", "Dicotómica", "Nominal", "Demográfica",
"bmi", "Índice de masa corporal (kg/m²)", "Cuantitativa", "Continua", "Razón", "Demográfica",
"systolic_bp", "Presión arterial sistólica (mmHg)", "Cuantitativa", "Continua", "Razón", "Signo vital",
"diastolic_bp", "Presión arterial diastólica (mmHg)","Cuantitativa", "Continua", "Razón", "Signo vital",
"chol_total", "Colesterol total (mg/dL)", "Cuantitativa", "Continua", "Razón", "Perfil lipídico",
"ldl", "Colesterol LDL (mg/dL)", "Cuantitativa", "Continua", "Razón", "Perfil lipídico",
"hdl", "Colesterol HDL (mg/dL)", "Cuantitativa", "Continua", "Razón", "Perfil lipídico",
"triglycerides", "Triglicéridos (mg/dL)", "Cuantitativa", "Continua", "Razón", "Perfil lipídico",
"troponin", "Troponina ultrasensible (ng/mL)", "Cuantitativa", "Continua", "Razón", "Daño miocárdico",
"creatinine", "Creatinina sérica (mg/dL)", "Cuantitativa", "Continua", "Razón", "Función renal",
"ejection_fraction", "Fracción de eyección (%)", "Cuantitativa", "Continua", "Razón", "Función ventricular",
"diabetes", "Diabetes mellitus", "Cualitativa", "Dicotómica", "Nominal", "Comorbilidad",
"hypertension", "Hipertensión arterial", "Cualitativa", "Dicotómica", "Nominal", "Comorbilidad",
"heart_failure", "Insuficiencia cardiaca", "Cualitativa", "Dicotómica", "Nominal", "Comorbilidad",
"smoking_status", "Estado de tabaquismo", "Cualitativa", "Politómica", "Nominal", "Factor de riesgo",
"treat_statin", "Estatina al egreso", "Cualitativa", "Dicotómica", "Nominal", "Tratamiento",
"treat_beta_blocker","Betabloqueador al egreso", "Cualitativa", "Dicotómica", "Nominal", "Tratamiento",
"treat_acei", "IECA/ARA-II al egreso", "Cualitativa", "Dicotómica", "Nominal", "Tratamiento",
"length_of_stay", "Estancia hospitalaria (días)", "Cuantitativa", "Discreta", "Razón", "Evolución",
"mortality_30d", "Mortalidad a 30 días", "Cualitativa", "Dicotómica", "Nominal", "Desenlace primario",
"readmission_30d", "Reingreso a 30 días", "Cualitativa", "Dicotómica", "Nominal", "Desenlace secundario"
)
clasificacion |>
flextable::flextable() |>
flextable::set_header_labels(
variable = "Variable", etiqueta = "Etiqueta", naturaleza = "Naturaleza",
tipo = "Tipo", escala = "Escala de medición", rol = "Rol en el estudio"
) |>
tema_flex() |>
flextable::bg(i = ~ naturaleza == "Cuantitativa", bg = "#E3EEF7", part = "body") |>
flextable::bg(i = ~ naturaleza == "Cualitativa", bg = "#E8F1E7", part = "body") |>
flextable::bg(i = ~ naturaleza == "Identificador", bg = "#F0F0F0", part = "body") |>
flextable::align(align = "left", part = "all") |>
flextable::set_caption("Clasificación de las variables del estudio según su naturaleza, tipo y escala de medición")Variable | Etiqueta | Naturaleza | Tipo | Escala de medición | Rol en el estudio |
|---|---|---|---|---|---|
patient_id | Identificador del paciente | Identificador | Nominal | Nominal | Llave única; se excluye de los resúmenes |
age | Edad (años) | Cuantitativa | Continua | Razón | Demográfica |
sex | Sexo biológico | Cualitativa | Dicotómica | Nominal | Demográfica |
bmi | Índice de masa corporal (kg/m²) | Cuantitativa | Continua | Razón | Demográfica |
systolic_bp | Presión arterial sistólica (mmHg) | Cuantitativa | Continua | Razón | Signo vital |
diastolic_bp | Presión arterial diastólica (mmHg) | Cuantitativa | Continua | Razón | Signo vital |
chol_total | Colesterol total (mg/dL) | Cuantitativa | Continua | Razón | Perfil lipídico |
ldl | Colesterol LDL (mg/dL) | Cuantitativa | Continua | Razón | Perfil lipídico |
hdl | Colesterol HDL (mg/dL) | Cuantitativa | Continua | Razón | Perfil lipídico |
triglycerides | Triglicéridos (mg/dL) | Cuantitativa | Continua | Razón | Perfil lipídico |
troponin | Troponina ultrasensible (ng/mL) | Cuantitativa | Continua | Razón | Daño miocárdico |
creatinine | Creatinina sérica (mg/dL) | Cuantitativa | Continua | Razón | Función renal |
ejection_fraction | Fracción de eyección (%) | Cuantitativa | Continua | Razón | Función ventricular |
diabetes | Diabetes mellitus | Cualitativa | Dicotómica | Nominal | Comorbilidad |
hypertension | Hipertensión arterial | Cualitativa | Dicotómica | Nominal | Comorbilidad |
heart_failure | Insuficiencia cardiaca | Cualitativa | Dicotómica | Nominal | Comorbilidad |
smoking_status | Estado de tabaquismo | Cualitativa | Politómica | Nominal | Factor de riesgo |
treat_statin | Estatina al egreso | Cualitativa | Dicotómica | Nominal | Tratamiento |
treat_beta_blocker | Betabloqueador al egreso | Cualitativa | Dicotómica | Nominal | Tratamiento |
treat_acei | IECA/ARA-II al egreso | Cualitativa | Dicotómica | Nominal | Tratamiento |
length_of_stay | Estancia hospitalaria (días) | Cuantitativa | Discreta | Razón | Evolución |
mortality_30d | Mortalidad a 30 días | Cualitativa | Dicotómica | Nominal | Desenlace primario |
readmission_30d | Reingreso a 30 días | Cualitativa | Dicotómica | Nominal | Desenlace secundario |
Análisis: La clasificación me deja ver con claridad la composición de la base y me orienta lo que viene, pues reconozco un predominio de variables cuantitativas continuas, once en total, que reúnen la edad, el índice de masa corporal, los dos signos vitales, el perfil lipídico completo, la troponina, la creatinina y la fracción de eyección; todas son de escala de razón porque poseen un cero absoluto y admiten cocientes con sentido, y serán las que resuma con medidas de localización y de variabilidad y explore con histogramas, cajas de bigotes y violines, según el marco de exploración de Bruce (4).
Junto a ellas, la estancia hospitalaria es la única cuantitativa discreta, ya que cuenta días enteros, aunque por su comportamiento suele resumirse igual que las continuas.
Un segundo bloque lo forman las cualitativas dicotómicas de escala nominal, a saber, el sexo, las tres comorbilidades, los tres tratamientos al egreso y los dos desenlaces, todas con categorías sin orden interno y que describiré con frecuencias absolutas y relativas.
El estado de tabaquismo es la única politómica, con tres categorías; la trato como nominal porque sus niveles, nunca, exfumador y fumador actual, no constituyen una gradación estricta de severidad, si bien dejo anotado que podría leerse como una escala ordinal de exposición.
Por último, patient_id es un identificador, no una variable de análisis, de modo que lo excluiré de los resúmenes numéricos para no calcular estadísticas sin sentido sobre un código. Esta distinción cumple una doble función:
por un lado me garantiza que todas las variables relevantes queden presentadas y organizadas por bloques clínicos, en línea con los ejes fisiopatológicos y pronósticos que la evidencia reconoce para el síndrome coronario agudo (1).
por otro lado, me confirma el tratamiento que aplicaré en la fase de diagnóstico, a saber, la conversión de las nueve variables dicotómicas y la politómica a factores, para que R me las maneje correctamente en el análisis descriptivo y en la Tabla 1.
Inicio entonces, la fase central del aseguramiento de la calidad, pues diagnosticar los datos se parece a diagnosticar una enfermedad, pues se trata de descubrir qué está mal antes de actuar. Siguiendo a Van den Broeck (3), organizo este diagnóstico en tres momentos encadenados:
tamizaje, para detectar señales de anomalía.
diagnóstico, para decidir si cada señal es un error o un dato real; y
edición, para corregir, eliminar o marcar como faltante.
Recorreré esta sección en ese orden y comienzaré por una mirada general que tamiza toda la base de una sola vez.
Aplicaré la función diagnose sobre la base cruda completa; ya que para cada variable me reportará su tipo, su número y porcentaje de faltantes, y su número y tasa de valores únicos.
Esto es el tamizaje de Van den Broeck (3), donde con una sola mirada puedo recorrer las 23 variables y hace aflorar las señales de anomalía antes de cualquier corrección. Lo correré sobre la base cruda, sin tocar, porque el principio de trazabilidad exige detectar los problemas sobre el original.
La tasa de unicidad es importante, porque en una llave de identificación perfecta debería valer exactamente 1, esto es, un valor único por fila; cualquier valor menor me delatará registros repetidos.
diagnostico_general <- datos_crudos |>
dlookr::diagnose()
diagnostico_general |>
flextable::flextable() |>
flextable::set_header_labels(
variables = "Variable",
types = "Tipo",
missing_count = "Faltantes (n)",
missing_percent = "Faltantes (%)",
unique_count = "Valores únicos",
unique_rate = "Tasa de unicidad"
) |>
tema_flex() |>
flextable::align(j = 1:2, align = "left", part = "all") |>
flextable::bg(i = ~ variables == "patient_id", bg = "#FBE4E4", part = "body") |>
flextable::bg(i = ~ missing_count > 0, bg = "#FDF3E0", part = "body") |>
flextable::set_caption("Diagnóstico general de calidad de las 23 variables (base cruda, n = 2506)")Variable | Tipo | Faltantes (n) | Faltantes (%) | Valores únicos | Tasa de unicidad |
|---|---|---|---|---|---|
patient_id | numeric | 0 | 0.0000000 | 2,500 | 0.9976057462 |
age | numeric | 0 | 0.0000000 | 72 | 0.0287310455 |
sex | character | 0 | 0.0000000 | 2 | 0.0007980846 |
bmi | numeric | 10 | 0.3990423 | 261 | 0.1041500399 |
systolic_bp | numeric | 0 | 0.0000000 | 103 | 0.0411013567 |
diastolic_bp | numeric | 0 | 0.0000000 | 62 | 0.0247406225 |
chol_total | numeric | 451 | 17.9968077 | 192 | 0.0766161213 |
ldl | numeric | 10 | 0.3990423 | 157 | 0.0626496409 |
hdl | numeric | 448 | 17.8770950 | 83 | 0.0331205108 |
triglycerides | numeric | 0 | 0.0000000 | 239 | 0.0953711093 |
ejection_fraction | numeric | 0 | 0.0000000 | 422 | 0.1683958500 |
troponin | numeric | 10 | 0.3990423 | 276 | 0.1101356744 |
creatinine | numeric | 451 | 17.9968077 | 143 | 0.0570630487 |
diabetes | character | 0 | 0.0000000 | 2 | 0.0007980846 |
hypertension | character | 0 | 0.0000000 | 2 | 0.0007980846 |
heart_failure | character | 0 | 0.0000000 | 2 | 0.0007980846 |
smoking_status | character | 449 | 17.9169992 | 4 | 0.0015961692 |
treat_statin | character | 0 | 0.0000000 | 2 | 0.0007980846 |
treat_beta_blocker | character | 0 | 0.0000000 | 2 | 0.0007980846 |
treat_acei | character | 0 | 0.0000000 | 2 | 0.0007980846 |
length_of_stay | numeric | 0 | 0.0000000 | 15 | 0.0059856345 |
mortality_30d | character | 0 | 0.0000000 | 2 | 0.0007980846 |
readmission_30d | character | 0 | 0.0000000 | 2 | 0.0007980846 |
Antes de leer la tabla defino los dos indicadores que la sostienen y muestro su fórmula:
La tasa de unicidad es la proporción del número de valores distintos respecto al número total de registros:
\[\text{Tasa de unicidad} = \frac{\text{número de valores distintos}}{\text{número total de registros}}\]
Este indicador, toma el valor 1 únicamente cuando todos los registros son distintos, condición esperable en una variable que cumple la función de identificador.
El porcentaje de faltantes es la proporción del número de valores ausentes respecto al número total de registros, expresada en términos porcentuales:
\[\text{Porcentaje de faltantes} = \frac{\text{número de valores faltantes}}{\text{número total de registros}} \times 100\] A continuación derivo el porcentaje de faltantes de cada variable afectada, sustituyendo con los conteos reales sobre el total de N = 2506 registros.
N_filas <- nrow(datos_crudos)
derivacion_faltantes <- datos_crudos |>
dplyr::summarise(dplyr::across(dplyr::everything(), ~ sum(is.na(.x)))) |>
tidyr::pivot_longer(dplyr::everything(),
names_to = "variable", values_to = "faltantes") |>
dplyr::filter(faltantes > 0) |>
dplyr::mutate(
total = N_filas,
sustitucion = paste0("(", faltantes, " / ", total, ") \u00d7 100"),
porcentaje = faltantes / total * 100 # cálculo exacto, sin redondear
) |>
dplyr::arrange(dplyr::desc(porcentaje))
derivacion_faltantes |>
flextable::flextable() |>
flextable::set_header_labels(
variable = "Variable",
faltantes = "Faltantes (n)",
total = "Total de registros (N)",
sustitucion = "Fórmula con sustitución",
porcentaje = "Porcentaje de faltantes (≈ %)"
) |>
flextable::colformat_double(j = "porcentaje", digits = 4, decimal.mark = ",") |>
tema_flex() |>
flextable::align(j = "variable", align = "left", part = "all") |>
flextable::set_caption("Derivación del porcentaje de faltantes por variable (base cruda, N = 2506)")Variable | Faltantes (n) | Total de registros (N) | Fórmula con sustitución | Porcentaje de faltantes (≈ %) |
|---|---|---|---|---|
chol_total | 451 | 2,506 | (451 / 2506) × 100 | 17,9968 |
creatinine | 451 | 2,506 | (451 / 2506) × 100 | 17,9968 |
smoking_status | 449 | 2,506 | (449 / 2506) × 100 | 17,9170 |
hdl | 448 | 2,506 | (448 / 2506) × 100 | 17,8771 |
bmi | 10 | 2,506 | (10 / 2506) × 100 | 0,3990 |
ldl | 10 | 2,506 | (10 / 2506) × 100 | 0,3990 |
troponin | 10 | 2,506 | (10 / 2506) × 100 | 0,3990 |
Análisis: El tamizaje arroja las dos señales que examino:
\[\text{Tasa de unicidad}=\frac{\text{valores distintos}}{\text{total de registros}}=\frac{2500}{2506}\approx 0{,}99761\]
Por tanto, como una variable identificadora debería ser una llave perfecta, esto es, alcanzar el valor 1 cuando cada registro es único, esa tasa por debajo de la unidad me indica que hay valores repetidos.
El número de registros repetidos se obtiene de forma directa como la diferencia entre el total de filas y el número de valores distintos, así:
\[\text{Registros repetidos}=\text{total de filas}-\text{valores distintos}=2506-2500=6\]
De modo que la diferencia de 6 registros frente a los 2500 que declara el contexto corresponde a filas repetidas y no a pacientes adicionales.
Las demás variables no me registran faltantes; pero no me adelanto a interpretar este patrón todavía, porque siguiendo a Van den Broeck (3) cada señal merece su propia fase de diagnóstico, de modo que cuantificaré y examinaré la ausencia en detalle en el panorama de faltantes, una vez depurada la base.
Por último, el diagnóstico confirma que las variables cualitativas se importaron como texto y no como factores, lo cual ratifica la conversión que tengo pendiente. Para facilitar la lectura resaltaré en rosado la fila del identificador, por ser la señal de duplicados, y en ámbar las variables con faltantes, de manera que las dos anomalías detectadas las reconoceré de un vistazo.
La diferencia de seis registros que el tamizaje dejó señalada me conduce a examinar los duplicados de la base. Entiendo por registro duplicado aquella fila cuyos valores coinciden exactamente con los de otra en las 23 variables.
Según el artículo de Van den Broeck (3), la naturaleza de la repetición determina la decisión que corresponde tomar sobre ella, ya que un identificador repetido admite tres lecturas distintas, a saber:
duplicados_exactos <- datos_crudos |>
janitor::get_dupes()
duplicados_exactos |>
dplyr::select(patient_id, dupe_count, age, sex, ejection_fraction, mortality_30d) |>
dplyr::arrange(patient_id) |>
flextable::flextable() |>
flextable::set_header_labels(
patient_id = "patient_id",
dupe_count = "N.º de copias",
age = "Edad",
sex = "Sexo",
ejection_fraction = "Fracción de eyección (%)",
mortality_30d = "Mortalidad 30d"
) |>
tema_flex() |>
flextable::set_caption("Registros exactamente duplicados detectados en la base cruda")patient_id | N.º de copias | Edad | Sexo | Fracción de eyección (%) | Mortalidad 30d |
|---|---|---|---|---|---|
570 | 2 | 60 | Female | 48.0 | No |
570 | 2 | 60 | Female | 48.0 | No |
905 | 2 | 67 | Male | 54.1 | No |
905 | 2 | 67 | Male | 54.1 | No |
1,604 | 2 | 55 | Female | 62.0 | No |
1,604 | 2 | 55 | Female | 62.0 | No |
1,865 | 2 | 82 | Female | 46.2 | No |
1,865 | 2 | 82 | Female | 46.2 | No |
1,921 | 2 | 39 | Female | 63.5 | No |
1,921 | 2 | 39 | Female | 63.5 | No |
2,079 | 2 | 63 | Female | 75.0 | No |
2,079 | 2 | 63 | Female | 75.0 | No |
Análisis: Observo que el diagnóstico identifica seis filas exactamente duplicadas, correspondientes a los pacientes con identificador 570, 905, 1604, 1865, 1921 y 2079; cada uno aparece dos veces y sus dos copias resultan idénticas en las 23 variables, como lo confirma la columna del número de copias, que vale 2 en todos los casos.
Según el artículo de Van den Broeck (3), la naturaleza de la anomalía es la que define la conducta a seguir, y considero que aquí no estoy ante un mismo paciente con valores discordantes ni ante dos pacientes distintos que comparten un código, sino ante copias exactas, lo más compatible con un artefacto de duplicación durante el ensamblaje de la base.
Estimo que es precisamente esta distinción la que me autoriza la edición, porque si se tratara de registros en conflicto habría que reconciliarlos uno a uno, mientras que al ser idénticos la única acción correcta y sin pérdida de información consiste en conservar una sola copia de cada uno.
datos_sin_dup <- datos_crudos |>
dplyr::distinct() # conservo una única copia de cada registro
tibble::tibble(
etapa = c("Base cruda", "Tras eliminar duplicados exactos"),
filas = c(nrow(datos_crudos), nrow(datos_sin_dup)),
pacientes_unicos = c(dplyr::n_distinct(datos_crudos$patient_id),
dplyr::n_distinct(datos_sin_dup$patient_id))
) |>
flextable::flextable() |>
flextable::set_header_labels(
etapa = "Etapa", filas = "Filas", pacientes_unicos = "patient_id únicos"
) |>
tema_flex() |>
flextable::set_caption("Registro del cambio, con dimensión de la base antes y después de editar los duplicados")Etapa | Filas | patient_id únicos |
|---|---|---|
Base cruda | 2,506 | 2,500 |
Tras eliminar duplicados exactos | 2,500 | 2,500 |
Análisis: El número de filas duplicadas puede expresarse como la diferencia entre el número total de filas y el número de filas distintas:
\[\text{Filas duplicadas}=\text{número total de filas}-\text{número de filas distintas}=2506-2500=6\]
Observo que, tras conservar una sola copia de cada registro, la base pasa de 2506 a 2500 filas, y patient_id queda con 2500 valores distintos, de modo que recupera su condición de llave perfecta, con tasa de unicidad igual a 1.
Considero que el resultado no constituye una eliminación arbitraria sino una corrección de consistencia, pues el contexto del estudio declara una muestra de 2500 pacientes adultos hospitalizados por síndrome coronario agudo, y la base depurada coincide ahora exactamente con esa cifra.
Conservo intacta la base cruda para preservar la trazabilidad que recomienda el artículo de Van den Broeck (3), y a partir de este punto trabajo sobre la versión sin duplicados, que será la que trabajaré en las ediciones siguientes.
Para que R reconozca y procese correctamente las variables cualitativas en el diagnóstico, en el análisis descriptivo y en la Tabla 1, las convierto en factores y asigno a cada una sus niveles con etiqueta en español, además, conservaré el nivel “No” como referencia en las variables binarias, por convención epidemiológica, y ordenaré el tabaquismo de menor a mayor exposición.
datos_factores <- datos_sin_dup |>
dplyr::mutate(
dplyr::across( # binarias: "No" como referencia
c(diabetes, hypertension, heart_failure,
treat_statin, treat_beta_blocker, treat_acei,
mortality_30d, readmission_30d),
~ factor(.x, levels = c("No", "Yes"), labels = c("No", "Sí"))
),
sex = factor(sex,
levels = c("Female", "Male"),
labels = c("Femenino", "Masculino")),
smoking_status = factor(smoking_status,
levels = c("Never", "Former", "Current"),
labels = c("Nunca", "Exfumador", "Fumador actual"))
)
datos_factores |>
dplyr::select(where(is.factor)) |>
dplyr::glimpse()#> Rows: 2,500
#> Columns: 10
#> $ sex <fct> Masculino, Masculino, Femenino, Femenino, Femenino,…
#> $ diabetes <fct> Sí, No, Sí, No, No, Sí, No, Sí, No, No, Sí, No, No,…
#> $ hypertension <fct> No, No, Sí, Sí, No, Sí, No, Sí, No, Sí, Sí, Sí, Sí,…
#> $ heart_failure <fct> No, No, No, No, No, No, Sí, Sí, No, Sí, No, No, No,…
#> $ smoking_status <fct> Nunca, Exfumador, NA, NA, Nunca, Nunca, Exfumador, …
#> $ treat_statin <fct> No, Sí, No, Sí, Sí, Sí, No, Sí, Sí, No, Sí, Sí, Sí,…
#> $ treat_beta_blocker <fct> Sí, Sí, Sí, Sí, Sí, No, Sí, Sí, Sí, Sí, No, No, Sí,…
#> $ treat_acei <fct> No, No, No, Sí, No, No, No, No, Sí, Sí, Sí, Sí, Sí,…
#> $ mortality_30d <fct> No, Sí, No, Sí, No, Sí, Sí, No, No, Sí, No, No, No,…
#> $ readmission_30d <fct> No, No, No, Sí, No, Sí, No, No, No, No, Sí, Sí, Sí,…
Análisis: Observo que las diez variables cualitativas quedaron convertidas en factores, cada una con sus niveles en español y en el orden que definí; de aquí en adelante R me las tratará como categóricas en todos los análisis, lo cual es requisito para que las frecuencias, las pruebas de comparación y la Tabla 1 se calculen de manera correcta. Por otro lado, mantendré intactas las variables numéricas, cuya depuración de valores fuera de rango abordaré más adelante.
El diagnóstico de las variables cualitativas se apoya en la frecuencia de cada categoría, por lo cual, defino la frecuencia relativa como la proporción entre la frecuencia de una categoría y el número total de registros, expresada en términos porcentuales:
\[\text{Frecuencia relativa} = \frac{\text{frecuencia de la categoría}}{\text{número total de registros}} \times 100\]
La columna de proporción de la tabla siguiente aplicaré esta fórmula a cada categoría sobre el total de N = 2500 registros. Examino también la moda, esto es, la categoría de mayor frecuencia, concepto central en la exploración de datos categóricos según Bruce (4).
Presentaré primero la tabla de frecuencias y luego un panel de barras monocromático, al tratarse de un análisis univariado sin comparación entre grupos.
datos_factores |>
dlookr::diagnose_category() |>
flextable::flextable() |>
flextable::set_header_labels(
variables = "Variable", levels = "Categoría", N = "N",
freq = "Frecuencia", ratio = "Proporción (%)", rank = "Rango"
) |>
flextable::colformat_double(j = "ratio", digits = 4, decimal.mark = ",") |>
tema_flex() |>
flextable::align(j = c("variables", "levels"), align = "left", part = "all") |>
flextable::set_caption("Diagnóstico de las variables categóricas, con la frecuencia y proporción por categoría (n = 2500)")Variable | Categoría | N | Frecuencia | Proporción (%) | Rango |
|---|---|---|---|---|---|
sex | Masculino | 2,500 | 1,340 | 53,6000 | 1 |
sex | Femenino | 2,500 | 1,160 | 46,4000 | 2 |
diabetes | No | 2,500 | 1,751 | 70,0400 | 1 |
diabetes | Sí | 2,500 | 749 | 29,9600 | 2 |
hypertension | Sí | 2,500 | 1,506 | 60,2400 | 1 |
hypertension | No | 2,500 | 994 | 39,7600 | 2 |
heart_failure | No | 2,500 | 1,863 | 74,5200 | 1 |
heart_failure | Sí | 2,500 | 637 | 25,4800 | 2 |
smoking_status | Nunca | 2,500 | 936 | 37,4400 | 1 |
smoking_status | Exfumador | 2,500 | 712 | 28,4800 | 2 |
smoking_status | 2,500 | 449 | 17,9600 | 3 | |
smoking_status | Fumador actual | 2,500 | 403 | 16,1200 | 4 |
treat_statin | Sí | 2,500 | 1,766 | 70,6400 | 1 |
treat_statin | No | 2,500 | 734 | 29,3600 | 2 |
treat_beta_blocker | Sí | 2,500 | 1,462 | 58,4800 | 1 |
treat_beta_blocker | No | 2,500 | 1,038 | 41,5200 | 2 |
treat_acei | Sí | 2,500 | 1,252 | 50,0800 | 1 |
treat_acei | No | 2,500 | 1,248 | 49,9200 | 2 |
mortality_30d | No | 2,500 | 2,276 | 91,0400 | 1 |
mortality_30d | Sí | 2,500 | 224 | 8,9600 | 2 |
readmission_30d | No | 2,500 | 2,108 | 84,3200 | 1 |
readmission_30d | Sí | 2,500 | 392 | 15,6800 | 2 |
Análisis: Esta tabla corresponde al diagnóstico de las variables categóricas y descompone, para cada una, sus categorías con su frecuencia y su peso relativo, ordenadas de la más a la menos frecuente. Preciso que, por ejemplo, la columna N es el número total de observaciones sobre el que se calcula cada proporción, que vale 2500 en todas las filas porque corresponde al tamaño de la base depurada.
L a columnaFrecuencia es el número de pacientes que pertenecen a esa categoría, es decir su frecuencia absoluta; Proporción es la frecuencia relativa, esto es (Frecuencia / N) × 100, el porcentaje que la categoría representa dentro de la variable; y Rango ordena las categorías de la más a la menos frecuente, de modo que el rango 1 señala la moda, concepto que según Bruce (4) resume la categoría dominante de una variable cualitativa.
También es importante, explicar que en cuanto al cálculo, como aquí el denominador es 2500, toda proporción equivale a la frecuencia dividida entre 25, división que termina en dos decimales, de manera que estos porcentajes son exactos y no aproximaciones, a diferencia de los faltantes que calculé sobre 2506.
Leída así, la tabla me permite reconstruir la composición completa de cada variable, pues las proporciones de sus categorías suman 100%, además, observo que en la mayoría de las comorbilidades, la moda es la ausencia de la condición, con diabetes en “No” (1751 pacientes, 70,04%) e insuficiencia cardiaca en “No” (1863, 74,52%); la excepción es la hipertensión arterial, cuya moda es “Sí” (1506, 60,24%), de modo que es la única comorbilidad que afecta a la mayoría de la muestra, hallazgo coherente con su papel como principal factor de riesgo cardiovascular según el artículo de Kraler (1).
En el tratamiento al egreso, la moda es “Sí” en los tres fármacos, encabezada por la estatina (1766, 70,64%), mientras que el inhibidor de la enzima convertidora de angiotensina o antagonista del receptor reparte la muestra casi por mitades, con 1252 frente a 1248; considero que este predominio de la prescripción refleja la adherencia a la prevención secundaria que Kraler (1) sitúa en el centro del manejo.
Un punto que la tabla hace evidente es el del tabaquismo: su moda es “nunca” (936, 37,44%), pero la categoría vacía, que corresponde a los datos faltantes, ocupa el rango 3 con 449 pacientes (17,96%), por encima incluso de los fumadores actuales (403, 16,12%); estimo que esta ausencia es lo bastante grande como para condicionar la lectura de la variable, y por eso la examinaré en el panorama de faltantes sin imputarla por ahora.
Finalmente, en los dos desenlaces la moda es “No”, lo que confirma que la mortalidad a 30 días (224, 8,96%) y el reingreso no programado (392, 15,68%) son eventos infrecuentes, condición estadística que tendré presente al estratificar la Tabla 1, pues los contrastes recaerán sobre grupos pequeños.
La lectura la completo con la variable sexo, que encabeza el diagnóstico, cuya moda es “masculino” (1340, 53,60%) frente a “femenino” (1160, 46,40%).
etiquetas_cat <- c(
sex = "Sexo", diabetes = "Diabetes", hypertension = "Hipertensión arterial",
heart_failure = "Insuficiencia cardiaca", smoking_status = "Tabaquismo",
treat_statin = "Estatina al egreso", treat_beta_blocker = "Betabloqueador al egreso",
treat_acei = "IECA/ARA-II al egreso", mortality_30d = "Mortalidad a 30 días",
readmission_30d = "Reingreso a 30 días"
)
datos_factores |>
dplyr::select(where(is.factor)) |>
tidyr::pivot_longer(dplyr::everything(),
names_to = "variable", values_to = "categoria") |>
dplyr::filter(!is.na(categoria)) |>
dplyr::count(variable, categoria) |>
ggplot2::ggplot(ggplot2::aes(x = categoria, y = n)) +
ggplot2::geom_col(fill = "#4F7CAC", width = 0.7) +
ggplot2::geom_text(ggplot2::aes(label = n), vjust = -0.35, size = 2.8, color = "#1F3B57") +
ggplot2::facet_wrap(~ variable, scales = "free", ncol = 3,
labeller = ggplot2::labeller(variable = etiquetas_cat)) +
ggplot2::scale_y_continuous(expand = ggplot2::expansion(mult = c(0, 0.15))) +
ggplot2::labs(
title = "Distribución de frecuencias de las variables categóricas",
subtitle = "Conteo por categoría en la base depurada (n = 2500)",
x = NULL, y = "Frecuencia (número de pacientes)"
) +
ggplot2::theme_minimal(base_size = 11) +
ggplot2::theme(
panel.grid.major.x = ggplot2::element_blank(),
strip.text = ggplot2::element_text(face = "bold", size = 9),
plot.title = ggplot2::element_text(face = "bold")
)
Análisis: El panel de barras es un gráfico de barras
verticales en el que el eje horizontal de cada faceta muestra las
categorías de una variable y el eje vertical su frecuencia absoluta, es
decir, el número de pacientes; lo elegí monocromático porque describo
cada variable por separado, sin comparar grupos, y porque me permite
leer de un vistazo la moda de cada una, además se observa que casi todas
las variables tienen una categoría dominante que orienta el perfil de la
muestra.
En cuanto al sexo, el grupo masculino es mayoritario, con 1340 pacientes frente a 1160, equivalente a (1340/2500) × 100 = 53,60% de hombres; considero que este predominio masculino es clínicamente esperable, pues según el artículo de Kraler (1) el síndrome coronario agudo es más frecuente en hombres y las mujeres tienden a presentarlo a mayor edad, y según Timmis (2) la mortalidad por esta causa es mayor en hombres en todas las regiones del mundo.
Las comorbilidades dibujan un perfil cardiovascular de alto riesgo coherente con esta población, pues la hipertensión arterial es la más prevalente, presente en 1506 pacientes (60,24%), seguida de la diabetes en 749 (29,96%) y la insuficiencia cardiaca en 637 (25,48%).
Considero que esta carga es plausible y consistente con el cuadro, ya que el artículo de Kraler (1) reconoce estas condiciones como factores de riesgo e integrantes de los puntajes pronósticos del síndrome coronario agudo.
En el tratamiento al egreso observo una adherencia apreciable a la prevención secundaria, con estatina en 1766 pacientes (70,64%), betabloqueador en 1462 (58,48%) e inhibidor de la enzima convertidora de angiotensina o antagonista del receptor en 1252 (50,08%); estimo que el predominio de la estatina concuerda con el papel central que el artículo de Kraler (1) atribuye a la reducción intensiva del colesterol LDL en estos pacientes.
Respecto al tabaquismo, la moda es la categoría “nunca” con 936 pacientes (37,44%), seguida de “exfumador” con 712 (28,48%) y “fumador actual” con 403 (16,12%); noto además una ausencia del 17,96% (449 pacientes), que en la tabla aparece como una categoría vacía y que examinaré en el panorama de faltantes sin imputarla por ahora.
Finalmente, considero que los dos desenlaces son eventos relativamente infrecuentes, pues la mortalidad a 30 días ocurre en 224 pacientes, esto es (224/2500) × 100 = 8,96%, y el reingreso no programado en 392 (15,68%).
Estimo que este desbalance es estadísticamente relevante, porque al estratificar la Tabla 1 por mortalidad las comparaciones recaerán sobre un grupo pequeño de fallecidos, lo cual me conviene tener presente al interpretar los contrastes; por ejemplo, una mortalidad cercana al 9% resulta coherente con la gravedad de un evento coronario agudo que requiere hospitalización, desenlace que tanto Kraler (1) como Timmis (2) sitúan en el centro del pronóstico de esta enfermedad.
El diagnóstico de las variables numéricas exige compararlas con lo que es clínicamente posible,por lo cual, aplico los dos tipos de límite que propone el artículo de Van den Broeck (3):
1. El límite duro, que marca valores imposibles**, esto es, que no pueden existir por razones físicas o biológicas, como una edad negativa o una fracción de eyección superior al 100%, y que constituyen errores a corregir; y
2. El límite blando, que marca valores extremos pero posibles**, que se apartan del rango habitual sin ser imposibles y que, en lugar de eliminarse, deben examinarse.
Comparo el mínimo y el máximo observados de cada variable contra su rango plausible, tomado del contexto del estudio, y clasifico cada valor antes de decidir sobre él.
datos_factores |>
dplyr::select(where(is.numeric), -patient_id) |> # excluyo el identificador
dlookr::diagnose_numeric() |>
flextable::flextable() |>
flextable::set_header_labels(
variables = "Variable", min = "Mínimo", Q1 = "Q1", mean = "Media",
median = "Mediana", Q3 = "Q3", max = "Máximo",
zero = "Ceros", minus = "Negativos", outlier = "Atípicos"
) |>
flextable::colformat_double(digits = 2, decimal.mark = ",") |>
tema_flex() |>
flextable::align(j = "variables", align = "left", part = "all") |>
flextable::set_caption("Diagnóstico de las variables numéricas (base sin duplicados, n = 2500)")Variable | Mínimo | Q1 | Media | Mediana | Q3 | Máximo | Ceros | Negativos | Atípicos |
|---|---|---|---|---|---|---|---|---|---|
age | -5,00 | 57,00 | 65,39 | 65,00 | 74,00 | 160,00 | 0 | 1 | 15 |
bmi | -3,00 | 24,40 | 27,86 | 27,80 | 31,30 | 95,00 | 0 | 1 | 12 |
systolic_bp | -20,00 | 117,00 | 130,39 | 130,00 | 144,00 | 198,00 | 0 | 1 | 10 |
diastolic_bp | 50,00 | 73,00 | 79,77 | 80,00 | 86,00 | 114,00 | 0 | 0 | 29 |
chol_total | 120,00 | 173,00 | 201,19 | 201,00 | 228,00 | 337,00 | 0 | 0 | 9 |
ldl | 50,00 | 100,00 | 120,69 | 121,00 | 141,00 | 250,00 | 0 | 0 | 6 |
hdl | 15,00 | 40,00 | 50,21 | 50,00 | 61,00 | 100,00 | 0 | 0 | 5 |
triglycerides | 50,00 | 121,00 | 155,32 | 149,00 | 182,00 | 439,00 | 0 | 0 | 40 |
ejection_fraction | 18,40 | 47,80 | 54,68 | 54,90 | 61,40 | 120,00 | 0 | 0 | 6 |
troponin | -0,50 | 0,03 | 0,10 | 0,05 | 0,08 | 75,00 | 0 | 1 | 166 |
creatinine | 0,42 | 0,83 | 1,02 | 0,99 | 1,19 | 2,31 | 0 | 0 | 24 |
length_of_stay | 1,00 | 4,00 | 6,04 | 6,00 | 7,00 | 15,00 | 0 | 0 | 42 |
limites_rango <- tibble::tribble(
~variable, ~rango, ~valor, ~limite, ~naturaleza, ~accion,
"age", "18 – 110", "-5", "Duro", "Imposible (negativo)", "Convertir a faltante",
"age", "18 – 110", "160", "Duro", "Imposible (longevidad humana ~122)", "Convertir a faltante",
"bmi", "10 – 70", "-3", "Duro", "Imposible (negativo)", "Convertir a faltante",
"bmi", "10 – 70", "95", "Blando", "Sospechoso (entre blando y duro)", "Examinar como atípico",
"systolic_bp", "60 – 250", "-20", "Duro", "Imposible (negativo)", "Convertir a faltante",
"ejection_fraction", "10 – 90", "120", "Duro", "Imposible (> 100 %)", "Convertir a faltante",
"troponin", "0 – 50", "-0,5", "Duro", "Imposible (negativo)", "Convertir a faltante",
"troponin", "0 – 50", "75", "Blando", "Sospechoso (entre blando y duro)", "Examinar como atípico"
)
limites_rango |>
flextable::flextable() |>
flextable::set_header_labels(
variable = "Variable", rango = "Rango de referencia", valor = "Valor observado",
limite = "Tipo de límite", naturaleza = "Naturaleza", accion = "Acción"
) |>
tema_flex() |>
flextable::align(j = c("variable", "naturaleza", "accion"), align = "left", part = "all") |>
flextable::bg(i = ~ limite == "Duro", bg = "#FBE4E4", part = "body") |>
flextable::bg(i = ~ limite == "Blando", bg = "#FDF3E0", part = "body") |>
flextable::set_caption("Valores fuera del rango de referencia: límites duros (imposibles) y blandos (zona sospechosa, a examinar)")Variable | Rango de referencia | Valor observado | Tipo de límite | Naturaleza | Acción |
|---|---|---|---|---|---|
age | 18 – 110 | -5 | Duro | Imposible (negativo) | Convertir a faltante |
age | 18 – 110 | 160 | Duro | Imposible (longevidad humana ~122) | Convertir a faltante |
bmi | 10 – 70 | -3 | Duro | Imposible (negativo) | Convertir a faltante |
bmi | 10 – 70 | 95 | Blando | Sospechoso (entre blando y duro) | Examinar como atípico |
systolic_bp | 60 – 250 | -20 | Duro | Imposible (negativo) | Convertir a faltante |
ejection_fraction | 10 – 90 | 120 | Duro | Imposible (> 100 %) | Convertir a faltante |
troponin | 0 – 50 | -0,5 | Duro | Imposible (negativo) | Convertir a faltante |
troponin | 0 – 50 | 75 | Blando | Sospechoso (entre blando y duro) | Examinar como atípico |
Análisis: Observo que la mayoría de las variables numéricas se mueve dentro de su rango plausible, pero cinco lo desbordan, lo cual se ve de inmediato en las columnas de mínimo y máximo, que son valores exactos.
Aplicando la distinción del artículo de Van den Broeck (3), considero imposibles los valores que violan un límite duro, a saber, todos los negativos, pues ninguna de estas magnitudes puede serlo, la edad de 160 años, que supera la longevidad humana documentada, y la fracción de eyección de 120, que excede el 100% que por definición una fracción no puede rebasar; estos seis valores son errores y los convertiré en faltantes.
En cambio, el índice de masa corporal de 95 y la troponina de 75 caen en la zona sospechosa que el artículo de Van den Broeck (3) ubica entre el límite blando y el duro,pues superan el máximo de referencia, pero no son físicamente imposibles, de modo que no los elimino y los reservo para examinarlos en el análisis de valores atípicos, donde decidiré su tratamiento.
Quiero resaltar un punto estadístico, sobre la mediana que de todas estas variables permanece en valores clínicamente razonables, como 65 años para la edad o 54,9% para la fracción de eyección, lo cual ilustra por qué, según Bruce (4) y tal como procede Timmis (2) en su artículo, la mediana es una medida robusta que apenas se altera ante estos valores aberrantes, a diferencia de la media; esa robustez es la razón por la que más adelante resumiré las numéricas con mediana y rango intercuartílico.
# marcar como faltante lo que cae fuera de un rango posible (límite duro)
marcar_imposible <- function(x, minimo = -Inf, maximo = Inf) {
x[!is.na(x) & (x < minimo | x > maximo)] <- NA
x
}
datos_dep <- datos_factores |>
dplyr::mutate(
age = marcar_imposible(.data$age, minimo = 0, maximo = 120),
bmi = marcar_imposible(.data$bmi, minimo = 0),
systolic_bp = marcar_imposible(.data$systolic_bp, minimo = 0),
ejection_fraction = marcar_imposible(.data$ejection_fraction, maximo = 100),
troponin = marcar_imposible(.data$troponin, minimo = 0)
)vars_editadas <- c("age", "bmi", "systolic_bp", "ejection_fraction", "troponin")
tibble::tibble(
variable = vars_editadas,
na_antes = purrr::map_int(vars_editadas, ~ sum(is.na(datos_factores[[.x]]))),
na_despues = purrr::map_int(vars_editadas, ~ sum(is.na(datos_dep[[.x]])))
) |>
dplyr::mutate(imposibles_marcados = na_despues - na_antes) |>
flextable::flextable() |>
flextable::set_header_labels(
variable = "Variable", na_antes = "Faltantes antes",
na_despues = "Faltantes después", imposibles_marcados = "Imposibles marcados"
) |>
tema_flex() |>
flextable::align(j = "variable", align = "left", part = "all") |>
flextable::set_caption("Efecto de la edición: faltantes antes y después de marcar los valores imposibles")Variable | Faltantes antes | Faltantes después | Imposibles marcados |
|---|---|---|---|
age | 0 | 2 | 2 |
bmi | 10 | 11 | 1 |
systolic_bp | 0 | 1 | 1 |
ejection_fraction | 0 | 1 | 1 |
troponin | 10 | 11 | 1 |
Análisis: Tras la edición observo que los seis valores imposibles quedaron convertidos en faltantes, lo cual se refleja en el conteo, así:
Considero que esta es la conducta correcta frente a un error según el artículo de Van den Broeck (3), pues marcar el valor como faltante impide propagar un dato falso a los análisis sin inventar un valor en su reemplazo.
Los dos valores extremos pero plausibles, el índice de masa corporal de 95 y la troponina de 75, permanecen en la base y los examinaré en el siguiente bloque. Conservo el resultado en una nueva versión depurada de la base, manteniendo la trazabilidad sobre las versiones previas, de modo que esta será la que trabaje el análisis de valores atípicos y faltantes.
En este último paso del diagnóstico, examinaré los valores atípicos, aquellos que se aparten marcadamente del grueso de las observaciones, por consiguiente, defino un valor atípico mediante la regla de Tukey la misma que dibuja la caja de bigotes, donde a partir del primer cuartil (Q1, el percentil 25), el tercer cuartil (Q3, el percentil 75) y el rango intercuartílico (RIC), se define como la diferencia entre ambos, y se fijan dos límites:
\[\text{RIC} = Q_3 - Q_1\]
\[\text{Límite inferior} = Q_1 - 1{,}5 \times \text{RIC} \qquad \text{Límite superior} = Q_3 + 1{,}5 \times \text{RIC}\]
Por lo cual, se considera atípico todo valor que quede por debajo del límite inferior o por encima del límite superior. A diferencia del límite duro, que marca lo imposible, el valor atípico es extremo pero no necesariamente erróneo; por eso mi tarea aquí, siguiendo el límite blando del artículo de Van den Broeck (3), consiste en examinarlo y decidir su tratamiento, no en eliminarlo por defecto.
Presento el conteo de atípicos por variable y luego sus cajas de bigotes, monocromáticas por tratarse de un análisis univariado.
datos_dep |>
dplyr::select(where(is.numeric), -patient_id) |>
dlookr::diagnose_outlier() |>
flextable::flextable() |>
flextable::set_header_labels(
variables = "Variable", outliers_cnt = "Atípicos (n)",
outliers_ratio = "Atípicos (%)", outliers_mean = "Media de los atípicos",
with_mean = "Media con atípicos", without_mean = "Media sin atípicos"
) |>
flextable::colformat_double(digits = 2, decimal.mark = ",") |>
tema_flex() |>
flextable::align(j = "variables", align = "left", part = "all") |>
flextable::set_caption("Diagnóstico de valores atípicos por la regla de Tukey (base depurada, n = 2500)")Variable | Atípicos (n) | Atípicos (%) | Media de los atípicos | Media con atípicos | Media sin atípicos |
|---|---|---|---|---|---|
age | 13 | 0,52 | 28,46 | 65,38 | 65,58 |
bmi | 11 | 0,44 | 47,65 | 27,87 | 27,78 |
systolic_bp | 9 | 0,36 | 189,56 | 130,45 | 130,24 |
diastolic_bp | 29 | 1,16 | 68,97 | 79,77 | 79,89 |
chol_total | 9 | 0,36 | 324,78 | 201,19 | 200,65 |
ldl | 6 | 0,24 | 214,50 | 120,69 | 120,46 |
hdl | 5 | 0,20 | 96,00 | 50,21 | 50,10 |
triglycerides | 40 | 1,60 | 304,20 | 155,32 | 152,89 |
ejection_fraction | 5 | 0,20 | 24,04 | 54,65 | 54,71 |
troponin | 165 | 6,60 | 0,70 | 0,10 | 0,06 |
creatinine | 24 | 0,96 | 1,92 | 1,02 | 1,01 |
length_of_stay | 42 | 1,68 | 12,71 | 6,04 | 5,92 |
Análisis: Esta tabla cuantifica, para cada variable numérica, cuántos de sus valores son atípicos según la regla de Tukey y cómo influyen sobre el promedio, por lo que además, es preciso explicar, qué significa cada columna:
Atípicos (n): es el número de valores que caen por fuera de los límites de Tukey.
Atípicos (%): es su proporción sobre el total de la base depurada, es decir (Atípicos / 2500) × 100, de modo que en la troponina los 165 atípicos equivalen a (165/2500) × 100 = 6,60%.
Media de los atípicos: es el promedio calculado únicamente con esos valores extremos, que indica hacia qué lado de la distribución se ubican; y las dos últimas columnas, Media con atípicos y Media sin atípicos, comparan el promedio de la variable incluyendo y excluyendo los extremos, de manera que su diferencia mide cuánto arrastran la media.
Por lo cual,leída así, la tabla no solo me cuenta los atípicos, sino que me revela su naturaleza clínica, y tengo esto por la tabla:
Considero que estos valores altos no son errores sino la señal del infarto, ya que según el artículo de Kraler (1) la troponina ultrasensible refleja la magnitud del daño miocárdico; por eso su media completa engaña y la mediana describe mejor el centro.
Fracción de eyección, 5 atípicos (0,20%), media de atípicos 24,04: Observo que esa media de atípicos cae por debajo del límite inferior, lo que indica que los extremos son valores bajos; considero que corresponden a pacientes con disfunción ventricular severa, un fenotipo de alto riesgo que el artículo de Kraler (1) vincula al pronóstico. La media apenas cambia, de 54,65 a 54,71, porque son pocos casos.
Edad, 13 atípicos (0,52%), media de atípicos 28,46: La media de atípicos, muy inferior a la mediana de 65 años, revela que los extremos son pacientes jóvenes; estimo que reflejan el síndrome coronario agudo prematuro que describe el artículo de Kraler (1), el cual recuerda que cerca de uno de cada cuatro casos ocurre antes de los 55 años y obedece a mecanismos distintos.
Además, su presencia arrastra la media levemente hacia abajo, de 65,58 sin ellos a 65,38 con ellos.
Observo que el efecto sobre la media es mínimo en todas, lo cual confirma su escaso peso numérico.
Un detalle que me llama l atención, es la diastólica, pues, su media de atípicos, 68,97, queda dentro de los límites de Tukey, lo cual solo se entiende si los extremos se reparten en ambas colas, algunos por debajo del límite inferior y otros por encima del superior, de modo que su promedio cae en el medio.
etiquetas_num <- c(
age = "Edad (años)", bmi = "IMC (kg/m²)", systolic_bp = "PA sistólica (mmHg)",
diastolic_bp = "PA diastólica (mmHg)", chol_total = "Colesterol total (mg/dL)",
ldl = "LDL (mg/dL)", hdl = "HDL (mg/dL)", triglycerides = "Triglicéridos (mg/dL)",
creatinine = "Creatinina (mg/dL)", ejection_fraction = "Fracción de eyección (%)",
length_of_stay = "Estancia (días)"
)
medianas_num <- datos_dep |>
dplyr::select(where(is.numeric), -patient_id, -troponin) |> # la troponina va en su propio panel log
tidyr::pivot_longer(dplyr::everything(), names_to = "variable", values_to = "valor") |>
dplyr::group_by(variable) |>
dplyr::summarise(mediana = median(valor, na.rm = TRUE), .groups = "drop") |>
dplyr::mutate(etiqueta = formatC(mediana, format = "f", digits = 2, decimal.mark = ","))
datos_dep |>
dplyr::select(where(is.numeric), -patient_id, -troponin) |>
tidyr::pivot_longer(dplyr::everything(), names_to = "variable", values_to = "valor") |>
dplyr::filter(!is.na(valor)) |>
ggplot2::ggplot(ggplot2::aes(x = "", y = valor)) +
ggplot2::geom_boxplot(fill = "#4F7CAC", outlier.colour = "#C0392B",
outlier.alpha = 0.4, width = 0.5) +
ggplot2::geom_text(data = medianas_num,
ggplot2::aes(x = "", y = mediana, label = etiqueta),
position = ggplot2::position_nudge(x = 0.46),
size = 3.2, colour = "#1F3B57") +
ggplot2::facet_wrap(~ variable, scales = "free_y", ncol = 4,
labeller = ggplot2::labeller(variable = etiquetas_num)) +
ggplot2::labs(
title = "Cajas de bigotes de las variables numéricas",
subtitle = "La etiqueta marca la mediana; los puntos rojos son los atípicos de Tukey (n = 2500). La troponina la presento aparte por su fuerte sesgo",
x = NULL, y = NULL
) +
tema_gg(13) +
ggplot2::theme(axis.text.x = ggplot2::element_blank())datos_dep |>
dplyr::filter(!is.na(troponin)) |>
ggplot2::ggplot(ggplot2::aes(x = "", y = troponin)) +
ggplot2::geom_boxplot(fill = "#4F7CAC", outlier.colour = "#C0392B",
outlier.alpha = 0.4, width = 0.4) +
ggplot2::scale_y_log10() +
ggplot2::coord_flip() +
ggplot2::labs(
title = "Troponina en escala logarítmica",
subtitle = "La transformación log10 despliega la distribución que en escala lineal queda aplastada",
x = NULL, y = "Troponina (ng/mL, escala logarítmica)"
) +
tema_gg(14) +
ggplot2::theme(axis.text.y = ggplot2::element_blank())
Análisis: En las cajas de bigotes observo lo que la
tabla cuantificó, teniendo en cuenta, que cada caja delimita el rango
intercuartílico con la mediana en su interior, y que etiqueté sobre cada
una para poder leerla; los bigotes se extienden hasta el último dato
dentro de 1,5 veces ese rango y los puntos rojos son los valores
atípicos.
Tengo que decir, que los valores exactos provienen de la tabla de diagnóstico, mientras que la caja muestra la forma de la distribución y, sobre todo, la dirección de sus extremos, que es donde encuentro su mayor riqueza, porque al recorrer el panel observo que los puntos rojos no son ruido disperso, sino que se ordenan en fenotipos clínicos reconocibles.
El caso de la troponina es tan extremo que debo analizarla aparte,pues, en el panel su caja se aplasta contra la base del eje, reducida casi a una línea cerca de cero, mientras una nube de puntos rojos asciende muy por encima; ese aplastamiento no es un defecto del gráfico sino el retrato de una distribución intensamente sesgada a la derecha, donde el grueso de los pacientes se concentra en valores bajos, con cuartiles de 0,03 y 0,09 y mediana de 0,05, y una minoría alcanza valores hasta de 75.
Por eso añadí la caja en escala logarítmica, donde la distribución por fin se despliega, y es aquí precisamente, donde la estadística y la clínica se encuentran, porque considero que esos **165 valores altos no son errores sino la señal del infarto*, que según el artículo de Kraler (1), que dice que la troponina ultrasensible refleja la magnitud del daño miocárdico y el riesgo de muerte, de modo que lo que la regla de Tukey marca como atípico es el grupo de pacientes con infarto**, y borrarlo considero que equivaldría a borrar el fenómeno que el estudio busca capturar.
Esa misma lógica recorre las variables cuyos puntos rojos apuntan hacia arriba, como por ejemplo, en el índice de masa corporal, la caja se ubica en rango de sobrepeso, con mediana de 27,8, y 11 puntos rojos se elevan entre 41,8 y 95, retratando la obesidad; en los triglicéridos, 40 puntos rojos ascienden entre 274 y 439, y en el colesterol total y el LDL otros pocos suben hasta 337 y 250, dibujando la dislipidemia.
En la creatinina, 24 puntos rojos se elevan entre 1,74 y 2,31,lo que significa una señal de compromiso renal; y en la presión arterial sistólica, 9 puntos rojos se sitúan entre 185 y 198, propios de picos hipertensivos.
Considero que todos estos perfiles componen la constelación de riesgo cardiovascular que el artículo de Kraler (1) sitúa en el centro del síndrome coronario agudo, con un papel destacado para el colesterol LDL y la función renal que integran sus puntajes pronósticos.
No todos los extremos apuntan hacia arriba, pues en la edad, la caja es amplia y casi simétrica, con mediana de 65 años, pero sus 13 puntos rojos se descuelgan hacia abajo, entre 23 y 31 años; estimo que retratan el síndrome coronario agudo prematuro que el propio Kraler (1) describe, al recordar que cerca de uno de cada cuatro casos ocurre antes de los 55 años.
En la fracción de eyección, la caja se mantiene en rango conservado, con mediana de 54,9, y sus 5 puntos rojos también caen por debajo, entre 18,4 y 27,1, identificando a los pacientes con disfunción ventricular severa, un fenotipo de alto riesgo.
La presión arterial diastólica completa el cuadro con un comportamiento singular, pues es la única con puntos rojos en ambos lados, 20 por debajo, entre 50 y 53, y 9 por encima, entre 106 y 114; esto explica por qué su media de atípicos caía justo en el medio, ya que sus extremos se compensan a lado y lado.
Frente a ella, el colesterol HDL apenas aporta 5 puntos altos, entre 93 y 100, que representan un perfil lipídico favorable, y la estancia hospitalaria suma 42 puntos altos, entre 12 y 15 días, propios de hospitalizaciones prolongadas.
De esta lectura en conjunto, concluyo que la dirección de los extremos tiene sentido clínico en cada variable, baja en la edad y la fracción de eyección, alta en los marcadores de daño y de riesgo metabólico, y repartida solo en la diastólica, de modo que ninguno se comporta como un error que justifique su eliminación.
Por ello, siguiendo el límite blando del artículo de Van den Broeck (3), decido conservar los valores atípicos sin modificarlos, pues ninguno es imposible y muchos portan información clínica esencial.
Esta decisión, reforzada por la diferencia entre la media con atípicos y sin atípicos que evidenció la tabla, sostiene de manera definitiva que resuma las variables numéricas con la mediana y el rango intercuartílico, las medidas robustas que, conforme a Bruce (4) y al procedimiento que emplea Timmis (2) en su artículo, describen el centro y la dispersión de estas distribuciones sin dejarse arrastrar por sus extremos.
El último componente de la fase de calidad caracteriza los valores faltantes, esto es, las celdas sin dato, por lo cual, su tratamiento no es único, pues según el artículo de Van den Broeck (3), la decisión depende de cuánta ausencia hay y, sobre todo, de cómo se distribuye.
La pregunta de fondo es si me conviene eliminar las filas incompletas o manejar la ausencia variable por variable, y para responderla con criterio examinaré primero su magnitud y su patrón mediante tres gráficos complementarios:
el diagrama de Pareto, que ordena la ausencia por variable
el mapa de faltantes, que muestra cómo se reparte entre los pacientes; y
el gráfico de intersecciones, que revela si las variables faltan juntas o por separado.
faltantes_resumen <- datos_dep |>
dplyr::summarise(dplyr::across(dplyr::everything(), ~ sum(is.na(.x)))) |>
tidyr::pivot_longer(dplyr::everything(),
names_to = "variable", values_to = "faltantes") |>
dplyr::filter(faltantes > 0) |>
dplyr::arrange(dplyr::desc(faltantes)) |>
dplyr::mutate(
variable = forcats::fct_inorder(.data$variable),
porcentaje = .data$faltantes / nrow(datos_dep) * 100,
acumulado = cumsum(.data$faltantes) / sum(.data$faltantes) * 100
)
tope <- max(faltantes_resumen$porcentaje)
ggplot2::ggplot(faltantes_resumen, ggplot2::aes(x = variable)) +
ggplot2::geom_col(ggplot2::aes(y = porcentaje), fill = "#4F7CAC", width = 0.7) +
ggplot2::geom_text(ggplot2::aes(y = porcentaje,
label = scales::number(porcentaje, accuracy = 0.01, decimal.mark = ",")),
vjust = -0.5, size = tam_etiqueta, colour = "#1F3B57") +
ggplot2::geom_line(ggplot2::aes(y = acumulado / 100 * tope, group = 1),
colour = "#C0392B", linewidth = 0.8) +
ggplot2::geom_point(ggplot2::aes(y = acumulado / 100 * tope),
colour = "#C0392B", size = 2) +
ggplot2::scale_y_continuous(
name = "Faltantes por variable (%)",
sec.axis = ggplot2::sec_axis(~ . / tope * 100, name = "Porcentaje acumulado (%)")
) +
ggplot2::labs(
title = "Diagrama de Pareto de valores faltantes",
subtitle = "Barras: porcentaje por variable; línea roja: acumulado sobre el total de ausencia (n = 2500)",
x = NULL
) +
tema_gg(14) +
ggplot2::theme(
panel.grid.major.x = ggplot2::element_blank(),
axis.text.x = ggplot2::element_text(angle = 35, hjust = 1),
axis.title.y.right = ggplot2::element_text(colour = "#C0392B") # eje derecho en rojo, va después de tema_gg
)
Análisis: Este es un diagrama de Pareto, que combina
barras y una línea sobre dos ejes para mostrar dónde se concentra la
ausencia; distribuido de la siguiente manera: - En el eje
horizontal aparecen las variables con faltantes, ordenadas de
mayor a menor.
el eje vertical izquierdo mide la altura de cada barra como el porcentaje de faltantes de esa variable, y
el eje vertical derecho, en rojo, mide la línea de porcentaje acumulado, que suma la ausencia a medida que avanzo de izquierda a derecha.
Así, cada barra responde “cuánto falta en esta variable” y la línea responde “cuánto del total de la ausencia llevo explicado hasta aquí”.
Observo cuatro barras claramente más altas, agrupadas en torno al 18%: colesterol total y creatinina encabezan empatadas, ambas con (450/2500) × 100 = 18,00%, seguidas de tabaquismo con (449/2500) × 100 = 17,96% y HDL con (448/2500) × 100 = 17,92%, valores exactos porque el denominador es 2500.
Tras ellas, la altura cae de forma abrupta a valores muy pequeños, troponina e índice de masa corporal con 0,44%, LDL con 0,40%, y tres apenas perceptibles, edad con 0,08%, presión sistólica y fracción de eyección con 0,04%.
La línea acumulada asciende con fuerte pendiente sobre las cuatro primeras variables y luego se aplana casi por completo, lo que confirma de un vistazo que la ausencia se concentra en esas cuatro y que el resto es prácticamente despreciable.
Considero relevante que las cuatro afectadas sean tres valores de laboratorio, colesterol total, creatinina y HDL, y un dato de anamnesis, el tabaquismo, un patrón verosímil en datos clínicos donde no siempre se completa el panel ni se registra el antecedente; son, además, variables que el artículo de Kraler (1) vincula al riesgo, de modo que su ausencia en este estudio importa y debo realizar un manejo cuidadoso.
datos_dep |>
visdat::vis_miss(sort_miss = TRUE) +
ggplot2::scale_fill_manual(
values = c("TRUE" = "#C0392B", "FALSE" = "#EAF0F6"),
labels = c("TRUE" = "Ausente", "FALSE" = "Presente")
) +
ggplot2::labs(title = "Mapa de valores faltantes por paciente y variable", fill = NULL) +
ggplot2::theme(
legend.position = "top",
plot.title = ggplot2::element_text(face = "bold", size = 16, colour = "#16314A"),
axis.text = ggplot2::element_text(size = 11, colour = "#1F3B57"),
legend.text = ggplot2::element_text(size = 12, colour = "#1F3B57")
)Análisis: El mapa de vis_miss representa la base completa como una cuadrícula:
cada fila es un paciente y cada columna una variable, ordenadas de mayor a menor ausencia.
El color distingue dos estados de cada celda, el coral marca el valor ausente y el azul claro el presente, y
el encabezado de cada columna anota su porcentaje de faltantes.
De modo que el gráfico responde dos preguntas a la vez: en qué variables se concentra la ausencia y cómo se reparte entre los pacientes.
Observo que las celdas coral se concentran en las cuatro primeras columnas, colesterol total, creatinina, tabaquismo y HDL, mientras casi toda la cuadrícula restante aparece en azul claro.
Más a la derecha distingo unos pocos trazos coral aislados en troponina, índice de masa corporal y LDL, y apenas tres o cuatro líneas sueltas en edad, presión sistólica y fracción de eyección, que corresponden a sus faltantes menores y a los seis valores imposibles que convertí en ausentes; su escasez confirma de manera visual lo que el Pareto ya cuantificó.
Lo decisivo es que, dentro de las cuatro columnas grandes, las celdas coral no se alinean en las mismas filas, sino que se reparten salpicadas a lo largo de toda la matriz; considero que esa es la evidencia visual de que no es un mismo subgrupo de pacientes el que carece de las cuatro variables, sino pacientes distintos en cada caso.
El panel resume además que solo cerca del 3,2% del total de celdas de la base está ausente (la suma de todos los faltantes sobre las 2500 × 23 celdas), una proporción global baja pese a la concentración por columna, lo que anticipa que el problema no es la cantidad de ausencia sino su reparto, y me refuerza la decisión de manejar la variable por variable en lugar de eliminar filas.
Análisis: El gráfico de intersecciones descompone las
combinaciones de ausencia así:
1. las barras superiores indican cuántos pacientes comparten exactamente el mismo patrón de faltantes y
2. la matriz de puntos inferior señala qué variables componen cada patrón.
Observo que:
las barras más altas corresponden, con diferencia, a los patrones de una sola variable ausente, alrededor de 265 pacientes a quienes solo les falta el HDL, 256 solo el colesterol total, 245 solo la creatinina y 240 solo el tabaquismo.
las barras de combinaciones de dos variables son mucho más bajas, entre unos 55 y 61 pacientes, y las de tres o cuatro variables se vuelven minúsculas, hasta el punto de que apenas 6 pacientes carecen de las cuatro a la vez.
Considero que este patrón es la prueba más contundente de que la ausencia está dispersa, pues si las cuatro variables faltaran sistemáticamente en los mismos pacientes, esperaría cerca de 450 con las cuatro ausentes; pero observo solo 6, de modo que lo más frecuente, con diferencia, es que al paciente incompleto le falte una sola de las cuatro, es decir, de los 1377 pacientes con al menos una ausencia, 1016 (73,8%) carecen de una única variable, mientras que 308 carecen de dos, 47 de tres y apenas 6 de las cuatro a la vez.
vars_grandes <- c("chol_total", "creatinine", "smoking_status", "hdl")
conteo_ausencias <- datos_dep |>
dplyr::mutate(n_faltantes = rowSums(is.na(dplyr::across(dplyr::all_of(vars_grandes))))) |>
dplyr::count(n_faltantes, name = "pacientes") |>
dplyr::mutate(
porcentaje = pacientes / sum(pacientes) * 100,
etiqueta = paste0(pacientes, "\n(", scales::number(porcentaje, accuracy = 0.1, decimal.mark = ","), "%)")
)
ggplot2::ggplot(conteo_ausencias,
ggplot2::aes(x = factor(n_faltantes), y = pacientes)) +
ggplot2::geom_col(fill = "#4F7CAC", width = 0.7) +
ggplot2::geom_text(ggplot2::aes(label = etiqueta),
vjust = -0.2, size = tam_etiqueta, colour = "#1F3B57", lineheight = 0.95) +
ggplot2::scale_y_continuous(expand = ggplot2::expansion(mult = c(0, 0.22))) +
ggplot2::labs(
title = "Número de variables ausentes por paciente",
subtitle = "Sobre las cuatro variables con mayor ausencia; cada barra agrupa a los pacientes según cuántas les faltan (n = 2500)",
x = "Número de variables ausentes en el paciente",
y = "Número de pacientes"
) +
tema_gg(14) +
ggplot2::theme(panel.grid.major.x = ggplot2::element_blank())
Análisis: Para hacer aún más legible el patrón
anterior, traduzco la misma información a un gráfico de barras que
cuenta a los pacientes según cuántas de las cuatro variables les
faltan, sin importar cuáles, así:
en el eje horizontal está el número de variables ausentes, de 0 a 4, y
en el vertical, el número de pacientes en cada grupo.
Donde el gráfico de intersecciones me mostraba los patrones exactos, este me resume su forma de un vistazo; además se observa, que el grupo más numeroso es el de 0 ausencias, con 1123 pacientes (44,9%), los casos completos en estas cuatro variables, seguido muy de cerca por el de una sola ausencia, con 1016 pacientes (40,6%); a partir de ahí la caída es pronunciada, pues 308 pacientes (12,3%) carecen de dos, 47 (1,9%) de tres y apenas 6 (0,2%) de las cuatro a la vez.
Considero que esta forma escalonada y descendente es la confirmación más sencilla de que la ausencia está dispersa, pues, si faltaran sistemáticamente en los mismos pacientes, la barra de “cuatro ausencias” dominaría el gráfico, pero es la más baja de todas.
Dicho de otro modo, entre quienes tienen alguna ausencia, lo abrumadoramente frecuente es que les falte un único dato y no un bloque, lo que sostiene mi decisión de manejar la ausencia variable por variable en lugar de eliminar filas, conforme al artículo de Van den Broeck, Data Cleaning (Detecting, Diagnosing, and Editing Data Abnormalities (3).
Con la base ya depurada y su calidad asegurada, doy paso a la descripción de la muestra, esto es, a caracterizar cada variable por separado antes de explorar las relaciones entre ellas y de construir la Tabla 1. Sigo aquí el marco de análisis exploratorio de Bruce (4), que ordena esta etapa en estimaciones de localización y de variabilidad, exploración de la forma de la distribución y, más adelante, exploración de dos o más variables.
Comienzo por un panorama numérico que reúne, en una sola tabla, las medidas de tendencia central y de dispersión de las doce variables cuantitativas; sobre él decido cómo resumirlas y qué esperar de su forma.
Luego examino esa forma con histogramas, cajas y violines, compruebo la normalidad y, por último, paso al análisis bivariado.
Antes de leer la tabla defino los estadísticos que la sostienen, además de la mediana y el rango intercuartílico que ya formulé en el diagnóstico de atípicos.
\[\bar{x} = \frac{1}{n}\sum_{i=1}^{n} x_i\]
\[s = \sqrt{\frac{1}{\,n-1\,}\sum_{i=1}^{n}\left(x_i - \bar{x}\right)^2}\]
\[g_1 = \frac{\dfrac{1}{n}\sum_{i=1}^{n}\left(x_i-\bar{x}\right)^3}{\left(\dfrac{1}{n}\sum_{i=1}^{n}\left(x_i-\bar{x}\right)^2\right)^{3/2}}\]
Presento en la tabla, para cada variable, su tamaño con dato y sus faltantes, su resumen robusto (mediana con rango intercuartílico), su resumen clásico (media con desviación estándar), su rango observado y su asimetría; resalto en azul la columna de mediana y rango intercuartílico, que es la que llevaré a la Tabla 1.
# coeficiente de asimetría (momento estandarizado)
sesgo <- function(x) {
x <- x[!is.na(x)]; n <- length(x); m <- mean(x)
(sum((x - m)^3) / n) / (sum((x - m)^2) / n)^(3/2)
}
# etiquetas de las numéricas (12 variables)
etiquetas_num <- c(
age = "Edad (años)", bmi = "IMC (kg/m²)", systolic_bp = "PA sistólica (mmHg)",
diastolic_bp = "PA diastólica (mmHg)", chol_total = "Colesterol total (mg/dL)",
ldl = "LDL (mg/dL)", hdl = "HDL (mg/dL)", triglycerides = "Triglicéridos (mg/dL)",
troponin = "Troponina (ng/mL)", creatinine = "Creatinina (mg/dL)",
ejection_fraction = "Fracción de eyección (%)", length_of_stay = "Estancia (días)"
)
fmt <- function(x, d = 2) formatC(x, format = "f", digits = d, decimal.mark = ",")
panorama_num <- datos_dep |>
dplyr::select(dplyr::all_of(names(etiquetas_num))) |>
tidyr::pivot_longer(dplyr::everything(), names_to = "variable", values_to = "valor") |>
dplyr::group_by(variable) |>
dplyr::summarise(
n = sum(!is.na(.data$valor)),
faltantes = sum(is.na(.data$valor)),
media = mean(.data$valor, na.rm = TRUE),
de = sd(.data$valor, na.rm = TRUE),
mediana = median(.data$valor, na.rm = TRUE),
q1 = quantile(.data$valor, 0.25, na.rm = TRUE),
q3 = quantile(.data$valor, 0.75, na.rm = TRUE),
minimo = min(.data$valor, na.rm = TRUE),
maximo = max(.data$valor, na.rm = TRUE),
asimetria = sesgo(.data$valor),
.groups = "drop"
) |>
dplyr::mutate(
etiqueta = etiquetas_num[.data$variable],
med_iqr = paste0(fmt(mediana), " [", fmt(q1), " – ", fmt(q3), "]"),
media_de = paste0(fmt(media), " (", fmt(de), ")"),
rango = paste0(fmt(minimo), " – ", fmt(maximo))
) |>
dplyr::arrange(match(.data$variable, names(etiquetas_num))) |>
dplyr::select(etiqueta, n, faltantes, med_iqr, media_de, rango, asimetria)
panorama_num |>
flextable::flextable() |>
flextable::set_header_labels(
etiqueta = "Variable", n = "n", faltantes = "Faltantes",
med_iqr = "Mediana [Q1 – Q3]", media_de = "Media (DE)",
rango = "Mínimo – Máximo", asimetria = "Asimetría"
) |>
flextable::colformat_double(j = "asimetria", digits = 2, decimal.mark = ",") |>
tema_flex() |>
flextable::align(j = "etiqueta", align = "left", part = "all") |>
flextable::bg(j = "med_iqr", bg = "#E3EEF7", part = "body") |>
flextable::set_caption("Panorama descriptivo de las variables numéricas (base depurada, n = 2500)")Variable | n | Faltantes | Mediana [Q1 – Q3] | Media (DE) | Mínimo – Máximo | Asimetría |
|---|---|---|---|---|---|---|
Edad (años) | 2,498 | 2 | 65,00 [57,00 – 74,00] | 65,38 (12,65) | 23,00 – 95,00 | -0,04 |
IMC (kg/m²) | 2,489 | 11 | 27,80 [24,40 – 31,30] | 27,87 (5,21) | 15,00 – 95,00 | 0,91 |
PA sistólica (mmHg) | 2,499 | 1 | 130,00 [117,00 – 144,00] | 130,45 (19,82) | 90,00 – 198,00 | 0,11 |
PA diastólica (mmHg) | 2,500 | 0 | 80,00 [73,00 – 86,00] | 79,77 (9,85) | 50,00 – 114,00 | -0,03 |
Colesterol total (mg/dL) | 2,050 | 450 | 201,00 [173,00 – 228,00] | 201,19 (39,57) | 120,00 – 337,00 | 0,18 |
LDL (mg/dL) | 2,490 | 10 | 121,00 [100,00 – 141,00] | 120,69 (29,84) | 50,00 – 250,00 | 0,05 |
HDL (mg/dL) | 2,052 | 448 | 50,00 [40,00 – 61,00] | 50,21 (14,90) | 15,00 – 100,00 | 0,04 |
Triglicéridos (mg/dL) | 2,500 | 0 | 149,00 [121,00 – 182,00] | 155,32 (47,19) | 50,00 – 439,00 | 0,85 |
Troponina (ng/mL) | 2,489 | 11 | 0,05 [0,03 – 0,08] | 0,10 (1,50) | 0,00 – 75,00 | 49,73 |
Creatinina (mg/dL) | 2,050 | 450 | 0,99 [0,83 – 1,19] | 1,02 (0,26) | 0,42 – 2,31 | 0,75 |
Fracción de eyección (%) | 2,499 | 1 | 54,90 [47,80 – 61,40] | 54,65 (9,66) | 18,40 – 75,00 | -0,09 |
Estancia (días) | 2,500 | 0 | 6,00 [4,00 – 7,00] | 6,04 (2,22) | 1,00 – 15,00 | 0,53 |
Análisis: Esta tabla reúne el retrato numérico de la muestra y enfrenta, columna a columna, las dos formas de resumir una variable, esto es:
la columna n indica cuántos pacientes aportan dato y la de faltantes cuántos no.
la columna resaltada en azul, Mediana [Q1 – Q3], es el resumen robusto que centra y dispersa con percentiles, insensible a los extremos.
Media (DE) es el resumen clásico, que se deja arrastrar por ellos; Mínimo – Máximo delimita el recorrido observado; y Asimetría resume la forma, con el signo apuntando hacia la cola larga.
Leída en conjunto, la tabla separa las numéricas en dos familias claras:
En todas ellas, la coincidencia entre media y mediana confirma que ningún extremo desvía el centro, de modo que cualquiera de los dos resúmenes sería válido.
Considero que esta forma es clínicamente esperable, pues retrata la obesidad y la dislipidemia que el artículo de Kraler (1) sitúa entre los factores de riesgo del síndrome coronario agudo, el compromiso renal que integra sus puntajes pronósticos y las hospitalizaciones prolongadas de los casos complicados.
Conviene precisar que en la tabla el mínimo de la troponina aparece como 0,00 y su tercer cuartil como 0,08 por efecto de mostrar dos decimales, pero el mínimo real es 0,003 ng/mL, un valor positivo y no un cero, y el tercer cuartil real es 0,085; lo señalo porque en una variable de cola larga los valores pequeños se comprimen al redondear, por tanto se recomienda segun la literatura, no leer ese 0,00 como un cero imposible.
La tabla me recuerda además el patrón de faltantes ya caracterizado, con colesterol total y creatinina en 450 ausencias y HDL en 448, mientras el resto de las variables conserva casi toda su información.
En conjunto, la convivencia de variables simétricas con otras fuertemente sesgadas, sumada a los atípicos clínicos que decidí conservar, sostiene de manera definitiva mi elección de resumir todas las numéricas con mediana y rango intercuartílico, las medidas robustas que, conforme a Bruce (4) y al procedimiento de Timmis (2), describen el centro y la dispersión sin dejarse arrastrar por los extremos y permiten que toda la Tabla 1 tenga un mismo lenguaje.
El panorama me anticipó la forma de cada variable; ahora la hago visible, ya que presentaré un panel de histogramas, uno por variable, con su media en rojo y su mediana en azul punteado superpuestas, de modo que la distancia entre ambas líneas confirme de un vistazo la simetría o el sesgo que la tabla cuantificó.
Reservo la troponina para un histograma aparte en escala logarítmica, porque su sesgo extremo la aplastaría en escala lineal, además,mantengo el panel monocromático, al describir cada variable por separado sin comparar grupos.
vars_panel <- setdiff(names(etiquetas_num), "troponin") # la troponina va aparte
lineas_centro <- datos_dep |>
dplyr::select(dplyr::all_of(vars_panel)) |>
tidyr::pivot_longer(dplyr::everything(), names_to = "variable", values_to = "valor") |>
dplyr::group_by(variable) |>
dplyr::summarise(media = mean(.data$valor, na.rm = TRUE),
mediana = median(.data$valor, na.rm = TRUE), .groups = "drop")
datos_dep |>
dplyr::select(dplyr::all_of(vars_panel)) |>
tidyr::pivot_longer(dplyr::everything(), names_to = "variable", values_to = "valor") |>
dplyr::filter(!is.na(.data$valor)) |>
ggplot2::ggplot(ggplot2::aes(x = valor)) +
ggplot2::geom_histogram(bins = 30, fill = "#4F7CAC", colour = "white", linewidth = 0.2) +
ggplot2::geom_vline(data = lineas_centro,
ggplot2::aes(xintercept = media, colour = "Media"), linewidth = 0.7) +
ggplot2::geom_vline(data = lineas_centro,
ggplot2::aes(xintercept = mediana, colour = "Mediana"),
linewidth = 0.7, linetype = "dashed") +
ggplot2::scale_colour_manual(name = NULL,
values = c("Media" = "#C0392B", "Mediana" = "#16314A")) +
ggplot2::facet_wrap(~ variable, scales = "free", ncol = 4,
labeller = ggplot2::labeller(variable = etiquetas_num)) +
ggplot2::labs(
title = "Distribución de las variables numéricas",
subtitle = "Histogramas con la media (línea roja) y la mediana (línea azul punteada); la troponina la presento aparte por su sesgo extremo (n = 2500)",
x = NULL, y = "Frecuencia"
) +
tema_gg(13) +
ggplot2::theme(legend.position = "top")
Análisis: Cada faceta es un histograma, un gráfico que
agrupa los valores de una variable en intervalos y dibuja en el eje
vertical cuántos pacientes caen en cada uno, de modo que su silueta
revela la forma de la distribución; sobre cada uno tracé la
media en rojo y la mediana en azul punteado, y su separación es
la lectura clave, pues cuando ambas líneas se confunden la distribución
es simétrica y cuando se separan hay sesgo.
Observo que en la mayoría de las variables las dos líneas se superponen casi por completo, lo que dibuja siluetas acampanadas y centradas, así:
la edad se concentra en torno a los 65 años con forma simétrica, retrato de una población mayoritariamente mayor coherente con el síndrome coronario agudo según Kraler (1);
**las presiones sistólica y diastólica, el colesterol total, el LDL y el HDL repiten ese patrón equilibrado, y
la fracción de eyección se acampana alrededor de 55 con una discreta cola hacia los valores bajos que corresponde a los pacientes con disfunción ventricular severa.
En contraste, un grupo de variables muestra la cola a la derecha que la tabla ya anticipó, con la media roja desplazada por encima de la mediana azul, así:
el índice de masa corporal se extiende hacia la obesidad, los triglicéridos y la creatinina arrastran sus valores altos de dislipidemia y compromiso renal, y
la estancia hospitalaria, por ser un conteo de días, dibuja un histograma discreto con la mayoría de las altas tempranas y una minoría de hospitalizaciones prolongadas.
Considero que esta separación visible entre media y mediana es la confirmación gráfica de por qué prefiero la mediana, ya que en estas variables la media engaña al inclinarse hacia los extremos.
La troponina la dejé fuera del panel anterior porque, en palabras de Bruce (4), es una distribución de cola larga, esto es, una variable en la que la mayoría de los valores se agolpa en un extremo y una minoría se aleja muchísimo, de modo que en escala lineal su histograma se reduce a una sola barra junto al cero, por lo cual, para poder verla, aplicaré una transformación logarítmica al eje, que comprime la cola y despliega la forma; añadiré su mediana como referencia.
mediana_tro <- median(datos_dep$troponin, na.rm = TRUE)
datos_dep |>
dplyr::filter(!is.na(.data$troponin)) |>
ggplot2::ggplot(ggplot2::aes(x = troponin)) +
ggplot2::geom_histogram(bins = 30, fill = "#4F7CAC", colour = "white", linewidth = 0.2) +
ggplot2::geom_vline(ggplot2::aes(xintercept = mediana_tro, colour = "Mediana"),
linewidth = 0.7, linetype = "dashed") +
ggplot2::scale_colour_manual(name = NULL, values = c("Mediana" = "#16314A")) +
ggplot2::scale_x_log10() +
ggplot2::labs(
title = "Distribución de la troponina en escala logarítmica",
subtitle = "La transformación log10 despliega la cola larga que en escala lineal queda aplastada (n = 2489)",
x = "Troponina (ng/mL, escala logarítmica)", y = "Frecuencia"
) +
tema_gg(14) +
ggplot2::theme(legend.position = "top")
Análisis: Este histograma muestra la troponina con el
eje horizontal en escala logarítmica, de modo que cada paso del eje
multiplica el valor en lugar de sumarle una cantidad fija; esa
transformación es la herramienta que Bruce (4) sugiere para examinar las
distribuciones de cola larga, porque comprime los valores grandes y deja
ver la forma del grueso de los datos.
Observo que, una vez desplegada, la distribución se aproxima a una campana asimétrica centrada en una mediana de 0,05 ng/mL, con la mayoría de los pacientes concentrada en valores bajos y una cola que se extiende hacia la derecha hasta el máximo de 75.
Considero que esta es la lectura más fiel de la variable: en escala lineal su asimetría de 49,73 la volvía ilegible, mientras que aquí se reconoce que no hay dos poblaciones separadas, sino un continuo que va desde los pacientes con daño miocárdico mínimo hasta los de mayor necrosis.
Ello concuerda con el artículo de Kraler (1), según el cual la troponina ultrasensible refleja la magnitud del daño y, por tanto, su cola larga es la señal cuantitativa del infarto, no un defecto del dato.
Para enriquecer la lectura de la forma incorporo el gráfico de violín, que en términos de Bruce (4) combina la curva de densidad con el diagrama de caja, de la siguiente manera:
su ancho en cada altura representa la densidad de pacientes con ese valor mientras la caja interior conserva la mediana y los cuartiles.
Donde el histograma muestra la silueta con barras, el violín la muestra con un contorno suave, lo que me facilita comparar dónde se concentra la masa de cada variable.
Nuevamente decido conservar el panel monocromático y, de nuevo, dejaré la troponina fuera por su cola larga.
vars_panel <- setdiff(names(etiquetas_num), "troponin") # mismas 11 variables del histograma
datos_dep |>
dplyr::select(dplyr::all_of(vars_panel)) |>
tidyr::pivot_longer(dplyr::everything(), names_to = "variable", values_to = "valor") |>
dplyr::filter(!is.na(.data$valor)) |>
ggplot2::ggplot(ggplot2::aes(x = "", y = valor)) +
ggplot2::geom_violin(fill = "#4F7CAC", colour = "#4F7CAC", alpha = 0.45, width = 0.9) +
ggplot2::geom_boxplot(width = 0.12, fill = "white", colour = "#16314A",
outlier.colour = "#C0392B", outlier.alpha = 0.4) +
ggplot2::facet_wrap(~ variable, scales = "free_y", ncol = 4,
labeller = ggplot2::labeller(variable = etiquetas_num)) +
ggplot2::labs(
title = "Forma de las variables numéricas mediante gráficos de violín",
subtitle = "El ancho del violín es la densidad de pacientes; la caja interior marca mediana y cuartiles (n = 2500)",
x = NULL, y = NULL
) +
tema_gg(13) +
ggplot2::theme(axis.text.x = ggplot2::element_blank())
Análisis: Cada violín representa la densidad de una
variable, es decir, su ancho en cada altura indica cuántos pacientes
toman ese valor, y la caja blanca de su interior conserva la
mediana y el rango intercuartílico; leído así, el gráfico
traduce a un contorno suave la misma forma que los histogramas mostraron
con barras, conforme a la curva de densidad que describe Bruce (4).
Observo que las variables simétricas dibujan violines en forma de huso equilibrado, anchos en el centro y afilados de manera pareja hacia ambos extremos, como ocurre con la edad, las dos presiones, el colesterol total, el LDL y el HDL.
En la fracción de eyección el huso se ensancha en torno a 55 pero se prolonga un poco más hacia abajo, lo que vuelve a señalar a la minoría con disfunción ventricular severa.
En contraste, las distribuciones de cola larga dibujan violines abultados en la base y estirados hacia arriba, de esta manera:
el índice de masa corporal, los triglicéridos y la creatinina concentran su masa en valores moderados y proyectan una cola fina hacia los extremos altos, retrato visual de la obesidad, la dislipidemia y el compromiso renal que el artículo de Kraler (1) integra en el riesgo del síndrome coronario agudo.
la estancia hospitalaria repite esa forma, con casi todos los pacientes en estancias cortas y una cola de hospitalizaciones prolongadas.
Considero que esta segunda lectura de la forma, ahora con densidad,me confirma sin ambigüedad que las medidas robustas son las adecuadas y prepara la comprobación formal de normalidad que seguiré.
Los histogramas y los violines me sugirieron qué variables se acercan a la forma acampanada y cuáles se apartan; ahora lo verifico de manera formal.
El capítulo 2 de Bruce (4) distingue la distribución normal de las distribuciones de cola larga, y advierte que la normalidad perfecta es una idealización que los datos reales rara vez cumplen, de modo que la pregunta útil no es si una variable es exactamente normal, sino cuánto se aparta.
Para responderla combinaré una prueba formal, la de Shapiro-Wilk, con su contraparte visual, el gráfico cuantil-cuantil.
fmt_p <- function(p) ifelse(p < 0.001, "< 0,001",
formatC(p, format = "f", digits = 3, decimal.mark = ","))
shapiro_tab <- datos_dep |>
dplyr::select(dplyr::all_of(names(etiquetas_num))) |>
tidyr::pivot_longer(dplyr::everything(), names_to = "variable", values_to = "valor") |>
dplyr::filter(!is.na(.data$valor)) |>
dplyr::group_by(variable) |>
dplyr::summarise(
n = dplyr::n(),
sw = list(stats::shapiro.test(.data$valor)), # n por variable <= 2500, dentro del límite
.groups = "drop"
) |>
dplyr::mutate(
etiqueta = etiquetas_num[.data$variable],
W = purrr::map_dbl(sw, ~ .x$statistic),
p = purrr::map_dbl(sw, ~ .x$p.value),
p_fmt = fmt_p(p),
decision = "Se rechaza la normalidad"
) |>
dplyr::arrange(match(.data$variable, names(etiquetas_num))) |>
dplyr::select(etiqueta, n, W, p_fmt, decision)
shapiro_tab |>
flextable::flextable() |>
flextable::set_header_labels(
etiqueta = "Variable", n = "n", W = "Estadístico W",
p_fmt = "Valor p", decision = "Decisión (α = 0,05)"
) |>
flextable::colformat_double(j = "W", digits = 3, decimal.mark = ",") |>
tema_flex() |>
flextable::align(j = c("etiqueta", "decision"), align = "left", part = "all") |>
flextable::bg(i = ~ W < 0.97, bg = "#FBE4E4", part = "body") |> # resalto las distribuciones de cola larga
flextable::set_caption("Prueba de normalidad de Shapiro-Wilk para las variables numéricas (base depurada, n = 2500)")Variable | n | Estadístico W | Valor p | Decisión (α = 0,05) |
|---|---|---|---|---|
Edad (años) | 2,498 | 0,997 | < 0,001 | Se rechaza la normalidad |
IMC (kg/m²) | 2,489 | 0,964 | < 0,001 | Se rechaza la normalidad |
PA sistólica (mmHg) | 2,499 | 0,994 | < 0,001 | Se rechaza la normalidad |
PA diastólica (mmHg) | 2,500 | 0,998 | 0,005 | Se rechaza la normalidad |
Colesterol total (mg/dL) | 2,050 | 0,994 | < 0,001 | Se rechaza la normalidad |
LDL (mg/dL) | 2,490 | 0,998 | < 0,001 | Se rechaza la normalidad |
HDL (mg/dL) | 2,052 | 0,997 | 0,001 | Se rechaza la normalidad |
Triglicéridos (mg/dL) | 2,500 | 0,961 | < 0,001 | Se rechaza la normalidad |
Troponina (ng/mL) | 2,489 | 0,012 | < 0,001 | Se rechaza la normalidad |
Creatinina (mg/dL) | 2,050 | 0,970 | < 0,001 | Se rechaza la normalidad |
Fracción de eyección (%) | 2,499 | 0,995 | < 0,001 | Se rechaza la normalidad |
Estancia (días) | 2,500 | 0,967 | < 0,001 | Se rechaza la normalidad |
Análisis: La prueba de Shapiro-Wilk contrasta la hipótesis nula de que los datos provienen de una distribución normal; su estadístico W se mueve entre 0 y 1, donde un valor cercano a 1 indica gran parecido con la normal, y se rechaza la normalidad cuando el valor p es menor que el nivel de significancia, que fijo en 0,05.
Observo que las doce variables rechazan la normalidad, todas con un valor p inferior a 0,05. Sin embargo, conforme advierte Bruce (4), este resultado debe leerse con cautela, porque con un tamaño de muestra de 2500 la prueba se vuelve extremadamente sensible y detecta hasta las desviaciones más triviales, de modo que el rechazo, por sí solo, no distingue una variable casi normal de una intensamente sesgada, es por eso, que el estadístico W es aquí más informativo que el valor p.
Leído así, el W ordena las variables en las dos familias que ya venía describiendo; donde un grupo conserva un W muy cercano a 1, entre 0,994 y 0,998, en la edad, las dos presiones, el colesterol total, el LDL, el HDL y la fracción de eyección.
considero que estas son, en la práctica, aproximadamente normales, y su rechazo formal obedece más al tamaño de la muestra que a una desviación real.
El otro grupo, resaltado en la tabla, exhibe un W claramente menor:
Estimo que esta comprobación tiene una consecuencia metodológica directa, pues dado que ni siquiera las variables más simétricas superan la prueba formal y que varias son genuinamente de cola larga, lo correcto es describirlas con mediana y rango intercuartílico y, más adelante, comparar los grupos de la Tabla 1 con pruebas no paramétricas, que no exigen el supuesto de normalidad, tal como procede Timmis (2) en su artículo.
vars_panel <- setdiff(names(etiquetas_num), "troponin") # la troponina, con W = 0,012, es caso aparte
datos_dep |>
dplyr::select(dplyr::all_of(vars_panel)) |>
tidyr::pivot_longer(dplyr::everything(), names_to = "variable", values_to = "valor") |>
dplyr::filter(!is.na(.data$valor)) |>
ggplot2::ggplot(ggplot2::aes(sample = valor)) +
ggplot2::stat_qq(colour = "#4F7CAC", alpha = 0.35, size = 0.6) +
ggplot2::stat_qq_line(colour = "#C0392B", linewidth = 0.7) +
ggplot2::facet_wrap(~ variable, scales = "free", ncol = 4,
labeller = ggplot2::labeller(variable = etiquetas_num)) +
ggplot2::labs(
title = "Gráficos cuantil-cuantil de las variables numéricas",
subtitle = "Los puntos sobre la recta roja indican ajuste a la normal; las desviaciones en los extremos revelan colas largas (n = 2500)",
x = "Cuantiles teóricos (distribución normal)", y = "Cuantiles observados"
) +
tema_gg(13)
Análisis: El gráfico cuantil-cuantil enfrenta, para
cada variable, los cuantiles observados de los datos
contra los cuantiles teóricos que tendrían si fueran
perfectamente normales; cuando una variable es normal sus puntos caen
sobre la recta roja, y cuando se aparta, los puntos se despegan de ella,
sobre todo en los extremos, que es donde viven las colas.
Observo que las variables del primer grupo, la edad, las presiones, el colesterol, el LDL, el HDL y la fracción de eyección, siguen la recta casi en toda su extensión, con apenas leves despegues en las puntas; esa fidelidad confirma que son aproximadamente normales y que su rechazo en Shapiro-Wilk es un artefacto del tamaño muestral, no una desviación de fondo.
En cambio, los triglicéridos, el índice de masa corporal y la creatinina se curvan hacia arriba en el extremo derecho, el dibujo característico de una cola larga, mientras la estancia hospitalaria forma una escalera, propia de una variable discreta que cuenta días enteros.
Considero que la coincidencia entre la prueba formal y los gráficos cuantil-cuantil cierra el argumento de manera clara, donde la muestra combina variables casi normales con distribuciones de cola larga, y esa heterogeneidad, sumada a los atípicos clínicos que conservé, justifica de forma definitiva el uso uniforme de medidas robustas y de pruebas no paramétricas en lo que sigue, en línea con Bruce (4) y con el procedimiento de Timmis (2).
Tras describir cada variable por separado, exploro ahora las relaciones entre ellas, el paso que Bruce (4) denomina exploración de dos o más variables.
Examinaré primero la correlación entre las variables numéricas, con un mapa de calor y una matriz de dispersión, y luego la asociación entre las categóricas mediante la V de Cramér; este examen me indica si alguna pareja comparte información, lo que importaría para detectar redundancias o multicolinealidad, antes de llevar las variables a la Tabla 1.
Para medir la fuerza y el sentido de la relación entre dos variables numéricas emplearé el coeficiente de correlación de Spearman, ahora bien, como mis variables no son normales y conservan valores atípicos, esta es la opción robusta frente al coeficiente clásico de Pearson, pues en lugar de operar sobre los valores directos, los reemplaza por sus rangos, esto es, la posición ordenada de cada dato, y sobre esos rangos calcula la correlación, de modo que capta relaciones monótonas sin dejarse arrastrar por los extremos.
El coeficiente de Spearman se define como:
\[\rho = \frac{\sum_{i=1}^{n}\left(R(x_i)-\overline{R(x)}\right)\left(R(y_i)-\overline{R(y)}\right)}{\sqrt{\sum_{i=1}^{n}\left(R(x_i)-\overline{R(x)}\right)^2}\;\sqrt{\sum_{i=1}^{n}\left(R(y_i)-\overline{R(y)}\right)^2}}\]
donde R(x_i) y R(y_i) son los rangos de cada observación, tomando valores entre −1 y 1, donde los extremos indican una relación monótona perfecta y el cero, ausencia de relación.
etiq_corta <- c(
age = "Edad", bmi = "IMC", systolic_bp = "PA sist.", diastolic_bp = "PA diast.",
chol_total = "Colest.", ldl = "LDL", hdl = "HDL", triglycerides = "Triglic.",
troponin = "Troponina", creatinine = "Creatinina",
ejection_fraction = "FE", length_of_stay = "Estancia"
)
# matriz de correlación de Spearman (robusta ante la no normalidad y los atípicos)
corr_sp <- datos_dep |>
dplyr::select(dplyr::all_of(names(etiq_corta))) |>
stats::cor(method = "spearman", use = "pairwise.complete.obs")
# conservo solo el triángulo inferior, sin la diagonal (que siempre vale 1)
corr_sp[upper.tri(corr_sp, diag = TRUE)] <- NA
corr_long <- corr_sp |>
as.data.frame() |>
tibble::rownames_to_column("v1") |>
tidyr::pivot_longer(-v1, names_to = "v2", values_to = "rho") |>
dplyr::filter(!is.na(.data$rho)) |>
dplyr::mutate(
fila = factor(etiq_corta[.data$v1], levels = rev(etiq_corta)),
columna = factor(etiq_corta[.data$v2], levels = etiq_corta),
etiqueta = formatC(.data$rho, format = "f", digits = 2, decimal.mark = ",")
)
ggplot2::ggplot(corr_long, ggplot2::aes(x = columna, y = fila, fill = rho)) +
ggplot2::geom_tile(colour = "white", linewidth = 0.8) +
ggplot2::geom_text(ggplot2::aes(label = etiqueta), size = 3.1, colour = "#1F3B57") +
ggplot2::scale_fill_gradient2(
low = "#2C7FB8", mid = "#FAF3E3", high = "#C0392B",
midpoint = 0, limits = c(-0.10, 0.10), name = "Spearman ρ"
) +
ggplot2::coord_fixed() +
ggplot2::labs(
title = "Correlación de Spearman entre las variables numéricas",
subtitle = "Rojo: relación directa; azul: inversa; la intensidad crece con la fuerza de la correlación (n = 2500)",
x = NULL, y = NULL
) +
tema_gg(12) +
ggplot2::theme(
axis.text.x = ggplot2::element_text(angle = 45, hjust = 1),
panel.grid = ggplot2::element_blank(),
axis.ticks = ggplot2::element_blank()
)Análisis: Este mapa de calor muestra el triángulo inferior de la matriz de correlación de Spearman:
cada cuadro cruza dos variables numéricas, la de su fila con la de su columna, y resume su relación con un número y un color. Omití la diagonal, que siempre valdría 1 porque cada variable se correlaciona perfectamente consigo misma, y la mitad superior, que sería un espejo redundante, de modo que cada par aparece una sola vez.
El número de cada celda es el valor exacto del coeficiente, que va de −1 a 1; el color indica el sentido, rojo para una relación directa y azul para una inversa, y su intensidad crece con la fuerza de la correlación.
Un detalle importante de lectura, es que ajusté la escala de color a un rango de apenas ±0,10, muy estrecho, precisamente para que se distingan los matices entre valores diminutos, de manera que un cuadro con color no significa una correlación fuerte, sino solo perceptible dentro de ese rango mínimo.
Se observa que todos los cuadros se mueven en tonos pálidos y cercanos al beige central, sin que ninguno se acerque al rojo o al azul intensos que marcarían una relación real; el valor más alto en magnitud es de apenas 0,08, entre la edad y la fracción de eyección, seguido de 0,06 entre el LDL y la troponina, mientras el resto oscila entre −0,05 y 0,05, es decir, prácticamente cero.
Considero que este resultado tiene una lectura clara, donde las variables numéricas de esta base son mutuamente independientes y no existe multicolinealidad entre ellas, pues ni siquiera las parejas con mayor color superan el umbral de una correlación débil.
Debo señalar con honestidad que este hallazgo, siendo limpio para el modelado, resulta atípico para datos clínicos reales, en los que cabría esperar correlaciones reconocibles,por ejemplo, entre la edad y la creatinina, o entre el índice de masa corporal y los triglicéridos, que aquí no aparecen; estimo que ello refleja la forma en que se construyó la base, con las variables generadas de manera independiente, y lo dejo anotado como una característica del conjunto de datos que retomaré en las limitaciones.
Para mi propósito inmediato de la Tabla 1, la consecuencia favorable es que ninguna variable numérica duplica la información de otra.
Complemento el mapa de calor con una matriz de dispersión sobre un conjunto representativo de variables clínicas, que permite inspeccionar la forma de cada relación, y no solo su número resumen.
sub_pair <- c("age", "bmi", "systolic_bp", "ldl", "creatinine", "ejection_fraction")
datos_dep |>
dplyr::select(dplyr::all_of(sub_pair)) |>
dplyr::rename_with(~ etiq_corta[.x]) |>
GGally::ggpairs(
lower = list(continuous = GGally::wrap("points", colour = "#4F7CAC",
alpha = 0.18, size = 0.5)),
diag = list(continuous = GGally::wrap("densityDiag", fill = "#4F7CAC",
alpha = 0.5, colour = "#16314A")),
upper = list(continuous = GGally::wrap("cor", size = 3, colour = "#1F3B57")),
title = "Matriz de dispersión de variables clínicas representativas (n = 2500)"
) +
tema_gg(11)
Análisis: Esta matriz de dispersión cruza seis
variables clínicas representativas y las leo en tres zonas, así:
En la diagonal aparece la curva de densidad de cada variable, que reproduce la forma que ya describí, donde la edad y el LDL se ven acampanados y casi simétricos, mientras el índice de masa corporal, la creatinina y la presión sistólica muestran su cola larga hacia la derecha, y la fracción de eyección se inclina levemente hacia los valores bajos.
En la mitad inferior se despliegan los diagramas de dispersión por pares, donde cada punto es un paciente ubicado según los valores de las dos variables que se cruzan.
En la mitad superior, en espejo de cada diagrama, se imprime el coeficiente de correlación de ese par. Es la herramienta que Bruce (4) propone para inspeccionar varias relaciones a la vez, combinando la forma individual, la relación por pares y su resumen numérico en una sola vista.
Observo que cada nube de puntos es difusa y redondeada, sin orientación, pues no se inclina en diagonal ascendente ni descendente, sino que se extiende como una mancha sin dirección, que es precisamente el dibujo de la ausencia de relación.
Los coeficientes de la mitad superior lo confirman, todos cercanos a cero, encabezados por el de la edad con la fracción de eyección (0,078) y la edad con la creatinina (0,047).
Debo aclarar un detalle, y es que algunos coeficientes llevan asteriscos, que señalan significancia estadística, pero esa significancia no equivale a fuerza, pues con una muestra tan grande como 2500 hasta una correlación insignificante de 0,078 alcanza a ser estadísticamente significativa sin tener ninguna relevancia práctica; por eso me fijo más en la magnitud,y no en los asteriscos.
Considero que esta es la confirmación visual de lo que el mapa de calor cuantificó, ya que entre estas variables no hay estructura conjunta, de modo que cada una aporta información propia a la descripción de la muestra.
Ahora para las variables categóricas la correlación no aplica; en su lugar mido la asociación con la V de Cramér, que Bruce (4) ubica en el examen de dos variables categóricas, el cual se construye a partir del estadístico chi cuadrado de la tabla de contingencia y se normaliza para moverse entre 0 y 1:
\[V = \sqrt{\dfrac{\chi^{2}}{n \,\cdot\, \min\!\left(r-1,\; k-1\right)}}\]
donde el chi cuadrado mide la discrepancia entre las frecuencias observadas y las esperadas bajo independencia, n es el número total de observaciones, y r y k son el número de filas y columnas de la tabla.
Un valor cercano a 0 indica independencia y uno cercano a 1, asociación fuerte.
cat_vars <- c("sex", "diabetes", "hypertension", "heart_failure", "smoking_status",
"treat_statin", "treat_beta_blocker", "treat_acei",
"mortality_30d", "readmission_30d")
etiq_cat <- c(
sex = "Sexo", diabetes = "Diabetes", hypertension = "HTA",
heart_failure = "Insuf. card.", smoking_status = "Tabaquismo",
treat_statin = "Estatina", treat_beta_blocker = "Betabloq.",
treat_acei = "IECA/ARA-II", mortality_30d = "Mortalidad", readmission_30d = "Reingreso"
)
v_cramer <- function(a, b) DescTools::CramerV(datos_dep[[a]], datos_dep[[b]])
cramer_mat <- outer(cat_vars, cat_vars, Vectorize(v_cramer))
dimnames(cramer_mat) <- list(cat_vars, cat_vars)
# conservo solo el triángulo inferior, sin la diagonal (que siempre vale 1)
cramer_mat[upper.tri(cramer_mat, diag = TRUE)] <- NA
cramer_long <- cramer_mat |>
as.data.frame() |>
tibble::rownames_to_column("v1") |>
tidyr::pivot_longer(-v1, names_to = "v2", values_to = "V") |>
dplyr::filter(!is.na(.data$V)) |>
dplyr::mutate(
fila = factor(etiq_cat[.data$v1], levels = rev(etiq_cat)),
columna = factor(etiq_cat[.data$v2], levels = etiq_cat),
etiqueta = formatC(.data$V, format = "f", digits = 2, decimal.mark = ",")
)
ggplot2::ggplot(cramer_long, ggplot2::aes(x = columna, y = fila, fill = V)) +
ggplot2::geom_tile(colour = "white", linewidth = 0.8) +
ggplot2::geom_text(ggplot2::aes(label = etiqueta), size = 3.1, colour = "#1F3B57") +
ggplot2::scale_fill_gradientn(
colours = c("#FAF3E3", "#FDD49E", "#FDBB84", "#E8853C", "#B35806"), # cálido secuencial
limits = c(0, 0.10),
oob = scales::squish, # valores > 0,10 se llevan al extremo cálido
name = "V de Cramér"
) +
ggplot2::coord_fixed() +
ggplot2::labs(
title = "Asociación entre las variables categóricas (V de Cramér)",
subtitle = "Cuanto más intenso el tono, mayor la asociación; escala ajustada al rango observado (0–0,10) para resaltar el contraste (n = 2500)",
x = NULL, y = NULL
) +
tema_gg(12) +
ggplot2::theme(
axis.text.x = ggplot2::element_text(angle = 45, hjust = 1),
panel.grid = ggplot2::element_blank(),
axis.ticks = ggplot2::element_blank()
)Para interpretar lo que el mapa me muestra, primero preciso qué significa que dos variables categóricas estén asociadas, ya que dos variables están asociadas cuando conocer el valor de una cambia lo que cabe esperar de la otra; por ejemplo, si entre los pacientes con diabetes la proporción de hipertensos fuera mucho mayor que entre los no diabéticos, diría que la diabetes y la hipertensión se asocian.
Cuando, por el contrario, la distribución de una variable es la misma sin importar el valor de la otra, las dos son independientes, esto es, saber una no aporta información sobre la otra.
La V de Cramér pone número a esa idea, pues vale 0 cuando hay independencia completa y 1 cuando la asociación es total, y por convención un valor inferior a 0,10 se interpreta como una asociación tan pequeña que se considera despreciable, porque corresponde a diferencias mínimas de frecuencia entre las categorías.
Con esa definición reviso los cuadros uno a uno y observo que todos, sin excepción, se quedan en tonos pálidos cercanos al beige. El más teñido es el cruce entre la diabetes y el tabaquismo, con una V de 0,06, seguido de unos pocos en 0,02 o 0,03; ninguno alcanza el 0,10 que marcaría el inicio de una asociación digna de mención, y la mayoría se aproxima a cero.
Esto significa que, en esta base, conocer si un paciente tiene una comorbilidad, recibe un tratamiento o sufre un desenlace no permite anticipar casi nada sobre las demás variables categóricas, de modo que todas se comportan como independientes entre sí.
¿Por qué este resultado merece una explicación y no solo una constatación? Porque clínicamente se podría esperar lo contrario,por ejemplo, en una cohorte real de síndrome coronario agudo varias de estas categorías deberían agruparse, dado que el artículo de Kraler (1) describe que las comorbilidades tienden a coexistir —la diabetes y la hipertensión suelen presentarse juntas— y que los tratamientos siguen a sus indicaciones, mientras Timmis (2) sitúa la hipertensión y el tabaquismo entre los principales determinantes de la mortalidad por esta enfermedad.
Que el mapa no recoja ninguna de esas asociaciones esperables me indica que la razón no es clínica, sino del propio dato, ya que las variables de esta base se generaron de forma independiente, sin la covariación que el fenómeno real impondría.
Conviene recordar, siguiendo a Bruce (4), que una medida de asociación describe solo si dos variables se mueven juntas, y no la causa que las une; aquí esa medida es nula de manera sistemática, así que no aparece ni siquiera el primer indicio de relación.
Lo consigno con transparencia y lo reservo para las limitaciones del trabajo, conforme a la recomendación de Van den Broeck (3) de documentar las características del dato que condicionan la interpretación.
Hay, por último, una observación que quiero subrayar porque condiciona la fase siguiente, pues cuando reviso de manera específica las dos filas de los desenlaces, la mortalidad y el reingreso a 30 días, las encuentro tan pálidas como el resto del mapa, con valores de V que no superan 0,02 frente a ninguna otra variable.
Esto significa que la frecuencia de cada característica es casi la misma entre quienes fallecen y quienes sobreviven, lo que me permite prever que en la Tabla 1 estratificada por mortalidad las diferencias entre ambos grupos serán pequeñas.
No lo afirmo para descartar de antemano esos contrastes, sino para examinarlos después con prudencia, teniendo presente además que con solo 224 fallecidos la capacidad de la muestra para detectar diferencias reales y pequeñas —lo que en estadística se denomina potencia— será limitada.
Hasta aquí describí cada variable sobre el total de la muestra; ahora doy el paso que conduce a la Tabla 1. Divido la cohorte en dos grupos según el desenlace primario:
Realizaré una comparación de sus características de ingreso, porque la pregunta de fondo de todo estudio con desenlace es si quienes mueren difieren, en algo medible y temprano, de quienes sobreviven. Esa comparación es el corazón de la Tabla 1, y la exploro primero de forma visual para entender qué esperar de los contrastes formales.
grupos_col <- c("No" = "#4F7CAC", "Sí" = "#C0392B")
vars_comp <- setdiff(names(etiquetas_num), "troponin") # troponina aparte por su escala
datos_dep |>
dplyr::filter(!is.na(.data$mortality_30d)) |>
dplyr::select(mortality_30d, dplyr::all_of(vars_comp)) |>
tidyr::pivot_longer(-mortality_30d, names_to = "variable", values_to = "valor") |>
dplyr::filter(!is.na(.data$valor)) |>
ggplot2::ggplot(ggplot2::aes(x = mortality_30d, y = valor, fill = mortality_30d)) +
ggplot2::geom_boxplot(width = 0.6, outlier.alpha = 0.15, outlier.size = 0.7) +
ggplot2::facet_wrap(~ variable, scales = "free_y", ncol = 4,
labeller = ggplot2::labeller(variable = etiquetas_num)) +
ggplot2::scale_fill_manual(values = grupos_col, name = "¿Falleció a 30 días?") +
ggplot2::labs(
title = "Variables numéricas según la mortalidad a 30 días",
subtitle = "Cada par de cajas compara a quienes sobrevivieron (azul) y a quienes fallecieron (rojo); la troponina la presento aparte (n = 2500)",
x = NULL, y = NULL
) +
tema_gg(13) +
ggplot2::theme(legend.position = "top",
axis.text.x = ggplot2::element_text(size = 10))
Análisis: Esta figura es un panel de diagramas de caja
comparativos, donde en cada faceta, el eje horizontal separa los
dos grupos del desenlace —“No” para los 2276 supervivientes y “Sí” para
los 224 fallecidos— y el eje vertical mide la variable
de esa faceta.
Dentro de cada grupo, la caja abarca el rango intercuartílico, la línea interior marca la mediana, los bigotes alcanzan los valores no atípicos y los puntos son los extremos.
Coloreé los grupos, en azul para quienes sobreviven, rojo para quienes fallecen, porque aquí sí comparo, y el color permite seguir cada grupo de un vistazo a lo largo de las once variables.
La idea es que si una característica influyera en la mortalidad, esperaría ver las dos cajas de esa variable desplazadas una respecto de la otra; si no influye, las cajas se solaparían.
Antes de leerlas aclaro cómo decido si una diferencia entre dos cajas es real o fruto del azar, para ello, aplicaré la prueba de Mann-Whitney, también llamada de Wilcoxon para muestras independientes, que en lugar de comparar las medias de los dos grupos ordena todos los valores y compara sus rangos, es decir, sus posiciones.
La elijo porque las variables no son normales, y esta prueba, a diferencia de la t de Student, no exige ese supuesto, en coherencia con el enfoque robusto que vengo aplicando.
Su resultado es un valor p, que, siguiendo a Bruce (4), expresa la probabilidad de observar una diferencia al menos tan grande como la encontrada si en verdad los dos grupos no difirieran; cuando ese valor p es menor que 0,05, considero que la diferencia es estadísticamente significativa.
Con esa regla recorro el panel y encuentro un patrón nítido: - solo una variable separa sus cajas, la edad, pues en ella, la caja de los fallecidos se sitúa visiblemente más arriba, con una mediana de 71,5 años frente a 65 en los supervivientes, y la prueba confirma que la diferencia es significativa (p<0,001).
Considero que este hallazgo es clínicamente coherente y no casual, porque tanto el artículo de Kraler (1) como el de Timmis (2) sitúan la edad como uno de los principales determinantes del pronóstico en el síndrome coronario agudo, hasta el punto de que es la variable que más pesa en los puntajes de riesgo como el GRACE; que mis datos reproduzcan precisamente esa señal, y no otra, le da verosimilitud.
En todas las demás variables, en cambio, las dos cajas se solapan casi por completo, y las pruebas lo confirman, pues ni el índice de masa corporal (p=0,178), ni las presiones, ni el perfil lipídico, ni la creatinina, ni la fracción de eyección (p=0,188) muestran diferencias significativas.
En cuanto a la troponina, que examiné aparte, tampoco las muestra (p=0,236), por lo cual, debo detenerme en esto porque es contraintuitivo, ya que clínicamente esperaría que una troponina más alta o una fracción de eyección más baja, como marcadores de daño miocárdicoque el artículo de Kraler (1) liga directamente al riesgo de muerte, se concentraran en los fallecidos, y sin embargo no es así.
Interpreto que esta ausencia no es un hallazgo sobre la enfermedad, sino la misma característica del dato que el bivariado ya reveló, pues las variables se generaron de forma independiente del desenlace.
Anoto, por último, una cautela estadística que Bruce (4) subraya en su capítulo sobre significancia, pues al comparar muchas variables a la vez aumenta la probabilidad de que alguna resulte significativa por azar, de modo que el peso de la edad reside no solo en su valor p, sino en que su diferencia es amplia y clínicamente esperable.
positivo <- c(sex = "Masculino", diabetes = "Sí", hypertension = "Sí",
heart_failure = "Sí", treat_statin = "Sí", treat_beta_blocker = "Sí",
treat_acei = "Sí", readmission_30d = "Sí")
etiq_pos <- c(sex = "Sexo masculino", diabetes = "Diabetes", hypertension = "HTA",
heart_failure = "Insuf. cardiaca", treat_statin = "Estatina",
treat_beta_blocker = "Betabloqueador", treat_acei = "IECA/ARA-II",
readmission_30d = "Reingreso 30d")
prop_bin <- purrr::map_dfr(names(positivo), function(v) {
datos_dep |>
dplyr::filter(!is.na(.data[[v]]), !is.na(.data$mortality_30d)) |>
dplyr::group_by(mortality_30d) |>
dplyr::summarise(prop = mean(.data[[v]] == positivo[[v]]) * 100, .groups = "drop") |>
dplyr::mutate(variable = etiq_pos[[v]])
})
ggplot2::ggplot(prop_bin,
ggplot2::aes(x = reorder(variable, -prop), y = prop, fill = mortality_30d)) +
ggplot2::geom_col(position = ggplot2::position_dodge(0.75), width = 0.7) +
ggplot2::geom_text(ggplot2::aes(label = formatC(prop, format = "f", digits = 1, decimal.mark = ",")),
position = ggplot2::position_dodge(0.75), vjust = -0.4,
size = 3.2, colour = "#1F3B57") +
ggplot2::scale_fill_manual(values = grupos_col, name = "¿Falleció a 30 días?") +
ggplot2::scale_y_continuous(expand = ggplot2::expansion(mult = c(0, 0.15))) +
ggplot2::labs(
title = "Características categóricas según la mortalidad a 30 días",
subtitle = "Porcentaje de pacientes con cada característica en supervivientes (azul) y fallecidos (rojo) (n = 2500)",
x = NULL, y = "Porcentaje dentro del grupo (%)"
) +
tema_gg(12) +
ggplot2::theme(legend.position = "top",
axis.text.x = ggplot2::element_text(angle = 25, hjust = 1),
panel.grid.major.x = ggplot2::element_blank())
Análisis: Para las variables categóricas la comparación
la cambié de forma, pues en lugar de cajas, comparé
proporciones. Esta figura es un gráfico de barras
agrupadas en el que, para cada característica del eje horizontal, dos
barras contiguas muestran qué porcentaje de pacientes la presenta dentro
de cada grupo del desenlace, azul en los supervivientes y rojo
en los fallecidos.
la altura de cada barra es ese porcentaje, y la lógica es la misma que con las numéricas, pues si una característica se asociara a la mortalidad, su barra roja y su barra azul tendrían alturas distintas; si no, quedarían a la par.
La prueba que corresponde aquí es la de chi-cuadrado, que compara las frecuencias observadas en cada categoría con las que se esperarían si la característica y el desenlace fueran independientes, y resume esa discrepancia en un valor p con el mismo significado que antes.
Conviene precisar una condición de validez que Bruce (4) menciona, dado que la prueba chi-cuadrado es fiable cuando las frecuencias esperadas en cada celda son suficientes, y en mi caso, pese a que los fallecidos son solo 224, todas las frecuencias esperadas superan el umbral de 5, de modo que la prueba es aplicable sin necesidad de alternativas exactas.
Al observar la figura encuentro que, en todas las características, la barra roja y la azul alcanzan prácticamente la misma altura. La proporción de hombres es casi idéntica (53,1% en fallecidos frente a 53,6% en supervivientes), igual que la de diabetes (27,7% frente a 30,2%), hipertensión (63,4% frente a 59,9%), insuficiencia cardiaca, tratamientos y reingreso; ninguna de estas diferencias resultó significativa, con valores p que van de 0,34 a 0,94.
Interpreto que las comorbilidades, los tratamientos y el sexo se distribuyen por igual entre quienes mueren y quienes sobreviven, lo que confirma a nivel de proporciones la independencia que el mapa de V de Cramér ya había anticipado.
Reúno entonces lo que esta comparación me enseña de cara a la Tabla 1, pues de todas las características exploradas, solo la edad se asocia a la mortalidad, mientras el resto —numéricas y categóricas— se reparte de forma equilibrada entre los dos grupos.
Preveo, por tanto, que la Tabla 1 estratificada mostrará una única diferencia significativa, la de la edad, y que los demás contrastes serán no significativos; lo anticipo no como una limitación del análisis, sino como un resultado en sí mismo, que interpretaré a la luz de la forma en que al parecer se construyó esta base.
La Tabla 1 es, por convención, la primera tabla de todo estudio clínico y epidemiológico, donde se condensa en un único cuadro las características basales de la muestra, de modo que cualquier lector sepa a quién se estudió antes de entrar en los análisis de desenlace.
La presento en dos versiones complementarias:
La primera describe la cohorte completa y consolida en una sola vista todo lo que la exploración fue mostrando variable por variable.
La segunda la estratifica por el desenlace primario —la mortalidad a 30 días— para comparar a quienes fallecieron con quienes sobrevivieron y poner a prueba si difieren en algo.
Construyo ambas con la librería gtsummary, que aplica de manera automática los resúmenes que he venido justificando a lo largo del documento, teniendo en cuenta la mediana con su rango intercuartílico para las variables numéricas, por su robustez frente a los atípicos y las distribuciones de cola larga, y la frecuencia con su porcentaje para las categóricas.
vars_tabla1 <- datos_dep |>
dplyr::select(
age, sex, bmi, systolic_bp, diastolic_bp, chol_total, ldl, hdl, triglycerides,
troponin, creatinine, ejection_fraction, diabetes, hypertension, heart_failure,
smoking_status, treat_statin, treat_beta_blocker, treat_acei, length_of_stay,
mortality_30d, readmission_30d
)
etiquetas_t1 <- list(
age ~ "Edad (años)", sex ~ "Sexo", bmi ~ "IMC (kg/m²)",
systolic_bp ~ "PA sistólica (mmHg)", diastolic_bp ~ "PA diastólica (mmHg)",
chol_total ~ "Colesterol total (mg/dL)", ldl ~ "LDL (mg/dL)", hdl ~ "HDL (mg/dL)",
triglycerides ~ "Triglicéridos (mg/dL)", troponin ~ "Troponina (ng/mL)",
creatinine ~ "Creatinina (mg/dL)", ejection_fraction ~ "Fracción de eyección (%)",
diabetes ~ "Diabetes", hypertension ~ "Hipertensión arterial",
heart_failure ~ "Insuficiencia cardiaca", smoking_status ~ "Tabaquismo",
treat_statin ~ "Estatina al egreso", treat_beta_blocker ~ "Betabloqueador al egreso",
treat_acei ~ "IECA/ARA-II al egreso", length_of_stay ~ "Estancia (días)",
mortality_30d ~ "Mortalidad a 30 días", readmission_30d ~ "Reingreso a 30 días"
)
vars_tabla1 |>
gtsummary::tbl_summary(
statistic = list(
gtsummary::all_continuous() ~ "{median} [{p25} \u2013 {p75}]",
gtsummary::all_categorical() ~ "{n} ({p}%)"
),
digits = list(
gtsummary::all_continuous() ~ 0,
bmi ~ 1, troponin ~ 2, creatinine ~ 2, ejection_fraction ~ 1
),
label = etiquetas_t1,
missing = "ifany",
missing_text = "Faltantes (n)"
) |>
gtsummary::bold_labels() |>
gtsummary::modify_header(label ~ "**Característica**") |>
gtsummary::modify_caption("**Tabla 1. Características basales de la cohorte de síndrome coronario agudo (n = 2500)**")| Característica | N = 2.5001 |
|---|---|
| Edad (años) | 65 [57 – 74] |
| Faltantes (n) | 2 |
| Sexo | |
| Femenino | 1.160 (46%) |
| Masculino | 1.340 (54%) |
| IMC (kg/m²) | 27,8 [24,4 – 31,3] |
| Faltantes (n) | 11 |
| PA sistólica (mmHg) | 130 [117 – 144] |
| Faltantes (n) | 1 |
| PA diastólica (mmHg) | 80 [73 – 86] |
| Colesterol total (mg/dL) | 201 [173 – 228] |
| Faltantes (n) | 450 |
| LDL (mg/dL) | 121 [100 – 141] |
| Faltantes (n) | 10 |
| HDL (mg/dL) | 50 [40 – 61] |
| Faltantes (n) | 448 |
| Triglicéridos (mg/dL) | 149 [121 – 182] |
| Troponina (ng/mL) | 0,05 [0,03 – 0,09] |
| Faltantes (n) | 11 |
| Creatinina (mg/dL) | 0,99 [0,83 – 1,19] |
| Faltantes (n) | 450 |
| Fracción de eyección (%) | 54,9 [47,8 – 61,4] |
| Faltantes (n) | 1 |
| Diabetes | |
| No | 1.751 (70%) |
| Sí | 749 (30%) |
| Hipertensión arterial | |
| No | 994 (40%) |
| Sí | 1.506 (60%) |
| Insuficiencia cardiaca | |
| No | 1.863 (75%) |
| Sí | 637 (25%) |
| Tabaquismo | |
| Nunca | 936 (46%) |
| Exfumador | 712 (35%) |
| Fumador actual | 403 (20%) |
| Faltantes (n) | 449 |
| Estatina al egreso | |
| No | 734 (29%) |
| Sí | 1.766 (71%) |
| Betabloqueador al egreso | |
| No | 1.038 (42%) |
| Sí | 1.462 (58%) |
| IECA/ARA-II al egreso | |
| No | 1.248 (50%) |
| Sí | 1.252 (50%) |
| Estancia (días) | 6 [4 – 7] |
| Mortalidad a 30 días | |
| No | 2.276 (91%) |
| Sí | 224 (9,0%) |
| Reingreso a 30 días | |
| No | 2.108 (84%) |
| Sí | 392 (16%) |
| 1 Mediana [Q1 – Q3]; n (%) | |
Análisis: Antes de recorrer la tabla preciso cómo leer cada celda, porque cada tipo de variable usa un resumen distinto:
En las variables numéricas el formato es la mediana seguida, entre corchetes, de su rango intercuartílico, es decir, el valor central que parte la muestra en dos mitades y, a su lado, los dos valores que delimitan al 50% de pacientes más típico, siendo el primer cuartil (P25), que deja por debajo a una cuarta parte, y el tercero (P75), que deja por debajo a tres cuartas partes.
En las variables categóricas, en cambio, cada celda muestra el número de pacientes seguido, entre paréntesis, de su porcentaje dentro de la cohorte.
Con esa estructura, comprendo la tabla por bloques clínicos, así:
El sexo muestra 1340 hombres, que representan el 53,6% de la cohorte, frente a 1160 mujeres (46,4%), un predominio masculino esperable que tanto Kraler (1) como Timmis (2) describen como rasgo característico de esta enfermedad.
El índice de masa corporal tiene una mediana de 27,8 kg/m², con un rango intercuartílico de 24,4 a 31,3; esa mediana cae de lleno en el rango de sobrepeso, de modo que el paciente típico de la cohorte excede el peso saludable, en línea con la obesidad que Kraler (1) sitúa entre los factores de riesgo.
En el perfil lipídico, el colesterol total tiene una mediana de 201 mg/dL (173 a 228) y declara 450 faltantes, el LDL una mediana de 121 mg/dL (100 a 141), el HDL de 50 mg/dL (40 a 61) con 448 faltantes, y los triglicéridos de 149 mg/dL (121 a 182).
Considero que estos valores, ligeramente por encima de lo óptimo en el caso del colesterol y el LDL, dibujan el perfil dislipidémico que el artículo de Kraler (1) coloca en el centro de la enfermedad, con un papel destacado para el LDL.
la creatinina muestra una mediana de 0,99 mg/dL (0,83 a 1,19) con 450 faltantes, compatible con una función renal conservada en la mayoría; y la fracción de eyección una mediana de 54,9% (47,8 a 61,4), que se sitúa en el rango preservado, aunque su cuartil inferior alcanza el territorio de la disfunción leve.
El tabaquismo merece una lectura aparte por sus faltantes, pues la tabla me muestra 936 pacientes que nunca fumaron (45,6%), 712 exfumadores (34,7%) y 403 fumadores actuales (19,6%), además de 449 faltantes; esto es importante, porque estos porcentajes se calcularon sobre los 2051 pacientes con el antecedente registrado, no sobre los 2500 totales, razón por la cual difieren de los que reporté en el diagnóstico de calidad, donde usé el denominador completo.
En el bloque de tratamiento al egreso, observo una adherencia apreciable a la prevención secundaria, con estatina en 1766 pacientes (70,6%), betabloqueador en 1462 (58,5%) e IECA o ARA-II en 1252 (50,1%); considero que el predominio de la estatina concuerda con el peso que Kraler (1) atribuye a la reducción intensiva del LDL.
Por último, en el bloque de desenlaces, la mortalidad a 30 días ocurre en 224 pacientes (9,0%) y el reingreso en 392 (15,7%), eventos infrecuentes que dan cuenta de la gravedad del cuadro sin afectar a la mayoría.
Concluyo que esta tabla global consolida, en una sola vista y con sus medidas robustas, el perfil de una cohorte mayor, masculina y de alto riesgo cardiovascular que la exploración me ha mostrado variable a variable.
datos_dep |>
dplyr::select(
age, sex, bmi, systolic_bp, diastolic_bp, chol_total, ldl, hdl, triglycerides,
troponin, creatinine, ejection_fraction, diabetes, hypertension, heart_failure,
smoking_status, treat_statin, treat_beta_blocker, treat_acei, length_of_stay,
readmission_30d, mortality_30d
) |>
gtsummary::tbl_summary(
by = mortality_30d,
statistic = list(
gtsummary::all_continuous() ~ "{median} [{p25} \u2013 {p75}]",
gtsummary::all_categorical() ~ "{n} ({p}%)"
),
digits = list(
gtsummary::all_continuous() ~ 0,
bmi ~ 1, troponin ~ 2, creatinine ~ 2, ejection_fraction ~ 1
),
label = list(
age ~ "Edad (años)", sex ~ "Sexo", bmi ~ "IMC (kg/m²)",
systolic_bp ~ "PA sistólica (mmHg)", diastolic_bp ~ "PA diastólica (mmHg)",
chol_total ~ "Colesterol total (mg/dL)", ldl ~ "LDL (mg/dL)", hdl ~ "HDL (mg/dL)",
triglycerides ~ "Triglicéridos (mg/dL)", troponin ~ "Troponina (ng/mL)",
creatinine ~ "Creatinina (mg/dL)", ejection_fraction ~ "Fracción de eyección (%)",
diabetes ~ "Diabetes", hypertension ~ "Hipertensión arterial",
heart_failure ~ "Insuficiencia cardiaca", smoking_status ~ "Tabaquismo",
treat_statin ~ "Estatina al egreso", treat_beta_blocker ~ "Betabloqueador al egreso",
treat_acei ~ "IECA/ARA-II al egreso", length_of_stay ~ "Estancia (días)",
readmission_30d ~ "Reingreso a 30 días"
),
missing = "ifany",
missing_text = "Faltantes (n)"
) |>
gtsummary::add_overall(col_label = "**Total**, N = 2.500") |>
gtsummary::add_p(
test = list(
gtsummary::all_continuous() ~ "wilcox.test",
gtsummary::all_categorical() ~ "chisq.test"
),
pvalue_fun = function(x) gtsummary::style_pvalue(x, digits = 3)
) |>
gtsummary::bold_labels() |>
gtsummary::bold_p(t = 0.05) |>
gtsummary::modify_header(label ~ "**Característica**") |>
gtsummary::modify_spanning_header(
c("stat_1", "stat_2") ~ "**Mortalidad a 30 días**"
) |>
gtsummary::modify_caption("**Tabla 1. Características basales según la mortalidad a 30 días (n = 2500)**")| Característica | Total, N = 2.5001 |
Mortalidad a 30 días
|
p-valor2 | |
|---|---|---|---|---|
| No N = 2.2761 |
Sí N = 2241 |
|||
| Edad (años) | 65 [57 – 74] | 65 [56 – 73] | 72 [62 – 80] | <0,001 |
| Faltantes (n) | 2 | 2 | 0 | |
| Sexo | 0,937 | |||
| Femenino | 1.160 (46%) | 1.055 (46%) | 105 (47%) | |
| Masculino | 1.340 (54%) | 1.221 (54%) | 119 (53%) | |
| IMC (kg/m²) | 27,8 [24,4 – 31,3] | 27,8 [24,4 – 31,3] | 28,5 [24,7 – 31,5] | 0,178 |
| Faltantes (n) | 11 | 11 | 0 | |
| PA sistólica (mmHg) | 130 [117 – 144] | 130 [117 – 144] | 130 [116 – 145] | 0,846 |
| Faltantes (n) | 1 | 1 | 0 | |
| PA diastólica (mmHg) | 80 [73 – 86] | 80 [73 – 86] | 79 [72 – 87] | 0,630 |
| Colesterol total (mg/dL) | 201 [173 – 228] | 200 [173 – 227] | 205 [173 – 236] | 0,092 |
| Faltantes (n) | 450 | 400 | 50 | |
| LDL (mg/dL) | 121 [100 – 141] | 121 [100 – 141] | 121 [104 – 139] | 0,458 |
| Faltantes (n) | 10 | 10 | 0 | |
| HDL (mg/dL) | 50 [40 – 61] | 50 [40 – 61] | 50 [41 – 59] | 0,658 |
| Faltantes (n) | 448 | 418 | 30 | |
| Triglicéridos (mg/dL) | 149 [121 – 182] | 149 [122 – 182] | 149 [119 – 189] | 0,981 |
| Troponina (ng/mL) | 0,05 [0,03 – 0,09] | 0,05 [0,03 – 0,09] | 0,05 [0,03 – 0,08] | 0,236 |
| Faltantes (n) | 11 | 10 | 1 | |
| Creatinina (mg/dL) | 0,99 [0,83 – 1,19] | 1,00 [0,83 – 1,19] | 0,96 [0,84 – 1,15] | 0,324 |
| Faltantes (n) | 450 | 413 | 37 | |
| Fracción de eyección (%) | 54,9 [47,8 – 61,4] | 54,8 [47,8 – 61,3] | 55,7 [48,5 – 62,8] | 0,188 |
| Faltantes (n) | 1 | 1 | 0 | |
| Diabetes | 0,481 | |||
| No | 1.751 (70%) | 1.589 (70%) | 162 (72%) | |
| Sí | 749 (30%) | 687 (30%) | 62 (28%) | |
| Hipertensión arterial | 0,348 | |||
| No | 994 (40%) | 912 (40%) | 82 (37%) | |
| Sí | 1.506 (60%) | 1.364 (60%) | 142 (63%) | |
| Insuficiencia cardiaca | 0,819 | |||
| No | 1.863 (75%) | 1.698 (75%) | 165 (74%) | |
| Sí | 637 (25%) | 578 (25%) | 59 (26%) | |
| Tabaquismo | 0,991 | |||
| Nunca | 936 (46%) | 848 (46%) | 88 (46%) | |
| Exfumador | 712 (35%) | 646 (35%) | 66 (35%) | |
| Fumador actual | 403 (20%) | 366 (20%) | 37 (19%) | |
| Faltantes (n) | 449 | 416 | 33 | |
| Estatina al egreso | 0,335 | |||
| No | 734 (29%) | 675 (30%) | 59 (26%) | |
| Sí | 1.766 (71%) | 1.601 (70%) | 165 (74%) | |
| Betabloqueador al egreso | 0,944 | |||
| No | 1.038 (42%) | 944 (41%) | 94 (42%) | |
| Sí | 1.462 (58%) | 1.332 (59%) | 130 (58%) | |
| IECA/ARA-II al egreso | 0,814 | |||
| No | 1.248 (50%) | 1.134 (50%) | 114 (51%) | |
| Sí | 1.252 (50%) | 1.142 (50%) | 110 (49%) | |
| Estancia (días) | 6 [4 – 7] | 6 [4 – 7] | 6 [5 – 7] | 0,865 |
| Reingreso a 30 días | 0,515 | |||
| No | 2.108 (84%) | 1.923 (84%) | 185 (83%) | |
| Sí | 392 (16%) | 353 (16%) | 39 (17%) | |
| 1 Mediana [Q1 – Q3]; n (%) | ||||
| 2 Prueba de la suma de rangos de Wilcoxon; prueba chi cuadrado de independencia | ||||
Análisis: Esta versión estratificada añade, tras la columna del total, dos columnas con los mismos resúmenes calculados por separado en los 2276 supervivientes y los 224 fallecidos, y una columna final con el valor p de cada contraste.
Recorriéndola variable por variable, la edad es la única que difiere, pues su mediana es de 71,5 años en los fallecidos frente a 65 en los supervivientes, una diferencia de más de seis años que la prueba de Mann-Whitney confirma como significativa (p<0,001).
En todas las demás numéricas las medianas son casi idénticas entre grupos y sus valores p quedan muy por encima de 0,05, por ejemplo:
En las categóricas el patrón se repite, con proporciones equilibradas y ningún contraste significativo: el sexo (p=0,937), la diabetes (27,7% frente a 30,2%; p=0,481), la hipertensión (63,4% frente a 59,9%; p=0,348), la insuficiencia cardiaca (p=0,819), el tabaquismo (p=0,991), los tres tratamientos (p de 0,34 a 0,94) y el reingreso (p=0,515).
Debo, no obstante, hacer una precisión metodológica sobre el uso del valor p en esta tabla, pues existe un debate establecido en epidemiología acerca de incluir pruebas de hipótesis en la Tabla 1, y la objeción es válida sobre todo en los ensayos clínicos aleatorizados, donde cualquier diferencia basal es por construcción producto del azar y contrastarla carece de sentido.
En un estudio observacional como este, en cambio, no hubo aleatorización, de modo que las diferencias entre fallecidos y supervivientes pueden ser reales y su contraste resulta legítimo.
Aun así, asumo la limitación de fondo que la literatura señala, pues el valor p confunde la magnitud de una diferencia con el tamaño de la muestra, pues con una n grande una diferencia trivial puede salir significativa, y con una n pequeña (o con muchos faltantes) una diferencia relevante puede pasar inadvertida.
Por esa razón no leo el valor p de forma aislada, y a continuación lo contrasto con una medida que no depende del tamaño muestral, la diferencia estandarizada.
# requiero el paquete smd (y cardx) para el cálculo de la diferencia estandarizada
if (!requireNamespace("smd", quietly = TRUE)) install.packages("smd")
datos_dep |>
dplyr::select(
age, sex, bmi, systolic_bp, diastolic_bp, chol_total, ldl, hdl, triglycerides,
troponin, creatinine, ejection_fraction, diabetes, hypertension, heart_failure,
smoking_status, treat_statin, treat_beta_blocker, treat_acei, length_of_stay,
readmission_30d, mortality_30d
) |>
gtsummary::tbl_summary(
by = mortality_30d,
statistic = list(
gtsummary::all_continuous() ~ "{median} [{p25} \u2013 {p75}]",
gtsummary::all_categorical() ~ "{n} ({p}%)"
),
digits = list(
gtsummary::all_continuous() ~ 0,
bmi ~ 1, troponin ~ 2, creatinine ~ 2, ejection_fraction ~ 1
),
label = list(
age ~ "Edad (años)", sex ~ "Sexo", bmi ~ "IMC (kg/m²)",
systolic_bp ~ "PA sistólica (mmHg)", diastolic_bp ~ "PA diastólica (mmHg)",
chol_total ~ "Colesterol total (mg/dL)", ldl ~ "LDL (mg/dL)", hdl ~ "HDL (mg/dL)",
triglycerides ~ "Triglicéridos (mg/dL)", troponin ~ "Troponina (ng/mL)",
creatinine ~ "Creatinina (mg/dL)", ejection_fraction ~ "Fracción de eyección (%)",
diabetes ~ "Diabetes", hypertension ~ "Hipertensión arterial",
heart_failure ~ "Insuficiencia cardiaca", smoking_status ~ "Tabaquismo",
treat_statin ~ "Estatina al egreso", treat_beta_blocker ~ "Betabloqueador al egreso",
treat_acei ~ "IECA/ARA-II al egreso", length_of_stay ~ "Estancia (días)",
readmission_30d ~ "Reingreso a 30 días"
),
missing = "ifany", missing_text = "Faltantes (n)"
) |>
gtsummary::add_difference(test = gtsummary::everything() ~ "smd") |>
gtsummary::bold_labels() |>
gtsummary::modify_header(label ~ "**Característica**") |>
gtsummary::modify_spanning_header(
c("stat_1", "stat_2") ~ "**Mortalidad a 30 días**"
) |>
gtsummary::modify_caption("**Tabla 1. Características basales según la mortalidad, con diferencia estandarizada (n = 2500)**")| Característica |
Mortalidad a 30 días
|
Diferencia2 | 95% CI2 | |
|---|---|---|---|---|
| No N = 2.2761 |
Sí N = 2241 |
|||
| Edad (años) | 65 [56 – 73] | 72 [62 – 80] | -0,49 | -0,63 – -0,36 |
| Faltantes (n) | 2 | 0 | ||
| Sexo | 0,01 | -0,13 – 0,15 | ||
| Femenino | 1.055 (46%) | 105 (47%) | ||
| Masculino | 1.221 (54%) | 119 (53%) | ||
| IMC (kg/m²) | 27,8 [24,4 – 31,3] | 28,5 [24,7 – 31,5] | -0,08 | -0,21 – 0,06 |
| Faltantes (n) | 11 | 0 | ||
| PA sistólica (mmHg) | 130 [117 – 144] | 130 [116 – 145] | 0,01 | -0,12 – 0,15 |
| Faltantes (n) | 1 | 0 | ||
| PA diastólica (mmHg) | 80 [73 – 86] | 79 [72 – 87] | 0,03 | -0,11 – 0,16 |
| Colesterol total (mg/dL) | 200 [173 – 227] | 205 [173 – 236] | -0,14 | -0,29 – 0,02 |
| Faltantes (n) | 400 | 50 | ||
| LDL (mg/dL) | 121 [100 – 141] | 121 [104 – 139] | -0,06 | -0,20 – 0,08 |
| Faltantes (n) | 10 | 0 | ||
| HDL (mg/dL) | 50 [40 – 61] | 50 [41 – 59] | 0,03 | -0,12 – 0,17 |
| Faltantes (n) | 418 | 30 | ||
| Triglicéridos (mg/dL) | 149 [122 – 182] | 149 [119 – 189] | -0,03 | -0,16 – 0,11 |
| Troponina (ng/mL) | 0,05 [0,03 – 0,09] | 0,05 [0,03 – 0,08] | -0,09 | -0,23 – 0,04 |
| Faltantes (n) | 10 | 1 | ||
| Creatinina (mg/dL) | 1,00 [0,83 – 1,19] | 0,96 [0,84 – 1,15] | 0,10 | -0,05 – 0,25 |
| Faltantes (n) | 413 | 37 | ||
| Fracción de eyección (%) | 54,8 [47,8 – 61,3] | 55,7 [48,5 – 62,8] | -0,09 | -0,23 – 0,04 |
| Faltantes (n) | 1 | 0 | ||
| Diabetes | 0,06 | -0,08 – 0,19 | ||
| No | 1.589 (70%) | 162 (72%) | ||
| Sí | 687 (30%) | 62 (28%) | ||
| Hipertensión arterial | 0,07 | -0,07 – 0,21 | ||
| No | 912 (40%) | 82 (37%) | ||
| Sí | 1.364 (60%) | 142 (63%) | ||
| Insuficiencia cardiaca | 0,02 | -0,12 – 0,16 | ||
| No | 1.698 (75%) | 165 (74%) | ||
| Sí | 578 (25%) | 59 (26%) | ||
| Tabaquismo | 0,01 | -0,14 – 0,16 | ||
| Nunca | 848 (46%) | 88 (46%) | ||
| Exfumador | 646 (35%) | 66 (35%) | ||
| Fumador actual | 366 (20%) | 37 (19%) | ||
| Faltantes (n) | 416 | 33 | ||
| Estatina al egreso | 0,07 | -0,06 – 0,21 | ||
| No | 675 (30%) | 59 (26%) | ||
| Sí | 1.601 (70%) | 165 (74%) | ||
| Betabloqueador al egreso | 0,01 | -0,13 – 0,15 | ||
| No | 944 (41%) | 94 (42%) | ||
| Sí | 1.332 (59%) | 130 (58%) | ||
| IECA/ARA-II al egreso | 0,02 | -0,12 – 0,16 | ||
| No | 1.134 (50%) | 114 (51%) | ||
| Sí | 1.142 (50%) | 110 (49%) | ||
| Estancia (días) | 6 [4 – 7] | 6 [5 – 7] | -0,01 | -0,15 – 0,13 |
| Reingreso a 30 días | 0,05 | -0,09 – 0,19 | ||
| No | 1.923 (84%) | 185 (83%) | ||
| Sí | 353 (16%) | 39 (17%) | ||
| 1 Mediana [Q1 – Q3]; n (%) | ||||
| 2 Standardized Mean Difference | ||||
| Abreviacion: CI = Intervalo de confianza | ||||
Análisis: Esta tercera tabla reemplaza la columna del valor p por la diferencia estandarizada de medias (SMD), y explico por qué la prefiero, pues la SMD mide la distancia entre los dos grupos en una característica, expresada en unidades de desviación estándar, de modo que pone todas las variables —numéricas y categóricas— en una misma escala comparable y, a diferencia del valor p, no depende del tamaño de la muestra ni de los faltantes.
La SDM refleja únicamente la magnitud del desbalance, y por convención, un valor absoluto inferior a 0,10 indica que los grupos están bien balanceados en esa variable, mientras que un valor superior señala un desbalance digno de atención.
Al leer la columna de SMD aparece un matiz que el valor p me había ocultado, pues la edad vuelve a destacar, ahora con una SMD de 0,49, la única de magnitud moderada y la confirmación de que es la diferencia más sólida.
Pero observo además que el colesterol total (SMD 0,14) y la creatinina (SMD 0,10) superan el umbral de 0,10, pese a que sus valores p —0,092 y 0,324— no eran significativos.
Considero que esta discrepancia es precisamente el argumento a favor de la SMD, pues ambas variables tienen 450 datos faltantes, lo que reduce el número de pacientes disponibles y, con ello, la potencia de la prueba para detectar diferencias.
En cuanto al valor p, es sensible a esa pérdida de tamaño, por lo que las declaró no significativas, mientras la SMD, que solo mide magnitud, sí detecta el desbalance.
Esto me ilustra, de cómo el valor p confunde tamaño muestral con relevancia, tal como advierte la literatura metodológica y como Bruce (4) recuerda al discutir la significancia estadística.
Honestamente debo interpretar esos dos desbalances con cautela, pues sus magnitudes son pequeñas, apenas por encima del umbral y, dado que el análisis bivariado demostró que las variables de esta base se generaron de forma independiente del desenlace, estimo que reflejan fluctuaciones del azar en el subconjunto de pacientes con dato disponible, más que asociaciones clínicas reales.
La edad, en cambio, mantiene su magnitud moderada y su coherencia con la evidencia, de modo que se sostiene como el único factor genuinamente asociado a la mortalidad;en cuanto a las demás características, se observa que conservan una SMD inferior a 0,10, lo que confirma su equilibrio entre los grupos.
Concluyo que las dos versiones de la tabla son complementarias y que mostrarlas juntas enriquece el análisis, dado que el valor p ofrece la prueba formal de hipótesis, mientras la SMD aporta una medida de magnitud robusta frente al tamaño muestral y frente a los faltantes, capaz de revelar desbalances que la significancia pasa por alto.
Esta lectura conjunta, sobre magnitud y significancia, se debe interpretar siempre a la luz de la plausibilidad clínica, pues es la que me permite afirmar, con criterio y no por inercia, que en esta cohorte la edad es el factor basal asociado a la mortalidad a 30 días.
Al cerrar este trabajo considero que el aseguramiento de la calidad fue determinante para todo lo que siguió, pues identifiqué y eliminé 6 registros duplicados, convertí en ausentes 6 valores biológicamente imposibles y caractericé una ausencia cercana al 18% en cuatro variables, en consecuencia, esa ausencia, sin embargo, resultó dispersa entre pacientes distintos, lo que justificó manejarla por análisis y no eliminar filas.
Al describir la cohorte observo el perfil de una población mayor, con mediana de 65 años, de predominio masculino y con una carga cardiovascular notable encabezada por la hipertensión.
Considero que este retrato es coherente con lo que Kraler (1) y Timmis (2) documentan para el síndrome coronario agudo. En cuanto a el plano de la distribución, encontré dos familias de variables, un grupo que se comportó de forma aproximadamente simétrica, mientras otro, encabezado por la troponina, que formó distribuciones de cola larga.
Como ninguna variable superó la prueba de normalidad, resumí todas con mediana y rango intercuartílico, las medidas robustas que recomienda Bruce (4).
El análisis bivariado arrojó un resultado claro, pues no hallé correlaciones relevantes entre las variables numéricas ni asociaciones apreciables entre las categóricas, de modo que no existió multicolinealidad.
Por último, la Tabla 1 estratificada mostró que la edad es el único factor basal asociado a la mortalidad a 30 días, con una diferencia de más de seis años entre fallecidos y supervivientes.
Al contrastar el valor p con la diferencia estandarizada, observé que esta última reveló además pequeños desbalances en el colesterol y la creatinina que la significancia, afectada por los faltantes, había pasado por alto.
Reconozco varias limitaciones que me condicionan la interpretación de estos resultados:
Naturaleza del conjunto de datos: Las variables de esta base parecen haberse generado de forma independiente, por lo que no reproducen la estructura de asociación propia de una cohorte real, por lo que considero que esta es la limitación de fondo, y por ello no interpreto los hallazgos en clave etiológica.
Datos faltantes: La ausencia cercana al 18% en el colesterol, la creatinina, el HDL y el tabaquismo podría introducir un sesgo de selección si no fuera completamente aleatoria. Al desconocer su mecanismo, los resultados de esas variables descansan sobre los casos disponibles.
Comparaciones múltiples: Al contrastar más de veinte variables frente al desenlace, aumenta la probabilidad de un hallazgo significativo por azar, como advierte Bruce (4). Por eso valoro la edad no solo por su valor p, sino por la magnitud y la coherencia clínica de su diferencia.
Validez externa: Al tratarse de datos de carácter sintético, considero que los hallazgos no son generalizables a poblaciones de pacientes reales y deben leerse como un ejercicio metodológico.
Kraler S, Libby P, Bhatt DL, Mueller C. Acute coronary syndromes: mechanisms, challenges, and new opportunities. Eur Heart J. 2025. doi:10.1093/eurheartj/ehaf216. Publicación electrónica anticipada el 13 de mayo de 2025.
Timmis A, Kazakiewicz D, Townsend N, Huculeci R, Aboyans V, Vardas P. Global epidemiology of acute coronary syndromes. Nat Rev Cardiol. 2023;20(11):778-788.
Van den Broeck J, Cunningham SA, Eeckels R, Herbst K. Data cleaning: detecting, diagnosing, and editing data abnormalities. PLoS Med. 2005;2(10):e267.
Bruce P, Bruce A, Gedeck P. Estadística práctica para ciencia de datos con R y Python. 2ª ed. Barcelona: Marcombo; 2022.