保持员工满意的问题是一个长期存在且历史悠久的挑战。如果您投入了大量时间和金钱的员工离开,那么这意味着您将不得不花费更多的时间和金钱来雇佣其他人。这个项目使用了IBM的员工流失数据作为处理目标,看看我们是否可以构建一些模型,来对员工的流失进行预测。
我们尝试使用不断探索的方式来分析这份数据并建立机器学习模型。
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import time
import warnings
warnings.filterwarnings('ignore')
attrition = pd.read_csv('./data/WA_Fn-UseC_-HR-Employee-Attrition.csv')
# 输出列名
print(attrition.columns.values.tolist())
print('数据行数:', len(attrition))
attrition.head()
从员工流失数据样本中,我们可以看到数据的一些特征:
在我们即将构建的机器学习模型当中,'Attrition'将是模型训练的目标列。
此外,我们看到我们混合了数字和分类数据类型。
作为第一步,让我们快速进行一些简单的数据完整性检查,以查看数据中是否存在空值或无效值。
attrition.isnull().any()
target_map = {'Yes':1, 'No':0}
# TODO
# 抽取attrition中的'Attrition'这一列,存储到y这个Series类型的变量中,并且完成元素值的转换,将Yes转为1,No转为0。
y = attrition['Attrition'].apply(lambda x: target_map[x])
# 从attrition中将Attrition列移除
attrition = attrition.drop(['Attrition'], axis=1)
attrition.head()
可以看到,在列表中有很多列的数据都是由字符串组成的。
首先,我们将通过使用dtype方法将数值列与类别列分开,如下所示:
# categoricals列表将用于记录所有的非数值属性名
categoricals = []
for col, value in attrition.iteritems():
if value.dtype == 'object':
print (col+"是一个类别列")
categoricals.append(col)
numerical = attrition.columns.difference(categoricals)
# 将数值型属性列保存到变量attrition_num中
attrition_num = attrition[numerical]
# 将类别属性列存储到变量attrition_cat中
attrition_cat = attrition[categoricals]
from sklearn.model_selection import cross_val_score
from sklearn.model_selection import train_test_split
from sklearn.metrics import roc_auc_score
from sklearn.tree import DecisionTreeClassifier
from sklearn.model_selection import cross_val_score
# 决策树模型的参数列表(树最大高度)
parameters = range(1, 9)
def train_test_model(X, y):
'''依据输入数据,使用模型进行交叉验证训练。在训练过程中,打印针对不同的超参数值,模型在验证集上的auc分数。
同时,对训练所耗费的时间做记录。
输入:
X:数据集
y:预测属性
返回值:
选出最优参数之后的模型
'''
start = time.time()
# 切分训练集和测试集
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=1)
cv_scores = []
# 循环取parameters中的备选参数值,逐个尝试在不同参数状况下模型的表现(网格搜索 )
for param in parameters:
# TODO
# 新建DecisionTreeClassifier类型的对象clf,并指定其中参数max_depth为当前循环所取值param
clf = DecisionTreeClassifier(max_depth=param)
# TODO
# 使用10折交叉验证(通过sklearn.model_selection.cross_val_score可直接实现并计算分数),采用roc_auc作为评价指标。
# 对clf进行分数计算,将10个分数值放入到列表scores中。
scores = cross_val_score(clf, X_train, y_train, scoring='roc_auc', cv=10)
# 计算10次验证集上所获分数的均值,作为该模型分数存入变量cv_score
cv_score = np.mean(scores)
print('参数={},验证集上的AUC={:.3f}'.format(param, cv_score))
# 将分数存入cv_scores
cv_scores.append(cv_score)
# TODO
# 选出网格搜索及交叉验证时分数最高的参数值,存入best_para变量
best_para = parameters[cv_scores.index(max(cv_scores))]
# 输出结果打印
print('最优的参数值:{}'.format(best_para))
# 根据最好的参数值定义目标分类器
clf = DecisionTreeClassifier(max_depth=best_para)
# 对整个训练集再做一次完整的fit计算出模型的参数
clf.fit(X_train, y_train)
# TODO
# 通过predict对测试集进行测试,并将结果存入变量y_pred
y_pred = clf.predict(X_test)
#打印模型的auc值
print('模型AUC值:{:.3f}'.format(roc_auc_score(y_test, y_pred)))
end = time.time()
duration = end - start
print('\n耗时{:.4f}s'.format(duration))
return clf
train_test_model(attrition_num,y)
虽然模型在测试集上的auc值并不理想,但这个简单的尝试之后,我们还是可以发现,模型中参数的不同设置,会影响模型的效果。
以上过程中,我们使用了交叉验证和网格搜索来完成了挑选最优参数的工作。请分别解释一下什么是交叉验证,什么是网格搜索?
回答:
1.交叉验证是指在训练模型时,可将数据等分为m份,每次取其中的1份(每次测试集不同)作为验证集,其余m-1份作为训练集,循环训练m次,以m次验证集得分平均数作为最终训练模型的的得分。
2.而网格搜索可以针对给定的 estimator 和 不同的参数,选择出其中可以使得模型最优的参数。
在使用网格搜索时,为何结合交叉验证一起使用效果能更好?
回答: 网格搜索可以使得代码变得简介,交叉验证能让最终得到的最优参数更加合理。
from sklearn.preprocessing import LabelEncoder
encoder = LabelEncoder()
# 将attrition_cat拷贝到新对象new_attrition_cat中
new_attrition_cat = attrition_cat.copy()
for categorical in categoricals:
# TODO
# 使用以上encoder逐列将类别数据转化为数值的形式,重新存到new_attrition_cat的对应列中
new_attrition_cat[categorical] = encoder.fit_transform(new_attrition_cat[categorical])
# 打印一下做了labelEncode之后的属性值
print("{}\n".format(new_attrition_cat[categorical].head()))
类别属性标签化之后,我们可以看到,以上的BusinessTravel、Department、EducationField、Gender、JobRole、MaritalStatus这些属性都被转换成了0、1、2...这样的数字类型。数值之间有大小关系,但其实在我们这个案例里,某个属性取某个具体的类别值,不存在大小高低之分,它们都应是平等的。于是我们需要对这些属性进行独热编码处理。
from sklearn.preprocessing import OneHotEncoder
encoder = OneHotEncoder(sparse=False)
# TODO
# 使用encoder对new_attrition_cat中的类别列数据new_attrition_cat进行独热编码,并将结果保存在attrition_cat_one_hot中
attrition_cat_one_hot = pd.DataFrame(encoder.fit_transform(new_attrition_cat))
# TODO
# 将attrition_cat_one_hot与attrition_num进行水平拼接,生成新的训练数据并存储到变量attrition_comb中
attrition_comb = pd.concat(((attrition_cat_one_hot, attrition_num)), axis=1)
# 打印attrition_comb的结构
print(attrition_comb.shape)
# 打印attrition_comb前3行
print(attrition_comb[:3])
以上的标签编码以及独热编码两个步骤,在Pandas当中,我们可以直接使用get_dummies一步完成:
attrition_cat_one_hot = pd.get_dummies(attrition_cat)
print(attrition_cat_one_hot.shape)
# TODO
# 将attrition_cat_one_hot与attrition_num进行水平拼接,生成新的训练数据并存储到变量attrition_comb中
attrition_comb = pd.concat([attrition_cat_one_hot, attrition_num], axis=1)
# 打印attrition_comb的结构
print(attrition_comb.shape)
# 打印attrition_comb头部
print(attrition_comb.head())
使用我们进行独热编码之后的数据再次进行训练。
# 使用进行独热编码之后的数据进行训练
train_test_model(attrition_comb, y)
观察一下该模型构建过程中最终获得的AUC值以及耗时等信息,与之前对比一下,独热编码在模型AUC值上以及计算耗时上都有什么样的不同?为什么会带来这样的变化呢?
回答: AUC值和计算耗时都有所提高。对非数值特征进行编码后,用于训练的数据集特征数量增多,新增特征对结果的影响使得模型性能提高;同时特征的增多使得运算量加大,因此训练耗时增加。
对数值特征施加一些形式的缩放通常会是一个好的习惯。在数据上面施加一个缩放并不会改变数据分布的形式,但是,归一化保证了每一个特征在使用监督学习器的时候能够被平等的对待。
完成下面的代码单元来归一化每一个数字特征。我们将使用sklearn.preprocessing.MinMaxScaler
来完成这个任务。
from sklearn.preprocessing import MinMaxScaler
scaler = MinMaxScaler()
# TODO
# 使用MinMaxScaler将数值特征attrition_num进行归一化,并存在attrition_num_min_max_norm中
attrition_num_min_max_norm = pd.DataFrame(scaler.fit_transform(attrition_num))
# TODO
# 将attrition_cat_one_hot与attrition_num_min_max_norm拼接成新的训练数据存储到attrition_comb中
attrition_comb = pd.concat([attrition_cat_one_hot, attrition_num_min_max_norm], axis=1)
# 重新训练
train_test_model(attrition_comb,y)
还可以使用z-score作为归一化的方法,在sklearn中,提供了sklearn.preprocessing.StandardScaler
来直接实现这一指标变换。我们也使用这一归一化方式来尝试下效果。
from sklearn.preprocessing import StandardScaler
scaler = StandardScaler()
# TODO
# 使用z-score将数值特征attrition_num进行归一化,并存在attrition_num_std_norm中
attrition_num_std_norm = pd.DataFrame(scaler.fit_transform(attrition_num))
# TODO
# 将attrition_cat_one_hot与attrition_num_std_norm拼接成新的训练数据,存储到attrition_comb中
attrition_comb = pd.concat([attrition_cat_one_hot, attrition_num_std_norm], axis=1)
# 模型训练
train_test_model(attrition_comb,y)
数据归一化会有益于梯度下降的性能。在我们这份数据中,由于所使用模型及数据量的原因,这点表现不明显。
但,这里你还可以做些调研,看看MinMaxScaler跟StandardScaler分别适用于什么样特点的数据,在什么样的场景下,他们会对模型的效果造成较大的损坏。
回答:
(1)如果对输出结果范围有要求,用归一化。
(2)如果数据较为稳定,不存在极端的最大最小值,用归一化。
(3)如果数据存在异常值和较多噪音,用标准化,可以间接通过中心化避免异常值和极端值的影响
(猜测:当数据微小变化会对标签产生较大影响时,归一化将数据间的差异缩小,不利于模型的训练。)
我们以上实现的train_test_model函数,使用for循环实现了网格搜索。而在sklearn中,往往会通过GridSearchCV来使用网格搜索和交叉验证。我们通过以下代码,来优化之前的train_test_model函数实现。
from sklearn.model_selection import GridSearchCV
from sklearn.model_selection import train_test_split
warnings.filterwarnings('ignore')
def optimize_train_test_model(X_train, y_train, X_test, y_test, model_name, model, param_range):
"""
训练并测试模型
输入参数:
model_name: 模型名字
model:训练模型
param_range:模型参数取值
输出:
训练之后的GridSearchCV对象
"""
print('训练{}中'.format(model_name))
# TODO
# 基于传入的model构建一个名为clf的GridSearchCV对象
# 其中cv为10,scoring为roc_auc,参数选项为传入的param_range
clf = GridSearchCV(model, param_range, scoring='roc_auc', cv=10)
start = time.time()
clf.fit(X_train, y_train)
# 计时
end = time.time()
duration = end - start
# TODO
# 验证模型,得到模型在训练集和测试集上的评分,并分别存储到train_score和test_score中
train_score = clf.score(X_train, y_train)
test_score = clf.score(X_test, y_test)
print('训练AUC:{:.3f}'.format(train_score))
print('测试AUC:{:.3f}'.format(test_score))
print('最优参数:{}'.format(clf.best_params_))
print('训练模型耗时: {:.4f}s'.format(duration))
print('###########################################')
return clf
model_name_param_dict = {
'DT': (DecisionTreeClassifier(),
{'max_depth': parameters}),
}
# 划分数据集
X_train, X_test, y_train, y_test = train_test_split(attrition_comb, y, test_size= 0.2, random_state=1);
# TODO
# 调用optimize_train_test_model重新训练,模型及参数为以上model_name_param_dict中所定义的决策树,并存储到gscv中
(model, param_range) = model_name_param_dict['DT']
gscv = optimize_train_test_model(X_train, y_train, X_test, y_test, 'DT', model, param_range)
我们可以通过绘制学习曲线的方式来可视化网格搜索训练过程中,当超参数取不同值时,模型在训练集和验证集中的得分表现,从而以直观的方式看出随着超参数的变化,模型性能的变化。
from sklearn.model_selection import validation_curve
import matplotlib.pyplot as plt
param_range = parameters
train_score,test_score = validation_curve(DecisionTreeClassifier(), X_train, y_train, param_name='max_depth',
param_range=param_range,cv=10,scoring='roc_auc')
train_score = np.mean(train_score,axis=1)
test_score = np.mean(test_score,axis=1)
plt.plot(param_range,train_score,'o-',color = 'r',label = 'training')
plt.plot(param_range,test_score,'o-',color = 'g',label = 'testing')
plt.legend(loc='best')
plt.xlabel('depth')
plt.ylabel('AUC measurement')
plt.show()
1.根据学习曲线,你能看出,depth可能在多少的时候,模型达到最优么?为什么?
2.depth为6的时候,模型是欠拟合还是过拟合呢?这时候我们通常称之为方差过大还是偏差过大?
回答:
1.depth为5时,模型达到最优,此时在整个数据集上的表现最好。
2.depth为6时,过拟合,此时方差过高。
from sklearn.linear_model import LogisticRegression
lr_parameters = [0.01, 0.1, 1, 3, 5, 8, 12, 16]
# 重新定义model_name_param_dict,通常我们会将需要的尝试的模型放到一起,方便调用时取用
model_name_param_dict = {
'DT': (DecisionTreeClassifier(),
{'max_depth': parameters}),
'LR': (LogisticRegression(),
{'C': lr_parameters}),
}
# TODO
# 调用optimize_train_test_model重新训练,模型及参数为以上model_name_param_dict中所定义的逻辑回归模型LogisticRegression,同样存储到gscv中
(model, param_range)=model_name_param_dict['LR']
gscv = optimize_train_test_model(X_train, y_train, X_test, y_test, 'LR', model, param_range)
你在测试集上应该看到了一个大于0.8的AUC值,这看起来已经是很理想的一个模型了。(LR模型运算耗时要比DT高,而且,如果在数据没有进行归一化时耗时高的特点会更明显,大家可以自行尝试)
但是,让我们再仔细思考在这个案例中,我们所面对的这份数据。
# TODO
# 计算一下训练集y_train中有离职数据(为1)的比例是多少
attrition_num = y_train.sum()
print("离职数据比列:{}".format(attrition_num/len(y_train)))
结果应该是一个在10%到20%之间的较小比例值,这是一份不均衡的训练集。一般来说,当少量样本比例在10%以下时,是必定要开始处理数据的失衡问题,这里虽未达到这么小,我们也同样尝试做下处理。
从另一个角度考虑,我们做这份数据分析的初衷,是希望识别那些有较高离职可能的员工,从而采取些行动。从结果指标判断,我们其实希望结果有较高的召回率,我们来计算一下以上模型的召回率。
小问题: 为何我们这里关注的是召回率?
回答:
俗话说:宁可错杀一千,不可放过一个。当然这是比较夸张的说法。但我们希望识别那些有较高离职可能的员工,也就是希望,测试值为1真实值也为1的样本占所有真实值为1 样本的比例尽可能高。这在统计中的体现就是召回率,召回率越高,越不容易“放过”真实值为1的样本。
from sklearn.metrics import recall_score
def calculate_recall(y_true, y_pred):
# TODO
# 调用recall_score计算模型的召回率并存储到变量recall中
recall = recall_score(y_true, y_pred)
return recall
print('召回率:{}'.format(calculate_recall(y_test, gscv.best_estimator_.predict(X_test))))
结果应该是一个比较让人失望的数字。
通常使用重采样技术来解决数据失衡的问题,其中包括向上采样以及向下采用两种方法。
首先我们通过在从多数类中随机删除实例的方法,实践一下向下采样。
在我们的数据中,分类0的数据比分类1多,向下采样即将分类0的数据量缩减到跟分类1相等
# 将X_train以及y_train重新拼接,便于采样处理
#X_train_df = pd.DataFrame(X_train, columns=attrition_comb.columns.tolist())
attrition_wait_for_sampling = pd.concat([X_train, y_train], axis=1)
# TODO
# 获取0类数据个数,存入变量count_class_0,获取1类数据个数,存入变量count_class_1
count_class_0, count_class_1 = (len(y_train) - attrition_num) , attrition_num
print("0类数据个数:{}".format(count_class_0))
print("1类数据个数:{}".format(count_class_1))
# 将训练数据attrition_wait_for_sampling分成attrition_class_0以及attrition_class_1两部分
attrition_class_0 = attrition_wait_for_sampling[attrition_wait_for_sampling['Attrition'] == 0]
attrition_class_1 = attrition_wait_for_sampling[attrition_wait_for_sampling['Attrition'] == 1]
# TODO
# 从attrition_class_0中随机抽样并复制出新项,一共取出count_class_1个,生成由count_class_1个元素组成的新数据attrition_class_0_under
attrition_class_0_under = attrition_class_0.sample(count_class_1)
# 将attrition_class_0_under与attrition_class_1进行拼接,成为新的训练数据
attrition_data_under = pd.concat([attrition_class_0_under, attrition_class_1], axis=0)
# 重新取出训练数据及目标列
y_train_under = attrition_data_under['Attrition']
X_train_under = attrition_data_under.drop(['Attrition'], axis=1)
# 训练
gscv = optimize_train_test_model(X_train_under, y_train_under, X_test, y_test,
'LR', model_name_param_dict['LR'][0], model_name_param_dict['LR'][1])
# TODO
# 计算训练所得模型的召回率
print('召回率:{}'.format(calculate_recall(y_test, gscv.best_estimator_.predict(X_test))))
在我们的数据中,分类0的数据比分类1多,向上采样即将分类1的数据量随机增加到跟分类0相等。
# TODO
# 从attrition_class_1中随机抽样并复制出新项,一共取出count_class_0个,生成由count_class_0个元素组成的新数据attrition_class_1_over
attrition_class_1_over = attrition_class_1.sample(count_class_0, replace=True)
# 将attrition_class_1_over与attrition_class_0进行拼接,成为新的训练数据
attrition_data_over = pd.concat([attrition_class_0, attrition_class_1_over], axis=0)
# 重新取出训练数据及目标列
y_train_over = attrition_data_over['Attrition']
X_train_over = attrition_data_over.drop(['Attrition'], axis=1)
# 训练
gscv = optimize_train_test_model(X_train_over, y_train_over, X_test, y_test,
'LR', model_name_param_dict['LR'][0], model_name_param_dict['LR'][1])
# TODO
# 计算训练所得模型的召回率,并打印出来
print('召回率:{}'.format(calculate_recall(y_test, gscv.best_estimator_.predict(X_test))))
到目前为止,你所能看到的结果应该已经是个效果相当不错的模型了,AUC以及召回率都达到我们所期望的效果。
对于重采样操作,我们如上随机复制数据的方式并不是科学的方法。看似简单,可天下没有免费的午餐。
当随机复制数据并做上采样时,出现了大量重复数据,因为我们刻意强调了某些数据特点,会出现过拟合的可能。
当随机删除大类样本中的数据时,由于删除操作的随意性,有可能会使得大类样本中的某些信息被删除掉从而影响模型效果。
Python imbalanced-learn模块
提供了更为丰富和科学的重采样方法,比如在做下采样时,先对大类样本做聚类操作,然后从每个聚类中均匀删除记录以便尽可能保留大类样本中的有用信息。又比如在做上采样的时候,不是简单的做记录复制,而是在复制出的记录属性中,加入一些小的偏差值,以保证数据多样性的原本特点。
我们可以在conda命令行中执行 conda install -c conda-forge imbalanced-learn ,来安装这个模块。
以下代码中,我们尝试使用imblearn.combine.SMOTETomek来做上下采样相结合的数据处理,然后进行模型训练并查看结果。(在imblearn模块中,还存在其它如imblearn.over_sampling.SMOTE、imblearn.under_sampling.ClusterCentroids等多种上采样或者下采样方法)
from imblearn.combine import SMOTETomek
from sklearn.tree import DecisionTreeClassifier
# TODO
# 构建SMOTETomek对象smt并将ratio设置为auto
smt = SMOTETomek(ratio='auto')
# 对数据进行采样处理并生成到X_smt和y_smt中
X_smt, y_smt = smt.fit_sample(X_train, y_train)
# TODO
# 调用optimize_train_test_model使用LR算法对数据进行训练
gscv = optimize_train_test_model(X_smt, y_smt, X_test, y_test,
'LR', model_name_param_dict['LR'][0], model_name_param_dict['LR'][1])
# TODO
# 计算训练所得模型的召回率
print('召回率:{}'.format(calculate_recall(y_test, gscv.best_estimator_.predict(X_test))))