api导入:
import pandas as pd
import numpy as np
from sklearn.linear_model import LogisticRegression as LR
数据获取:
df = pd.read_csv('./Data/rankingcard.csv',index_col=0)
去除重复值:
人为录入或者输入多一行时,很有可能出现重复数据,需要先去除重复值,
# 去除重复值
df.drop_duplicates(inplace=True) # drop_duplicates 去除重复值
去除重复值后,数据被删除,但索引没有发生变化,此时需要重新设置索引值。
df.index = range(df.shape[0])
缺失值处理:
df.isnull().mean()
# 这里可以看到 家庭人数有%2左右的缺失值,收入这里有20% 左右的缺失值,信用评估时,月收入影响过大,实际情况不会出现大量缺失 这里用随机森林来填补
用平均值填补缺失较少的特征:
# 均值填补家庭人数
df.loc[:,'NumberOfDependents'].fillna(df.loc[:,'NumberOfDependents'].mean(),inplace=True)
收入对结果影响比较大,缺失数据比较多,这里用随机森林来填补收入的缺失值:
x = data.loc[:,data.columns != 'MonthlyIncome']
y = data.loc[:, 'MonthlyIncome']
# 分割数据集
y_train = y[y.notnull()]
y_test = y[y.isnull()]
x_train = x.iloc[y_train.index,:]
x_test = x.iloc[y_test.index,:]
from sklearn.ensemble import RandomForestRegressor
rf = RandomForestRegressor()
rf.fit(x_train,y_train)
y_pre = rf.predict(x_test)
df.loc[df.loc[:,'MonthlyIncome'].isnull(),'MonthlyIncome'] = y_pre
缺失值解决!
异常值处理:
# 异常值处理 -- 箱线图,3seigema法则,描述性统计观察来处理异常值, 这里使用描述性法则
观察数据:
# 观察数据会有两个异常值
# 年龄这里,有最小值为零,显然不合理
# 然后违约天数这里,两年内逾期30~59,60~89,90天的,都有一个最大值98次,这个是做不到的一件事
# 实际工作 中可以和数据来源工作者询问情况 请教是 如何计算 的 这里我们 直接当成异常值处理掉
# 删掉年龄异常项
df = df[df['age'] != 0]
# 删除逾期次数异常项
df = df[df['NumberOfTime30-59DaysPastDueNotWorse'] < 90]
# 删除数据,一定要重新规定索引
df.index = range(df.shape[0])
观察上面描述性统计,数据量纲明显不统一,但这里不做数据标准化处理,虽然数据服从正态分布的的话,梯度下降可以收敛的更快,但是我们最终目的是要 为业务 服务的,制作的评分卡就是要给业务人员使用的基于新客户填写的各种信息为客户打分的一张卡片,一但数据统一量纲,数据大小和范围都会改变,业务人员可能无法理解,所以由于业务需要,我们尽量保持数据的原貌。
imblearn处理样本不均衡问题:
import imblearn
#imblearn是专门用来处理不平衡数据集的库,在处理样本不均衡问题中性能高过sklearn很多
#imblearn里面也是一个个的类,也需要进行实例化,fit拟合,和sklearn用法相似
from imblearn.over_sampling import SMOTE
sm = SMOTE(random_state = 22)
x,y = sm.fit_resample(x,y)
y.value_counts()
到这里完成数据预处理:1 重复值处理,2 缺失值处理,3 异常值处理,4 样本不均衡处理
分训练集和测试集:
# 分训练集和测试集 -- 分箱(每个阶段建立不同的分数)时需要一个特征矩阵加标签的结构,分割数据集后我们可以分别合并训练集和测试集
y = pd.DataFrame(y)
x = pd.DataFrame(x)
x_train,x_test,y_train,y_test = train_test_split(x,y,test_size=0.2,random_state=22)
model_data = pd.concat([y_train,x_train],axis=1)
test_data = pd.concat([y_test,x_test],axis=1)
model_data.index = range(x_train.shape[0])
test_data.index = range(x_test.shape[0])
model_data.to_csv('./Data/model_data.csv')
test_data.to_csv('./Data/test_data.csv')
# 评分卡核心部分 -- 分箱
# 对各个特征进行分档,以便工作人员的打分
# 核心就是把连续数据离散化,所以箱子数量不应该太多,
# 要明白一个概念,连续变量离散化必然伴随着信息的损失,箱子越少,信息损失越大
# IV 为了衡量特征上的信息量以及特征对预测函数的贡献
#分箱思路:WOE(证据权重) bad%(坏客户的比重)
#1)我们首先把连续型变量分成一组数量较多的分类型变量,比如,将几万个样本分成100组,或50组
#2)确保每一组中都要包含两种类别的样本,否则IV值会无法计算
#3)我们对相邻的组进行卡方检验,卡方检验的P值很大(相关性不强)的组进行合并,直到数据中的组数小于设定的N箱为止
#4)我们让一个特征分别分成[2,3,4.....20]箱,观察每个分箱个数下的IV值如何变化,找出最适合的分箱个数(让N从2等到20,画一个分箱的学习曲线)
#5)分箱完毕后,我们计算每个箱的WOE值,bad% ,观察分箱效果
model_data['qcut'],updown = pd.qcut(model_data['age'],retbins=True,q=20,duplicates='drop')
"""
pd.qcut,基于分位数的分箱函数,本质是将连续型变量离散化
只能够处理一维数据。返回箱子的上限和下限
参数q:要分箱的个数
参数retbins=True来要求同时返回结构为索引为样本索引,元素为分到的箱子的Series(就是这个元素分到哪个箱)
现在返回两个值:每个样本属于哪个箱子,以及所有箱子的上限和下限
"""
# 分组聚合求分箱中 0 ,1 的个数。
count_0 = model_data[model_data['SeriousDlqin2yrs'] == 0].groupby(by='qcut').count()['SeriousDlqin2yrs']
count_1 = model_data[model_data['SeriousDlqin2yrs'] == 1].groupby(by='qcut').count()['SeriousDlqin2yrs']
# 把每个箱子的上限,下限,0的个数,1的个数打包在一起
num_bins = [*zip(updown,updown[1:],count_0,count_1)]
#计算WOE和BAD RATE
#BAD RATE与bad%不是一个东西
#BAD RATE是一个箱中,坏的样本所占的比例 (bad/total)
#而bad%是一个箱中的坏样本占整个特征中的坏样本的比例
def get_woe(num_bins):
column = ['min','max','count_0','count_1']
num_bin = pd.DataFrame(num_bins,columns=column)
num_bin['total'] = num_bin.count_0 + num_bin.count_1
num_bin['bad_rate'] = num_bin.count_1/num_bin.total
num_bin['percentage'] = num_bin.total/num_bin.total.sum()
num_bin['bad'] = num_bin.count_0/num_bin.count_0.sum()
num_bin['good'] = num_bin.count_1/num_bin.count_1.sum()
num_bin['woe'] = np.log(num_bin['good']/num_bin['bad'])
return num_bin
def get_iv(num_bin):
rate = num_bin['good'] - num_bin['bad']
iv = np.sum(rate * num_bin.woe)
return iv
计算出的iv值为:
0.36095534222757264
写出对应的函数后,进行卡方检验,合并箱体,画出iv曲线。
# 计算最大的p值
iv_nums = []
axisx = []
while len(num_bins_) > 2: # 这里n就代表是箱子个数,以此来判定要分多少箱合适
pvs = []
for j in range(len(num_bins_)-1):
pv = scipy.stats.chi2_contingency([num_bins_[j][2:],num_bins_[j+1][2:]])[1]
pvs.append(pv)
i = pvs.index(max(pvs))
# 观察p值最大值所在的索引,确定是num_bins_的第一列和第二列数据产生的(从零开始数),
# 合并第一列和第二列 合并后的数据开头是第一列数据的开头,结尾是第二列数据的结尾,后面标签等于0,1的树量是两列之和
num_bins_[i:i+2]= [(
num_bins_[i][0],
num_bins_[i+1][1],
num_bins_[i][2]+num_bins_[i+1][2],
num_bins_[i][3]+num_bins_[i+1][3])]
axisx.append(len(num_bins_))
iv_nums.append(get_iv(get_woe(num_bins_)))
plt.figure(figsize=(20,8),dpi=100)
plt.plot(axisx,iv_nums)
plt.xticks(axisx)
plt.show()
因为箱子越少本身就会导致信息损失越大,所以我们观察图像,选择图像下降最快的方向 6
使用最佳分箱个数,验证分箱结果
# 定义一个分箱函数
def get_bin(num_bins_,n):
while len(num_bins_) > n: # 这里n就代表是箱子个数,以此来判定要分多少箱合适
pvs = []
for j in range(len(num_bins_)-1):
pv = scipy.stats.chi2_contingency([num_bins_[j][2:],num_bins_[j+1][2:]])[1]
pvs.append(pv)
i = pvs.index(max(pvs))
# 观察p值最大值所在的索引,确定是num_bins_的第一列和第二列数据产生的(从零开始数),
# 合并第一列和第二列 合并后的数据开头是第一列数据的开头,结尾是第二列数据的结尾,后面标签等于0,1的树量是两列之和
num_bins_[i:i+2]= [(
num_bins_[i][0],
num_bins_[i+1][1],
num_bins_[i][2]+num_bins_[i+1][2],
num_bins_[i][3]+num_bins_[i+1][3])]
return num_bins_
get_woe(get_bin(num_bins,5)) # 箱子往下,woe的结果应该是单调的,最多可以有一个转折
将选取最佳分箱的过程包装成函数
def best_bin(DF,x,y,n=2,q=20,isshow=True):
"""
data:要传入的数据
x:要分箱的列名
y:数据对应的标签名
n:保留的箱子个数
q:一开始的箱子个数
isshow:是否画图,默认是True
"""
DF = DF[[x,y]].copy() # 复制含有x,y的两列
DF['qcut'],updown = pd.qcut(DF[x],retbins=True,q=q,duplicates='drop')
# 分组聚合求分箱中 0 ,1 的个数。
count_0 = DF[DF[y] == 0].groupby(by='qcut').count()[y]
count_1 = DF[DF[y] == 1].groupby(by='qcut').count()[y]
# 把每个箱子的上限,下限,0的个数,1的个数打包在一起
num_bins = [*zip(updown,updown[1:],count_0,count_1)]
# print(num_bins)
# 确保每个箱中都有0和1
for i in range(q):
if 0 in num_bins[0][2:]:
num_bins[0:2]=[(
num_bins[0][0]
,num_bins[1][1]
,num_bins[0][2]+num_bins[1][2]
,num_bins[1][3]+num_bins[1][3])]
"""如果里面存在第一行中存在为零或者为空的,就让它与下一行合并,
此时还要考虑一个,就是合并后的行中可能也会存在没有包含正样本和负样本的情况
所以这地方我们需要在回到if的位置,判断,也就是加一个箱子个数的循环,
每一次一个continue,回去判断一次,一直到行中一定有正样本和负样本为止"""
continue
for i in range(len(num_bins)):
if 0 in num_bins[i][2:]:
num_bins[i-1:i+1]=[(
num_bins[i-1][0]
,num_bins[i][1]
,num_bins[i-1][2]+num_bins[i][2]
,num_bins[i-1][3]+num_bins[i][3])]
break
"""这里不是和后一行合并,因为要检查每一行,最后一行是没有后一行的所以和前一行合并 而第一行是一开始就已经验证了"""
"""这个break,只有在if被满足的条件下才会被触发
也就是说,只有发生了合并,才会打断for i in range(len(num_bins))这个循环
为什么要打断这个循环?因为我们是在range(len(num_bins))中遍历
但合并发生后,len(num_bins)发生了改变,但循环却不会重新开始"""
else:
break
def get_woe(num_bins):
column = ['min','max','count_0','count_1']
num_bin = pd.DataFrame(num_bins,columns=column)
num_bin['total'] = num_bin.count_0 + num_bin.count_1
num_bin['bad_rate'] = num_bin.count_1/num_bin.total
num_bin['percentage'] = num_bin.total/num_bin.total.sum()
num_bin['bad'] = num_bin.count_0/num_bin.count_0.sum()
num_bin['good'] = num_bin.count_1/num_bin.count_1.sum()
num_bin['woe'] = np.log(num_bin['good']/num_bin['bad'])
return num_bin
def get_iv(num_bin):
rate = num_bin['good'] - num_bin['bad']
iv = np.sum(rate * num_bin.woe)
return iv
# 计算最大的p值
iv_nums = []
axisx = []
while len(num_bins) > n: # 这里n就代表是箱子个数,以此来判定要分多少箱合适
pvs = []
for j in range(len(num_bins)-1):
pv = scipy.stats.chi2_contingency([num_bins[j][2:],num_bins[j+1][2:]])[1]
pvs.append(pv)
# print(pvs)
i = pvs.index(max(pvs))
# 观察p值最大值所在的索引,确定是num_bins_的第一列和第二列数据产生的(从零开始数),
# 合并第一列和第二列 合并后的数据开头是第一列数据的开头,结尾是第二列数据的结尾,后面标签等于0,1的树量是两列之和
num_bins[i:i+2]= [(
num_bins[i][0],
num_bins[i+1][1],
num_bins[i][2]+num_bins[i+1][2],
num_bins[i][3]+num_bins[i+1][3])]
bins_df = pd.DataFrame(get_woe(num_bins))
axisx.append(len(num_bins))
iv_nums.append(get_iv(bins_df))
# print(bins_df)
# print(iv_nums)
if isshow:
plt.figure(figsize=(20,8),dpi=100)
plt.plot(axisx,iv_nums)
plt.xticks(axisx)
plt.show()
return None
n_columns = model_data.columns
for i in n_columns[1:-1]:
print(i)
best_bin(model_data,i,'SeriousDlqin2yrs',n=2,q=20,isshow=True)
# 观察上面的图像,有些图像会出现异常,这里要明白,
# 分箱的目的是把连续性变量变成离散型,
# 但是像家庭人数,预期次数这些本身就不是很连续的变量,
# 所以这里对这些特征和连续性变量一起进行分箱就会出现这种情况
# 这时候需要手动对这些结果进行分箱
auto_col_bins = {
'RevolvingUtilizationOfUnsecuredLines':6,
'age':5,
'DebtRatio':4,
'MonthlyIncome':4,
'NumberOfOpenCreditLinesAndLoans':5
}
# 需要手动分箱的数据
hand_bins = {
'NumberOfTime30-59DaysPastDueNotWorse':[0,1,213],
'NumberOfTimes90DaysLate':[0,1,2,17],
'NumberRealEstateLoansOrLines':[0,1,2,3,54],
'NumberOfTime60-89DaysPastDueNotWorse':[0,1,2,11],
'NumberOfDependents':[0,1,2,3,4,20]}
# 考虑实际情况,保证区间覆盖范围最大,用np.inf替换最大值,用-np.inf替换最大值(极大和极小值)
hand_bins = {k:[-np.inf,*i[:-1],np.inf] for k,i in hand_bins.items()}
# 这里我再定义了一个 最优分箱结果的函数(在这里发现 当分箱达不到标准时,返回不了第一个函数中的bins_df(也就是分箱结果) 而分箱正常的数据可以正常返回)
def best_bin_s(DF,x,y,n=2,q=20,isshow=True):
"""
data:要传入的数据
x:要分箱的列名
y:数据对应的标签名
n:保留的箱子个数
q:一开始的箱子个数
isshow:是否画图,默认是True
"""
DF = DF[[x,y]].copy() # 复制含有x,y的两列
DF['qcut'],updown = pd.qcut(DF[x],retbins=True,q=q,duplicates='drop')
# 分组聚合求分箱中 0 ,1 的个数。
count_0 = DF[DF[y] == 0].groupby(by='qcut').count()[y]
count_1 = DF[DF[y] == 1].groupby(by='qcut').count()[y]
# 把每个箱子的上限,下限,0的个数,1的个数打包在一起
num_bins = [*zip(updown,updown[1:],count_0,count_1)]
# print(num_bins)
# 确保每个箱中都有0和1
for i in range(q):
if 0 in num_bins[0][2:]:
num_bins[0:2]=[(
num_bins[0][0]
,num_bins[1][1]
,num_bins[0][2]+num_bins[1][2]
,num_bins[1][3]+num_bins[1][3])]
"""如果里面存在第一行中存在为零或者为空的,就让它与下一行合并,
此时还要考虑一个,就是合并后的行中可能也会存在没有包含正样本和负样本的情况
所以这地方我们需要在回到if的位置,判断,也就是加一个箱子个数的循环,
每一次一个continue,回去判断一次,一直到行中一定有正样本和负样本为止"""
continue
for i in range(len(num_bins)):
if 0 in num_bins[i][2:]:
num_bins[i-1:i+1]=[(
num_bins[i-1][0]
,num_bins[i][1]
,num_bins[i-1][2]+num_bins[i][2]
,num_bins[i-1][3]+num_bins[i][3])]
break
"""这里不是和后一行合并,因为要检查每一行,最后一行是没有后一行的所以和前一行合并 而第一行是一开始就已经验证了"""
"""这个break,只有在if被满足的条件下才会被触发
也就是说,只有发生了合并,才会打断for i in range(len(num_bins))这个循环
为什么要打断这个循环?因为我们是在range(len(num_bins))中遍历
但合并发生后,len(num_bins)发生了改变,但循环却不会重新开始"""
else:
break
def get_woe(num_bins):
column = ['min','max','count_0','count_1']
num_bin = pd.DataFrame(num_bins,columns=column)
num_bin['total'] = num_bin.count_0 + num_bin.count_1
num_bin['bad_rate'] = num_bin.count_1/num_bin.total
num_bin['percentage'] = num_bin.total/num_bin.total.sum()
num_bin['bad'] = num_bin.count_0/num_bin.count_0.sum()
num_bin['good'] = num_bin.count_1/num_bin.count_1.sum()
num_bin['woe'] = np.log(num_bin['good']/num_bin['bad'])
return num_bin
def get_iv(num_bin):
rate = num_bin['good'] - num_bin['bad']
iv = np.sum(rate * num_bin.woe)
return iv
# 计算最大的p值
iv_nums = []
axisx = []
while len(num_bins) > n: # 这里n就代表是箱子个数,以此来判定要分多少箱合适
pvs = []
for j in range(len(num_bins)-1):
pv = scipy.stats.chi2_contingency([num_bins[j][2:],num_bins[j+1][2:]])[1]
pvs.append(pv)
# print(pvs)
i = pvs.index(max(pvs))
# 观察p值最大值所在的索引,确定是num_bins_的第一列和第二列数据产生的(从零开始数),
# 合并第一列和第二列 合并后的数据开头是第一列数据的开头,结尾是第二列数据的结尾,后面标签等于0,1的树量是两列之和
num_bins[i:i+2]= [(
num_bins[i][0],
num_bins[i+1][1],
num_bins[i][2]+num_bins[i+1][2],
num_bins[i][3]+num_bins[i+1][3])]
bins_df = pd.DataFrame(get_woe(num_bins))
axisx.append(len(num_bins))
iv_nums.append(get_iv(bins_df))
# print(bins_df)
# print(iv_nums)
if isshow:
plt.figure(figsize=(20,8),dpi=100)
plt.plot(axisx,iv_nums)
plt.xticks(axisx)
plt.show()
return bins_df
# 定义上面这个函数,传入可以自动分箱的特征数据,让其返回有详细信息的df,并且最小值和最大值替换成-np.inf,np.inf
bins_col = {}
for col in auto_col_bins:
bins_df = best_bin_s(model_data,col,
'SeriousDlqin2yrs',
n=auto_col_bins[col],
q=20,isshow=False)
bins_df = sorted(set(bins_df['min']).union(bins_df['max'])) # 返回多个集合的并集
bins_df[0],bins_df[-1] = -np.inf,np.inf
bins_col[col] =bins_df
bins_col.update(hand_bins)
计算各箱的WOE值并映射到数据中¶
# 当我们使用函数,能够被分箱的特征的WOE值是能够被自动算出来的,但 不能分享的需要手动处理
# 还要将得到的WOE值(没个箱子的不违约的概率) 替换到原始数据model_data中,
# 这里不使用原数据是想用WOE代表每个箱子的不同 我们希望获取的是‘各个箱‘的的分类结果,即评分卡上各个评分项目的分类结果。
def new_get_woe(df,x,y,bins):
df = df[[x,y]].copy()
df['cut'] = pd.cut(df[x],bins)
bins_df = df.groupby('cut')[y].value_counts().unstack()
woe = bins_df['WOE'] = np.log((bins_df[0]/bins_df[0].sum()))/(bins_df[1]/bins_df[1].sum())
return woe
woeall = {}
for col in bins_col:
woeall[col] = new_get_woe(model_data,col,'SeriousDlqin2yrs',bins_col[col])
# 接下来把所有的woe映射到原数据上
model_woe = pd.DataFrame(index=model_data.index)
# 针对所有数据 --
for col in bins_col:
model_woe[col] = pd.cut(model_data[col],bins_col[col]).map(woeall[col])
model_woe["SeriousDlqin2yrs"] = model_data["SeriousDlqin2yrs"]
# 这就是要使用的建模数据了(就是每个数据所属的箱子的woe值)
model_woe
建模与模型验证¶
test_woe = pd.DataFrame(index=test_data.index)
# 针对所有数据 --
for col in bins_col:
test_woe[col] = pd.cut(test_data[col],bins_col[col]).map(woeall[col])
X = model_woe.iloc[:,:-1]
y = model_woe.iloc[:,-1]
test_x = test_woe.iloc[:,:-1]
test_y = test_woe.iloc[:,-1]
机器学习:
lr = LR().fit(X,y)
lr.score(test_x,test_y)
调优:
c_1 = np.linspace(0.01,1,20)
c_2 = np.linspace(0.01,0.2,20)
import matplotlib.pyplot as plt
score = []
for i in c_2:
lr = LR(solver='liblinear',C=i).fit(X,y)
score.append(lr.score(test_x,test_y))
print(max(score),score.index(max(score)))
plt.figure(figsize=(20,8),dpi=80)
plt.plot(c_2,score)
plt.show()
lr.n_iter_ # 查看迭代次数
score = []
for i in [1,2,3,4,5,6]:
lr = LR(solver='liblinear',C=0.01,max_iter=i).fit(X,y)
score.append(lr.score(test_x,test_y))
plt.figure()
plt.plot([1,2,3,4,5,6],score)
plt.show()
# 得到最优模型
lr = LR(solver='liblinear',C=0.01,max_iter=i).fit(X,y)
lr.score(test_x,test_y)
# roc曲线
import scikitplot as skplt
#%%cmd
#pip install scikit-plot
vali_proba_df = pd.DataFrame(lr.predict_proba(test_x))
skplt.metrics.plot_roc(test_y, vali_proba_df,plot_micro=False,figsize=(6,6),plot_macro=False)
制作评分卡:
# 建模完毕,我们使用准确率和ROC曲线验证了模型的预测能力。接下来就是要讲逻辑回归转换为标准评分卡了。评
# 分卡中的分数,由以下公式计算:
B = 20/np.log(2)
A = 600 + B*np.log(1/60)
B,A
# 计算基础分
base_score = A - B*lr.intercept_ # intercept_ 截距
base_score
with open('./Data/ScoreData.csv','w')as f:
f.write('base_score,{}\n'.format(base_score))
for i,col in enumerate(X.columns):
score = woeall[col] * (-B*lr.coef_[0][i])
score.name = "Score"
score.index.name = col
score.to_csv('./Data/ScoreData.csv',header=True,mode='a')
over!