Approccio tradizionale
Molte implementazioni esistenti sull'analisi di sopravvivenza iniziano con un set di dati contenente una osservazione per individuo (pazienti in uno studio sanitario, dipendenti nel caso di abbandono, clienti nel caso di abbandono dei clienti e così via). Per questi individui abbiamo in genere due variabili chiave: una che segnala l'evento di interesse (un dipendente che lascia l'azienda) e un'altra che misura il tempo (da quanto tempo sono stati in azienda, fino a oggi o alla loro partenza). Insieme a queste due variabili, abbiamo poi delle variabili esplicative con le quali miriamo a prevedere il rischio di ciascun individuo. Queste caratteristiche possono includere, ad esempio, il ruolo lavorativo, l'età o la retribuzione del dipendente.
Andando avanti, la maggior parte delle implementazioni disponibili prendono un modello di sopravvivenza (dagli stimatori più semplici come Kaplan Meier a quelli più complessi come i modelli di insieme o anche le reti neurali), li adattano a un set di treni e quindi valutano su un set di test. Questa suddivisione del test del treno viene solitamente eseguita sulle singole osservazioni, generalmente creando una suddivisione stratificata.
Nel mio caso, ho iniziato con un set di dati che seguiva mensilmente diversi dipendenti di un'azienda fino a dicembre 2023 (nel caso in cui il dipendente fosse ancora in azienda) o fino al mese in cui ha lasciato l'azienda, ovvero la data dell'evento:
Per adattare i miei dati al caso di sopravvivenza, ho preso l'ultima osservazione di ciascun dipendente come mostrato nella figura sopra (i punti blu per i dipendenti attivi e le croci rosse per i dipendenti che se ne sono andati). A quel punto ho registrato per ciascun dipendente se l'evento si era verificato o meno in quella data (se erano attivi o se erano usciti), la loro permanenza in carica in mesi in quel momento e tutte le variabili esplicative. Ho quindi eseguito una suddivisione stratificata del test del treno su questi dati, in questo modo:
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)
Dopo aver eseguito lo split, ho provveduto ad adattare un modello. In questo caso, ho scelto di sperimentare a Foresta di sopravvivenza casuale usando il scikit-sopravvivenza 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))
Dopo una rapida esecuzione utilizzando le impostazioni predefinite del modello, sono rimasto entusiasta delle metriche di test che ho visto. Prima di tutto, stavo ottenendo un indice di concordanza superiore a 0,90 nel set di test. L’indice di concordanza è una misura della capacità del modello di prevedere l’ordine degli eventi: riflette se i dipendenti ritenuti ad alto rischio erano effettivamente quelli che lasciavano per primi l’azienda. Un indice pari a 1 corrisponde alla perfetta accuratezza della previsione, mentre un indice pari a 0,5 indica una previsione non migliore della casualità.
Ero particolarmente interessato a vedere se i dipendenti che se ne andavano nel set di test corrispondevano ai dipendenti più a rischio secondo il modello. Nel caso della Random Survival Forest, il modello restituisce i punteggi di rischio di ciascuna osservazione. Ho preso la percentuale di dipendenti che hanno lasciato l'azienda nel set di test e l'ho utilizzata per filtrare i dipendenti più a rischio in base al modello. I risultati sono stati molto solidi, con i dipendenti contrassegnati con il rischio maggiore che corrispondono quasi perfettamente con quelli che effettivamente hanno abbandonato, con un punteggio F1 superiore a 0,90 nella classe 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))
Ottenere parametri di +0,9 alla prima esecuzione dovrebbe far scattare un allarme: il modello era davvero in grado di prevedere se un dipendente sarebbe rimasto o se ne sarebbe andato con tale sicurezza? Immagina questo: inviamo le nostre previsioni indicando quali dipendenti hanno maggiori probabilità di andarsene. Tuttavia, passano un paio di mesi e le risorse umane ci raggiungono preoccupate, dicendo che le persone che se ne sono andate durante l'ultimo periodo non corrispondevano esattamente alle nostre previsioni, almeno al ritmo previsto dai nostri parametri di test.
Abbiamo due problemi principali qui: il primo è che il nostro modello non estrapola così bene come pensavamo. Il secondo, e ancora peggiore, è che non siamo stati in grado di misurare questa mancanza di prestazioni. Innanzitutto, mostrerò un modo semplice per stimare quanto bene il nostro modello stia effettivamente estrapolando, quindi parlerò di un potenziale motivo per cui potrebbe non riuscire a farlo e di come mitigarlo.
Stima delle capacità di generalizzazione
La chiave qui è avere accesso ai dati del panel, ovvero a diversi record dei nostri individui nel tempo, fino al momento dell'evento o al momento in cui è terminato lo studio (la data della nostra istantanea, nel caso dell'abbandono dei dipendenti). Invece di scartare tutte queste informazioni e conservare solo l’ultimo record di ciascun dipendente, potremmo usarlo per creare un set di test che rifletterà meglio le prestazioni future del modello. L'idea è abbastanza semplice: supponiamo di avere registri mensili dei nostri dipendenti fino a dicembre 2023. Potremmo tornare indietro, diciamo, di 6 mesi e far finta di aver scattato l'istantanea a giugno invece che a dicembre. Quindi, considereremmo l’ultima osservazione per i dipendenti che hanno lasciato l’azienda prima di giugno 2023 come eventi positivi, e il record di giugno 2023 dei dipendenti che sono sopravvissuti oltre tale data come eventi negativi, anche se sappiamo già che alcuni di loro se ne sono andati successivamente. Facciamo finta di non saperlo ancora.
Come mostra l'immagine sopra, scatto un'istantanea nel mese di giugno e tutti i dipendenti che erano attivi in quel momento vengono considerati attivi. Il set di dati di test prende in considerazione tutti i dipendenti attivi a giugno con le relative variabili esplicative così come erano in quella data e prende in considerazione l'ultimo incarico raggiunto entro dicembre:
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'))
Adattiamo nuovamente il nostro modello ai nuovi dati del treno e, una volta terminato, facciamo le nostre previsioni per tutti i dipendenti attivi a giugno. Confrontiamo quindi queste previsioni con il risultato effettivo di luglio-dicembre 2023: questo è il nostro set di test. Se i dipendenti che abbiamo contrassegnato come a rischio più elevato sono rimasti durante il semestre e quelli che abbiamo contrassegnato come a rischio più basso non se ne sono andati, o se ne sono andati piuttosto tardi nel periodo, allora il nostro modello sta estrapolando bene. Spostando la nostra analisi indietro nel tempo e lasciando l'ultimo periodo per la valutazione, possiamo comprendere meglio quanto bene il nostro modello si sta generalizzando. Naturalmente, potremmo fare un ulteriore passo avanti ed eseguire qualche tipo di convalida incrociata di serie temporali. Ad esempio, potremmo ripetere questo processo più volte, ogni volta tornando indietro di 6 mesi nel tempo e valutando l'accuratezza del modello su diversi intervalli di tempo.
Dopo aver addestrato nuovamente il nostro modello, ora vediamo un drastico calo delle prestazioni. Innanzitutto, l’indice di concordanza è ora intorno a 0,5, equivalente a quello di un predittore casuale. Inoltre, se proviamo ad abbinare gli “n” dipendenti più a rischio secondo il modello con gli “n” dipendenti che hanno abbandonato il set di test, vediamo una classificazione molto scarsa con uno 0,15 F1 per la classe minoritaria:
Quindi è chiaro che c'è qualcosa che non va, ma almeno ora siamo in grado di rilevarlo invece di lasciarci ingannare. La conclusione principale è che il nostro modello funziona bene con una suddivisione tradizionale, ma non estrapola quando si esegue una suddivisione basata sul tempo. Questo è un chiaro segnale che potrebbe essere presente una certa distorsione temporale. In breve, si stanno diffondendo informazioni dipendenti dal tempo e il nostro modello si sta adattando eccessivamente ad esse. Questo è comune in casi come il nostro problema di logoramento dei dipendenti, quando il set di dati proviene da un'istantanea scattata in una certa data.
Bias temporale
Il problema si riduce a questo: tutte le nostre osservazioni positive (dipendenti che se ne sono andati) appartengono a date passate, e tutte le nostre osservazioni negative (dipendenti attualmente attivi) sono tutte misurate nella stessa data: oggi. Se c'è una singola caratteristica che lo rivela al modello, allora invece di prevedere il rischio, prevederemo se un dipendente è stato registrato nel dicembre 2023 o prima. Questo potrebbe essere molto sottile. Ad esempio, una funzionalità che potremmo utilizzare è il punteggio di coinvolgimento dei dipendenti. Questa caratteristica potrebbe mostrare alcuni modelli stagionali e misurarla allo stesso tempo per i dipendenti attivi introdurrà sicuramente qualche distorsione nel modello. Magari a dicembre, durante le festività natalizie, questo punteggio di engagement tende a diminuire. Il modello vedrà un punteggio basso associato a tutti i dipendenti attivi, quindi potrebbe imparare a prevedere che ogni volta che il coinvolgimento diminuisce, anche il rischio di abbandono diminuisce, quando in realtà dovrebbe essere il contrario!
A questo punto dovrebbe essere chiara una soluzione semplice ma abbastanza efficace a questo problema: invece di prendere l’ultima osservazione per ciascun dipendente attivo, potremmo semplicemente scegliere un mese a caso da tutta la sua storia all’interno dell’azienda. Ciò ridurrà fortemente le possibilità che il modello scelga eventuali modelli temporali su cui non vogliamo che si adatti eccessivamente:
Nell'immagine sopra possiamo vedere che ora stiamo coprendo una serie più ampia di date per i dipendenti attivi. Invece di utilizzare i punti blu a giugno 2023, prendiamo invece i punti arancioni casuali e registriamo le loro variabili in quel momento e il mandato che hanno avuto finora in azienda:
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'))
Quindi addestriamo nuovamente il nostro modello e lo valutiamo sullo stesso set di test che avevamo prima. Ora vediamo un indice di concordanza di circa 0,80. Questo non è il +0,90 che avevamo prima, ma è sicuramente un passo avanti rispetto al livello di casualità di 0,5. Per quanto riguarda il nostro interesse nella classificazione dei dipendenti, siamo ancora molto lontani dal +0,9 F1 che avevamo prima, ma vediamo un leggero aumento rispetto all'approccio precedente, soprattutto per la classe minoritaria.
Fonte: towardsdatascience.com