library(readxl) # leer el archivo de Excel
library(dplyr) # manipular datos
library(tidyr) # limpiar datos (drop_na)
library(ggplot2) # gráficos
library(plotly) # gráficos interactivos y 3D
library(skimr) # resumen descriptivo completo
library(rsample) # dividir en entrenamiento y prueba
library(broom) # extraer coeficientes y residuos en tablas
library(yardstick) # métricas de desempeño (AUC, sensibilidad, RMSE)
library(randomForest) # bosque aleatorio (algoritmo alternativo)
library(DiagrammeR) # diagrama del esquema conceptual
sueno <- read_excel("Sleep_health_and_lifestyle_dataset.xlsx")
set.seed(2026)
Una IPS que presta servicios de salud ocupacional y promoción y prevención a empresas quiere identificar a los trabajadores con trastornos del sueño (insomnio o apnea). Estos trastornos aumentan el ausentismo, los accidentes laborales y el riesgo cardiovascular, pero confirmarlos requiere estudios costosos (polisomnografía) y valoración por especialista, con cupos limitados.
Reto: construir un modelo que, con datos que se recogen en un examen ocupacional de rutina (edad, sexo, presión arterial, IMC, nivel de estrés, horas de sueño, actividad física), estime la probabilidad de que un trabajador tenga un trastorno del sueño. Con esa probabilidad, la IPS puede priorizar a quién enviar primero a estudio especializado y dirigir mejor los recursos.
Sleep Disorder.| Modelo | Métrica | Qué mide | Meta |
|---|---|---|---|
| Clasificación | AUC (área bajo la curva ROC) | Capacidad de separar a quienes tienen y no tienen trastorno (0,5 = azar, 1 = perfecto) | ≥ 0,85 en prueba |
| Clasificación | Sensibilidad | De los que sí tienen trastorno, cuántos detecta el modelo | ≥ 0,80 (no dejar casos sin remitir) |
| Clasificación | Especificidad | De los que no tienen trastorno, cuántos descarta correctamente | ≥ 0,75 (no desperdiciar cupos de estudio) |
| Regresión | RMSE (raíz del error cuadrático medio) | Error típico al predecir la calidad del sueño, en puntos | ≤ 0,5 puntos |
| Ambos | Brecha entrenamiento–prueba | Si el modelo “memorizó” los datos (sobreajuste) | Diferencia pequeña (AUC ≤ 0,05) |
En tamizaje, la sensibilidad pesa más que la especificidad: es preferible remitir a alguien sano a un estudio que dejar sin diagnóstico a un trabajador con apnea.
glimpse() y
skim()glimpse(sueno)
## Rows: 374
## Columns: 13
## $ `Person ID` <dbl> 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 1…
## $ Gender <chr> "Male", "Male", "Male", "Male", "Male", "Mal…
## $ Age <dbl> 27, 28, 28, 28, 28, 28, 29, 29, 29, 29, 29, …
## $ Occupation <chr> "Software Engineer", "Doctor", "Doctor", "Sa…
## $ `Sleep Duration` <chr> "6.1", "6.2", "6.2", "5.9", "5.9", "5.9", "6…
## $ `Quality of Sleep` <dbl> 6, 6, 6, 4, 4, 4, 6, 7, 7, 7, 6, 7, 6, 6, 6,…
## $ `Physical Activity Level` <dbl> 42, 60, 60, 30, 30, 30, 40, 75, 75, 75, 30, …
## $ `Stress Level` <dbl> 6, 8, 8, 8, 8, 8, 7, 6, 6, 6, 8, 6, 8, 8, 8,…
## $ `BMI Category` <chr> "Overweight", "Normal", "Normal", "Obese", "…
## $ `Blood Pressure` <chr> "126/83", "125/80", "125/80", "140/90", "140…
## $ `Heart Rate` <dbl> 77, 75, 75, 85, 85, 85, 82, 70, 70, 70, 70, …
## $ `Daily Steps` <dbl> 4200, 10000, 10000, 3000, 3000, 3000, 3500, …
## $ `Sleep Disorder` <dbl> 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0,…
skim(sueno)
| Name | sueno |
| Number of rows | 374 |
| Number of columns | 13 |
| _______________________ | |
| Column type frequency: | |
| character | 5 |
| numeric | 8 |
| ________________________ | |
| Group variables | None |
Variable type: character
| skim_variable | n_missing | complete_rate | min | max | empty | n_unique | whitespace |
|---|---|---|---|---|---|---|---|
| Gender | 0 | 1 | 4 | 6 | 0 | 2 | 0 |
| Occupation | 0 | 1 | 5 | 20 | 0 | 11 | 0 |
| Sleep Duration | 0 | 1 | 1 | 3 | 0 | 27 | 0 |
| BMI Category | 0 | 1 | 5 | 13 | 0 | 4 | 0 |
| Blood Pressure | 0 | 1 | 6 | 6 | 0 | 25 | 0 |
Variable type: numeric
| skim_variable | n_missing | complete_rate | mean | sd | p0 | p25 | p50 | p75 | p100 | hist |
|---|---|---|---|---|---|---|---|---|---|---|
| Person ID | 0 | 1 | 187.50 | 108.11 | 1 | 94.25 | 187.5 | 280.75 | 374 | ▇▇▇▇▇ |
| Age | 0 | 1 | 42.18 | 8.67 | 27 | 35.25 | 43.0 | 50.00 | 59 | ▆▆▇▃▅ |
| Quality of Sleep | 0 | 1 | 7.31 | 1.20 | 4 | 6.00 | 7.0 | 8.00 | 9 | ▁▇▆▇▅ |
| Physical Activity Level | 0 | 1 | 59.17 | 20.83 | 30 | 45.00 | 60.0 | 75.00 | 90 | ▇▇▇▇▇ |
| Stress Level | 0 | 1 | 5.39 | 1.77 | 3 | 4.00 | 5.0 | 7.00 | 8 | ▇▃▂▃▃ |
| Heart Rate | 0 | 1 | 70.17 | 4.14 | 65 | 68.00 | 70.0 | 72.00 | 86 | ▇▇▂▁▁ |
| Daily Steps | 0 | 1 | 6816.84 | 1617.92 | 3000 | 5600.00 | 7000.0 | 8000.00 | 10000 | ▁▅▇▆▂ |
| Sleep Disorder | 0 | 1 | 0.41 | 0.49 | 0 | 0.00 | 0.0 | 1.00 | 1 | ▇▁▁▁▆ |
Interpretación:
Sleep Duration aparece como texto (chr) y
Blood Pressure viene como “126/83”. Ambas deben convertirse
a números.Sleep Disorder ya viene codificada como 0 = sin
trastorno y 1 = con trastorno (insomnio o apnea).# ¿Cuántos registros son copias exactas de otro (sin contar el ID)?
sum(duplicated(select(sueno, -`Person ID`)))
## [1] 248
Hallazgo de calidad de datos: 248 de los 374 registros son copias exactas de otro trabajador (mismos valores en todas las variables). Esto es típico de bases de práctica generadas artificialmente y se tendrá en cuenta al evaluar el modelo (sección 3.5.2).
base_modelo <- sueno %>%
select(Gender, Age, `Sleep Duration`, `Quality of Sleep`, `Physical Activity Level`,
`Stress Level`, `BMI Category`, `Blood Pressure`, `Heart Rate`, `Daily Steps`,
`Sleep Disorder`) %>%
mutate(
sexo = factor(Gender, levels = c("Female", "Male"), labels = c("Mujer", "Hombre")),
edad = as.numeric(Age),
duracion = as.numeric(`Sleep Duration`), # viene como texto en el Excel
calidad = as.numeric(`Quality of Sleep`),
actividad = as.numeric(`Physical Activity Level`), # minutos de actividad al día
estres = as.numeric(`Stress Level`), # escala 1 a 10
# "Normal" y "Normal Weight" son lo mismo; sobrepeso y obesidad se agrupan
imc = factor(ifelse(`BMI Category` %in% c("Normal", "Normal Weight"),
"Normal", "Sobrepeso/Obesidad")),
sistolica = as.numeric(sub("/.*", "", `Blood Pressure`)), # número antes de "/"
diastolica = as.numeric(sub(".*/", "", `Blood Pressure`)), # número después de "/"
fc = as.numeric(`Heart Rate`), # frecuencia cardíaca
pasos = as.numeric(`Daily Steps`),
trastorno = factor(`Sleep Disorder`, levels = c(0, 1), labels = c("No", "Si"))
) %>%
select(trastorno, calidad, sexo, edad, duracion, actividad, estres, imc,
sistolica, diastolica, fc, pasos) %>%
drop_na()
dim(base_modelo)
## [1] 374 12
summary(base_modelo)
## trastorno calidad sexo edad duracion
## No:219 Min. :4.000 Mujer :185 Min. :27.00 Min. :5.800
## Si:155 1st Qu.:6.000 Hombre:189 1st Qu.:35.25 1st Qu.:6.400
## Median :7.000 Median :43.00 Median :7.200
## Mean :7.313 Mean :42.18 Mean :7.132
## 3rd Qu.:8.000 3rd Qu.:50.00 3rd Qu.:7.800
## Max. :9.000 Max. :59.00 Max. :8.500
## actividad estres imc sistolica
## Min. :30.00 Min. :3.000 Normal :216 Min. :115.0
## 1st Qu.:45.00 1st Qu.:4.000 Sobrepeso/Obesidad:158 1st Qu.:125.0
## Median :60.00 Median :5.000 Median :130.0
## Mean :59.17 Mean :5.385 Mean :128.6
## 3rd Qu.:75.00 3rd Qu.:7.000 3rd Qu.:135.0
## Max. :90.00 Max. :8.000 Max. :142.0
## diastolica fc pasos
## Min. :75.00 Min. :65.00 Min. : 3000
## 1st Qu.:80.00 1st Qu.:68.00 1st Qu.: 5600
## Median :85.00 Median :70.00 Median : 7000
## Mean :84.65 Mean :70.17 Mean : 6817
## 3rd Qu.:90.00 3rd Qu.:72.00 3rd Qu.: 8000
## Max. :95.00 Max. :86.00 Max. :10000
Variables candidatas y justificación:
| Variable | Tipo | Por qué se incluye |
|---|---|---|
trastorno |
Categórica Sí/No (respuesta del modelo principal) | Es lo que se quiere detectar. |
calidad |
Numérica 1–10 (respuesta del modelo complementario) | Indicador de bienestar que se quiere mejorar. |
edad, sexo |
Numérica / categórica | La apnea aumenta con la edad y es más frecuente en hombres. |
duracion |
Numérica (horas) | Dormir poco es síntoma típico del insomnio. |
estres |
Numérica 1–10 | El estrés laboral se asocia con insomnio. |
actividad, pasos |
Numéricas | El sedentarismo empeora la calidad del sueño. |
imc |
Categórica | El sobrepeso y la obesidad son el principal factor de riesgo de apnea. |
sistolica, fc |
Numéricas | La hipertensión y la frecuencia cardíaca alta acompañan a la apnea. |
Variables excluidas:
Person ID: es solo un identificador.Occupation: tiene 11 categorías, varias con 1 a 4
personas, y el modelo debe servir para cualquier empresa.diastolica: va casi idéntica a la sistólica
(correlación ≈ 0,97); usar ambas repetiría la misma información.# Proporción de trabajadores con trastorno según IMC
base_modelo %>% count(imc, trastorno) %>% group_by(imc) %>%
mutate(porcentaje = round(100 * n / sum(n), 1))
## # A tibble: 4 × 4
## # Groups: imc [2]
## imc trastorno n porcentaje
## <fct> <fct> <int> <dbl>
## 1 Normal No 200 92.6
## 2 Normal Si 16 7.4
## 3 Sobrepeso/Obesidad No 19 12
## 4 Sobrepeso/Obesidad Si 139 88
# Promedio de las variables numéricas según presencia de trastorno
base_modelo %>% group_by(trastorno) %>%
summarise(across(c(edad, duracion, estres, sistolica, fc, actividad), ~ round(mean(.x), 1)))
## # A tibble: 2 × 7
## trastorno edad duracion estres sistolica fc actividad
## <fct> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>
## 1 No 39 7.4 5.1 124 69 57.9
## 2 Si 46.6 6.8 5.8 135. 71.8 60.9
Interpretación: el 88 % de los trabajadores con sobrepeso u obesidad tiene trastorno, contra el 7 % de los de peso normal. Quienes tienen trastorno también tienen mayor presión sistólica (≈ 135 vs. 124 mmHg), mayor edad y menos horas de sueño. Estas diferencias anticipan que el IMC y la presión arterial serán las variables con más peso.
grViz("
digraph modelo {
graph [rankdir = LR, fontsize = 11]
node [shape = box, style = 'rounded,filled', fillcolor = '#EAF2FB', fontname = Helvetica]
subgraph cluster_x {
label = 'Datos del examen ocupacional (X)'; style = dashed
dem [label = 'Edad y sexo']
imc [label = 'IMC']
pa [label = 'Presión sistólica\\ny frecuencia cardíaca']
hab [label = 'Horas de sueño, estrés,\\nactividad y pasos']
}
glm [label = 'Regresión logística\\nglm(family = binomial)', shape = ellipse, fillcolor = '#D5F5E3']
lm [label = 'Regresión lineal\\nlm()', shape = ellipse, fillcolor = '#D5F5E3']
y1 [label = 'Probabilidad de\\ntrastorno del sueño', fillcolor = '#FADBD8']
y2 [label = 'Calidad del sueño\\n(1 a 10)', fillcolor = '#FADBD8']
dec [label = 'Priorizar remisión a\\npolisomnografía', shape = note, fillcolor = '#F9E79F']
prog [label = 'Diseñar programa\\nde bienestar', shape = note, fillcolor = '#F9E79F']
{dem imc pa hab} -> glm -> y1 -> dec
{dem imc pa hab} -> lm -> y2 -> prog
}
")
1. Leer el Excel, convertir texto a números y separar la presión arterial.
2. Seleccionar variables y eliminar vacíos.
3. Dividir: 75 % entrenamiento / 25 % prueba (manteniendo la misma proporción de casos con trastorno).
4. Modelo principal: P(trastorno = Sí) = logística(b0 + b1*edad + b2*sexo + ... + b9*pasos)
5. Si P ≥ 0,5 → "remitir a estudio"; si no → "control rutinario".
6. Evaluar AUC, sensibilidad y especificidad en entrenamiento y prueba.
7. Modelo complementario: calidad = b0 + b1*duración + b2*estrés + ... + error
8. Diagnosticar residuos (error = real − predicho): ¿varianza constante? ¿forma normal?
9. Mejoras: evaluar sin registros duplicados y comparar con bosque aleatorio.
division <- initial_split(base_modelo, prop = 0.75, strata = trastorno) # strata: misma proporción de "Sí" en ambos
entrenamiento <- training(division)
prueba <- testing(division)
c(entrenamiento = nrow(entrenamiento), prueba = nrow(prueba))
## entrenamiento prueba
## 280 94
prop.table(table(entrenamiento$trastorno)); prop.table(table(prueba$trastorno))
##
## No Si
## 0.5857143 0.4142857
##
## No Si
## 0.5851064 0.4148936
Interpretación: el modelo aprende con 280 trabajadores y se califica con 94 que no vio. En ambos grupos, cerca del 41 % tiene trastorno, por lo que la comparación es justa.
formula_clas <- trastorno ~ edad + sexo + duracion + estres + actividad + imc + sistolica + fc + pasos
modelo_log <- glm(formula_clas, data = entrenamiento, family = binomial)
# Equivalente con caret: caret::train(formula_clas, data = entrenamiento, method = "glm", family = "binomial")
tidy(modelo_log) %>%
mutate(odds_ratio = exp(estimate)) %>% # cuántas veces cambia el riesgo
mutate(across(where(is.numeric), ~ round(.x, 3)))
## # A tibble: 10 × 6
## term estimate std.error statistic p.value odds_ratio
## <chr> <dbl> <dbl> <dbl> <dbl> <dbl>
## 1 (Intercept) -68.9 18.1 -3.82 0 0
## 2 edad -0.054 0.08 -0.673 0.501 0.948
## 3 sexoHombre -0.398 0.696 -0.572 0.567 0.671
## 4 duracion 1.95 1.24 1.57 0.117 7.04
## 5 estres 1.23 0.506 2.43 0.015 3.42
## 6 actividad -0.041 0.032 -1.27 0.203 0.96
## 7 imcSobrepeso/Obesidad 2.66 0.907 2.93 0.003 14.3
## 8 sistolica 0.43 0.106 4.06 0 1.54
## 9 fc -0.062 0.125 -0.494 0.621 0.94
## 10 pasos 0 0 0.019 0.984 1
Interpretación (odds_ratio): razón de
momios, es decir, cuántas veces se multiplica el riesgo de tener
trastorno cuando la variable aumenta en 1 unidad, manteniendo lo demás
igual. Solo se interpretan las variables con valor p < 0,05.
umbral <- 0.5
prob_ent <- predict(modelo_log, newdata = entrenamiento, type = "response") # probabilidad de "Si"
prob_pru <- predict(modelo_log, newdata = prueba, type = "response")
metricas_clas <- function(real, prob) {
pred <- factor(ifelse(prob >= umbral, "Si", "No"), levels = c("No", "Si"))
c(AUC = roc_auc_vec(real, prob, event_level = "second"),
Sensibilidad = sens_vec(real, pred, event_level = "second"),
Especificidad = spec_vec(real, pred, event_level = "second"),
Exactitud = accuracy_vec(real, pred))
}
tabla_log <- rbind(Entrenamiento = metricas_clas(entrenamiento$trastorno, prob_ent),
Prueba = metricas_clas(prueba$trastorno, prob_pru))
round(tabla_log, 3)
## AUC Sensibilidad Especificidad Exactitud
## Entrenamiento 0.949 0.922 0.963 0.946
## Prueba 0.921 0.923 0.945 0.936
# Matriz de confusión en prueba (filas = real, columnas = predicho)
table(Real = prueba$trastorno,
Predicho = factor(ifelse(prob_pru >= umbral, "Si", "No"), levels = c("No", "Si")))
## Predicho
## Real No Si
## No 52 3
## Si 3 36
Interpretación:
# Curva ROC: muestra sensibilidad y falsos positivos para cada umbral posible
umbrales <- seq(0, 1, by = 0.01)
curva_roc <- data.frame(
umbral = umbrales,
sensibilidad = sapply(umbrales, function(u) mean(prob_pru[prueba$trastorno == "Si"] >= u)),
falsos_pos = sapply(umbrales, function(u) mean(prob_pru[prueba$trastorno == "No"] >= u))
)
g_roc <- ggplot(curva_roc, aes(x = falsos_pos, y = sensibilidad, text = paste("Umbral:", umbral))) +
geom_path(aes(group = 1), color = "steelblue", linewidth = 1) +
geom_abline(linetype = "dashed", color = "grey50") +
labs(title = "Curva ROC del modelo logístico (conjunto de prueba)",
x = "1 − Especificidad (proporción de falsos positivos)", y = "Sensibilidad") +
theme_minimal()
ggplotly(g_roc, tooltip = c("text", "x", "y"))
Comentario: la curva sube pegada al eje izquierdo y se aleja de la diagonal (que representa adivinar al azar). Al pasar el mouse se ve el umbral de cada punto. Si la IPS quiere detectar aún más casos, puede bajar el umbral (por ejemplo, a 0,3), aceptando más remisiones innecesarias.
plot_ly(base_modelo,
x = ~jitter(sistolica, 1), y = ~jitter(estres, 0.6), z = ~jitter(edad, 1), # jitter: separa puntos repetidos
color = ~trastorno, colors = c("#2E86C1", "#E74C3C"),
symbol = ~imc, symbols = c("circle", "diamond"),
type = "scatter3d", mode = "markers", marker = list(size = 4, opacity = 0.7)) %>%
layout(title = "Trabajadores según presión sistólica, estrés y edad",
scene = list(xaxis = list(title = "Presión sistólica (mmHg)"),
yaxis = list(title = "Estrés (1-10)"),
zaxis = list(title = "Edad")))
Comentario: los trabajadores con trastorno (rojo) se concentran en la zona de presión sistólica alta, y la mayoría tiene forma de rombo (sobrepeso u obesidad). Los de azul se agrupan con presión por debajo de 130 mmHg.
# Superficie de probabilidad predicha según presión sistólica y estrés,
# para un trabajador con peso normal y otro con sobrepeso/obesidad (demás variables en la mediana)
rejilla <- expand.grid(sistolica = seq(115, 142, length.out = 30),
estres = seq(3, 8, length.out = 30))
valores_tipicos <- base_modelo %>%
summarise(across(c(edad, duracion, actividad, fc, pasos), median))
rejilla <- cbind(rejilla, valores_tipicos, sexo = factor("Mujer", levels = levels(base_modelo$sexo)))
prob_normal <- predict(modelo_log, type = "response",
newdata = mutate(rejilla, imc = factor("Normal", levels = levels(base_modelo$imc))))
prob_sobrepeso <- predict(modelo_log, type = "response",
newdata = mutate(rejilla, imc = factor("Sobrepeso/Obesidad", levels = levels(base_modelo$imc))))
x_sis <- unique(rejilla$sistolica); y_est <- unique(rejilla$estres)
plot_ly() %>%
add_surface(x = x_sis, y = y_est, z = t(matrix(prob_normal, nrow = 30)),
colorscale = list(c(0, 1), c("#D6EAF8", "#1B4F72")), showscale = FALSE,
name = "IMC normal", opacity = 0.9) %>%
add_surface(x = x_sis, y = y_est, z = t(matrix(prob_sobrepeso, nrow = 30)),
colorscale = list(c(0, 1), c("#FADBD8", "#922B21")), showscale = FALSE,
name = "Sobrepeso/obesidad", opacity = 0.9) %>%
layout(title = "Probabilidad de trastorno (azul: IMC normal; rojo: sobrepeso)",
scene = list(xaxis = list(title = "Presión sistólica"),
yaxis = list(title = "Estrés"),
zaxis = list(title = "Probabilidad", range = c(0, 1)),
camera = list(eye = list(x = 1.6, y = -1.6, z = 0.9))))
Comentario: la superficie roja (sobrepeso/obesidad) sube mucho antes que la azul. Con sobrepeso, una presión de 130 mmHg ya implica alta probabilidad de trastorno; con peso normal, la probabilidad solo aumenta cuando se combinan presión alta y estrés alto. Esta gráfica se puede usar como regla práctica de remisión en el examen ocupacional.
formula_reg <- calidad ~ duracion + estres + edad + fc + actividad + pasos + sexo + imc
modelo_lin <- lm(formula_reg, data = entrenamiento)
tidy(modelo_lin) %>% mutate(across(where(is.numeric), ~ round(.x, 4)))
## # A tibble: 9 × 5
## term estimate std.error statistic p.value
## <chr> <dbl> <dbl> <dbl> <dbl>
## 1 (Intercept) 7.46 0.639 11.7 0
## 2 duracion 0.141 0.0519 2.72 0.007
## 3 estres -0.417 0.0255 -16.3 0
## 4 edad 0.0534 0.0037 14.6 0
## 5 fc -0.0215 0.0078 -2.76 0.0061
## 6 actividad 0.0044 0.0019 2.39 0.0177
## 7 pasos 0 0 1.62 0.107
## 8 sexoHombre 0.244 0.0492 4.96 0
## 9 imcSobrepeso/Obesidad -0.750 0.0613 -12.2 0
glance(modelo_lin) %>% select(r.squared, adj.r.squared, sigma) %>% round(3)
## # A tibble: 1 × 3
## r.squared adj.r.squared sigma
## <dbl> <dbl> <dbl>
## 1 0.949 0.947 0.279
pred_ent_lin <- predict(modelo_lin, newdata = entrenamiento)
pred_pru_lin <- predict(modelo_lin, newdata = prueba)
tabla_lin <- rbind(
Entrenamiento = c(RMSE = rmse_vec(entrenamiento$calidad, pred_ent_lin),
R2 = rsq_vec(entrenamiento$calidad, pred_ent_lin)),
Prueba = c(RMSE = rmse_vec(prueba$calidad, pred_pru_lin),
R2 = rsq_vec(prueba$calidad, pred_pru_lin)))
round(tabla_lin, 3)
## RMSE R2
## Entrenamiento 0.275 0.949
## Prueba 0.344 0.912
Interpretación:
La regresión lineal supone que los residuos (error = calidad real − calidad predicha):
broom::augment() agrega a cada fila su valor predicho
(.fitted) y su residuo (.resid). ggplot2 no
tiene una función geom_residuals(), por lo que los residuos
se grafican con geom_point(), geom_histogram()
y stat_qq().
diagnostico <- augment(modelo_lin)
g_res <- ggplot(diagnostico, aes(x = .fitted, y = .resid)) +
geom_jitter(alpha = 0.5, color = "steelblue", width = 0.03, height = 0.03) +
geom_hline(yintercept = 0, color = "red", linetype = "dashed") +
geom_smooth(se = FALSE, color = "darkorange", method = "loess") +
labs(title = "Residuos vs. valores predichos (calidad del sueño)",
x = "Calidad predicha", y = "Residuo (puntos)") +
theme_minimal()
ggplotly(g_res)
g_hist <- ggplot(diagnostico, aes(x = .resid)) +
geom_histogram(bins = 25, fill = "steelblue", color = "white") +
labs(title = "Distribución de los residuos", x = "Residuo (puntos)", y = "Frecuencia") +
theme_minimal()
ggplotly(g_hist)
ggplot(diagnostico, aes(sample = .resid)) +
stat_qq(alpha = 0.5) + stat_qq_line(color = "red") +
labs(title = "Gráfico Q-Q de los residuos",
x = "Cuantiles teóricos (normal)", y = "Cuantiles de los residuos") +
theme_minimal()
shapiro.test(diagnostico$.resid) # prueba de normalidad: p < 0,05 indica que no es normal
##
## Shapiro-Wilk normality test
##
## data: diagnostico$.resid
## W = 0.96354, p-value = 1.635e-06
Interpretación:
data.frame(
Metrica = c("AUC (prueba)", "Sensibilidad (prueba)", "Especificidad (prueba)",
"Brecha AUC entrenamiento - prueba", "RMSE calidad (prueba)"),
Resultado = round(c(tabla_log["Prueba", "AUC"], tabla_log["Prueba", "Sensibilidad"],
tabla_log["Prueba", "Especificidad"],
tabla_log["Entrenamiento", "AUC"] - tabla_log["Prueba", "AUC"],
tabla_lin["Prueba", "RMSE"]), 3),
Meta = c(">= 0,85", ">= 0,80", ">= 0,75", "<= 0,05", "<= 0,50")
)
## Metrica Resultado Meta
## 1 AUC (prueba) 0.921 >= 0,85
## 2 Sensibilidad (prueba) 0.923 >= 0,80
## 3 Especificidad (prueba) 0.945 >= 0,75
## 4 Brecha AUC entrenamiento - prueba 0.028 <= 0,05
## 5 RMSE calidad (prueba) 0.344 <= 0,50
Interpretación: con la base completa, los dos modelos cumplen todas las metas. Antes de recomendar su uso, hay que revisar si el resultado es demasiado optimista por los registros duplicados.
# ¿Qué porcentaje de los trabajadores de prueba tiene una "copia exacta" en entrenamiento?
clave <- function(df) do.call(paste, df)
round(100 * mean(clave(prueba) %in% clave(entrenamiento)), 1)
## [1] 75.5
Interpretación: cerca del 75 % de los trabajadores de prueba (3 de cada 4) ya aparecen idénticos en entrenamiento. El modelo está siendo calificado en gran parte con “preguntas que ya vio”, lo que se conoce como fuga de información. Para medir el desempeño real, se repite todo con perfiles únicos.
base_unica <- distinct(base_modelo) # deja un solo registro por perfil
nrow(base_unica)
## [1] 122
division_u <- initial_split(base_unica, prop = 0.75, strata = trastorno)
ent_u <- training(division_u); pru_u <- testing(division_u)
modelo_log_u <- glm(formula_clas, data = ent_u, family = binomial)
tabla_unica <- rbind(
Entrenamiento = metricas_clas(ent_u$trastorno, predict(modelo_log_u, ent_u, type = "response")),
Prueba = metricas_clas(pru_u$trastorno, predict(modelo_log_u, pru_u, type = "response")))
round(tabla_unica, 3)
## AUC Sensibilidad Especificidad Exactitud
## Entrenamiento 0.894 0.816 0.849 0.835
## Prueba 0.825 0.846 0.778 0.806
Interpretación:
Un bosque aleatorio combina cientos de árboles de decisión y puede capturar relaciones no lineales. Se entrena con las mismas variables y se califica con el mismo conjunto de prueba de la sección 3.4.
modelo_rf <- randomForest(formula_clas, data = entrenamiento, ntree = 500, importance = TRUE)
# Equivalente con caret: caret::train(formula_clas, data = entrenamiento, method = "rf")
prob_ent_rf <- predict(modelo_rf, newdata = entrenamiento, type = "prob")[, "Si"]
prob_pru_rf <- predict(modelo_rf, newdata = prueba, type = "prob")[, "Si"]
comparacion <- data.frame(
Modelo = rep(c("Regresión logística", "Bosque aleatorio"), each = 2),
Conjunto = rep(c("Entrenamiento", "Prueba"), 2),
rbind(metricas_clas(entrenamiento$trastorno, prob_ent),
metricas_clas(prueba$trastorno, prob_pru),
metricas_clas(entrenamiento$trastorno, prob_ent_rf),
metricas_clas(prueba$trastorno, prob_pru_rf)))
comparacion %>% mutate(across(where(is.numeric), ~ round(.x, 3)))
## Modelo Conjunto AUC Sensibilidad Especificidad Exactitud
## 1 Regresión logística Entrenamiento 0.949 0.922 0.963 0.946
## 2 Regresión logística Prueba 0.921 0.923 0.945 0.936
## 3 Bosque aleatorio Entrenamiento 0.980 0.922 0.963 0.946
## 4 Bosque aleatorio Prueba 0.916 0.923 0.945 0.936
importancia <- data.frame(variable = rownames(importance(modelo_rf)),
importancia = importance(modelo_rf)[, "MeanDecreaseGini"]) %>%
arrange(importancia)
g_imp <- ggplot(importancia, aes(x = reorder(variable, importancia), y = importancia)) +
geom_col(fill = "darkgreen") + coord_flip() +
labs(title = "Importancia de las variables (bosque aleatorio)",
x = "", y = "Disminución media de impureza (Gini)") +
theme_minimal()
ggplotly(g_imp)
Interpretación: