Analítica de Personas · Semestre otoño 2026 · Semana 3 · Caso InnovaCo
Queremos predecir si un empleado se va a ir (Sí/No). La variable dependiente es binaria. La regresión lineal no funciona aquí porque puede predecir probabilidades menores que 0 o mayores que 1, lo cual no tiene sentido.
La regresión logística resuelve esto con la función logística (sigmoid), que transforma cualquier valor real en una probabilidad acotada entre 0 y 1:
La curva tiene forma de "S": cuando el predictor es muy bajo, la probabilidad está cerca de 0; cuando es muy alto, cerca de 1; y la transición es gradual, no abrupta.
library(tidyverse)
library(broom)
innovaco <- read_csv("innovaco_empleados.csv")
# Crear variable binaria
innovaco <- innovaco |>
mutate(rotacion_bin = if_else(rotacion == "Sí", 1, 0))
# Verificar la tasa base
mean(innovaco$rotacion_bin)
# ~0.27 → el 27% se va. Este es nuestro baseline.
# Sin predictores: solo la tasa base
mod_nulo <- glm(rotacion_bin ~ 1, data = innovaco, family = binomial)
# El intercepto en probabilidad:
plogis(coef(mod_nulo)) # ≈ 0.27 (la tasa de rotación)
# Modelo 1: solo satisfacción
mod_1 <- glm(rotacion_bin ~ satisfaccion_laboral,
data = innovaco, family = binomial)
# Modelo 2: satisfacción + antigüedad + edad
mod_3 <- glm(rotacion_bin ~ satisfaccion_laboral + antiguedad_anios + edad,
data = innovaco, family = binomial)
# Modelo 3: agregar departamento e ingreso
mod_ext <- glm(rotacion_bin ~ satisfaccion_laboral + antiguedad_anios +
edad + departamento + ingreso_mensual,
data = innovaco, family = binomial)
bind_rows(
glance(mod_nulo) |> mutate(modelo = "0. Nulo"),
glance(mod_1) |> mutate(modelo = "1. Satisfacción"),
glance(mod_3) |> mutate(modelo = "2. Sat+Antig+Edad"),
glance(mod_ext) |> mutate(modelo = "3. Extendido")
) |>
select(modelo, AIC, BIC, deviance) |>
arrange(AIC)
# Tabla de odds ratios del mejor modelo
tidy(mod_ext, exponentiate = TRUE, conf.int = TRUE) |>
filter(term != "(Intercept)") |>
mutate(
interpretacion = case_when(
estimate < 1 ~ paste0("Reduce chances en ",
round((1 - estimate) * 100), "%"),
estimate > 1 ~ paste0("Aumenta chances en ",
round((estimate - 1) * 100), "%"),
TRUE ~ "Sin efecto"
)
)
No digas "el odds ratio de satisfacción laboral es 0.65". Di:
"Por cada punto adicional de satisfacción laboral, las chances de que un empleado renuncie se reducen en un 35%. Esto significa que un empleado con satisfacción 5 tiene aproximadamente la mitad de probabilidad de irse que uno con satisfacción 3."
Esta visualización muestra la curva logística: cómo cambia la probabilidad de rotación a medida que varía un predictor, manteniendo los demás constantes.
# Crear datos para la curva (variando satisfacción, fijando el resto en la mediana)
datos_curva <- tibble(
satisfaccion_laboral = seq(1, 5, by = 0.1),
antiguedad_anios = median(innovaco$antiguedad_anios),
edad = median(innovaco$edad),
departamento = "Desarrollo", # categoría de referencia
ingreso_mensual = median(innovaco$ingreso_mensual)
)
# Predecir probabilidades
datos_curva <- datos_curva |>
mutate(prob_predicha = predict(mod_ext, newdata = datos_curva,
type = "response"))
# Gráfico de la curva S
ggplot(datos_curva, aes(x = satisfaccion_laboral, y = prob_predicha)) +
geom_line(color = "#B85042", linewidth = 1.2) +
geom_hline(yintercept = mean(innovaco$rotacion_bin),
linetype = "dashed", color = "gray50") +
annotate("text", x = 4.5, y = mean(innovaco$rotacion_bin) + 0.03,
label = "Tasa base (27%)", color = "gray50", size = 3) +
scale_y_continuous(labels = scales::percent, limits = c(0, 1)) +
labs(
title = "Probabilidad de rotación según satisfacción laboral",
subtitle = "Manteniendo edad, antigüedad e ingreso en la mediana",
x = "Satisfacción laboral (1-5)",
y = "Probabilidad predicha de irse"
) +
theme_minimal()
Este gráfico superpone las probabilidades predichas sobre los datos reales (0/1), mostrando cuán bien separa el modelo a los dos grupos.
# Agregar predicciones al dataset
innovaco <- innovaco |>
mutate(prob_predicha = predict(mod_ext, type = "response"))
# Jitter plot: distribución de probabilidades por grupo real
ggplot(innovaco, aes(x = rotacion, y = prob_predicha, color = rotacion)) +
geom_jitter(alpha = 0.3, width = 0.2, height = 0) +
geom_boxplot(alpha = 0.5, outlier.shape = NA, width = 0.4) +
geom_hline(yintercept = 0.5, linetype = "dashed", color = "gray40") +
annotate("text", x = 2.4, y = 0.52,
label = "Umbral = 0.5", color = "gray40", size = 3) +
scale_color_manual(values = c("No" = "#0D9488", "Sí" = "#B85042")) +
scale_y_continuous(labels = scales::percent) +
labs(
title = "Distribución de probabilidades predichas por grupo real",
subtitle = "Un buen modelo separa claramente los dos grupos",
x = "Rotación real",
y = "Probabilidad predicha",
color = "Rotación real"
) +
theme_minimal() +
theme(legend.position = "none")
Figura 4: Distribución de probabilidades predichas por grupo real
# Histograma facetado
ggplot(innovaco, aes(x = prob_predicha, fill = rotacion)) +
geom_histogram(bins = 30, alpha = 0.7, position = "identity") +
geom_vline(xintercept = 0.5, linetype = "dashed") +
scale_fill_manual(values = c("No" = "#0D9488", "Sí" = "#B85042")) +
facet_wrap(~ rotacion, ncol = 1) +
scale_x_continuous(labels = scales::percent) +
labs(
title = "Distribución de probabilidades predichas",
subtitle = "¿El modelo logra separar los que se van de los que se quedan?",
x = "Probabilidad predicha de irse",
y = "Cantidad de empleados"
) +
theme_minimal() +
theme(legend.position = "none")
Figura 5: Histograma de probabilidades predichas facetado por grupo
# Preparar datos
or_datos <- tidy(mod_ext, exponentiate = TRUE, conf.int = TRUE) |>
filter(term != "(Intercept)") |>
mutate(
variable = case_when(
term == "satisfaccion_laboral" ~ "Satisfacción laboral",
term == "antiguedad_anios" ~ "Antigüedad (años)",
term == "edad" ~ "Edad",
term == "ingreso_mensual" ~ "Ingreso mensual",
str_detect(term, "departamento") ~
str_replace(term, "departamento", "Depto: "),
TRUE ~ term
),
significativo = p.value < 0.05
)
# Forest plot
ggplot(or_datos, aes(x = estimate, y = reorder(variable, estimate))) +
geom_vline(xintercept = 1, linetype = "dashed", color = "gray50") +
geom_point(aes(color = significativo), size = 3) +
geom_errorbarh(aes(xmin = conf.low, xmax = conf.high,
color = significativo), height = 0.2) +
scale_color_manual(
values = c("TRUE" = "#B85042", "FALSE" = "gray60"),
labels = c("TRUE" = "p < .05", "FALSE" = "No significativo")
) +
labs(
title = "Factores asociados a la rotación en InnovaCo",
subtitle = "OR > 1 = mayor riesgo | OR < 1 = factor protector",
x = "Odds Ratio", y = NULL, color = "Significancia"
) +
theme_minimal() +
theme(legend.position = "bottom")
Figura 6: Forest plot de odds ratios con IC 95%
# Curva logística con datos reales (puntos jitter)
# geom_smooth construye el IC en la escala logit y lo transforma
# de vuelta a probabilidades automáticamente
ggplot(innovaco, aes(x = satisfaccion_laboral, y = rotacion_bin)) +
geom_jitter(alpha = 0.15, height = 0.05, width = 0.1,
color = "gray50") +
geom_smooth(method = "glm", method.args = list(family = "binomial"),
color = "#B85042", fill = "#B85042", alpha = 0.2) +
scale_y_continuous(labels = scales::percent,
breaks = c(0, 0.25, 0.5, 0.75, 1)) +
labs(
title = "La curva logística: satisfacción laboral y rotación",
subtitle = "Cada punto es un empleado (0 = se queda, 1 = se fue)",
x = "Satisfacción laboral",
y = "Probabilidad de irse"
) +
theme_minimal()
Figura 1: Curva logística con IC 95% — un solo predictor (satisfacción laboral)
geom_smooth() ajusta el modelo y dibuja la curva S con banda de confianza automáticamente. Internamente, ggplot2 construye el intervalo de confianza en la escala del enlace (logit) y luego lo transforma de vuelta a la escala de probabilidades, lo cual es el procedimiento correcto.
Cuando el modelo tiene múltiples predictores, geom_smooth() ya no sirve directamente (solo trabaja con un predictor en el eje X). En ese caso, construimos las predicciones y los intervalos de confianza manualmente usando augment() o predict().
Para construir un intervalo de confianza correcto en regresión logística, hay que:
plogis() (la función inversa del logit).¿Por qué no construir el IC directamente en probabilidades? Porque el IC en la escala logit es simétrico, pero al transformarlo a probabilidades queda correctamente asimétrico y acotado entre 0 y 1.
library(broom)
# Paso 1: obtener predicciones en la escala del enlace (logit)
# type.predict = "link" devuelve log-odds + SE
pred_datos <- augment(mod_ext, type.predict = "link", se_fit = TRUE)
# Paso 2: construir IC en la escala logit, luego transformar a probabilidades
pred_datos <- pred_datos |>
mutate(
prob_predicha = plogis(.fitted), # probabilidad
prob_lower = plogis(.fitted - 1.96 * .se.fit), # IC inferior
prob_upper = plogis(.fitted + 1.96 * .se.fit) # IC superior
)
# Verificar: las probabilidades están entre 0 y 1 (siempre)
pred_datos |>
summarise(
min_prob = min(prob_predicha),
max_prob = max(prob_predicha),
min_lower = min(prob_lower),
max_upper = max(prob_upper)
)
# Crear grilla de valores: variar satisfacción, fijar el resto en la mediana
grilla <- tibble(
satisfaccion_laboral = seq(1, 5, by = 0.1),
antiguedad_anios = median(innovaco$antiguedad_anios),
edad = median(innovaco$edad),
departamento = "Desarrollo",
ingreso_mensual = median(innovaco$ingreso_mensual)
)
# Predecir en la escala logit con SE
pred_grilla <- predict(mod_ext, newdata = grilla,
type = "link", se.fit = TRUE)
# Agregar al tibble y transformar a probabilidades
grilla <- grilla |>
mutate(
logit_fit = pred_grilla$fit,
logit_se = pred_grilla$se.fit,
prob = plogis(logit_fit),
prob_low = plogis(logit_fit - 1.96 * logit_se),
prob_high = plogis(logit_fit + 1.96 * logit_se)
)
# Gráfico con banda de confianza
ggplot(grilla, aes(x = satisfaccion_laboral)) +
geom_ribbon(aes(ymin = prob_low, ymax = prob_high),
fill = "#B85042", alpha = 0.2) +
geom_line(aes(y = prob), color = "#B85042", linewidth = 1.2) +
geom_jitter(data = innovaco,
aes(x = satisfaccion_laboral, y = rotacion_bin),
alpha = 0.1, height = 0.03, width = 0.1,
color = "gray50") +
geom_hline(yintercept = mean(innovaco$rotacion_bin),
linetype = "dashed", color = "gray60") +
scale_y_continuous(labels = scales::percent, limits = c(0, 1)) +
labs(
title = "Probabilidad predicha de rotación según satisfacción",
subtitle = "Línea = predicción | Banda = IC 95% | Línea punteada = tasa base",
x = "Satisfacción laboral (1-5)",
y = "P(irse)",
caption = "Variables fijadas en la mediana. Modelo: regresión logística multivariada."
) +
theme_minimal()
Figura 2: Predicción multivariada con IC 95% — satisfacción variando, resto en la mediana
# Grilla con todos los departamentos
deptos <- distinct(innovaco, departamento)
grilla_depto <- expand_grid(
satisfaccion_laboral = seq(1, 5, by = 0.1),
departamento = deptos$departamento,
antiguedad_anios = median(innovaco$antiguedad_anios),
edad = median(innovaco$edad),
ingreso_mensual = median(innovaco$ingreso_mensual)
)
# Predecir con IC
pred_depto <- predict(mod_ext, newdata = grilla_depto,
type = "link", se.fit = TRUE)
grilla_depto <- grilla_depto |>
mutate(
prob = plogis(pred_depto$fit),
prob_low = plogis(pred_depto$fit - 1.96 * pred_depto$se.fit),
prob_high = plogis(pred_depto$fit + 1.96 * pred_depto$se.fit)
)
# Facetado por departamento
ggplot(grilla_depto, aes(x = satisfaccion_laboral)) +
geom_ribbon(aes(ymin = prob_low, ymax = prob_high),
fill = "#B85042", alpha = 0.2) +
geom_line(aes(y = prob), color = "#B85042", linewidth = 0.8) +
facet_wrap(~ departamento) +
scale_y_continuous(labels = scales::percent) +
labs(
title = "Probabilidad de rotación por departamento",
subtitle = "IC 95% | Edad, antigüedad e ingreso fijados en la mediana",
x = "Satisfacción laboral", y = "P(irse)"
) +
theme_minimal()
Figura 3: Predicción facetada por departamento con IC 95%
Si calcularas el IC como prob ± 1.96 × SE_prob, podrías obtener valores fuera de [0, 1]. La transformación logit→plogis garantiza que el IC siempre esté entre 0 y 1, y además es asimétrico como corresponde a una probabilidad: más estrecho cerca de 0 y 1, más ancho cerca de 0.5.
| Método | Cuándo usarlo | Código clave |
|---|---|---|
geom_smooth(method="glm") | Un solo predictor en el eje X. Rápido y correcto. | geom_smooth(method = "glm", method.args = list(family = "binomial")) |
Manual: predict(type="link") + plogis() | Modelo multivariado. Control total sobre qué variables fijar. | plogis(fit ± 1.96 × se.fit) |
# Clasificar con umbral 0.5
innovaco <- innovaco |>
mutate(prediccion = if_else(prob_predicha >= 0.5, "Sí", "No"))
tabla_conf <- table(Predicho = innovaco$prediccion, Real = innovaco$rotacion)
tabla_conf
# Métricas
vn <- tabla_conf["No", "No"]
vp <- tabla_conf["Sí", "Sí"]
fp <- tabla_conf["Sí", "No"]
fn <- tabla_conf["No", "Sí"]
accuracy <- (vp + vn) / sum(tabla_conf)
sensibilidad <- vp / (vp + fn)
especificidad <- vn / (vn + fp)
# Preparar datos para el gráfico
conf_datos <- as.data.frame(tabla_conf) |>
mutate(
tipo = case_when(
Predicho == "No" & Real == "No" ~ "Verdadero Negativo",
Predicho == "Sí" & Real == "Sí" ~ "Verdadero Positivo",
Predicho == "Sí" & Real == "No" ~ "Falso Positivo",
Predicho == "No" & Real == "Sí" ~ "Falso Negativo"
),
correcto = tipo %in% c("Verdadero Negativo", "Verdadero Positivo")
)
ggplot(conf_datos, aes(x = Real, y = Predicho, fill = correcto)) +
geom_tile(color = "white", linewidth = 2) +
geom_text(aes(label = paste0(Freq, "\n", tipo)),
size = 4, color = "white") +
scale_fill_manual(values = c("TRUE" = "#0D9488", "FALSE" = "#B85042")) +
labs(title = "Matriz de confusión", x = "Valor real", y = "Predicción") +
theme_minimal() +
theme(legend.position = "none")
# Los 20 empleados ACTUALES con mayor probabilidad de irse
riesgo <- innovaco |>
filter(rotacion == "No") |>
select(id_empleado, nombre, departamento, cargo,
satisfaccion_laboral, antiguedad_anios, prob_predicha) |>
arrange(desc(prob_predicha)) |>
head(20)
riesgo
# Visualización: top 20 en riesgo
ggplot(riesgo, aes(x = prob_predicha,
y = reorder(paste(nombre, "-", cargo), prob_predicha))) +
geom_col(fill = "#B85042") +
geom_text(aes(label = scales::percent(prob_predicha, accuracy = 1)),
hjust = -0.1, size = 3) +
scale_x_continuous(labels = scales::percent, limits = c(0, 1)) +
labs(
title = "Top 20 empleados en riesgo de rotación",
subtitle = "Empleados actuales con mayor probabilidad predicha",
x = "Probabilidad de irse", y = NULL
) +
theme_minimal()
| Limitación | Traducción para el VP |
|---|---|
| Solo captura insatisfacción gradual (path 4 del modelo unfolding) | "El modelo no puede predecir renuncias por shocks: un nuevo jefe, una oferta inesperada, un traslado familiar" |
| Correlación ≠ causalidad | "Que la satisfacción baja prediga rotación no significa que subirla la evite. Puede ser un síntoma, no la causa" |
| Datos retrospectivos | "Modelamos quiénes ya se fueron. Si la empresa cambia (reestructuración, nuevo gerente), el modelo puede quedar obsoleto" |
| Sin validación externa | "No probamos el modelo con datos que no haya visto. Podría funcionar peor con empleados nuevos" |
| Desbalance de clases | "El 73% no se va. El modelo puede sobreestimar su accuracy si no miramos la sensibilidad" |
| ✓ | Criterio |
|---|---|
| ☐ | ¿Comparaste al menos 3 modelos con AIC? |
| ☐ | ¿Los predictores tienen sentido teórico (no solo estadístico)? |
| ☐ | ¿Reportaste odds ratios, no log-odds? |
| ☐ | ¿Incluiste intervalos de confianza? |
| ☐ | ¿Calculaste la sensibilidad (no solo el accuracy)? |
| ☐ | ¿Comparaste el accuracy con el baseline (73%)? |
| ☐ | ¿Incluiste al menos una visualización de la predicción? |
| ☐ | ¿Tradujiste todos los resultados a lenguaje de negocio? |
| ☐ | ¿Mencionaste las limitaciones honestas del modelo? |
| ☐ | ¿Diste recomendaciones concretas y accionables? |