桑坦德银行客户满意度预测
本文所用代码及教学内容均来自于B站:讯飞AI大学堂 kaggle教学合集
从一线支持团队到最高管理层,客户满意度是衡量成功的关键。不满意的客户不会留下来,更重要的是,不满意的顾客很少在离开前表达他们的不满。
因此,桑坦德银行(Santander Bank)向 Kaggle 社区求助,希望帮助他们在早期识别不满意的客户。这样做就可以让桑坦德银行采取积极措施,来改善客户的满意度,以免为时已晚。在本次比赛中,你将使用数百个匿名特征来预测客户是否对他们的银行业务体验感到满意或不满意。
数据及具体目标
★ 文件描述
train.csv - 包含目标变量的训练集
test.csv - 不包含目标变量的测试集
sample_submission.csv - 格式正确的示例提交文件
★ 数据字段
训练集是一个包含大量数值变量的匿名数据集,匿名数据集是指所有变量丧失原有的业务含义,所有变量经过了脱敏变换,这样我们就很难从业务角度去寻找突破口。来看一下里面具体的数据字段:
第一列是ID,中间有 369 列是经过处理的匿名变量,最后一列是目标变量 TARGET,取值为 0 表示满意的客户,取值为 1 表示不满意的客户。测试集不包含目标变量列,其余列与训练集一致,因此不再赘述。
★ 最终目标:对于测试集中的每个 ID,预测 TARGET 变量的概率。
★ 评估指标:要求用 AUC 作为评判标准,即预测概率和观察到的目标之间的 ROC 曲线下的区域面积。
实施流程
代码实现
导入数据包
# 数据处理
import numpy as np
import pandas as pd
import random
import itertools
from scipy import stats
from scipy.sparse import hstack
# 数据可视化
import matplotlib.pyplot as plt
import seaborn as sns
# 特征工程
from sklearn.preprocessing import StandardScaler
from sklearn.preprocessing import OneHotEncoder
# 模型
from sklearn.model_selection import train_test_split
from sklearn.model_selection import RandomizedSearchCV
from sklearn.linear_model import LogisticRegression
from sklearn.tree import DecisionTreeClassifier
from sklearn.calibration import CalibratedClassifierCV
from sklearn.ensemble import RandomForestClassifier
import xgboost as xgb
import lightgbm as lgb
from sklearn.metrics import roc_curve, roc_auc_score, log_loss
# 杂项
from tqdm import tqdm
import warnings
warnings.filterwarnings("ignore")
%matplotlib inline
sns.set(palette='muted', style='whitegrid')
np.random.seed(13154)
导入数据
train = pd.read_csv('./data/santander-customer-satisfaction/train.csv')
test = pd.read_csv('./data/santander-customer-satisfaction/test.csv')
print('训练集样本数为 %i, 变量数为 %i' % (train.shape[0],train.shape[1]))
print('测试集样本数为 %i, 变量数为 %i' % (test.shape[0],test.shape[1]))
训练集和测试集的shape为:
训练集样本数为 76020, 变量数为 371
测试集样本数为 75818, 变量数为 370
我们打印出训练集的前五行:
train.head()
探索性数据分析(EDA)
在基于机器学习方法的数据分析过程中,我们往往要面临数据格式的不规范,单位的不统一,数据的分布不符合模型的需求等等问题。事实上,这也是在数据分析行业中人们更多的选择传统统计分析方法的原因之一。为了让数据易于被模型拟合,提升测试集的分类效果,我们需要对数据进行一定的调整。那么,应该如何选择调整策略呢?对于这种具有300多个特征的高维数据,逐个分析显然是不现实的,这里我们采取探索性数据分析方法对数据的特点进行提取。
在最初的数据处理过程中,我们有一些基本的原则是可以借鉴采纳的:
1、一个特征如果具有0方差,即对每一行记录该字段的数据都相同,那么我们认为他对目标变量是没有影响的,这样的特征我们选择删除。
2、通常来说,我们会将信息很少的特征称为稀疏特征,这里我们对“稀疏”一词作如下定义:该特征的0值的个数占到了所有值的99%。
3、判断特征是否重复。如果发现,我们仅保留其一,并删除重复特征之外的所有特征
4、删除/插补特征中带有缺失值的行
删除零方差特征
i = 0
for col in train.columns:
if train[col].var() == 0:
i += 1
del train[col]
del test[col]
print('%i 个特征具有零方差并且已被删除' % i)
删除稀疏特征
#过滤稀疏特征
#函数numpy.percentile():百分位数是统计中使用的度量,表示小于这个值的观察值的百分比。
i=0
for col in train.columns:
if np.percentile(train[col],99) == 0:
i += 1
del train[col]
del test[col]
print('%i 个特征是稀疏的并且已被删除' % (i))
删除重复特征
#获取所有列的两两组合
#来自 itertools 模块的函数 combinations(list_name, x) 将一个列表和数字 ‘x’ 作为参数,并返回一个元组列表,每个元组的长度为 ‘x’,其中包含x个元素的所有可能组合。
# 列表中元素不能与自己结合,不包含列表中重复元素
combinations = list(itertools.combinations(train.columns,2))
print(combinations[:20])
'''
[('ID', 'var3'), ('ID', 'var15'), ('ID', 'imp_ent_var16_ult1'), ('ID', 'imp_op_var39_comer_ult1'), ('ID', 'imp_op_var39_comer_ult3'), ('ID', 'imp_op_var41_comer_ult1'), ('ID', 'imp_op_var41_comer_ult3'), ('ID', 'imp_op_var41_efect_ult1'), ('ID', 'imp_op_var41_efect_ult3'), ('ID', 'imp_op_var41_ult1'), ('ID', 'imp_op_var39_efect_ult1'), ('ID', 'imp_op_var39_efect_ult3'), ('ID', 'imp_op_var39_ult1'), ('ID', 'ind_var1_0'), ('ID', 'ind_var5_0'), ('ID', 'ind_var5'), ('ID', 'ind_var8_0'), ('ID', 'ind_var8'), ('ID', 'ind_var12_0'), ('ID', 'ind_var12')]
'''
len(combinations)#11026
#删除重复特征,保留其一
remove = []
keep = []
for f1,f2 in combinations:
if (f1 not in remove) & (f2 not in remove):
if train[f1].equals(train[f2]):
remove.append(f1)
keep.append(f2)
train.drop(remove,axis=1,inplace=True)
test.drop(remove,axis=1,inplace=True)
print('%i 个特征是重复的,并且 %i个特征已被删除' % (len(remove)*2,len(remove)))
print('其中特征 %s被删除\n特征 %s 被保留下来' % (remove,keep))
del remove
del keep
del combinations
'''
12 个特征是重复的,并且 6个特征已被删除
其中特征 ['ind_var26_0', 'ind_var25_0', 'ind_var37_0', 'num_var26_0', 'num_var25_0', 'num_var37_0']被删除
特征 ['ind_var26', 'ind_var25', 'ind_var37', 'num_var26', 'num_var25', 'num_var37'] 被保留下来
'''
判断并删除缺失值
print('训练集缺失值数量和: %i' % (train.isnull().sum().sum()))
print('测试集缺失值数量和: %i' % (test.isnull().sum().sum()))
'''训练集缺失值数量和: 0
测试集缺失值数量和: 0'''
进一步探索性分析
定义绘图函数
def countplot_target(df,h=500):
'''
:desc 绘制目标变量的频率分布,并输出满意客户和不满意客户的数量
:param h:数据标签的附加高度
'''
plt.figure(figsize=(5,5))
plt.rcParams['font.sans-serif'] = ['SimHei']
plt.rcParams['axes.unicode_minus'] = False#运行配置参数总的轴(axes)正常显示正负号(minus)
ax = sns.countplot(x='TARGET',data=df)
# ax.patches 表示条形图中的每一个矩形
for p in ax.patches:
height = p.get_height()
ax.text(p.get_x()+p.get_width()/2,height + h,'{:1.2f}%'.format(height*100/df.shape[0]),ha='center')#指定文字显示的位置
plt.title('TARGET变量的频率分布图')
print('满意客户的数量为%i,不满意客户的数量为 %i' % (
df[df['TARGET']==0].shape[0],
df[df['TARGET']==1].shape[0]
))
plt.show()
#定义绘制函数hisplot_comb
def hisplot_comb(col,train=train,test=test,size=(20,5),bins=20):
'''
绘制训练集和测试集某一特征的直方图
'''
plt.subplots(1,2,figsize=size)#分割界面为1行2列
plt.subplot(121)
plt.title('训练集特征{}的分布'.format(col))
plt.ylabel('频数')
plt.xlabel(col)
plt.hist(train[col],bins=bins)
plt.subplot(122)
plt.title('测试集特征{}的分布'.format(col))
plt.ylabel('频数')
plt.xlabel(col)
plt.hist(test[col],bins=bins)#bins:直方图的柱数,即要分的组数
plt.show()
def valuecounts_plot(col,train=train,test=test):
'''
绘制训练集和测试集特定列的频数分布折线图,并输出出现百分比最高的前5个值和最低的前5个值
'''
plt.subplots(1,2,figsize=(15,6))
plt.subplot(121)
df = train[col].value_counts().sort_index()
sns.lineplot(x=df.index,y=df.values)
plt.title("%s的频数分布折线图" % (col))
plt.ylabel('频数')
plt.subplot(122)
df = test[col].value_counts().sort_index()
sns.lineplot(x=df.index,y = df.values)
plt.title("%s的频数分布折线图" % (col))
plt.ylabel('频数')
plt.tight_layout()
#tight_layout会自动调整子图参数,使之填充整个图像区域。
# 这是个实验特性,可能在一些情况下不工作。它仅仅检查坐标轴标签、刻度标签以及标题的部分。
plt.show()
print("*"*100)
print("训练集特征'%s'其值占比(top 5): " % (col))
print("值\t 占比%")
print((train[col].value_counts()*100/train.shape[0]).iloc[:5])
print("*"*100)
print("训练集特征'%s'其值占比(bottom 5): " % (col))
print("值\t 占比%")
print((train[col].value_counts()*100/train.shape[0]).iloc[-5:])
print("测试集特征'%s'其值占比(top 5): " % (col))
print("值\t 占比%")
print((test[col].value_counts()*100/test.shape[0]).iloc[:5])
print("*"*100)
print("测试集特征'%s'其值占比(bottom 5): " % (col))
print("值\t 占比%")
print((test[col].value_counts()*100/test.shape[0]).iloc[-5:])
#定义绘图函数hisplot_target
def histplot_target(col,df=train,height=6,bins=20):
'''
:param col: 特征
:param df: 数据集
:param height: 附加高度
:param bins: 柱子数量
:return:
'''
sns.FacetGrid(data=df,hue='TARGET',height=height).map(plt.hist,col,bins=bins).add_legend()
plt.title('特征%s在不同目标变量下的频数分布' % (col))
plt.ylabel('频数')
plt.show()
目标变量的分布
满意客户的数量为73012,不满意客户的数量为 3008
我们可以看到数据集高度不平衡,96.04%是满意客户,只有3.96%是不满意客户
选取部分特征进行分析
1、var3(Region)
我们首先将var3的唯一值进行降序排序
np.array(sorted(train.var3.unique()))
print('共有%i个唯一值' % (len(np.array(sorted(train.var3.unique())))))
结果如下:
ID | Value |
---|---|
0 | -999999 |
1 | 0 |
2 | 1 |
3 | 2 |
4 | 3 |
在这里,我们可以看到var3唯一值的范围踩那个0到238,例外-999999可能是缺失值。这可能表明特定客户的国籍/地区,因为208对于想桑坦德这样的全球性公司来说是一个合理的数字
这里我们发现,可能是地区的var3中出现了值为-999999的取值,我们考虑可能是系统录入数据错误,那么,这样的数据有多少呢?
print("值\t 计数")
print((train['var3'].value_counts()[:5]))
print("值\t 占比%")
print(train['var3'].value_counts()[:5]/train.shape[0]*100)
值 计数
2 74165
8 138
-999999 116
9 110
3 108
Name: var3, dtype: int64
值 占比%
2 97.559853
8 0.181531
-999999 0.152591
9 0.144699
3 0.142068
这里我们输出了数量排名前五的唯一值及其占比,可以发现,-999999共有116个,占比0.15%,总体来讲对整体影响不大。因此我们可以考虑将-999999替换为出现次数最多的2,当然我们删除也可以,为了保证数据的完整性,我们选择前者。那么,替换后是否对我们目标变量的分布产生了影响呢?所以这里我们还需要查看替换后var3=2和var3≠2时训练集目标变量的分布
train['var3'].replace(-999999,2,inplace=True)
test['var3'].replace(-999999,2,inplace=True)
countplot_target(train[train['var3'] == 2],h=20)
countplot_target(train[train['var3'] != 2],h=10)
替换缺失值后基本不改变目标变量在var3中的一个分布情况
2、var15(Age)
print('var15 最小值为: %i,最大值为: %i' % (train['var15'].min(),train['var15'].max()))
#var15 最小值为: 5,最大值为: 105
var15的取值范围在5到105之间,与年龄较为相似,我们可以假定该特征为年龄进一步分析
hisplot_comb('var15')
#stats.percentileofscore 计算分数相对于分数列表的一个排名情况 第一个参数是分数列表第二个是分数
print("训练集中年龄在30岁以下的客户约占所有数据的 %.2f%%" % (stats.percentileofscore(train['var15'].values,30)))
print("测试集中年龄在30岁以下的客户约占所有数据的 %.2f%%" % (stats.percentileofscore(test['var15'].values,30)))
#训练集中年龄在30岁以下的客户约占所有数据的 56.15%
#测试集中年龄在30岁以下的客户约占所有数据的 56.58%
输出结果为:
由此可见,该银行的客户主要以年轻人为主,那么年轻人对银行业务的满意程度如何呢?
ax = histplot_target('var15',bins=10)
plt.figure(figsize=(6,6))
mask = train[train['TARGET']==1]
plt.hist(mask['var15'],color='orange')
plt.title('特征var15在target=1下的频数分布')
plt.xlabel('var15')
plt.show()
结果如下:
从上图可以看出,不满意客户的年龄范围是23-102岁。所以我们可以进一步提取年龄信息,创建一个特征,用来判断客户是否小于23岁,是的话就取值为1,不是就取值为0
# 创建新特征用来判断客户是否小于23岁
for df in [train,test]:
df['var15_below_23'] = np.zeros(df.shape[0],dtype=int)
df.loc[df['var15'] < 23,'var15_below_23'] = 1#把var15列小于23的行记录中的var15_below_23的部分赋值为1
年龄是一个数值型变量且取值只有一百个左右,但我们面临的是一个分类问题,一个分类型变量对我们的模型拟合过程显然是更有帮助的,所以我们对var15,即年龄字段进行等距分箱操作,转化为分类变量
_,bins = pd.cut(train['var15'].values , 5,retbins=True)#retbins: 是否显示分箱的分界值。默认为False,当bins取整数时可以设置retbins=True以显示分界值,得到划分后的区间
print(_)
[(4.9, 25.0], (25.0, 45.0], (4.9, 25.0], (25.0, 45.0], (25.0, 45.0], ..., (45.0, 65.0], (25.0, 45.0], (4.9, 25.0], (4.9, 25.0], (45.0, 65.0]]
此时我们再输出变量的分布
train['var15'] = pd.cut(train['var15'].values,bins,labels=False)
test['var15'] = pd.cut(test['var15'].values,bins,labels=False)
histplot_target('var15')
结果如下:
在不满意的客户(图中橙色柱)中,分箱值为1的数据最多,也就是说,绝大部分不满意的客户都在1至2之间,即25到45岁
3、var38(Mortgage values)
print('最小值是 %i,最大值为 %i' % (train['var38'].min(),train['var38'].max()))
sorted(train['var38'].unique())
train.var38.value_counts()
最小值是 5163,最大值为 22034738
[5163.75,
6480.66,
6773.13,
8290.86,
8394.93,
8856.21,
9213.75,
9342.33,
9486.36...]
再查看一下唯一值的分布:
train.var38.value_counts()
通过上述分析我们几乎不能得到任何信息,因为一个值具有非常高的分布频率。我们将输出每个百分位值进一步探索
for i in np.arange(0,1.1,0.1):
print('%i percentile : %i' % (i*100,np.quantile(train.var38.values,i)))
结果如下:
0 percentile : 5163
10 percentile : 48070
20 percentile : 61496
30 percentile : 74152
40 percentile : 88571
50 percentile : 106409
60 percentile : 117310
70 percentile : 117310
80 percentile : 132859
90 percentile : 182585
100 percentile : 22034738
我们可以看到,0百分值和10百分位值之间存在巨大差异,90百分位值和100百分位值也是相同情况
查看一下特征var38在不同目标变量下的频数分布。因为最大值太大,所以只绘制小于0.975分位数的取值分布
mask = train[train['var38'] <= np.quantile(train.var38.values,0.975)]
histplot_target('var38',df=mask,bins=20)
数据整体呈现右偏分布,我们可以对其进行对数变换
mask['var38'] = np.log(mask.var38).values
histplot_target('var38',df=mask,bins=20)
可以看到经过对数变换,分布比前者好看很多。因此,我们将对数转换应用于特征var38
for df in [train,test]:
df['var38'] = np.log(df['var38']).values
histplot_target('var38',bins=20)
4、var36 & var21
因为特征var36和var21唯一值都比较少,所以可以不用对其转换。当然,如果觉得特征var21的唯一值比较多,也可以将出现频数小于100的值比如4500一起合并为一类,看看这样能否提高后期的预测能力