De los 1,470 empleados de la base, 237 (16%) cambiaron de cargo. Queríamos saber qué los diferencia de quienes se quedaron y, sobre todo, poder anticipar quién será el próximo.
Para eso usamos un modelo logit (regresión logística binomial) con seis variables. Encontramos que las seis influyen en la rotación, y tres de ellas pesan mucho más que el resto:
En cambio, la edad, un mejor salario y los años en el cargo funcionan como “anclas” que reducen el riesgo.
El modelo acierta razonablemente: con datos que nunca había visto, logra un AUC de 0.75. Para usarlo en la práctica recomendamos marcar como “en riesgo” a todo empleado con probabilidad de 22% o más, y no de 50%, que es el corte habitual. La razón se explica en la sección 7.
La empresa quiere dejar de enterarse de la rotación cuando ya ocurrió y empezar a anticiparla. La pregunta es de sí o no (¿esta persona rotará?), así que la variable respuesta solo toma dos valores:
\[y_i = \begin{cases} 1 & \text{si el empleado rota} \\ 0 & \text{si no rota} \end{cases} \qquad y_i \sim \text{Bernoulli}(\pi_i)\]
Una regresión lineal podría dar “probabilidades” de 130% o de −20%. El modelo logit, también llamado regresión logística binaria o binomial, resuelve ese problema: no modela la probabilidad directamente, sino su logaritmo de chances (odds), y así garantiza un resultado siempre entre 0 y 1:
\[\ln\left(\frac{\pi_i}{1-\pi_i}\right) = \beta_0 + \beta_1 x_{1i} + \dots + \beta_k x_{ki}\]
Los coeficientes se estiman por máxima verosimilitud. Para leerlos usamos \(e^{\beta}\), la razón de odds (OR): un OR de 2 significa que las chances de rotar se duplican, y un OR de 0.9, que caen un 10%.
La base no tiene datos faltantes ni registros duplicados, así que pudimos trabajarla directamente. De sus 24 variables elegimos seis, cada una con una hipótesis concreta:
| Variable | Tipo | Lo que esperamos encontrar |
|---|---|---|
| Horas extra | Categórica | Trabajar de más desgasta y quita tiempo personal. Quien hace horas extra rotará más. |
| Estado civil | Categórica | Un soltero suele tener menos ataduras para moverse. Los solteros rotarán más que los casados. |
| Viajes de negocio | Categórica | Viajar seguido cansa y altera la vida familiar. Quien viaja frecuentemente rotará más. |
| Edad | Cuantitativa | Los más jóvenes están explorando su carrera. A mayor edad, menos rotación. |
| Ingreso mensual | Cuantitativa | Un buen salario quita motivos para buscar otra cosa. A mayor ingreso, menos rotación. |
| Antigüedad en el cargo | Cuantitativa | Los primeros años en un cargo son los más inestables. A más años en el cargo, menos rotación. |
Preferimos “antigüedad en el cargo” a “años de experiencia” porque esta última está muy correlacionada con la edad y el ingreso (r ≈ 0.7). Incluir las tres habría causado multicolinealidad desde el principio.
d <- rotacion %>%
transmute(
y = as.integer(Rotación == "Si"),
Rotacion = Rotación,
Horas_Extra = factor(Horas_Extra, levels = c("No", "Si")),
Estado_Civil = factor(Estado_Civil, levels = c("Casado", "Divorciado", "Soltero")),
Viaje = factor(`Viaje de Negocios`, levels = c("No_Viaja", "Raramente", "Frecuentemente")),
Edad, Ingreso_Mensual, Antigüedad_Cargo
)
ggplot(d, aes(Rotacion, fill = Rotacion)) +
geom_bar(width = 0.5) +
geom_text(stat = "count", aes(label = after_stat(count)), vjust = -0.4) +
scale_fill_manual(values = c(No = azul, Si = rojo), guide = "none") +
labs(x = "¿Rotó?", y = "Empleados") + theme_minimal()
Uno de cada seis empleados rotó (16.1%). Es una cifra alta para cualquier área de talento humano, pero sigue siendo la minoría, y eso tiene una consecuencia importante: un “modelo” que simplemente dijera nadie se va acertaría el 84% de las veces sin haber aprendido nada. Por eso, más adelante no juzgaremos al modelo solo por su porcentaje de aciertos.
porc <- function(x) round(100 * prop.table(table(x)), 1)
data.frame(
Variable = c("Horas extra", "", "Estado civil", "", "", "Viajes", "", ""),
Categoría = c(levels(d$Horas_Extra), levels(d$Estado_Civil), levels(d$Viaje)),
`% de empleados` = c(porc(d$Horas_Extra), porc(d$Estado_Civil), porc(d$Viaje)),
check.names = FALSE
) %>% tabla(1)
| Variable | Categoría | % de empleados | |
|---|---|---|---|
| No | Horas extra | No | 71.7 |
| Si | Si | 28.3 | |
| Casado | Estado civil | Casado | 45.8 |
| Divorciado | Divorciado | 22.2 | |
| Soltero | Soltero | 32.0 | |
| No_Viaja | Viajes | No_Viaja | 10.2 |
| Raramente | Raramente | 71.0 | |
| Frecuentemente | Frecuentemente | 18.8 |
Poco más de una cuarta parte de la plantilla trabaja horas extra (28%). La mayoría está casada (46%) y casi todos viajan “raramente” (71%). El grupo que no viaja es el más pequeño (10%, unas 150 personas), pero alcanza para usarlo como punto de comparación.
resumen <- function(x) c(Media = mean(x), Mediana = median(x), `Desv. est.` = sd(x),
Mín = min(x), Máx = max(x))
rbind(Edad = resumen(d$Edad),
`Ingreso mensual` = resumen(d$Ingreso_Mensual),
`Antigüedad en el cargo` = resumen(d$Antigüedad_Cargo)) %>% tabla(1)
| Media | Mediana | Desv. est. | Mín | Máx | |
|---|---|---|---|---|---|
| Edad | 36.9 | 36 | 9.1 | 18 | 60 |
| Ingreso mensual | 6,502.9 | 4,919 | 4,708.0 | 1,009 | 19,999 |
| Antigüedad en el cargo | 4.2 | 3 | 3.6 | 0 | 18 |
d %>%
tidyr::pivot_longer(c(Edad, Ingreso_Mensual, Antigüedad_Cargo)) %>%
ggplot(aes(value)) +
geom_histogram(bins = 25, fill = "grey55", color = "white") +
facet_wrap(~name, scales = "free") +
labs(x = NULL, y = "Empleados") + theme_minimal()
El modelo logit no exige que estas variables tengan una distribución normal, así que el sesgo del ingreso no es un problema.
Ahora comparamos a quienes rotaron con quienes no, una variable a la vez. Para las categóricas usamos la prueba χ², que contrasta si la tasa de rotación cambia según la categoría. Para las cuantitativas usamos la prueba t y la de Wilcoxon, esta última porque el ingreso y la antigüedad tienen distribuciones sesgadas.
tasa <- function(var) {
d %>% group_by(Grupo = .data[[var]]) %>% summarise(tasa = 100 * mean(y)) %>% mutate(Variable = var)
}
bind_rows(tasa("Horas_Extra"), tasa("Estado_Civil"), tasa("Viaje")) %>%
mutate(Grupo = as.character(Grupo)) %>%
ggplot(aes(Grupo, tasa)) +
geom_col(fill = rojo, width = 0.6) +
geom_text(aes(label = paste0(round(tasa, 1), "%")), vjust = -0.4, size = 3.2) +
geom_hline(yintercept = 16.1, linetype = "dashed") +
facet_wrap(~Variable, scales = "free_x") +
labs(x = NULL, y = "% que rota") + ylim(0, 35) + theme_minimal()
chi <- lapply(c("Horas_Extra", "Estado_Civil", "Viaje"), function(v) chisq.test(table(d[[v]], d$y)))
data.frame(Variable = c("Horas extra", "Estado civil", "Viajes"),
`χ²` = sapply(chi, `[[`, "statistic"),
`valor p` = format.pval(sapply(chi, `[[`, "p.value"), digits = 2),
check.names = FALSE) %>% tabla(1)
| Variable | χ² | valor p |
|---|---|---|
| Horas extra | 87.6 | < 2e-16 |
| Estado civil | 46.2 | 9.5e-11 |
| Viajes | 24.2 | 5.6e-06 |
La línea punteada del gráfico marca la tasa general (16%). Las diferencias son claras, y las tres pruebas χ² dan p < 0.001:
d %>%
tidyr::pivot_longer(c(Edad, Ingreso_Mensual, Antigüedad_Cargo)) %>%
ggplot(aes(Rotacion, value, fill = Rotacion)) +
geom_boxplot(alpha = 0.85, outlier.alpha = 0.3) +
scale_fill_manual(values = c(No = azul, Si = rojo), guide = "none") +
facet_wrap(~name, scales = "free") +
labs(x = "¿Rotó?", y = NULL) + theme_minimal()
comparar <- function(v) {
c(`Promedio (no rota)` = mean(d[[v]][d$y == 0]),
`Promedio (rota)` = mean(d[[v]][d$y == 1]),
`p prueba t` = t.test(d[[v]] ~ d$y)$p.value,
`p Wilcoxon` = wilcox.test(d[[v]] ~ d$y)$p.value)
}
rbind(Edad = comparar("Edad"), Ingreso = comparar("Ingreso_Mensual"),
`Antigüedad en el cargo` = comparar("Antigüedad_Cargo")) %>% signif(3) %>% tabla()
| Promedio (no rota) | Promedio (rota) | p prueba t | p Wilcoxon | |
|---|---|---|---|---|
| Edad | 37.60 | 33.6 | 0 | 0 |
| Ingreso | 6,830.00 | 4,790.0 | 0 | 0 |
| Antigüedad en el cargo | 4.48 | 2.9 | 0 | 0 |
El perfil de quien rota es claro: en promedio es 4 años más joven, gana $2,000 menos al mes y lleva año y medio menos en su cargo. Las dos pruebas son significativas en las tres variables (p < 0.001).
Para cerrar el análisis bivariado, ajustamos una regresión logit con cada variable sola. Lo que nos interesa es el signo de su coeficiente:
simple <- function(v) {
m <- glm(reformulate(v, "y"), data = d, family = binomial)
broom::tidy(m)[-1, ] %>% transmute(Variable = term, β = estimate, OR = exp(estimate), `valor p` = p.value)
}
bind_rows(lapply(c("Horas_Extra", "Estado_Civil", "Viaje", "Edad", "Ingreso_Mensual", "Antigüedad_Cargo"), simple)) %>%
mutate(`valor p` = format.pval(`valor p`, digits = 2, eps = 0.001)) %>% tabla(4)
| Variable | β | OR | valor p |
|---|---|---|---|
| Horas_ExtraSi | 1.3274 | 3.7712 | <0.001 |
| Estado_CivilDivorciado | -0.2395 | 0.7871 | 0.271 |
| Estado_CivilSoltero | 0.8772 | 2.4041 | <0.001 |
| ViajeRaramente | 0.7044 | 2.0225 | 0.025 |
| ViajeFrecuentemente | 1.3389 | 3.8149 | <0.001 |
| Edad | -0.0523 | 0.9491 | <0.001 |
| Ingreso_Mensual | -0.0001 | 0.9999 | <0.001 |
| Antigüedad_Cargo | -0.1463 | 0.8639 | <0.001 |
Las seis hipótesis se cumplen. Horas extra, soltería y viajes tienen signo positivo, es decir, aumentan el riesgo. Edad, ingreso y antigüedad tienen signo negativo, es decir, lo reducen. El único matiz es el de los divorciados: no rotan más que los casados (p = 0.27). Lo que aumenta el riesgo no es “no estar casado”, sino ser soltero.
Hasta ahora miramos cada variable por separado. Pero varias están relacionadas entre sí: los más jóvenes también suelen ganar menos y llevar menos tiempo en el cargo. Para separar el efecto propio de cada una, las metemos todas en un mismo modelo.
modelo <- glm(y ~ Horas_Extra + Estado_Civil + Viaje + Edad + Ingreso_Mensual + Antigüedad_Cargo,
data = d, family = binomial(link = "logit"))
broom::tidy(modelo) %>%
transmute(Variable = term, β = estimate, OR = exp(estimate),
`OR IC 95%` = sprintf("%.3f – %.3f", exp(estimate - 1.96 * std.error), exp(estimate + 1.96 * std.error)),
`valor p` = format.pval(p.value, digits = 2, eps = 0.001)) %>%
tabla(4)
| Variable | β | OR | OR IC 95% | valor p |
|---|---|---|---|---|
| (Intercept) | -1.4259 | 0.2403 | 0.097 – 0.593 | 0.0020 |
| Horas_ExtraSi | 1.4512 | 4.2682 | 3.130 – 5.820 | <0.001 |
| Estado_CivilDivorciado | -0.2913 | 0.7473 | 0.477 – 1.172 | 0.2043 |
| Estado_CivilSoltero | 0.7935 | 2.2111 | 1.580 – 3.094 | <0.001 |
| ViajeRaramente | 0.6700 | 1.9543 | 1.022 – 3.736 | 0.0427 |
| ViajeFrecuentemente | 1.3246 | 3.7607 | 1.883 – 7.511 | <0.001 |
| Edad | -0.0289 | 0.9716 | 0.953 – 0.991 | 0.0042 |
| Ingreso_Mensual | -0.0001 | 0.9999 | 1.000 – 1.000 | 0.0069 |
| Antigüedad_Cargo | -0.1041 | 0.9011 | 0.854 – 0.951 | <0.001 |
Cada efecto se interpreta comparando dos personas idénticas en todo lo demás:
Comparados con el análisis bivariado, los efectos de edad, ingreso y antigüedad se reducen un poco, porque en parte se solapaban entre sí. Aun así, los tres conservan un efecto propio.
La prueba de razón de verosimilitud compara qué tan bien explica los datos el modelo con una variable y sin ella. Si al quitarla el ajuste empeora mucho, esa variable importa.
nulo <- glm(y ~ 1, data = d, family = binomial)
global <- anova(nulo, modelo, test = "LRT")
cat("Prueba global: G² =", round(global$Deviance[2], 1), "con", global$Df[2],
"gl, p", format.pval(global$`Pr(>Chi)`[2], eps = 0.001), "\n")
## Prueba global: G² = 216.2 con 8 gl, p < 0.001
drop1(modelo, test = "LRT")[-1, c("Df", "LRT", "Pr(>Chi)")] %>%
arrange(desc(LRT)) %>%
setNames(c("gl", "G² al quitarla", "valor p")) %>% tabla(4)
| gl | G² al quitarla | valor p | |
|---|---|---|---|
| Horas_Extra | 1 | 85.4125 | 0.0000 |
| Estado_Civil | 2 | 32.8408 | 0.0000 |
| Viaje | 2 | 20.1808 | 0.0000 |
| Antigüedad_Cargo | 1 | 15.5692 | 0.0001 |
| Edad | 1 | 8.5536 | 0.0034 |
| Ingreso_Mensual | 1 | 7.9060 | 0.0049 |
vif(modelo)[, 1] %>% round(2)
## Horas_Extra Estado_Civil Viaje Edad
## 1.03 1.03 1.01 1.27
## Ingreso_Mensual Antigüedad_Cargo
## 1.33 1.10
Evaluar un modelo con los mismos datos con los que se construyó es como calificar a un estudiante con el examen que ya resolvió. Por eso separamos la base: con el 70% estimamos el modelo y con el 30% restante (442 empleados que el modelo nunca vio) medimos su desempeño.
set.seed(123)
idx <- unlist(lapply(split(seq_len(nrow(d)), d$y), function(i) sample(i, floor(0.7 * length(i)))))
train <- d[idx, ]
test <- d[-idx, ]
modelo_train <- glm(formula(modelo), data = train, family = binomial)
prob_test <- predict(modelo_train, test, type = "response")
roc_test <- roc(test$y, prob_test, quiet = TRUE)
plot(roc_test, col = rojo, lwd = 2, legacy.axes = TRUE, print.auc = TRUE,
xlab = "1 - Especificidad", ylab = "Sensibilidad", main = "Curva ROC (datos de validación)")
La curva ROC muestra cuántos de los que rotan detecta el modelo (sensibilidad) frente a cuántas falsas alarmas genera, para todos los puntos de corte posibles. El área bajo la curva, el AUC, resume todo en un número:
El modelo entrega una probabilidad. Para actuar necesitamos una regla: si la probabilidad supera el corte, intervenimos. Ese corte define el equilibrio entre dos errores:
metricas <- function(corte) {
pred <- prob_test >= corte
c(Corte = corte,
Exactitud = mean(pred == test$y),
Sensibilidad = mean(pred[test$y == 1]),
Especificidad = mean(!pred[test$y == 0]),
`% alertados` = mean(pred))
}
sens <- as.data.frame(t(sapply(seq(0.05, 0.6, by = 0.01), metricas)))
youden <- coords(roc(train$y, fitted(modelo_train), quiet = TRUE), "best", ret = "threshold")$threshold
sens %>%
tidyr::pivot_longer(c(Exactitud, Sensibilidad, Especificidad)) %>%
ggplot(aes(Corte, value, color = name)) +
geom_line(linewidth = 1) +
geom_vline(xintercept = c(youden, 0.5), linetype = "dashed", color = c("black", "grey60")) +
annotate("text", x = youden + 0.01, y = 0.05, label = "corte recomendado", hjust = 0, size = 3) +
scale_color_manual(values = c(Exactitud = "grey40", Sensibilidad = rojo, Especificidad = azul)) +
scale_y_continuous(labels = scales::percent) +
labs(x = "Punto de corte", y = NULL, color = NULL) +
theme_minimal() + theme(legend.position = "bottom")
as.data.frame(t(sapply(c(0.10, 0.15, 0.20, youden, 0.30, 0.40, 0.50), metricas))) %>%
mutate(across(-Corte, ~ round(100 * .x, 1)), Corte = round(Corte, 2)) %>%
tabla(2) %>% row_spec(4, bold = TRUE, background = "#FBEDE9")
| Corte | Exactitud | Sensibilidad | Especificidad | % alertados |
|---|---|---|---|---|
| 0.10 | 57.2 | 84.7 | 51.9 | 54.1 |
| 0.15 | 68.1 | 72.2 | 67.3 | 39.1 |
| 0.20 | 74.0 | 62.5 | 76.2 | 30.1 |
| 0.22 | 76.9 | 61.1 | 80.0 | 26.7 |
| 0.30 | 80.5 | 43.1 | 87.8 | 17.2 |
| 0.40 | 82.8 | 27.8 | 93.5 | 10.0 |
| 0.50 | 83.5 | 12.5 | 97.3 | 4.3 |
Todas las métricas en porcentaje, sobre los 442 empleados de validación.
El corte de 0.5 no sirve aquí. Con él, el modelo acierta el 83.5% de las veces, una cifra que suena bien hasta que se recuerda que decir “nadie se va” acierta el 83.7%. Detrás de ese porcentaje, el modelo solo detecta a 1 de cada 8 personas que realmente se van. Para retener talento, eso no sirve.
Recomendamos un corte de 0.22. Es el que mejor equilibra sensibilidad y especificidad (índice de Youden) y lo elegimos con los datos de entrenamiento. Con él:
El corte puede ajustarse según el costo de intervenir. Si la intervención es barata, como una conversación con el jefe, conviene bajarlo a 0.15 para cubrir a más gente. Si es costosa, como un aumento de sueldo, conviene subirlo a 0.30 para concentrarse en los casos más claros.
Imaginemos a Andrés: 28 años, soltero, hace horas extra, viaja con frecuencia, gana $3,000 y lleva un año en su cargo.
andres <- data.frame(Horas_Extra = "Si", Estado_Civil = "Soltero", Viaje = "Frecuentemente",
Edad = 28, Ingreso_Mensual = 3000, Antigüedad_Cargo = 1)
escenarios <- andres[rep(1, 4), ]
escenarios$Horas_Extra[2:4] <- "No"
escenarios$Viaje[3:4] <- "Raramente"
escenarios$Ingreso_Mensual[4] <- 4500
data.frame(Escenario = c("Andrés hoy", "Sin horas extra", "Sin horas extra y viajando menos",
"Lo anterior + $1,500 más de salario"),
`Probabilidad de rotar` = paste0(round(100 * predict(modelo, escenarios, type = "response")), "%"),
check.names = FALSE) %>% tabla()
| Escenario | Probabilidad de rotar |
|---|---|
| Andrés hoy | 74% |
| Sin horas extra | 40% |
| Sin horas extra y viajando menos | 25% |
| Lo anterior + $1,500 más de salario | 23% |
Hoy, Andrés tiene un 74% de probabilidad de rotar, muy por encima del corte de 22%. Hay que intervenir.
La tabla muestra el efecto de las medidas que sí dependen de la empresa:
Ninguna medida basta por sí sola: el riesgo de Andrés se acumula por varios frentes, y la solución también tiene que llegar por varios. Lo que la empresa no puede cambiar, como su edad o su estado civil, sirve para saber a quién mirar primero.
Con base en las variables significativas, proponemos:
Una advertencia final. El modelo muestra asociaciones, no causas. Es razonable pensar que las horas extra empujan a la gente a irse, pero también podría ocurrir que quien ya decidió irse acepte más horas extra para ahorrar. Por eso conviene probar estas medidas primero con un grupo piloto. Además, las probabilidades sirven mejor para ordenar a los empleados por riesgo que como un pronóstico exacto: la prueba de Hosmer-Lemeshow sugiere que la calibración no es perfecta. Usado así, como una forma de saber a quién escuchar primero, el modelo es una herramienta útil.
summary(modelo)
##
## Call:
## glm(formula = y ~ Horas_Extra + Estado_Civil + Viaje + Edad +
## Ingreso_Mensual + Antigüedad_Cargo, family = binomial(link = "logit"),
## data = d)
##
## Coefficients:
## Estimate Std. Error z value Pr(>|z|)
## (Intercept) -1.426e+00 4.605e-01 -3.096 0.001961 **
## Horas_ExtraSi 1.451e+00 1.582e-01 9.173 < 2e-16 ***
## Estado_CivilDivorciado -2.913e-01 2.295e-01 -1.269 0.204340
## Estado_CivilSoltero 7.935e-01 1.714e-01 4.631 3.64e-06 ***
## ViajeRaramente 6.700e-01 3.306e-01 2.027 0.042676 *
## ViajeFrecuentemente 1.325e+00 3.530e-01 3.753 0.000175 ***
## Edad -2.885e-02 1.009e-02 -2.860 0.004232 **
## Ingreso_Mensual -6.808e-05 2.518e-05 -2.703 0.006870 **
## Antigüedad_Cargo -1.041e-01 2.731e-02 -3.812 0.000138 ***
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## (Dispersion parameter for binomial family taken to be 1)
##
## Null deviance: 1298.6 on 1469 degrees of freedom
## Residual deviance: 1082.4 on 1461 degrees of freedom
## AIC: 1100.4
##
## Number of Fisher Scoring iterations: 5