Predict Employee Churn with Machine Learning
14,249명의 직원으로 훈련된 분류모델
새로운 직원을 고용하는 것은 기존 재능있는 사람을 유지하는 것보다 상당히 비싸다는 것은 HR에서 잘 알려져 있다. 경험은 왕이고 떠나는 사람들은 조직의 유용한 경험과 지식을 가지고 간다.
우리는 직원들이 계속 근무할 것인지 아니면 퇴사할 것인지를 예측하기 위해 직원의 직위, 행복도, 효율, 작업량과 근무기간에 대한 데이터를 사용하여 쥬피터 노트북에서 약간의 ML 모델을 훈련할 것이다.
우리의 목표 변수는 범주(categorical)이다. 따라서 ML은 분류(classification)이다. (숫자 목표에 대해서는 그 작업은 회귀-regression가 된다. )
우리는 14,249명의 과거와 현재 직원을 가진 대규모 회사를 시률레이션 하는 elitedatascience.com의 10개 컬럼으로 된 데이터셋을 사용할 것이다.
_** 이 데이터셋은 비공개 데이터셋으로 보인다. 따라서 이번글은 테스트 없이 진행하고 이후 유사한 다른 데이터셋을 사용하여 테스트해볼까 한다.
간만에 실습을 병행할 수 있는 기사를 찾아 좋았는데...._
Snapshot of the original dataset.
다음 단계와 같이 진행한다.
EDA & data-processing: 탐색(explore), 시각화(visualize), 데이터 클리닝(clean)
EDA (Exploratory Data Analysis) : 탐험적 데이터 분석
Feature engineering: 도메인 전문지식 활용, 새로운 특성(feature) 생성
Model training: 로지스틱 회구, 랜덤 포레스트, gradinet-boosted tree 같은 입증된 분류 알고리즘을 훈련, 튜닝
Performance evaluation: F1와 AUROC를 포함한 점수의 범위 확인
Deployment: 일괄 실행(batch-run) 또는 일부 데이터 엔지니어/ML 엔지니어가 자동화된 파이프라인을 구축
이상적으로 회사는 위험한 상태인 직원을 식별하기 위해 현재 근무중인 직원에게 모델을 적용한다. 이것은 실행 가능한 사업적 통찰력을 제공한는 ML의 한가지 예제이다.
1. Data exploration and processing
탐험적 데이터 분석(EDA)는 데이터를 이해하는데 도움이 되고 데이터 클리닝(data cleaning)과 특성 공학(feature engineering)에 대한 아이디어와 통찰을 제공한다.
데이터 클리닝은 알고리즘에 대한 데이터를 준비하지만 특성 공학은 알고리즘이 데이터셋으로부터 근본이되는 패턴을 끄집어내는 것에 정말로 도움이 되는 마법의 소스(sauce)이다. 다음을 기억하자.
좋은 데이터는 항상 더 멋진 알고리즘을 능가한다.
Better data always beats fancier algorithms!
이제 몇가지 표준 데이터 분석용 파이썬 패지지를 로딩하자.
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sb
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier,
GradientBoostingClassifier
from sklearn.model_selection import train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import GridSearchCV
from sklearn.metrics import confusion_matrix, accuracy_score,
f1_score, roc_curve, roc_auc_score
import pickle
데이터셋을 로딩한다.
df = pd.read_csv('employee_data.csv')
아래는 데이터프레임의 스냅샷으로 (14, 249, 10)의 모양을 갖는다.
Snapshot of original dataset.
목표 변수(target variable)은 status이다. 이 범주적 변수는 Employed 또는 _Left_값을 갖는다.
데이터에는 25개의 컬럼/특성이 있다.
- department
- salary
- satisfaction, filed_complaint - happiness로 대체
- last_evaluatin, recent_promoted - performance로 대체
- avg_monthly_hrs, n_projects - workload로 대체
- tenure - experience로 대체
1.1 Numerical features
수치적 특성의 분포에 대한 아이디어를 얻기 위해 몇가지 히스토그램을 그려보자.
df.hist(figsize=(10,10), xrot=-45)
데이터를 확실하게 하기 위해 수치적 특성에 하는 아래 작업은 데이터가 알고리즘과 잘 어울리도록 할 것이다.
- filed_complaint와 recently_promoted내 NaN을 0으로 변환한다. 이는 잘못 레이블링 된것이다.
- last_evalution 특성 내 NaN을 0으로 변환하기 전에 누락 값에 대한 지시 변수(indeicator variable)을 생성한다.
df.filed_complaint.fillna(0, inplace=True)
df.recently_promoted.fillna(0, inplace=True)
df['last_evaluation_missing'] = df.last_evaluation.isnull().astype(int)
df.last_evaluation.fillna(0, inplace=True)
다음은 수치적 특성에 대한 상관관계 히트맵(correlation heatmap)이다.
1.2 Categorical features
범주적 특성에 대해 몇가지 막대 그래프를 그려보자. Seaborn은 굉장히 유용하다.
for feature in df.dtypes[df.dtypes=='object'].index:
sb.countplot(data=df, y='{}'.format(features))
가장 큰 department는 sales_이다. 단지 직원이 작은 부분을 차지하는 것은 _high salary이다. (아, 갑자기 슬퍼지네...) 그리고 데이터셋은 소수 직원이 회사를 떠나는 것에서 불균형(imbalance)하다. 즉, 단지 직원의 작은 일부만이 status = _Left_이다. 이는 알고리즘 성능을 평가하기 위해 선택한 지표(metrics)에 영향이 있다. 이 부분은 결과부분에서 좀더 이야기 할 것이다.
데이터 클리닝의 관점에서, department 특성에서 IT_와 _information_technology 범주는 합쳐져야 한다.
df.department.replace('information_technology', 'IT', inplace=True)
게다가, HR은 단지 계속 근무하는(permanent) 직원에 대해서만 관리한다. 따라서 임시(temp) 부서(department)를 걸러낼 수 있다.
df = df[df.department != 'temp']
따라서 department 속성은 아래와 같이 된다.
데이터를 확실하게 하기 위해 범주 특성에 하는 다음 작업은 데이터가 알고리즘과 더 잘 어울리게 할 것이다.
- department 특성의 손실(missing) 데이터는 Missing 범주로 합쳐진다.
- department와 salary 범주적 특성은 또한 one-hot 인코딩된다.
- status 목표 변수는 이진 데이터로 변환된다.
df['department'].fillna('Missing', inplace=True)
df = pd.get_dummies(df, columns=['department', 'salary'])
df['status'] = pd.get_dummies(df.status).Left
1.3 Segmentations
범주적 특성에 대한 수치적 특성을 분할(segmentation)하여 더 많은 통찰력을 얻을 수 있다. 몇가지 일변량 분할(univariate segmentations)으로 시작해 보자.
특히, happiness, performance, workload 그리고 experience를 나타내는 수치적 특성을 범주적 목표 변수 status분할 할 것이다.
Segment satisfaction by status :
sb.violinplot(y='status', x='satisfaction', data=df)
직업(job)에 매우 만족한 이탈 직원의 수가 있다는 점이 보인다.
Segment last_evaluation status :
sb.violinplot(y='status', x='last_evaluation', data=df)
많은 이탈 직원의 수가 높은 능력을 가진 것을 볼 수 있다. 아마도 더이상 성장의 기회가 없다고 생각했을 것이다.
Segmentatin avg_monthly_hrs and n_projects by status :
sb.violinplot(y='status', x='avg_monthly_hrs', data=df)
sb.violinplot(y='status', x='n_projects', data=df)
이는 이직한 직원이 꽤 많은 작업량(workload)이거나 꽤 적은 작업량이라는 것을 나타낸다. 이는 지쳐서 퇴직한 전직원을 나타내는 것이 아닐까?
Segment tenure by status :
sb.violinplot(y='status', x='tenure', data=df)
여기서는 3년동안 갑자기 퇴사한 직원에 주목한다. 약 6년 이후로 남은 직원은 계속 근무하려는 경향을 보인다.
2. Feature engineering
나중에 특성공항에 동기를 부여할 이변량 분할(bivariate segmentations)를 보자.
각 도표는 두개의 수치적 특성(happiness, performance, workload 또는 experience)을 status로 분할할 것이다. 이는 직원 고정관념에 기초하여 몇가지 군집(cluster)를 제공할 것이다.
Performance and happiness :
근무중인(employed) 직원은 읽기 힘든 위 그래프를 생성한다. 우리가 정말 이해하려고 하는 대상으로써 퇴직한(left) 직원만으로 표시해 보자.
sb.lmplot(x='satisfaction',
y='last_evaluation',
data=df[df.status=='Left'],
fit_reg=False
)
위 도표에서 퇴직한 직원에 대한 3가지 군집을 얻을 수 있다.
- Underperformers : last_evaluation < 0.6
- Unhapppy : satisfaction_level < 0.2
- Overachievers :last_evaluation > 0.8 and satisfaction > 0.7
Workload and performance :
sb.lmplot(x='last_evaluation',
y='avg_monthly_hrs',
data=df[df.status=='Left'],
fit_reg=False
)
퇴직한 직원에 대해 2가지 군집을 얻을 수 있다.
- Stars : avb_monthly_hrs > 215 and last_evaluation > 0.75
- Slackers : avg_monthly_hrs < 165 and last_evaluation < 0.65
Workload and happiness :
sb.lmplot(x='satisfaction',
y='avg_monthly_hrs',
data=df[df.status=='Left'],
fit_reg=False,
)
퇴직한 직원에 대한 3가지 군집을 얻을 수 있다.
- Workaholics : avg_monthly_hrs > 210 and satisfation > 0.7
- Just-a-job : avg_monthly_hrs < 170
- Overworked : avg_monthly_hrs > 225 and satisfaction < 0.2
직원의 위 8가지 틀에 박힌 새로운 특성(Underperformers, Unhappy, Overachievers, Stars, Slackers, Workaholics, Just-a-job, Overworked)을 조작해 보자.
df['underperformer'] = ((df.last_evaluation < 0.6) & (df.last_evaluation_missing==0)).astype(int)
df['unhappy'] = (df.satisfaction < 0.2).astype(int)
df['overachiever'] = ((df.last_evaluation > 0.8) & (df.satisfaction > 0.7)).astype(int)
df['stars'] = ((df.avg_monthly_hrs > 215) & (df.last_evaluation > 0.75)).astype(int)
df['slackers'] = ((df.avg_monthly_hrs < 165) & (df.last_evaluation < 0.65) & (df.last_evaluation_missing==0)).astype(int)
df['workaholic'] = ((df.avg_monthly_hrs > 210) & (df.satisfaction > 0.7)).astype(int)
df['justajob'] = (df.avg_monthly_hrs < 170).astype(int)
df['overworked'] = ((df.avg_monthly_hrs > 225) & (df.satisfaction < 0.2)).astype(int)
이 8가지 그룹 각각에서 직원의 비중을 한눈에 볼 수 있다.
df[['underperformer', 'unhappy', 'overachiever', 'stars',
'slackers', 'workaholic', 'justajob', 'overworked']].mean()
underperformer 0.285257
unhappy 0.092195
overachiever 0.177069
stars 0.241825
slackers 0.167686
workaholic 0.226685
justajob 0.339281
overworked 0.071581
직원의 34%가 단순하게 업무를 진행하는 직원이다. 반면 단지 7%는 초과근무로 녹초가 된다.
분석 기본 테이블(ABT - Analytical Base Table) : 모든 데이터 클리닝과 특성공합을 적용한 후의 데이터셋이 ABT 이고 이것이 모델을 훈련하는 데이터이다.
ABT는 14,068명의 직원과 31개의 컬럼을 갖는다. (원본 데이터셋은 14,249명의 직원과 10개의 컬럼을 갖는다.)
3. Modeling
4가지 입증된 분류 모델(classification model)을 훈련할 것이다.
- 로지스틱 회귀(logistic regressions) : L1과 L2 정규화
- random forests
- gradient-boosted trees
우선, ABT를 나눈다. - 데이터와 레이블(status)로 나눔
y = df.status
X = df.drop('status', axis=1)
그리고 훈련과 테스트 셋으로 나눈다. 데이터셋이 약간 불균형(imbalanced)하기 때문에 보상을 위해 계층화된 샘플링(stratified sampling)을 사용한다.
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=1234, stratify=df.status)
훈련을 위해 Pipeline object를 설정한다. 이는 모델 훈련 절차를 간소화한다.
pipelines = {
'l1': make_pipeline(StandardScaler(),
LogisticRegression(penalty='l1', random_state=123)),
'l2': make_pipeline(StandardScaler(),
LogisticRegression(penalty='l2', random_state=123)),
'rf': make_pipeline(
RandomForestClassifier(random_state=123)),
'gb': make_pipeline(
GradientBoostingClassifier(random_state=123))
}
또한 각 알고리즘에 대한 하이퍼파라미터(hyperparameter)를 튜닝하도록 한다. 로지스틱 회괴에서 가장 영향력있는 하이퍼파라미터는 정규화 강도 C이다.
l1_hyperparameters = {'logisticregression__C' : [0.001, 0.005, 0.01,
0.05, 0.1, 0.5, 1, 5, 10, 50, 100, 500, 1000]
}
l2_hyperparameters = {'logisticregression__C' :
[0.001, 0.005, 0.01, 0.05, 0.1, 0.5,
1, 5, 10, 50, 100, 500, 1000]
}
Random forest에서는 평가자의 수(n_estimators), 분할(split)중 고려하기 위한 최대 특성 수(max_features) 그리고 leaf가 되는 최소 샘플 수(min_samples_leaf)를 튜닝한다.
rf_hyperparameters = {
'randomforestclassifier__n_estimators' : [100, 200],
'randomforestclassifier__max_features' : ['auto', 'sqrt', 0.33],
'randomforestclassifier__min_samples_leaf' : [1, 3, 5, 10]
}
Gradient-boosted tree에서는 평가자의 수(n_estimators), 학습률(learning rage) 그리고 각 트리의 최대 깊이(max_depth)를 튜닝한다.
gb_hyperparameters = {
'gradientboostingclassifier__n_estimators' : [100, 200],
'gradientboostingclassifier__learning_rate' : [0.05, 0.1, 0.2],
'gradientboostingclassifier__max_depth' : [1, 3, 5]
}
이들 하이퍼파라미터를 dictionary에 저장한다.
hyperparameters = {
'l1' : l1_hyperparameters,
'l2' : l2_hyperparameters,
'rf' : rf_hyperparameters,
'gb' : gb_hyperparameters
}
마지막으로 모델을 학습하고 튜닝한다. GridSearchCV를 사용하여 단지 몇줄의 코드로 위에서 정의한 모든 하이퍼파라미터에서 교차검증(cross-validation)하는 모델을 훈련할 수 있다.
fitted_models = {}
for name, pipeline in pipelines.items():
model = GridSearchCV(pipeline,
hyperparameters[name],
cv=10,
n_jobs=-1)
model.fit(X_train, y_train)
fitted_models[name] = model
4. Evaluation
4.1 Performance scores
교차 검증 점수(cross-validation scores)를 출력해 보자. 이것은 10개의 홀드아웃 폴드에 대한 평균 성능(performance)이고 오로지 훈련 데이터만을 사용하여 신뢰할 수 있는 모델 성능 추정치를 얻을 수 있는 방법이다.
for name, model in fitted_models.items():
print(name, model.best_score_)
Output:
l1 0.9088324151412831
l2 0.9088324151412831
rf 0.9793851075173272
gb 0.975475386529234
테스트 데이터로 가서 다음을 수행한다.
- 정확도(accuracy) 계산
- 혼돈 매트릭스(confusion matrix) 출력, 정밀도(precision), 재현률(recall), F1 점수(F1 score) 계산
- ROC를 표시, AUROC 점수 계산
정확도(accuracy)는 올바르게 예측된 비율을 측정한다. 하지만 이메일 스팸 필터링(spam vs. not spam), 의료 테스트(sick vs. not sick) 같은 불균형(imbalanced) 데이터셋에 대해서는 적합하지 않은 지표(metric) 이다. 예를 들어 만약 데이터셋이 target = _Left_를 만족하는 직원의 1%만을 갖는다면 직원이 여전히 회사에서 일하고 있다고 항상 예측하는 모델은 즉시 99 %의 정확도를 갖는다.
이런 경우에는 정밀도(precision)과 재현률(recall)이 좀더 적합하다. 자주 사용하는 것은 Type 1 error(False Positives) 또는 Type 2 error(False Negatives)를 최소화 할지에 따라 다르다. 스팸 메일의 경우, Type 1 error가 더 나쁘다.(몇몇 스팸은 중요한 이메일을 실수로 필터링하지 않는한 OK이다.) 반면, Type 2 error는 의료 테스트에서는 받아들일 수 없다.(암이 걸리지 않은 누군가에게 암이 걸렸다고 말하는 것은 재앙이다.)
F1 score는 정밀도와 재현률에 가중치가 적용된 평균을 가져와 양쪽의 장점을 갖는다.
$F1 = \frac{2 \times precision \times recall}{precision + recall}$
AUROC로 알려진 ROC 아래 영역은 분류 문제에 대한 또다른 표준 지표이다. 이는 범주(class)를 구분하기 위한 분류기의 능력에 대한 효과적인 측정이고 노이즈로부터 신호를 분리한다. 이 지표는 또한 불균형 데이터셋에 대해서도 강하다.
아래 코드는 위에서 설명한 점수를 생성하고 그래프를 그린다.
for name, model in fitted_models.items():
print('Results for:', name)
# obtain predictions
pred = fitted_models[name].predict(X_test)
# confusion matrix
cm = confusion_matrix(y_test, pred)
print(cm)
# accuracy score
print('Accuracy:', accuracy_score(y_test, pred))
# precision
precision = cm[1][1]/(cm[0][1]+cm[1][1])
print('Precision:', precision)
# recall
recall = cm[1][1]/(cm[1][0]+cm[1][1])
print('Recall:', recall)
# F1_score
print('F1:', f1_score(y_test, pred))
# obtain prediction probabilities
pred = fitted_models[name].predict_proba(X_test)
pred = [p[1] for p in pred]
# plot ROC
fpr, tpr, thresholds = roc_curve(y_test, pred)
plt.title('Receiver Operating Characteristic (ROC)')
plt.plot(fpr, tpr, label=name)
plt.legend(loc='lower right')
plt.plot([0,1],[0,1],'k--')
plt.xlim([-0.1,1.1])
plt.ylim([-0.1,1.1])
plt.ylabel('True Positive Rate (TPR) i.e. Recall')
plt.xlabel('False Positive Rate (FPR)')
plt.show()
# AUROC score
print('AUROC:', roc_auc_score(y_test, pred))
로지스틱 회귀(logistic regression) - L1 정규화(L1-regularised)
Output:
[[2015 126]
[ 111 562]]
Accuracy: 0.9157782515991472
Precision: 0.8168604651162791
Recall: 0.8350668647845468
F1: 0.8258633357825129
AUROC: 0.9423905869485105
로지스틱 회귀(logistic regression) - L1 정규화(L2-regularised)
Output:
[[2014 127]
[ 110 563]]
Accuracy: 0.9157782515991472
Precision: 0.8159420289855073
Recall: 0.836552748885587
F1: 0.8261188554658841
AUROC: 0.9423246556128734
Gradient-boosted tree
Output:
[[2120 21]
[ 48 625]]
Accuracy: 0.9754797441364605
Precision: 0.9674922600619195
Recall: 0.9286775631500743
F1: 0.9476876421531464
AUROC: 0.9883547910913578
Random forest
Output:
[[2129 12]
[ 45 628]]
Accuracy: 0.9797441364605544
Precision: 0.98125
Recall: 0.9331352154531947
F1: 0.9565879664889566
AUROC: 0.9916117990718256
가장 좋은 알고리즘은 99% AUROC와 96% F1 점수를 가진 랜덤포레스트이다. 이 알고리즘은 퇴사자와 근무자간을 99% 확률로 구분한다.
테스트 셋의 2,814명의 직원의 알고리즘 결과는 다음과 같다.
- 628명의 퇴사자를 올바르게 분류(True Positive)하였지만 12명을 잘못 분류(Type 1 error)
- 2,129명의 근무자를 올바르게 분류(True Negative)하였지만, 45명을 잘못 분류(Type 2 error)
참고로, 가장 좋은 랜덤포레스의 GridSearchCV를 사용하여 튜닝된 하이퍼파라미터는 다음과 같다.
RandomForestClassifier(bootstrap=True,
class_weight=None,
criterion='gini',
max_depth=None,
max_features=0.33,
max_leaf_nodes=None,
min_impurity_decrease=0.0,
min_impurity_split=None,
min_samples_leaf=1,
min_samples_split=2,
min_weight_fraction_leaf=0,
n_estimators=200,
n_jobs=None,
oob_score=False,
random_state=123,
verbose=0,
warm_start=False
)
4.2 Feature Importance
다음 코드를 보자.
coef = winning_model.feature_importances_
ind = np.argsort(-coef)
for i in range(X_train.shape[1]):
print("%d. %s (%f)" % (i + 1, X.columns[ind[i]], coef[ind[i]]))
x = range(X_train.shape[1])
y = coef[ind][:X_train.shape[1]]
plt.title("Feature importances")
ax = plt.subplot()
plt.barh(x, y, color='red')
ax.set_yticks(x)
ax.set_yticklabels(X.columns[ind])
plt.gca().invert_yaxis()
위 코드는 중요도 순서에 따른 특성 목록과 그에 해당하는 막대 그래프를 출력한다.
Ranking of feature importance:
1. n_projects (0.201004)
2. satisfaction (0.178810)
3. tenure (0.169454)
4. avg_monthly_hrs (0.091827)
5. stars (0.074373)
6. overworked (0.068334)
7. last_evaluation (0.063630)
8. slackers (0.028261)
9. overachiever (0.027244)
10. workaholic (0.018925)
11. justajob (0.016831)
12. unhappy (0.016486)
13. underperformer (0.006015)
14. last_evaluation_missing (0.005084)
15. salary_low (0.004372)
16. filed_complaint (0.004254)
17. salary_high (0.003596)
18. department_engineering (0.003429)
19. department_sales (0.003158)
20. salary_medium (0.003122)
21. department_support (0.002655)
22. department_IT (0.001628)
23. department_finance (0.001389)
24. department_management (0.001239)
25. department_Missing (0.001168)
26. department_marketing (0.001011)
27. recently_promoted (0.000983)
28. department_product (0.000851)
29. department_admin (0.000568)
30. department_procurement (0.000296)
퇴사자에 대해 특히 강한 예측변수(predicator)가 3가지 있다.
- n_projects(workload)
- satisfaction(happiness)
- tenure(experience)
게다가, 특성 중요도(feature importance)상에 높이 랭크된 두가지 조작된(engineered) 특성이 있다.
- stars(high happiness & workload)
- overworked(low happiness & high workload)
흥미롭지만 전체적으로 놀랍지는 않다. _stars_는 더 나은 기회를 위해 떠났을테지만 _overworked_는 지쳐서 떠났을 것이다.
5. Deployment
이 모델의 실행가능한 버전(.pkl)을 쥬피터 노트북에서 저장할 수 있다.
with open('final_model.pkl', 'wb') as f:
pickle.dump(fitted_models['rf'].best_estimator_, f)
HR은 신규 직원 데이터를 훈련되 모델에 제공하기 전에 전처리를 할 수 있다. 이를 일괄 실행(batch-run)이라고 한다.
대규모 조직에서는 data engineer와 machine learning engineer가 협업하여 운영 환경(production environment)에 모델을 배포하길 원한다. 이들 전문가는 신규 데이터가 전처리되고 정기적으로 HR에 보고되도록 모델에 자동화된 파이프라인을 구축한다.
Final Comments
대규모 회사에서 HR이 퇴사자에 대한 수용가능한 통찰력을 원한다는 business problem으로 시작되었다.
14,000명 이상의 퇴사자와 근무자로 구성된 데이터로 가장 좋은 성능을 보인 랜덤포레스트 모델을 훈련하였다.
HR은 훈련된 .pkl 파일에서 수동으로 또는 엔지니어 부서에서 구축한 자동화된 파이프라인에서 새로운 데이터를 실행할 수 있다.
모델은 이진 분류 모델이다. 여기서 목표 변수(target variable)는 범주(category)이다. 모렐은 이산(discrete) 가능성의 수를 예측한다. - 여기서는 '퇴사' 또는 '근무' 이다.
Supervised learning에 대한 동전의 다른면은 목표 변수가 수치(numeric)인 회귀 모델(regression model)이다.