Apunte 10 — Regresión logística aplicada

Analítica de Personas · Semestre otoño 2026 · Semana 3 · Caso InnovaCo

1. La intuición: ¿por qué regresión logística?

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:

P(irse = 1) = 1 / (1 + e−(β₀ + β₁X₁ + β₂X₂ + ...))

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.

2. Paso a paso: construir el modelo en R

2.1 Preparar los datos

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.

2.2 Modelo nulo (referencia)

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

2.3 Modelo simple → múltiple → extendido

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

2.4 Comparar con AIC

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)

3. Interpretar odds ratios

# 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"
    )
  )
📝 Traducir odds ratios para el VP

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

4. Visualizaciones del modelo

4.1 La curva S: probabilidad predicha vs. un predictor

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

4.2 Probabilidades predichas vs. datos reales (jitter plot)

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

Figura 4: Distribución de probabilidades predichas por grupo real

¿Cómo leer este gráfico? Si el modelo funciona bien, los puntos "No" (teal) estarán concentrados abajo (probabilidad baja de irse) y los "Sí" (terracotta) estarán concentrados arriba (probabilidad alta de irse). Si los dos grupos se superponen mucho, el modelo no discrimina bien.

4.3 Histograma de probabilidades predichas por grupo

# 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

Figura 5: Histograma de probabilidades predichas facetado por grupo

4.4 Forest plot de odds ratios

# 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%

Figura 6: Forest plot de odds ratios con IC 95%

4.5 Curva S con datos reales superpuestos

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

Figura 1: Curva logística con IC 95% — un solo predictor (satisfacción laboral)

geom_smooth() con method = "glm" y family = "binomial" Esta es la forma más rápida de visualizar una regresión logística sobre un solo predictor. 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.

4B. Gráficos predictivos con intervalo de confianza (método manual)

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

La lógica clave

Para construir un intervalo de confianza correcto en regresión logística, hay que:

  1. Obtener las predicciones y errores estándar en la escala del enlace (logit), no en probabilidades.
  2. Construir el IC en esa escala: predicción ± 1.96 × SE.
  3. Transformar de vuelta a probabilidades usando 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.

4B.1 Método con augment() + plogis()

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

4B.2 Gráfico: probabilidad predicha con IC, variando un predictor

# 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

Figura 2: Predicción multivariada con IC 95% — satisfacción variando, resto en la mediana

4B.3 Gráfico por departamento (facetado)

# 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

Figura 3: Predicción facetada por departamento con IC 95%

⚠️ Por qué es importante transformar con plogis() y NO calcular el IC directamente en probabilidades

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.

📝 Resumen: dos métodos para graficar predicciones con IC
MétodoCuándo usarloCó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)

5. Evaluar el modelo

5.1 Matriz de confusión

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

5.2 Visualizar la matriz de confusión

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

5.3 Ranking de empleados en riesgo

# 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()

6. Limitaciones honestas para el VP

LimitaciónTraducció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"
⚠️ Un modelo no reemplaza el juicio gerencial El modelo es una herramienta para priorizar conversaciones, no un oráculo. Un empleado con probabilidad del 60% de irse puede tener razones que los datos no capturan. El modelo sugiere dónde mirar; el gerente decide qué hacer.

7. Checklist: ¿tu modelo está listo para el VP?

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?