Apunte 23 — Análisis de supervivencia: Kaplan-Meier y log-rank

Analítica de Personas · Semestre otoño 2026 · Semana 9 · Prof. René Gempp

1. ¿Qué es un análisis de supervivencia?

En la Clase 3 construimos un modelo logístico para predecir si un empleado se va de InnovaCo. La variable era binaria: "Sí" o "No". Pero el directorio ahora quiere saber algo distinto: ¿cuánto tiempo se quedan? ¿Hay un momento crítico donde es más probable que se vayan? ¿Se van más rápido en Ventas que en Desarrollo?

El análisis de supervivencia es la familia de técnicas estadísticas diseñadas para estudiar el tiempo hasta que ocurre un evento. El nombre viene de la medicina —originalmente se usaba para estudiar cuánto tiempo sobrevivían pacientes después de un tratamiento— pero hoy se aplica en muchos campos:

CampoEventoTiempo
MedicinaMuerte, recurrencia de cáncerDías/meses desde diagnóstico
IngenieríaFalla de un componenteHoras de operación
People analyticsSalida del empleadoMeses desde ingreso
People analyticsCierre de vacante (time-to-fill)Días desde apertura
People analyticsPromociónMeses en el cargo actual
MarketingCancelación de suscripción (churn)Meses desde alta

La semana pasada ya hiciste un primer análisis de "tiempo hasta evento" sin saberlo: cuando calculaste el time-to-fill con lubridate, estabas midiendo cuánto tardaba en cerrarse una vacante. La diferencia es que todas las vacantes del dataset estaban cerradas: no había observaciones incompletas. Hoy eso cambia.

2. El problema de la censura

Imagina que quieres responder: "¿Cuánto tiempo permanecen nuestros empleados?" Tu primer instinto podría ser calcular el promedio de antigüedad:

mean(empleados$antiguedad_anios)
# [1] 3.5

Pero hay un problema grave: ese promedio mezcla dos tipos de datos completamente distintos:

Los empleados que siguen tienen una antigüedad que es un límite inferior de su permanencia real. Incluirlos en el promedio como si fueran datos completos subestima la permanencia real.

El error más común Calcular mean(antiguedad_anios) mezclando empleados activos y desvinculados es estadísticamente incorrecto. Es como calcular la esperanza de vida promedio incluyendo personas que todavía están vivas: el resultado no tiene sentido.

En la jerga del análisis de supervivencia, un empleado activo es una observación censurada por la derecha (right-censored): sabemos que al menos lleva X años, pero no sabemos cuándo (ni si) experimentará el evento. La censura no es un dato perdido — es una observación parcial que aporta información valiosa.

Tipos de censura (solo la primera nos interesa)

TipoQué significaEjemplo en HRUso en este curso
Censura por la derechaEl evento no ha ocurrido al cierre del estudioEl empleado sigue activo al 1 de marzo de 2025Sí — es el caso dominante
Censura por la izquierdaEl evento ya había ocurrido antes del inicio del estudioUn empleado que se fue antes de que existiera el SIRHNo se cubre
Censura por intervaloSolo sabemos que el evento ocurrió dentro de un rango"Se fue entre enero y marzo, no sabemos el día exacto"No se cubre

Visualizar la censura: diagrama de líneas de vida

Un diagrama de líneas de vida muestra cada empleado como una línea horizontal desde su ingreso hasta su salida (o hasta el cierre del estudio). Una × marca un evento (se fue); una marca una observación censurada (sigue).

# Ejemplo con 8 empleados (concepto):
# Ana:    |────────×                    (se fue a los 14 meses)
# Bruno:  |──────────────────→          (sigue a los 24 meses: censurado)
# Carla:  |───×                         (se fue a los 5 meses)
# Diego:  |────────────→                (sigue a los 18 meses: censurado)
# Elena:  |──────────────────────→      (sigue a los 30 meses: censurado)
# Felipe: |─────────×                   (se fue a los 12 meses)
# Gloria: |──×                          (se fue a los 3 meses)
# Hugo:   |──────────────────────────→  (sigue a los 36 meses: censurado)

De estos 8 empleados, 4 se fueron (eventos) y 4 siguen (censurados). El Kaplan-Meier usa toda esta información para estimar la curva de permanencia.

3. El paquete survival

El análisis de supervivencia en R se apoya en el paquete survival, escrito y mantenido por Terry Therneau (Mayo Clinic). Es uno de los paquetes más antiguos de R — existe desde la versión 2.x — y viene pre-instalado con la distribución estándar. No necesitas install.packages(), solo cargarlo:

library(survival)     # Surv(), survfit(), coxph(), survdiff(), cox.zph()
library(survminer)    # ggsurvplot(), ggforest(), ggcoxzph() — este SÍ hay que instalarlo
Instalar survminer El paquete survminer no viene pre-instalado. Ejecútalo una vez: install.packages("survminer"). Proporciona visualizaciones basadas en ggplot2 para los objetos del paquete survival.

4. Surv(): crear el objeto de supervivencia

El primer paso siempre es crear un objeto Surv, que combina dos piezas de información: el tiempo hasta el evento (o censura) y el indicador de evento (1 = ocurrió, 0 = censurado).

# Preparar los datos
datos_surv <- empleados |>
  mutate(
    evento = if_else(rotacion == "Sí", 1L, 0L),
    tiempo_meses = antiguedad_anios * 12
  )

# Crear el objeto Surv
surv_obj <- Surv(time  = datos_surv$tiempo_meses,
                 event = datos_surv$evento)

# Inspeccionar
head(surv_obj, 10)
  [1]   6   159.6  57.6  56.4+ 16.8+ 20.4+ 52.8   10.8  180  + 36+

Los números sin signo son tiempos de evento (el empleado se fue a los X meses). Los números con + son tiempos censurados (el empleado seguía activo a los X meses).

Error frecuente: el indicador de evento debe ser numérico Surv() necesita un vector numérico para event: 1 = evento, 0 = censurado. Si le pasas "Sí"/"No", R tirará un error. Por eso creamos evento con if_else() antes.

5. survfit(): estimar la curva de Kaplan-Meier

Con el objeto Surv listo, estimamos la curva de supervivencia con survfit(). La fórmula usa la misma notación que lm() y glm(): el lado izquierdo es el Surv() y el lado derecho dice cómo agrupar.

# Curva global (sin agrupar: ~ 1)
km_global <- survfit(Surv(tiempo_meses, evento) ~ 1, data = datos_surv)

# Ver el resumen
km_global
Call: survfit(formula = Surv(tiempo_meses, evento) ~ 1, data = datos_surv)

       n events median 0.95LCL 0.95UCL
[1,] 1200    326     NA      NA      NA

El output te dice: hay 1.200 observaciones, 326 eventos (salidas), y la mediana de supervivencia es NA. ¿Por qué NA? Porque la curva nunca baja del 50 %: más de la mitad de los empleados sigue activo al cierre del periodo. La mediana solo se puede calcular si la curva cruza el 50 %. Que sea NA es, en sí mismo, un resultado informativo: la mayoría se queda.

Para ver el detalle paso a paso:

summary(km_global)

Esto imprime una tabla con cada "peldaño" de la escalera de Kaplan-Meier: el tiempo, el número en riesgo, los eventos, y la probabilidad acumulada de permanencia S(t).

6. ggsurvplot(): visualizar la curva

La función estrella de survminer convierte un objeto survfit en una visualización completa con un solo llamado:

ggsurvplot(
  km_global,
  data        = datos_surv,
  conf.int    = TRUE,                # Banda de confianza al 95 %
  risk.table  = TRUE,                # Tabla de n en riesgo debajo del gráfico
  xlab        = "Tiempo (meses)",
  ylab        = "Probabilidad de permanencia",
  title       = "Curva de supervivencia — InnovaCo",
  ggtheme     = theme_minimal(),
  palette     = "#B85042",           # Color terracotta
  surv.median.line = "hv",          # Líneas de mediana (si existe)
  break.x.by  = 12                   # Marcas cada 12 meses
)

Parámetros más útiles de ggsurvplot()

ParámetroQué haceValor típico
conf.intMuestra la banda de confianzaTRUE para curvas solas; FALSE si hay muchos grupos
risk.tableAgrega tabla de "número en riesgo" debajoTRUE — muy útil para reportes
pvalMuestra el p-valor del log-rank testTRUE cuando comparas grupos
surv.median.lineLíneas que marcan la mediana"hv" = horizontal + vertical
break.x.byIntervalo de marcas en el eje X12 si mides en meses
xlimLímites del eje Xc(0, 180) para 15 años en meses
paletteColores (uno por grupo)Vector de hex o nombre de paleta
legend.titleTítulo de la leyenda"Departamento", "Género", etc.

7. Leer la curva: mediana y probabilidades a tiempos clave

Para extraer probabilidades de permanencia a tiempos específicos, usa summary() con el argumento times:

# ¿Qué fracción sigue a los 12, 36 y 60 meses?
summary(km_global, times = c(12, 36, 60))

La columna survival te da S(t): la probabilidad estimada de que un empleado siga en la empresa después de t meses. Con las columnas lower y upper tienes el intervalo de confianza.

Lenguaje ejecutivo Para el directorio, traduce los números a frases: "De cada 100 empleados que contratamos, a los 12 meses siguen 85. A los 3 años (36 meses), siguen 68. A los 5 años, siguen 52." Cada número es una decisión de inversión en retención.

8. Comparar curvas por subgrupo

Para obtener una curva de Kaplan-Meier por cada nivel de una variable categórica, cambia el lado derecho de la fórmula:

# Curvas por departamento
km_depto <- survfit(Surv(tiempo_meses, evento) ~ departamento,
                    data = datos_surv)

ggsurvplot(
  km_depto,
  data      = datos_surv,
  conf.int  = FALSE,       # Sin IC para legibilidad con 6 curvas
  pval      = TRUE,        # p-valor del log-rank test
  risk.table = TRUE,
  xlab      = "Tiempo (meses)",
  ylab      = "Probabilidad de permanencia",
  title     = "Supervivencia por departamento",
  ggtheme   = theme_minimal(),
  palette   = c("#1B2A4A", "#B85042", "#A7BEAE",
                "#0D9488", "#E8B96E", "#7B6B8D"),
  legend.title = "Departamento"
)

# Medianas por grupo
km_depto

El pval = TRUE muestra automáticamente el p-valor del log-rank test sobre el gráfico. Si es menor que 0,05, hay al menos un departamento con supervivencia significativamente distinta.

9. survdiff(): el log-rank test

El log-rank test (Mantel, 1966) compara formalmente si dos o más curvas de supervivencia son iguales. Es el análogo de un chi-cuadrado: compara eventos observados vs. esperados en cada grupo en cada tiempo de evento.

# Log-rank test
survdiff(Surv(tiempo_meses, evento) ~ departamento, data = datos_surv)
Call:
survdiff(formula = Surv(tiempo_meses, evento) ~ departamento, data = datos_surv)

                                  N Observed Expected (O-E)^2/E
departamento=Desarrollo de Software 417       93    113.5      3.71
departamento=Finanzas               120       29     32.6      0.40
departamento=Marketing               78       20     21.2      0.07
departamento=Recursos Humanos       133       42     36.2      0.94
departamento=Soporte Técnico        195       68     53.0      4.24
departamento=Ventas                 257       74     69.8      0.26

 Chisq= 9.6  on 5 degrees of freedom, p= 0.087

La columna Expected muestra cuántos eventos esperarías en cada departamento si todos tuvieran la misma curva de supervivencia. La diferencia (O-E)^2/E indica cuánto se desvía cada grupo. Soporte Técnico tiene más eventos de los esperados (68 observados vs. 53 esperados).

Hipótesis del log-rank test H₀: las funciones de supervivencia de los k grupos son idénticas. Un p-valor bajo rechaza la igualdad, pero no dice cuál grupo es distinto. Para eso, usa el modelo de Cox (Apunte 24) o haz comparaciones por pares.
Alternativas al log-rank test El log-rank estándar pondera todos los tiempos por igual. Si te interesa detectar diferencias tempranas (que es lo relevante si piensas en onboarding), puedes usar el test de Wilcoxon generalizado: survdiff(..., rho = 1). Con rho = 1, los tiempos tempranos (donde hay más gente en riesgo) reciben más peso. Esto no se cubre en clase pero queda como herramienta disponible.

10. Ejemplo completo con peopleanalyticsdata::job_retention

El paquete peopleanalyticsdata (Keith McNulty) incluye el dataset job_retention, diseñado para practicar análisis de supervivencia en HR. Tiene variables de supervivencia pre-construidas. Puedes usarlo para practicar de forma independiente:

# install.packages("peopleanalyticsdata")  # solo una vez
library(peopleanalyticsdata)

# Cargar el dataset
data(job_retention)
glimpse(job_retention)

# Las variables clave son:
# - month: tiempo en meses
# - event: 1 = se fue, 0 = censurado
# - gender, field, level, sentiment: covariables

# Kaplan-Meier global
km_jr <- survfit(Surv(month, event) ~ 1, data = job_retention)
ggsurvplot(km_jr, data = job_retention, conf.int = TRUE,
           xlab = "Meses", ylab = "S(t)",
           title = "Supervivencia — job_retention")

# Kaplan-Meier por nivel
km_jr_level <- survfit(Surv(month, event) ~ level, data = job_retention)
ggsurvplot(km_jr_level, data = job_retention, pval = TRUE)

Este dataset es excelente para practicar porque ya viene en formato de supervivencia. Los datos de InnovaCo requieren la transformación previa (construir evento y tiempo_meses) que es, en sí misma, un paso pedagógico valioso.

11. Errores frecuentes

Error 1: confundir censurado con dato perdido Una observación censurada no es un NA. Tiene información: sabemos que el empleado al menos lleva X meses. Si eliminas las observaciones censuradas con filter(evento == 1), sesgas todo el análisis (solo describes a los que se fueron).
Error 2: olvidar event en Surv() Si escribes Surv(tiempo_meses) sin el segundo argumento, R asume que todos son eventos (nadie está censurado). El resultado será una curva de Kaplan-Meier que cae mucho más rápido de lo real.
Error 3: interpretar la curva como predicción individual La curva de Kaplan-Meier describe el patrón histórico de una cohorte. No es una predicción de lo que le pasará a un empleado contratado hoy. Las condiciones futuras (mercado laboral, liderazgo, estrategia de la empresa) pueden ser distintas a las históricas. La curva es un punto de partida, no un pronóstico.
Error 4: comparar cohortes con distinto seguimiento Si comparas la curva de empleados contratados en 2023 con la de empleados contratados en 2015, las curvas tendrán largos distintos. Eso no significa que los de 2023 "se van más rápido" — simplemente tienen menos tiempo de observación. Para una comparación justa, restringe ambas cohortes al mismo horizonte temporal.