Enfoque tradicional
Muchas implementaciones existentes sobre análisis de supervivencia comienzan con un conjunto de datos que contiene una observación por individuo (pacientes en un estudio de salud, empleados en el caso de deserción, clientes en el caso de abandono de clientes, etc.). Para estas personas normalmente tenemos dos variables clave: una que señala el evento de interés (la renuncia de un empleado) y otra que mide el tiempo (cuánto tiempo han estado en la empresa, hasta hoy o hasta su partida). Junto a estas dos variables, tenemos luego variables explicativas con las que pretendemos predecir el riesgo de cada individuo. Estas características pueden incluir, por ejemplo, el puesto de trabajo, la edad o la remuneración del empleado.
Continuando, la mayoría de las implementaciones que existen toman un modelo de supervivencia (desde estimadores más simples como Kaplan Meier hasta otros más complejos como modelos de conjuntos o incluso redes neuronales), los ajustan a un conjunto de trenes y luego los evalúan a través de un conjunto de pruebas. Esta división tren-prueba generalmente se realiza sobre las observaciones individuales, generalmente haciendo una división estratificada.
En mi caso, comencé con un conjunto de datos que seguía a varios empleados de una empresa mensualmente hasta diciembre de 2023 (en caso de que el empleado todavía estuviera en la empresa), o hasta el mes en que dejaron la empresa, la fecha del evento:
Para adaptar mis datos al caso de supervivencia, tomé la última observación de cada empleado como se muestra en la imagen de arriba (los puntos azules para los empleados activos y las cruces rojas para los empleados que se fueron). En ese momento registré para cada empleado si el evento había ocurrido en esa fecha o no (si estaban activos o si se habían ido), su antigüedad en meses en ese momento y todas sus variables explicativas. Luego realicé una prueba de tren estratificada dividida sobre estos datos, así:
import numpy as np
import pandas as pd
from sklearn.model_selection import train_test_split# We load our dataset with several observations (record_date) per employee (employee_id)
# The event column indicates if the employee left on that given month (1) or if the employee was still active (0)
df = pd.read_csv(f'{FILE_NAME}.csv')
# Creating a label where positive events have tenure and negative events have negative tenure - required by Random Survival Forest
df_model['label'] = np.where(df_model['event'], df_model['tenure_in_months'], - df_model['tenure_in_months'])
df_train, df_test = train_test_split(df_model, test_size=0.2, stratify=df_model['event'], random_state=42)
Luego de realizar el split, procedí a montar un modelo. En este caso, elegí experimentar con un Bosque de supervivencia aleatorio utilizando el supervivencia-scikit biblioteca.
from sklearn.preprocessing import OrdinalEncoder
from sksurv.datasets import get_x_y
from sksurv.ensemble import RandomSurvivalForestcat_features = [] # list of all the categorical features
features = [] # list of all the features (both categorical and numeric)
# Categorical Encoding
encoder = OrdinalEncoder(handle_unknown='use_encoded_value', unknown_value=-1)
encoder.fit(df_train[cat_features])
df_train[cat_features] = encoder.transform(df_train[cat_features])
df_test[cat_features] = encoder.transform(df_test[cat_features])
# X & y
X_train, y_train = get_x_y(df_train, attr_labels=['event','tenure_in_months'], pos_label=1)
X_test, y_test = get_x_y(df_test, attr_labels=['event','tenure_in_months'], pos_label=1)
# Fit the model
estimator = RandomSurvivalForest(random_state=RANDOM_STATE)
estimator.fit(X_train[features], y_train)
# Store predictions
y_pred = estimator.predict(X_test[features])
Después de una ejecución rápida usando la configuración predeterminada del modelo, quedé encantado con las métricas de prueba que vi. En primer lugar, estaba recibiendo un índice de concordancia por encima de 0,90 en el conjunto de prueba. El índice de concordancia es una medida de qué tan bien el modelo predice el orden de los eventos: refleja si los empleados que se predijo que estarían en alto riesgo fueron en realidad los que abandonaron la empresa primero. Un índice de 1 corresponde a una precisión de predicción perfecta, mientras que un índice de 0,5 indica una predicción que no es mejor que la probabilidad aleatoria.
Estaba particularmente interesado en ver si los empleados que salieron del conjunto de prueba coincidían con los empleados de mayor riesgo según el modelo. En el caso del Random Survival Forest, el modelo devuelve las puntuaciones de riesgo de cada observación. Tomé el porcentaje de empleados que abandonaron la empresa en el conjunto de prueba y lo usé para filtrar a los empleados con mayor riesgo según el modelo. Los resultados fueron muy sólidos: los empleados señalados con mayor riesgo coincidieron casi perfectamente con los que realmente abandonaron, con una puntuación F1 superior a 0,90 en la clase minoritaria.
from lifelines.utils import concordance_index
from sklearn.metrics import classification_report# Concordance Index
ci_test = concordance_index(df_test['tenure_in_months'], -y_pred, df_test['event'])
print(f'Concordance index:{ci_test:0.5f}\n')
# Match the most risky employees (according to the model) with the employees who left
q_test = 1 - df_test['event'].mean()
thr = np.quantile(y_pred, q_test)
risky_employees = (y_pred >= thr) * 1
print(classification_report(df_test['event'], risky_employees))
Obtener métricas de +0,9 en la primera ejecución debería hacer sonar una alarma: ¿fue realmente capaz el modelo de predecir si un empleado se quedaría o se iría con tanta confianza? Imagínese esto: enviamos nuestras predicciones diciendo qué empleados tienen más probabilidades de irse. Sin embargo, pasan un par de meses y RR.HH. nos llega preocupado, diciendo que las personas que se fueron durante el último período no coincidieron exactamente con nuestras predicciones, al menos al ritmo que se esperaba de nuestras métricas de prueba.
Aquí tenemos dos problemas principales: el primero es que nuestro modelo no extrapola tan bien como pensábamos. El segundo, y peor aún, es que no pudimos medir esta falta de desempeño. Primero, mostraré una forma sencilla en la que podemos estimar qué tan bien nuestro modelo está realmente extrapolando, y luego hablaré sobre una posible razón por la que puede no hacerlo y cómo mitigarlo.
Estimación de capacidades de generalización
La clave aquí es tener acceso a datos de panel, es decir, varios registros de nuestros individuos a lo largo del tiempo, hasta el momento del evento o el momento en que finalizó el estudio (la fecha de nuestra instantánea, en el caso de deserción de empleados). En lugar de descartar toda esta información y mantener sólo el último registro de cada empleado, podríamos utilizarla para crear un conjunto de pruebas que refleje mejor el rendimiento del modelo en el futuro. La idea es bastante simple: supongamos que tenemos registros mensuales de nuestros empleados hasta diciembre de 2023. Podríamos retroceder, digamos, 6 meses y fingir que tomamos la instantánea en junio en lugar de diciembre. Luego, tomaríamos la última observación de los empleados que abandonaron la empresa antes de junio de 2023 como eventos positivos, y el registro de junio de 2023 de los empleados que sobrevivieron más allá de esa fecha como eventos negativos, incluso si ya sabemos que algunos de ellos finalmente se fueron después. Estamos fingiendo que aún no lo sabemos.
Como muestra la imagen de arriba, tomo una instantánea en junio y todos los empleados que estaban activos en ese momento se consideran activos. El conjunto de datos de prueba toma a todos esos empleados activos en junio con sus variables explicativas tal como estaban en esa fecha, y toma la última antigüedad que lograron en diciembre:
test_date = '2023-07-01'# Selecting training data from records before the test date and taking the last observation per employee
df_train = df[df.record_date < test_date].reset_index(drop=True).copy()
df_train = df_train.groupby('employee_id').tail(1).reset_index(drop=True)
df_train['label'] = np.where(df_train['event'], df_train['tenure_in_months'], - df_train['tenure_in_months'])
# Preparing test data with records of active employees at the test date
df_test = df[(df.record_date == test_date) & (df['event']==0)].reset_index(drop=True).copy()
df_test = df_test.groupby('employee_id').tail(1).reset_index(drop=True)
df_test = df_test.drop(columns = ['tenure_in_months','event'])
# Fetching the last tenure and event status for employees in the test dataset
df_last_tenure = df[df.employee_id.isin(df_test.employee_id.unique())].reset_index(drop=True).copy()
df_last_tenure = df_last_tenure.groupby('employee_id').tail(1).reset_index(drop=True)
df_test = df_test.merge(df_last_tenure[['employee_id','tenure_in_months','event']], how='left')
df_test['label'] = np.where(df_test['event'], df_test['tenure_in_months'], - df_test['tenure_in_months'])
Ajustamos nuestro modelo nuevamente a estos nuevos datos del tren y, una vez que terminamos, hacemos nuestras predicciones para todos los empleados que estuvieron activos en junio. Luego comparamos estas predicciones con el resultado real de julio a diciembre de 2023; este es nuestro conjunto de pruebas. Si los empleados que marcamos como de mayor riesgo se fueron durante el semestre, y aquellos que marcamos como de menor riesgo no se fueron, o lo hicieron más tarde en el período, entonces nuestro modelo está extrapolando bien. Al retroceder nuestro análisis en el tiempo y dejar el último período para la evaluación, podemos comprender mejor qué tan bien se está generalizando nuestro modelo. Por supuesto, podríamos ir un paso más allá y realizar algún tipo de validación cruzada de series temporales. Por ejemplo, podríamos repetir este proceso muchas veces, cada vez retrocediendo 6 meses en el tiempo y evaluando la precisión del modelo en varios períodos de tiempo.
Después de entrenar nuestro modelo una vez más, ahora vemos una disminución drástica en el rendimiento. En primer lugar, el índice de concordancia es ahora de alrededor de 0,5, equivalente al de un predictor aleatorio. Además, si intentamos hacer coincidir los ‘n’ empleados más riesgosos según el modelo con los ‘n’ empleados que abandonaron el conjunto de prueba, vemos una clasificación muy pobre con un F1 de 0,15 para la clase minoritaria:
Está claro que algo anda mal, pero al menos ahora podemos detectarlo en lugar de dejarnos engañar. La conclusión principal aquí es que nuestro modelo funciona bien con una división tradicional, pero no extrapola cuando se realiza una división basada en el tiempo. Esta es una señal clara de que puede haber algún sesgo temporal. En resumen, se está filtrando información que depende del tiempo y nuestro modelo se está sobreajustando sobre ella. Esto es común en casos como nuestro problema de deserción de empleados, cuando el conjunto de datos proviene de una instantánea tomada en alguna fecha.
Sesgo de tiempo
El problema se reduce a esto: todas nuestras observaciones positivas (empleados que se fueron) pertenecen a fechas pasadas, y todas nuestras observaciones negativas (empleados actualmente activos) se miden en la misma fecha: hoy. Si hay una sola característica que revela esto al modelo, entonces en lugar de predecir el riesgo estaremos prediciendo si un empleado fue registrado en diciembre de 2023 o antes. Esto podría ser muy sutil. Por ejemplo, una característica que podríamos utilizar es la puntuación de compromiso de los empleados. Esta característica bien podría mostrar algunos patrones estacionales, y medirla al mismo tiempo para los empleados activos seguramente introducirá algún sesgo en el modelo. Quizás en diciembre, durante la temporada navideña, este puntaje de participación tienda a disminuir. El modelo verá una puntuación baja asociada con todos los empleados activos, por lo que puede aprender a predecir que cuando el compromiso es bajo, el riesgo de abandono también disminuye, cuando en realidad debería ser lo contrario.
A estas alturas, debería estar clara una solución simple pero bastante efectiva para este problema: en lugar de tomar la última observación de cada empleado activo, podríamos simplemente elegir un mes aleatorio de todo su historial dentro de la empresa. Esto reducirá en gran medida las posibilidades de que el modelo detecte patrones temporales en los que no queremos que se sobreadapte:
En la imagen de arriba podemos ver que ahora abarcamos un conjunto más amplio de fechas para los empleados activos. En lugar de usar sus puntos azules en junio de 2023, tomamos los puntos naranjas aleatorios y registramos sus variables en ese momento y la antigüedad que tenían hasta ahora en la empresa:
np.random.seed(0)# Select training data before the test date
df_train = df[df.record_date < test_date].reset_index(drop=True).copy()
# Create an indicator for whether an employee eventually churns within the train set
df_train['indicator'] = df_train.groupby('employee_id').event.transform(max)
# Isolate records of employees who left, and store their last observation
churn = df_train[df_train.indicator==1].reset_index(drop=True).copy()
churn = churn.groupby('employee_id').tail(1).reset_index(drop=True)
# For employees who stayed, randomly pick one observation from their historic records
stay = df_train[df_train.indicator==0].reset_index(drop=True).copy()
stay = stay.groupby('employee_id').apply(lambda x: x.sample(1)).reset_index(drop=True)
# Combine churn and stay samples into the new training dataset
df_train = pd.concat([churn,stay], ignore_index=True).copy()
df_train['label'] = np.where(df_train['event'], df_train['tenure_in_months'], - df_train['tenure_in_months'])
del df_train['indicator']
# Prepare the test dataset similarly, using only the snapshot from the test date
df_test = df[(df.record_date == test_date) & (df.event==0)].reset_index(drop=True).copy()
df_test = df_test.groupby('employee_id').tail(1).reset_index(drop=True)
df_test = df_test.drop(columns = ['tenure_in_months','event'])
# Get the last known tenure and event status for employees in the test set
df_last_tenure = df[df.employee_id.isin(df_test.employee_id.unique())].reset_index(drop=True).copy()
df_last_tenure = df_last_tenure.groupby('employee_id').tail(1).reset_index(drop=True)
df_test = df_test.merge(df_last_tenure[['employee_id','tenure_in_months','event']], how='left')
df_test['label'] = np.where(df_test['event'], df_test['tenure_in_months'], - df_test['tenure_in_months'])
Luego entrenamos nuestro modelo una vez más y lo evaluamos con el mismo conjunto de pruebas que teníamos antes. Ahora vemos un índice de concordancia de alrededor de 0,80. Este no es el +0,90 que teníamos antes, pero definitivamente es un paso adelante respecto al nivel de probabilidad aleatoria de 0,5. En cuanto a nuestro interés en clasificar a los empleados, todavía estamos muy lejos del +0,9 F1 que teníamos antes, pero vemos un ligero aumento en comparación con el enfoque anterior, especialmente para la clase minoritaria.