Una EPS o una red de IPS necesita priorizar a quién invitar primero a un programa de tamizaje y prevención cardiovascular, porque no tiene capacidad para valorar por cardiología a toda su población al mismo tiempo. En el análisis anterior (probabilidad y estadística) se encontró que la enfermedad cardíaca afecta al 8,6 % de los encuestados y que se concentra en personas mayores, con antecedente de ACV, fumadoras y de sexo masculino. El siguiente paso es combinar todos esos factores en un modelo de clasificación que asigne a cada persona una probabilidad de tener enfermedad cardíaca y la clasifique como de riesgo alto o bajo.
| Métrica | Qué mide | Meta |
|---|---|---|
| AUC (área bajo la curva ROC) | Capacidad del modelo para asignar más riesgo a quien tiene la enfermedad que a quien no; 0,5 = azar, 1 = perfecto | ≥ 0,80 en el conjunto de prueba |
| Sensibilidad | De cada 100 personas con enfermedad cardíaca, cuántas clasifica el modelo como de riesgo alto | ≥ 70 % |
| Diferencia entre entrenamiento y prueba | Si el modelo “memorizó” los datos (sobreajuste) | AUC con una diferencia ≤ 0,02 |
La exactitud (porcentaje de aciertos) no se usa como métrica principal: como solo el 8,6 % tiene la enfermedad, un modelo que dijera que nadie la tiene acertaría en más del 91 % de los casos sin detectar a ningún paciente.
| Grupo | Variables incluidas | Justificación |
|---|---|---|
| Demográficas | Edad (AgeCategory), sexo (Sex) |
La probabilidad pasó de 0,6 % a 22,6 % con la edad y fue mayor en hombres (10,6 % frente a 6,7 %) |
| Hábitos y peso | Tabaquismo (Smoking), actividad física
(PhysicalActivity), IMC (BMI) |
Factores de riesgo modificables, sobre los que actúan los programas de prevención |
| Antecedentes clínicos | ACV (Stroke), diabetes (Diabetic),
enfermedad renal (KidneyDisease) |
Enfermedades que comparten mecanismos con la enfermedad cardíaca; el ACV mostró el riesgo más alto (36,4 %) |
| Estado funcional | Salud percibida (GenHealth), dificultad para caminar
(DiffWalking), días de mala salud física
(PhysicalHealth) |
Resumen la carga de enfermedad y se obtienen con preguntas sencillas |
Variables excluidas:
Race): un modelo que decide a
quién se le asignan recursos no debe usar la raza como criterio, por
razones de equidad.AlcoholDrinking): en esta base se asocia con
menos enfermedad cardíaca (5,2 % frente a 8,8 %), probablemente
porque muchas personas enfermas dejan de beber. Incluirlo daría un
mensaje equivocado.# Diagrama de relaciones entre las variables y la técnica elegida
DiagrammeR::grViz("
digraph modelo {
graph [rankdir = LR, fontname = Helvetica]
node [shape = box, style = 'rounded,filled', fillcolor = '#E8EEF7',
fontname = Helvetica, fontsize = 11]
demo [label = 'Demográficas\\nEdad, sexo']
habitos [label = 'Hábitos y peso\\nTabaquismo, actividad física, IMC']
clinicos [label = 'Antecedentes clínicos\\nACV, diabetes, enfermedad renal']
funcion [label = 'Estado funcional\\nSalud percibida, dificultad para caminar,\\ndías de mala salud física']
modelo [label = 'Regresión logística\\n(entrenamiento: 75 %)', fillcolor = '#3B528B', fontcolor = white]
prob [label = 'Probabilidad de\\nenfermedad cardíaca']
decision [label = 'Riesgo alto: priorizar tamizaje\\nRiesgo bajo: seguimiento habitual', fillcolor = '#FBE3D0']
evalua [label = 'Evaluación (prueba: 25 %)\\nAUC y sensibilidad', shape = note]
{demo habitos clinicos funcion} -> modelo -> prob -> decision
prob -> evalua [style = dashed]
}
")
Técnica elegida: regresión logística. Es el modelo estándar para un resultado de dos categorías (sí/no). Calcula la probabilidad de enfermedad cardíaca a partir de una suma ponderada de las variables:
log( p / (1 - p) ) = b0 + b1·edad + b2·sexo + b3·tabaquismo + ... + b11·días de mala salud física
p = probabilidad de enfermedad cardíaca
p / (1-p) = "odds" o chances (por ejemplo, p = 0,20 equivale a chances de 1 a 4)
exp(b) = razón de odds (OR): cuántas veces se multiplican las chances
cuando la variable aumenta en una unidad o pasa de "No" a "Sí"
Pseudocódigo del flujo:
1. Seleccionar variables y limpiar los datos
2. Dividir: 75 % entrenamiento / 25 % prueba (estratificado por HeartDisease)
3. Ajustar la regresión logística con validación cruzada de 5 pliegues
4. Diagnosticar: multicolinealidad, residuos, observaciones influyentes y calibración
5. Evaluar en prueba: AUC, sensibilidad, especificidad
6. Clasificar: riesgo alto si la probabilidad >= umbral
7. Proponer mejoras
# Estructura de la base completa: 18 variables candidatas
glimpse(corazon)
## Rows: 319,795
## Columns: 18
## $ HeartDisease <chr> "No", "No", "No", "No", "No", "Yes", "No", "No", "No"…
## $ BMI <dbl> 16.60, 20.34, 26.58, 24.21, 23.71, 28.87, 21.63, 31.6…
## $ Smoking <chr> "Yes", "No", "Yes", "No", "No", "Yes", "No", "Yes", "…
## $ AlcoholDrinking <chr> "No", "No", "No", "No", "No", "No", "No", "No", "No",…
## $ Stroke <chr> "No", "Yes", "No", "No", "No", "No", "No", "No", "No"…
## $ PhysicalHealth <dbl> 3, 0, 20, 0, 28, 6, 15, 5, 0, 0, 30, 0, 0, 7, 0, 1, 5…
## $ MentalHealth <dbl> 30, 0, 30, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 30, 0, 2,…
## $ DiffWalking <chr> "No", "No", "No", "No", "Yes", "Yes", "No", "Yes", "N…
## $ Sex <chr> "Female", "Female", "Male", "Female", "Female", "Fema…
## $ AgeCategory <chr> "55-59", "80 or older", "65-69", "75-79", "40-44", "7…
## $ Race <chr> "White", "White", "White", "White", "White", "Black",…
## $ Diabetic <chr> "Yes", "No", "Yes", "No", "No", "No", "No", "Yes", "N…
## $ PhysicalActivity <chr> "Yes", "Yes", "Yes", "No", "Yes", "No", "Yes", "No", …
## $ GenHealth <chr> "Very good", "Very good", "Fair", "Good", "Very good"…
## $ SleepTime <dbl> 5, 7, 8, 6, 8, 12, 4, 9, 5, 10, 15, 5, 8, 7, 5, 6, 10…
## $ Asthma <chr> "Yes", "No", "Yes", "No", "No", "No", "Yes", "Yes", "…
## $ KidneyDisease <chr> "No", "No", "No", "No", "No", "No", "No", "No", "Yes"…
## $ SkinCancer <chr> "Yes", "No", "No", "Yes", "No", "No", "Yes", "No", "N…
datos_modelo <- corazon |>
# Solo las variables justificadas en la sección anterior
select(HeartDisease, AgeCategory, Sex, Smoking, Stroke, Diabetic, KidneyDisease,
DiffWalking, PhysicalActivity, GenHealth, BMI, PhysicalHealth) |>
# La base no tiene faltantes, pero se deja el paso para que el flujo sirva con otros datos
drop_na() |>
mutate(
# "No" primero: así el modelo estima la probabilidad de "Yes"
HeartDisease = factor(HeartDisease, levels = c("No", "Yes")),
# Edad aproximada (inicio del grupo + 2 años; ej. "55-59" -> 57) para leer el efecto por año
edad = as.numeric(str_sub(AgeCategory, 1, 2)) + 2,
# "Excellent" como referencia: los OR indican cuánto aumenta el riesgo al empeorar la salud
GenHealth = factor(GenHealth, levels = c("Excellent", "Very good", "Good", "Fair", "Poor"))
) |>
select(-AgeCategory)
# Descripción de las variables seleccionadas: tipo, faltantes, distribución
skimr::skim(datos_modelo)
| Name | datos_modelo |
| Number of rows | 319795 |
| Number of columns | 12 |
| _______________________ | |
| Column type frequency: | |
| character | 7 |
| factor | 2 |
| numeric | 3 |
| ________________________ | |
| Group variables | None |
Variable type: character
| skim_variable | n_missing | complete_rate | min | max | empty | n_unique | whitespace |
|---|---|---|---|---|---|---|---|
| Sex | 0 | 1 | 4 | 6 | 0 | 2 | 0 |
| Smoking | 0 | 1 | 2 | 3 | 0 | 2 | 0 |
| Stroke | 0 | 1 | 2 | 3 | 0 | 2 | 0 |
| Diabetic | 0 | 1 | 2 | 23 | 0 | 4 | 0 |
| KidneyDisease | 0 | 1 | 2 | 3 | 0 | 2 | 0 |
| DiffWalking | 0 | 1 | 2 | 3 | 0 | 2 | 0 |
| PhysicalActivity | 0 | 1 | 2 | 3 | 0 | 2 | 0 |
Variable type: factor
| skim_variable | n_missing | complete_rate | ordered | n_unique | top_counts |
|---|---|---|---|---|---|
| HeartDisease | 0 | 1 | FALSE | 2 | No: 292422, Yes: 27373 |
| GenHealth | 0 | 1 | FALSE | 5 | Ver: 113858, Goo: 93129, Exc: 66842, Fai: 34677 |
Variable type: numeric
| skim_variable | n_missing | complete_rate | mean | sd | p0 | p25 | p50 | p75 | p100 | hist |
|---|---|---|---|---|---|---|---|---|---|---|
| BMI | 0 | 1 | 28.33 | 6.36 | 12.02 | 24.03 | 27.34 | 31.42 | 94.85 | ▇▅▁▁▁ |
| PhysicalHealth | 0 | 1 | 3.37 | 7.95 | 0.00 | 0.00 | 0.00 | 2.00 | 30.00 | ▇▁▁▁▁ |
| edad | 0 | 1 | 54.44 | 18.07 | 20.00 | 42.00 | 57.00 | 67.00 | 82.00 | ▆▅▇▇▇ |
# Semilla: la división al azar es reproducible
set.seed(2026)
# Estratificar por HeartDisease garantiza el mismo 8,6 % de casos en ambos conjuntos
division <- initial_split(datos_modelo, prop = 0.75, strata = HeartDisease)
entrenamiento <- training(division)
prueba <- testing(division)
# Verificación: tamaño y proporción de casos en cada conjunto
tibble(
conjunto = c("Entrenamiento", "Prueba"),
personas = c(nrow(entrenamiento), nrow(prueba)),
pct_casos = c(mean(entrenamiento$HeartDisease == "Yes"),
mean(prueba$HeartDisease == "Yes")) * 100
)
## # A tibble: 2 × 3
## conjunto personas pct_casos
## <chr> <int> <dbl>
## 1 Entrenamiento 239846 8.49
## 2 Prueba 79949 8.76
# Fórmula común para caret y glm: resultado ~ variables explicativas
formula_modelo <- HeartDisease ~ edad + Sex + Smoking + Stroke + Diabetic +
KidneyDisease + DiffWalking + PhysicalActivity + GenHealth + BMI + PhysicalHealth
# Validación cruzada de 5 pliegues: el entrenamiento se divide en 5 partes y el
# modelo se ajusta 5 veces, evaluando cada vez en la parte que no vio.
# Así se estima el desempeño sin tocar todavía el conjunto de prueba.
control_cv <- caret::trainControl(
method = "cv",
number = 5,
classProbs = TRUE, # necesario para calcular el AUC
summaryFunction = caret::twoClassSummary # reporta el AUC con el nombre "ROC"
)
set.seed(2026)
modelo_cv <- caret::train(
formula_modelo,
data = entrenamiento,
method = "glm", # regresión logística
family = "binomial",
trControl = control_cv,
metric = "ROC"
)
# AUC promedio de los 5 pliegues y su variación
modelo_cv$results |> select(ROC, ROCSD)
## ROC ROCSD
## 1 0.8386642 0.002821095
# El mismo modelo con glm(), la función que caret usa por dentro.
# Se ajusta directamente porque su salida es más fácil de leer y diagnosticar.
modelo_glm <- glm(formula_modelo, data = entrenamiento, family = binomial)
# Razones de odds (OR) con intervalo de confianza del 95 % (método de Wald)
tabla_or <- broom::tidy(modelo_glm) |>
filter(term != "(Intercept)") |>
mutate(
OR = exp(estimate),
IC_inf = exp(estimate - 1.96 * std.error),
IC_sup = exp(estimate + 1.96 * std.error)
) |>
select(term, OR, IC_inf, IC_sup, p.value) |>
arrange(desc(OR))
knitr::kable(tabla_or, digits = 3,
caption = "Razones de odds del modelo (ordenadas de mayor a menor)")
| term | OR | IC_inf | IC_sup | p.value |
|---|---|---|---|---|
| GenHealthPoor | 6.660 | 6.078 | 7.297 | 0.000 |
| GenHealthFair | 4.525 | 4.205 | 4.871 | 0.000 |
| StrokeYes | 2.863 | 2.721 | 3.013 | 0.000 |
| GenHealthGood | 2.797 | 2.617 | 2.989 | 0.000 |
| SexMale | 1.967 | 1.904 | 2.033 | 0.000 |
| KidneyDiseaseYes | 1.774 | 1.679 | 1.875 | 0.000 |
| DiabeticYes | 1.596 | 1.537 | 1.657 | 0.000 |
| GenHealthVery good | 1.557 | 1.454 | 1.667 | 0.000 |
| SmokingYes | 1.446 | 1.400 | 1.493 | 0.000 |
| DiffWalkingYes | 1.235 | 1.186 | 1.287 | 0.000 |
| DiabeticYes (during pregnancy) | 1.218 | 0.966 | 1.534 | 0.095 |
| DiabeticNo, borderline diabetes | 1.171 | 1.066 | 1.286 | 0.001 |
| edad | 1.055 | 1.054 | 1.057 | 0.000 |
| PhysicalActivityYes | 1.039 | 1.002 | 1.078 | 0.038 |
| BMI | 1.012 | 1.009 | 1.014 | 0.000 |
| PhysicalHealth | 1.007 | 1.005 | 1.009 | 0.000 |
# Vectores con nombre para citar los valores dentro del texto
or_vec <- setNames(tabla_or$OR, tabla_or$term)
ic_inf_vec <- setNames(tabla_or$IC_inf, tabla_or$term)
ic_sup_vec <- setNames(tabla_or$IC_sup, tabla_or$term)
Todas las asociaciones van en la dirección esperada, salvo la actividad física y la diabetes gestacional (esta última, con muy pocos casos, no muestra una asociación clara). Manteniendo constantes las demás variables:
En la regresión lineal se revisa que los residuos tengan varianza constante (homocedasticidad) y forma aproximadamente normal. Esos supuestos no aplican a la regresión logística, cuyo resultado es sí/no. En su lugar se revisan cuatro aspectos: (1) que las variables explicativas no estén demasiado correlacionadas entre sí (multicolinealidad), (2) los residuos extremos, (3) las observaciones con demasiado peso en el modelo (influyentes) y (4) que las probabilidades predichas coincidan con lo observado (calibración).
# Factor de inflación de la varianza (VIF): valores mayores de 5 indican
# multicolinealidad problemática. Para variables con varias categorías se lee
# la última columna, GVIF^(1/(2*Df)), comparable con la raíz de 5 (≈ 2,2).
vif_modelo <- car::vif(modelo_glm)
vif_modelo
## GVIF Df GVIF^(1/(2*Df))
## edad 1.099392 1 1.048519
## Sex 1.052128 1 1.025733
## Smoking 1.032113 1 1.015930
## Stroke 1.033226 1 1.016477
## Diabetic 1.139387 3 1.021987
## KidneyDisease 1.048568 1 1.023996
## DiffWalking 1.445711 1 1.202377
## PhysicalActivity 1.175277 1 1.084102
## GenHealth 1.895001 4 1.083181
## BMI 1.162888 1 1.078373
## PhysicalHealth 1.725014 1 1.313398
# augment() agrega a cada persona su valor ajustado, su residuo y su influencia
diagnostico <- broom::augment(modelo_glm)
# La actividad menciona geom_residuals(), que no existe en ggplot2:
# el gráfico de residuos se construye con geom_point()
c(pct_residuos_extremos = mean(abs(diagnostico$.std.resid) > 3) * 100,
cook_maximo = max(diagnostico$.cooksd))
## pct_residuos_extremos cook_maximo
## 0.108402892 0.000872242
# Se grafica una muestra de 3.000 personas: con 240.000 puntos el gráfico no se leería
set.seed(2026)
grafico_residuos <- diagnostico |>
slice_sample(n = 3000) |>
ggplot(aes(x = .fitted, y = .std.resid, color = HeartDisease)) +
geom_point(alpha = 0.4) +
geom_hline(yintercept = 0) +
geom_hline(yintercept = c(-3, 3), linetype = "dashed") +
scale_color_manual(values = c(No = "#3B528B", Yes = "#D95F02")) +
labs(
title = "Residuos estandarizados frente a valores ajustados (muestra de 3.000)",
x = "Valor ajustado (escala logit)",
y = "Residuo estandarizado de devianza",
color = "Enfermedad cardíaca"
) +
theme_minimal()
ggplotly(grafico_residuos)
# Calibración: se agrupan las personas de prueba en 10 grupos (deciles) según su
# probabilidad predicha y se compara con la proporción real de casos en cada grupo
prob_prueba <- predict(modelo_glm, newdata = prueba, type = "response")
calibracion <- tibble(
observado = prueba$HeartDisease == "Yes",
predicho = prob_prueba
) |>
mutate(decil = ntile(predicho, 10)) |>
group_by(decil) |>
summarise(prob_media = mean(predicho), prop_observada = mean(observado))
grafico_calibracion <- ggplot(calibracion, aes(x = prob_media, y = prop_observada)) +
geom_abline(linetype = "dashed", color = "grey50") + # calibración perfecta
geom_line(color = "#3B528B") +
geom_point(color = "#3B528B", size = 3) +
labs(
title = "Calibración en el conjunto de prueba (por deciles de riesgo)",
x = "Probabilidad promedio predicha",
y = "Proporción observada con enfermedad cardíaca"
) +
theme_minimal()
ggplotly(grafico_calibracion)
# Umbral de clasificación: la proporción de casos en entrenamiento (≈ 8,6 %).
# Una persona es de "riesgo alto" si su probabilidad supera la frecuencia promedio.
# Con 0,5, el umbral habitual, el modelo casi nunca diría "Sí", porque la enfermedad es poco frecuente.
umbral <- mean(entrenamiento$HeartDisease == "Yes")
prob_entrenamiento <- predict(modelo_glm, type = "response")
# Función para calcular las mismas métricas en cualquier conjunto
evaluar <- function(observado, probabilidad, umbral) {
clase <- factor(if_else(probabilidad >= umbral, "Yes", "No"), levels = c("No", "Yes"))
tibble(
AUC = yardstick::roc_auc_vec(observado, probabilidad, event_level = "second"),
Sensibilidad = yardstick::sens_vec(observado, clase, event_level = "second"),
Especificidad = yardstick::spec_vec(observado, clase, event_level = "second"),
Exactitud = yardstick::accuracy_vec(observado, clase),
Pct_alto_riesgo = mean(clase == "Yes")
)
}
metricas_entrenamiento <- evaluar(entrenamiento$HeartDisease, prob_entrenamiento, umbral)
metricas_prueba <- evaluar(prueba$HeartDisease, prob_prueba, umbral)
# Modelo base de comparación: decir que nadie tiene la enfermedad (probabilidad 0 para todos)
metricas_base <- evaluar(prueba$HeartDisease, rep(0, nrow(prueba)), 0.5)
tabla_metricas <- bind_rows(
metricas_entrenamiento |> mutate(Conjunto = "Entrenamiento"),
metricas_prueba |> mutate(Conjunto = "Prueba"),
metricas_base |> mutate(Conjunto = "Modelo base (todos 'No'), prueba")
) |>
relocate(Conjunto)
knitr::kable(tabla_metricas, digits = 3,
caption = "Métricas de desempeño: entrenamiento frente a prueba")
| Conjunto | AUC | Sensibilidad | Especificidad | Exactitud | Pct_alto_riesgo |
|---|---|---|---|---|---|
| Entrenamiento | 0.839 | 0.784 | 0.742 | 0.746 | 0.302 |
| Prueba | 0.841 | 0.786 | 0.742 | 0.745 | 0.305 |
| Modelo base (todos ‘No’), prueba | 0.500 | 0.000 | 1.000 | 0.912 | 0.000 |
# Matriz de confusión en prueba: aciertos y errores del modelo con el umbral elegido
clase_prueba <- factor(if_else(prob_prueba >= umbral, "Yes", "No"), levels = c("No", "Yes"))
matriz_prueba <- table(Predicho = clase_prueba, Observado = prueba$HeartDisease)
matriz_prueba
## Observado
## Predicho No Yes
## No 54099 1500
## Yes 18850 5500
# Superficie de probabilidad según edad e IMC para un perfil de referencia:
# hombre, salud "buena", físicamente activo, sin ACV, diabetes ni enfermedad renal.
# Se comparan dos superficies: fumador y no fumador.
edades <- seq(20, 82, by = 2)
imcs <- seq(16, 50, by = 1)
probabilidad_superficie <- function(fuma) {
rejilla <- expand_grid(
edad = edades, BMI = imcs,
Sex = "Male", Smoking = fuma, Stroke = "No", Diabetic = "No",
KidneyDisease = "No", DiffWalking = "No", PhysicalActivity = "Yes",
GenHealth = "Good", PhysicalHealth = 0
)
prob <- predict(modelo_glm, newdata = rejilla, type = "response")
# Filas = IMC y columnas = edad: el formato de matriz que plotly espera para una superficie
matrix(prob, nrow = length(imcs), ncol = length(edades))
}
z_no_fuma <- probabilidad_superficie("No")
z_fuma <- probabilidad_superficie("Yes")
plot_ly(x = edades, y = imcs) |>
add_surface(z = z_no_fuma, name = "No fumador", colorscale = "Viridis",
showscale = FALSE, opacity = 0.9) |>
add_surface(z = z_fuma, name = "Fumador", colorscale = "Reds",
showscale = FALSE, opacity = 0.7) |>
layout(
title = "Probabilidad predicha según edad e IMC: fumador (rojo) frente a no fumador",
scene = list(
xaxis = list(title = "Edad (años)"),
yaxis = list(title = "IMC (kg/m²)"),
zaxis = list(title = "Probabilidad")
)
)
# Nube 3D de 2.000 personas del conjunto de prueba: edad, IMC y probabilidad
# predicha, coloreadas según si realmente tienen la enfermedad
set.seed(2026)
muestra_prueba <- prueba |>
mutate(probabilidad = prob_prueba) |>
slice_sample(n = 2000)
plot_ly(
muestra_prueba,
x = ~edad, y = ~BMI, z = ~probabilidad,
color = ~HeartDisease, colors = c("#3B528B", "#D95F02"),
type = "scatter3d", mode = "markers", marker = list(size = 3)
) |>
layout(
title = "Probabilidad predicha en 2.000 personas de prueba (naranja = con enfermedad cardíaca)",
scene = list(
xaxis = list(title = "Edad (años)"),
yaxis = list(title = "IMC (kg/m²)"),
zaxis = list(title = "Probabilidad predicha")
)
)
Métricas de éxito: se cumplen las tres metas definidas (AUC ≥ 0,80; sensibilidad ≥ 70 %; diferencia entre entrenamiento y prueba ≤ 0,02).
Smoking:Diabetic o edad:Sex a la fórmula y
comparar el AUC.caret::train(method = "rf")) en una muestra de los datos,
para ver si captura relaciones más complejas y mejora el AUC, sin perder
de vista que la regresión logística es más fácil de explicar.Los datos provienen de una encuesta transversal y autorreportada, realizada en EE. UU. en 2020 y sin pesos de muestreo. El modelo identifica a personas que ya reportan enfermedad cardíaca, no predice quién la desarrollará en el futuro, y sus coeficientes muestran asociaciones, no causas. Antes de usarlo para asignar recursos debe validarse con datos de la población objetivo.