رویکرد سنتی
بسیاری از پیاده سازی های موجود تجزیه و تحلیل بقا با مجموعه داده ای شامل شروع می شود یک مشاهده برای هر فرد (بیماران در یک مطالعه بهداشتی، کارکنان در صورت ترک تحصیل، مشتریان در صورت خروج مشتری و غیره). برای این افراد، ما معمولاً دو متغیر کلیدی داریم: یکی نشاندهنده رویداد مورد علاقه (ترک کارکنان) و دیگری اندازهگیری زمان (مدت زمانی که با شرکت بودهاند، تا امروز یا تا زمانی که ترک کردهاند). در کنار این دو متغیر، متغیرهای توضیحی نیز داریم که با آن به دنبال پیش بینی ریسک برای هر فرد هستیم. این ویژگی ها ممکن است به عنوان مثال، عنوان شغلی، سن یا غرامت کارمند را شامل شود.
در ادامه، بیشتر پیادهسازیها از یک مدل بقا استفاده میکنند (از برآوردگرهای سادهتر مانند Kaplan Meier گرفته تا موارد پیچیدهتر مانند مدلهای مجموعه یا حتی شبکههای عصبی)، آنها را در مجموعهای از قطارها قرار میدهند، و سپس آنها را در یک مجموعه آزمایشی ارزیابی میکنند. این تقسیم آزمایشی قطار معمولاً بر روی مشاهدات فردی انجام می شود، معمولاً یک تقسیم طبقه ای انجام می شود.
در مورد من، من با مجموعه داده ای شروع کردم که چندین کارمند در یک شرکت را تا دسامبر 2023 ماهانه دنبال می کرد (در صورتی که کارمند هنوز در شرکت باشد) یا تا زمانی که آنها شرکت را ترک کردند – تاریخ رویداد:
برای تطبیق دادههایم با مورد بقا، آخرین مشاهده هر کارمند را همانطور که در تصویر بالا نشان داده شده است انجام دادم (نقاط آبی برای کارمندان فعال و صلیبهای قرمز برای کارمندانی که ترک کردند). در این مرحله، برای هر کارمند، من ثبت کردم که آیا رویداد در آن تاریخ رخ داده است یا خیر (اگر آنها فعال بودند یا ترک کردند)، مدت زمان تصدی آنها در ماه ها در آن نقطه، و همه متغیرهای توضیحی آنها را ثبت کردم. سپس من یک تقسیم بندی آزمایشی طبقه بندی قطار بر روی این داده ها به این صورت انجام دادم:
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)
بعد از انجام جدایی، اقدام به جا انداختن مدل کردم. در این مورد، من آزمایش با a را انتخاب کردم جنگل تصادفی برای بقا با استفاده از کتابخانه scikit-survival.
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])
پس از اجرای سریع با استفاده از تنظیمات پیشفرض مدل، از معیارهایی که دیدم هیجانزده شدم. اول از همه داشتم دریافت می کردم شاخص انسجام بیش از 0.90 در سری تست. شاخص تطابق معیاری است برای اینکه مدل چقدر توالی وقایع را پیشبینی میکند: این نشان میدهد که آیا کارکنانی که پیشبینی میشود در معرض ریسک بالایی هستند واقعاً اولین کسانی هستند که شرکت را ترک میکنند یا خیر. شاخص 1 مربوط به دقت پیشبینی کامل است، در حالی که شاخص 0.5 نشاندهنده پیشبینی بهتر از شانس تصادفی نیست.
من به ویژه علاقه مند بودم ببینم آیا کارمندانی که در گروه آزمایشی ترک می کنند با کارکنان در معرض خطر مطابق مدل مطابقت دارند یا خیر. در مورد جنگل بقای تصادفی، مدل امتیازهای ریسک را برای هر مشاهده برمی گرداند. من درصد کارمندانی را که شرکت را ترک کردند در مجموعه آزمایشی گرفتم و از آن برای فیلتر کردن کارکنان پرخطر طبق مدل استفاده کردم. نتایج بسیار قوی بود، به طوری که کارمندان در معرض بیشترین خطر تقریباً کاملاً مطابق با ترککنندگان واقعی بودند، با امتیاز F1 بالای 0.90 در کلاس اقلیت.
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))
دریافت معیارهای +0.9 در اولین اجرا باید زنگ خطر را به صدا در آورد: آیا این مدل واقعاً میتوانست پیشبینی کند که آیا کارمندی با چنین اطمینانی میماند یا میرود؟ این را تصور کنید: ما پیشبینیهای خود را ارائه میکنیم و میگوییم کدام کارمندان به احتمال زیاد ترک میکنند. با این حال، چند ماه می گذرد و سپس HR با نگرانی به سراغ ما می آید و می گوید افرادی که در دوره گذشته رفته اند دقیقاً با پیش بینی های ما مطابقت ندارند، حداقل با نرخی که از معیارهای آزمایش ما انتظار می رود.
ما در اینجا دو مشکل اصلی داریم: اولی اینکه مدل ما آنطور که فکر میکردیم برونیابی نمیشود. دوم، و حتی بدتر، ما نتوانستیم این کمبود بهره وری را اندازه گیری کنیم. ابتدا، روش سادهای را نشان میدهم که میتوانیم در مورد اینکه مدل ما واقعاً چقدر خوب استنباط میشود، قضاوت میکنم، و سپس در مورد یک دلیل بالقوه که چرا ممکن است این کار را انجام ندهد، و چگونگی کاهش آن صحبت خواهم کرد.
ارزیابی توانایی های تعمیم
نکته کلیدی در اینجا دسترسی به داده های پانل است، یعنی چندین رکورد از افراد ما در طول زمان، تا زمان رویداد یا زمان پایان مطالعه (تاریخ عکس فوری ما، در مورد اخراج کارکنان). به جای دور انداختن تمام این اطلاعات و حفظ آخرین رکورد هر کارمند، میتوانیم از آن برای ایجاد یک مجموعه آزمایشی استفاده کنیم که عملکرد مدل را در آینده بهتر نشان دهد. ایده بسیار ساده است: فرض کنید ما سوابق ماهانه کارمندان خود را تا دسامبر 2023 داریم. می توانیم مثلاً 6 ماه به عقب برگردیم و وانمود کنیم که عکس فوری را به جای دسامبر در ژوئن گرفته ایم. سپس آخرین مشاهدات را برای کارمندانی که قبل از ژوئن 2023 شرکت را ترک کردهاند بهعنوان رویدادهای مثبت و دادههای ژوئن 2023 برای کارکنانی که پس از آن تاریخ زنده ماندهاند را بهعنوان رویدادهای منفی در نظر میگیریم، حتی اگر از قبل بدانیم که برخی از آنها در نهایت به پایان رسیدهاند. ترک بعد از آن وانمود می کنیم که هنوز این را نمی دانیم.
همانطور که تصویر بالا نشان می دهد، من یک عکس فوری در ماه ژوئن می گیرم و همه کارمندانی که در آن زمان فعال بودند به عنوان فعال در نظر گرفته می شوند. مجموعه دادههای آزمون، همه این کارکنان فعال را از ژوئن، با متغیرهای توضیحی آنها در آن تاریخ، میگیرد و آخرین دوره تصدی را که تا دسامبر به دست آوردهاند، میگیرد:
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'])
ما دوباره مدل خود را با دادههای قطار جدید تطبیق میدهیم و پس از تکمیل، پیشبینیهای خود را برای همه کارمندانی که در ماه ژوئن فعال بودند انجام میدهیم. سپس این پیشبینیها را با نتیجه واقعی ژوئیه – دسامبر 2023 مقایسه میکنیم – این مجموعه آزمایشی ما است. اگر آن دسته از کارمندانی که در طول ترم بهعنوان بالاترین ریسک باقیمانده علامتگذاری کردهایم و آنهایی که بهعنوان کمخطر علامتگذاری کردهایم، در این دوره زمانی را ترک نکرده یا خیلی دیر ترک کردهاند، مدل ما به خوبی برونیابی میشود. با عقب بردن تحلیل خود در زمان و رها کردن آخرین دوره برای ارزیابی، بهتر می توانیم درک کنیم که مدل ما چقدر به خوبی تعمیم می یابد. البته، میتوانیم یک قدم جلوتر برویم و نوعی اعتبارسنجی متقابل روی سریهای زمانی انجام دهیم. برای مثال، میتوانیم این فرآیند را بارها تکرار کنیم، هر بار ۶ ماه به عقب برگردیم و دقت مدل را در چندین بازه زمانی ارزیابی کنیم.
پس از آموزش مجدد مدل خود، اکنون شاهد کاهش شدید عملکرد هستیم. اولاً، شاخص تطابق اکنون حدود 0.5 است – معادل شاخص یک پیش بینی تصادفی. همچنین، اگر سعی کنیم “n” کارمندان در معرض خطر را مطابق مدل با کارمندان “n” که در گروه آزمایشی ترک می کنند مطابقت دهیم، طبقه بندی بسیار ضعیفی با 0.15 F1 برای طبقه اقلیت مشاهده می کنیم:
بنابراین واضح است که چیزی اشتباه است، اما حداقل اکنون می توانیم به جای فریب خوردن، آن را تشخیص دهیم. نکته اصلی در اینجا این است که مدل ما با تقسیم سنتی به خوبی عمل می کند، اما هنگام انجام تقسیم بر اساس زمان، برون یابی نمی کند. این یک نشانه واضح است که ممکن است کمی انحراف زمانی وجود داشته باشد. به طور خلاصه، اطلاعات وابسته به زمان به بیرون درز می کند و مدل ما آن را دوباره تنظیم می کند. این در مواردی مانند مشکل انحراف کارمندان ما که مجموعه دادهها از یک عکس فوری گرفته شده در یک تاریخ مشخص میآیند، رایج است.
اعتیاد به زمان
مشکل به این خلاصه میشود: همه مشاهدات مثبت ما (کارکنان چپ) متعلق به تاریخهای گذشته هستند، و همه مشاهدات منفی ما (کارمندان فعال فعلی) در همان تاریخ – امروز – اندازهگیری میشوند. اگر یک تابع وجود دارد که این را در معرض مدل قرار می دهد، پس به جای پیشبینی ریسک، پیشبینی میکنیم که آیا کارمندی در دسامبر 2023 یا قبل از آن ثبتنام کرده است. این می تواند بسیار ظریف باشد. به عنوان مثال، یکی از ویژگی هایی که می توانیم از آن استفاده کنیم، امتیاز مشارکت کارکنان است. این تابع ممکن است برخی از الگوهای فصلی را نشان دهد، و اندازه گیری آن در همان زمان برای کارکنان فعال مطمئناً برخی از تعصبات را در مدل ایجاد می کند. شاید در ماه دسامبر، در طول فصل تعطیلات، این رتبه نامزدی تمایل به کاهش داشته باشد. این مدل امتیاز پایینی را در ارتباط با همه کارمندان فعال خواهد دید، بنابراین میتواند یاد بگیرد که پیشبینی کند که هر زمان تعامل کم باشد، خطر کنارهگیری نیز کاهش مییابد، در حالی که در واقع باید برعکس باشد!
در حال حاضر، یک راه حل ساده اما کاملا مؤثر برای این مشکل باید روشن باشد: به جای انجام آخرین مشاهده برای هر کارمند فعال، می توانیم به سادگی یک ماه تصادفی از کل تاریخچه شرکت آنها انتخاب کنیم. این احتمال به شدت کاهش مییابد که مدل هر الگوی زمانی را که ما نمیخواهیم آن را دوباره تنظیم کند انتخاب کند:
در تصویر بالا، می بینیم که ما اکنون طیف وسیع تری از تاریخ ها را برای کارمندان فعال پوشش می دهیم. به جای استفاده از نقاط آبی آنها در ژوئن 2023، به جای آن، نقاط نارنجی تصادفی را می گیریم و متغیرهای آنها را از هم اکنون و مدت تصدی آنها در شرکت ثبت می کنیم:
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'])
سپس دوباره مدل خود را آموزش می دهیم و آن را در همان مجموعه آزمایشی که قبلا داشتیم ارزیابی می کنیم. اکنون شاهد یک شاخص ثبات در حدود 0.80 هستیم. این 0.90+ نیست که قبلاً داشتیم، اما قطعاً یک پله بالاتر از سطح شانس تصادفی 0.5 است. در مورد علاقه ما به طبقه بندی کارمندان، ما هنوز با +0.9 F1 که قبلاً داشتیم فاصله زیادی داریم، اما نسبت به رویکرد قبلی، به خصوص برای طبقه اقلیت، شاهد افزایش اندکی هستیم.