1 Aseguramiento y control de la calidad de los datos en una cohorte de síndrome coronario agudo

1.1 Presentación y hoja de ruta

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:

  • Preparación del entorno: Configuro el documento, cargo las librerías y defino un estilo de tablas uniforme.
  • Carga y reconocimiento: Importaré la base, verificaré que se haya leído correctamente y examinaré su estructura.
  • Diagnóstico y depuración de calidad: Identificaré y corregiré los tres problemas que la guía anticipa, como son filas duplicadas, valores fuera del rango clínicamente plausible y datos faltantes.
  • Exploración univariada y bivariada: Describiré cada variable y luego las relaciones entre ellas, con gráficas variadas y su lectura razonada.
  • Síntesis en la Tabla 1: Resumiré la muestra de forma global y estratificada por el desenlace primario.

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

1.2 Marco bibliográfico y justificación de las fuentes

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:

  • por un lado la magnitud y la relevancia clínica del problema, y por otro mis decisiones metodológicas de resumir las numéricas con mediana y rango intercuartílico y de detectar atípicos con cajas de bigotes, ya que una revista de primer nivel hizo exactamente eso en R y por las mismas razones de robustez.

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.

2 Carga y reconocimiento de los datos

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.

2.1 Cargo la base

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
head(datos_crudos, 8)

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.

2.2 Reconocimiento de la estructura

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.

dplyr::glimpse(datos_crudos)
#> 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).

2.3 Clasificación de las variables

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

3 Diagnóstico de datos

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:

  1. tamizaje, para detectar señales de anomalía.

  2. diagnóstico, para decidir si cada señal es un error o un dato real; y

  3. 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.

3.1 Diagnóstico general de calidad

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

3.1.1 Definición de los indicadores y su cálculo

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

  • 1)La primera es la unicidad del identificador, donde aplico la fórmula de la tasa de unicidad a patient_id:

\[\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.

  • 2) La segunda señal es la de los faltantes, cuyo porcentaje por variable derivé en la tabla anterior. El diagnóstico revela un bloque de ausencia cercano al 18% en cuatro variables, encabezado por colesterol total y creatinina, ambas con 451 faltantes, es decir (451/2506) × 100 ≈ 17,9968%, seguidas de tabaquismo con 449, (449/2506) × 100 ≈ 17,9170%, y HDL con 448, (448/2506) × 100 ≈ 17,8771%; junto a una ausencia menor en índice de masa corporal, LDL y troponina, cada una con 10 faltantes, (10/2506) × 100 ≈ 0,3990%.

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.

3.2 Duplicados

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:

  • un mismo paciente con valores en conflicto
  • dos pacientes diferentes que comparten un código, o una copia exacta originada en un error de ensamblaje.
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")
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")
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.

3.3 Conversión de las variables categóricas a factores

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.

3.4 Diagnóstico de las variables categóricas

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

2,500

749

29,9600

2

hypertension

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

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

2,500

1,766

70,6400

1

treat_statin

No

2,500

734

29,3600

2

treat_beta_blocker

2,500

1,462

58,4800

1

treat_beta_blocker

No

2,500

1,038

41,5200

2

treat_acei

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

2,500

224

8,9600

2

readmission_30d

No

2,500

2,108

84,3200

1

readmission_30d

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.

3.5 Diagnóstico de las variables numéricas y depuración de valores fuera de rango

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

  • La edad registra un mínimo de -5 y un máximo de 160, el índice de masa corporal un mínimo de -3 y un máximo de 95, la presión arterial sistólica un mínimo de -20, la fracción de eyección un máximo de 120 y la troponina un mínimo de -0,5 y un máximo de 75; las siete variables restantes, entre ellas la presión diastólica, la creatinina, el perfil lipídico y la estancia hospitalaria, no presentan valores fuera de rango.

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")
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í:

  • la edad pasa de 0 a 2 faltantes
  • el índice de masa corporal de 10 a 11
  • la presión arterial sistólica de 0 a 1
  • la fracción de eyección de 0 a 1 y
  • la troponina de 10 a 11.

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.

3.6 Valores atípicos

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

  • Troponina, 165 atípicos (6,60%), media de atípicos 0,70: Observo que es la variable con más atípicos, y que la media de estos, 0,70 ng/mL, supera ampliamente tanto su mediana de 0,05 como el límite superior de Tukey de 0,17; además, es la única variable donde la media se mueve de forma notable al excluir los extremos, pues pasa de 0,10 con atípicos a 0,06 sin ellos.

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.

  • Perfil metabólico y lipídico: IMC 11 (0,44%, media 47,65), colesterol total 9 (0,36%, media 324,78), LDL 6 (0,24%, media 214,50), HDL 5 (0,20%, media 96,00) y triglicéridos 40 (1,60%, media 304,20): En todas, la media de los atípicos se ubica muy por encima del grueso de los datos, de modo que los extremos son valores elevados; considero que retratan obesidad y dislipidemia, factores de riesgo centrales del síndrome coronario agudo según el artículo de Kraler (1), que subraya el papel del colesterol LDL.

Observo que el efecto sobre la media es mínimo en todas, lo cual confirma su escaso peso numérico.

  • Hemodinamia, función renal y estancia: PA sistólica 9 (0,36%, media 189,56), PA diastólica 29 (1,16%, media 68,97), creatinina 24 (0,96%, media 1,92) y estancia 42 (1,68%, media 12,71): Observo que la sistólica extrema apunta a crisis hipertensivas, la creatinina alta a compromiso renal y las estancias largas a hospitalizaciones complicadas, todos perfiles plausibles.

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.

3.7 Valores faltantes

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.

datos_dep |>
  naniar::gg_miss_upset(nsets = 4, nintersects = NA)

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

4 Estadística descriptiva

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.

4.1 Panorama numérico

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.

  • La media aritmética es el promedio de los valores, esto es, su suma dividida entre el número de observaciones:

\[\bar{x} = \frac{1}{n}\sum_{i=1}^{n} x_i\]

  • La desviación estándar mide la dispersión típica de los datos alrededor de la media, como la raíz cuadrada del promedio de las desviaciones al cuadrado:

\[s = \sqrt{\frac{1}{\,n-1\,}\sum_{i=1}^{n}\left(x_i - \bar{x}\right)^2}\]

  • El coeficiente de asimetría resume la forma de la distribución comparando el tamaño de sus colas; vale aproximadamente cero cuando la distribución es simétrica, es positivo cuando la cola larga apunta a la derecha y negativo cuando apunta a la izquierda:

\[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)")
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:

  • Un primer grupo se comporta de forma prácticamente simétrica, con asimetría cercana a cero y media y mediana casi superpuestas, como la edad (asimetría −0,04, mediana 65 [57 – 74] frente a media ≈ 65,38), la presión sistólica (0,11, mediana 130) y diastólica (−0,03, mediana 80), el colesterol total (0,18, mediana 201), el LDL (0,05, mediana 121), el HDL (0,04, mediana 50) y la fracción de eyección (−0,09, mediana 54,9).

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.

  • Un segundo grupo muestra una cola a la derecha que separa la media de la mediana y la empuja hacia arriba, consiste en el índice de masa corporal (asimetría 0,91, mediana 27,80 con un máximo de 95), los triglicéridos (0,85, mediana 149 pero media ≈ 155,32), la creatinina (0,75, mediana 0,99) y la estancia hospitalaria (0,53, mediana 6 días).

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.

  • El caso extremo es la troponina, con una asimetría de 49,73 que no tiene comparación con ninguna otra variable, pues su mediana es apenas 0,05, su media casi la duplica en 0,10 y su máximo alcanza 75; estimo que esta distribución intensamente sesgada es la huella estadística del infarto, ya que según el artículo de Kraler (1) la troponina ultrasensible refleja la magnitud del daño miocárdico, de manera que la minoría con valores muy altos corresponde a los pacientes con mayor necrosis.

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.

4.2 Distribución univariada de las variables numéricas

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.

4.3 Exploración de la forma con violines

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é.

4.4 Comprobación de normalidad

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

  • los triglicéridos (0,961), el índice de masa corporal (0,964), la estancia (0,967) y la creatinina (0,970) son las distribuciones de cola larga de Bruce (4), y la troponina lleva el fenómeno al extremo con un W de apenas 0,012, el reflejo de su asimetría de 49,73.

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

4.5 Análisis bivariado

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.

4.6 Comparación de las características según la mortalidad a 30 días

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:

  • 1. los pacientes que sobrevivieron y
  • 2. los que fallecieron a 30 días.

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.

5 Tabla 1: síntesis descriptiva de la cohorte

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)**")
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í:

  • En el bloque demográfico, la edad tiene una mediana de 65 años con un rango intercuartílico de 57 a 74, lo que significa que la mitad central de los pacientes se concentra en ese intervalo y que estoy ante una población de edad avanzada; considero que esto es coherente con el síndrome coronario agudo, cuya incidencia, según el artículo de Kraler (1), aumenta de manera marcada con la edad.

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 bloque de signos vitales, la presión arterial sistólica presenta una mediana de 130 mmHg (rango intercuartílico 117 a 144) y la diastólica de 80 mmHg (73 a 86); interpreto estas cifras como tensiones en el límite alto de lo normal, un retrato consistente con la elevada prevalencia de hipertensión que la propia tabla muestra más abajo.

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.

  • En el bloque de daño miocárdico, función renal y ventricular, la troponina tiene una mediana de apenas 0,05 ng/mL (0,03 a 0,08) con 11 faltantes, reflejo de que el grueso de los pacientes se concentra en valores bajos pese a su conocida cola larga.

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.

  • En el bloque de comorbilidades, la hipertensión arterial es la más frecuente, presente en 1506 pacientes (60,2%), seguida de la diabetes en 749 (30,0%) y la insuficiencia cardiaca en 637 (25,5%); estimo que esta constelación reproduce con fidelidad el perfil de riesgo cardiovascular que la evidencia reconoce para el síndrome coronario agudo.

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)**")
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.276
1

N = 224
1
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:

  • el índice de masa corporal (28,5 frente a 27,8; p=0,178)
  • la presión sistólica (p=0,846) y diastólica (p=0,630)
  • el colesterol total (205 frente a 200; p=0,092)
  • el LDL (p=0,458), el HDL (p=0,658), los triglicéridos (p=0,981)
  • la troponina (0,05 frente a 0,05; p=0,236)
  • la creatinina (p=0,324), la fracción de eyección (55,7 frente a 54,8; p=0,188) y la estancia (p=0,865).

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)**")
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.276
1

N = 224
1
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.

6 Conclusiones

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.

7 Limitaciones

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.

8 Referencias

  1. 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.

  2. 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.

  3. Van den Broeck J, Cunningham SA, Eeckels R, Herbst K. Data cleaning: detecting, diagnosing, and editing data abnormalities. PLoS Med. 2005;2(10):e267.

  4. Bruce P, Bruce A, Gedeck P. Estadística práctica para ciencia de datos con R y Python. 2ª ed. Barcelona: Marcombo; 2022.