文章目录
在银行借贷场景中,评分卡是一种以分数形式来衡量一个客户的信用风险大小的手段,它衡量向别人借钱的人(受信人,需要融资的公司)不能如期履行合同中的还本付息责任,并让借钱给别人的人(授信人,银行等金融机构)造成经济损失的可能性。一般来说,评分卡打出的分数越高,客户的信用越好,风险越小。
这些”借钱的人“,可能是个人,有可能是有需求的公司和企业。对于企业来说,我们按照融资主体的融资用途,分别使用企业融资模型,现金流融资模型,项目融资模型等模型。而对于个人来说,我们有”四张卡“来评判个人的信用程度:A卡,B卡,C卡和F卡。而众人常说的“评分卡”其实是指A卡,又称为申请者评级模型,主要应用于相关融资类业务中新用户的主体评级,即判断金融机构是否应该借钱给一个新用户,如果这个人的风险太高,我们可以拒绝贷款。
一个完整的模型开发,需要有以下流程:
1、 探索数据与数据预处理
from sklearn.ensemble import RandomForestRegressor
from sklearn.linear_model import LogisticRegression as LR
from matplotlib import pyplot as plt
import numpy as np
import pandas as pd
df = pd.read_csv('rankingcard.csv', index_col=0)
df.shape
#(150000, 11)
df.info()
(1)去重
#去重
df.drop_duplicates(inplace=True)
df.info()
#更新索引,更新索引很重要喔,每次删除了一些数据之后一定要更新索引
df.index = range(df.shape[0])
更新索引之后,RangeIndex就发生了变化
计算每个字段的null值率
df.isnull().sum()/df.shape[0]
df.isnull().mean()
可以看到MonthlyIncome字段空值率比较高,但是不能随意删除这些数据,用随机森林回归来预测空值;NumberOfDependents用平均值进行填充。
(2)填补缺失值
用平均值来填补家庭成员
df['NumberOfDependents'].fillna(int(df['NumberOfDependents'].mean()), inplace=True)
df.info()
用随机森林填补MonthlyIncome字段
def fill_missing_rf(x, y, to_fill):
"""
使用随机森林填补一个特征的缺失值的函数
参数:
X:要填补的特征矩阵
y:完整的,没有缺失值的标签
to_fill:字符串,要填补的那一列的名称
"""
df = x.copy()
fill = df.loc[:, to_fill]
df = pd.concat([df.loc[:, df.columns != to_fill], pd.DataFrame(y)], axis=1)
#找出训练集和测试集
ytrain = fill[fill.notnull()]
ytest = fill[fill.isnull()]
xtrain = df.iloc[ytrain.index, :]
xtest = df.iloc[ytest.index, :]
rfc = RandomForestRegressor(n_estimators=100)
rfc = rfc.fit(xtrain, ytrain)
y_pred = rfc.predict(xtest)
return y_pred
x = df.iloc[:, 1:]
y = df['SeriousDlqin2yrs']
#print(x.shape) #(149391, 10)
y_pred = fill_missing_rf(x, y, 'MonthlyIncome')
df.loc[df.loc[:, 'MonthlyIncome'].isnull(), 'MonthlyIncome'] = y_pred
(3)描述性统计处理异常值
#描述性统计
df.describe([0.01, 0.1, 0.25, 0.5, 0.75, 0.9, 0.99]).T
返回每个字段的数据总数、平均值、方差、最小值,占比为1%、10%、25%、50%等比例的数值以及最大值。根据结果可以看出,年龄最小为0,不符合事实
(df['age'] == 0).sum()
#1
#去掉年龄为0的数据
df = df[df['age'] != 0]
另外,有三个指标看起来很奇怪:
“NumberOfTime30-59DaysPastDueNotWorse” “NumberOfTime60-89DaysPastDueNotWorse” “NumberOfTimes90DaysLate”
这三个指标分别是“过去两年内出现35-59天逾期但是没有发展的更坏的次数”,“过去两年内出现60-89天逾期但是没 有发展的更坏的次数”,“过去两年内出现90天逾期的次数”。这三个指标,在99%的分布的时候依然是2,最大值却是 98,看起来非常奇怪。一个人在过去两年内逾期35~59天98次,一年6个60天,两年内逾期98次这是怎么算出来的?
(df['NumberOfTimes90DaysLate'] > 90).sum()
#225
#同样把逾期90天大于90的数据删掉
df = df[df['NumberOfTimes90DaysLate'] < 90]
(df['NumberOfTime60-89DaysPastDueNotWorse'] > 90).sum()
#0验证不合理数据被删除
(4)为什么不统一量纲,也不标准化数据分布?
在描述性统计结果中,我们可以观察到数据量纲明显不统一,而且存在一部分极偏的分布,虽然逻辑回归对于数据没有分布要求,但是我们知道如果数据服从正态分布的话梯度下降可以收敛得更快。但在这里,我们不对数据进行标准化处理,也不进行量纲统一,为什么?
无论算法有什么样的规定,无论统计学中有什么样的要求,我们的最终目的都是要为业务服务。现在我们要制作评分卡,评分卡是要给业务人员们使用的基于新客户填写的各种信息为客户打分的一张卡片,而为了制作这张卡片,我们需要对我们的数据进行一个“分档”,比如说,年龄20-30岁为一档,年龄30-50岁为一档,月收入1W以上为一档,5000~1W为一档,每档的分数不同。
一旦我们将数据统一量纲,或者标准化了之后,数据大小和范围都会改变,统计结果是漂亮了,但是对于业务人员来说,他们完全无法理解,标准化后的年龄在0.00328~0.00467之间为一档是什么含义。并且,新客户填写的信息,天生就是量纲不统一的,我们的确可以将所有的信息录入之后,统一进行标准化,然后导入算法计算,但是最终落到业务人员手上去判断的时候,他们会完全不理解为什么录入的信息变成了一串统计上很美但实际上根本看不懂的数字。由于业务要求,在制作评分卡的时候,我们要尽量保持数据的原貌,年龄就是8-110的数字,收入就是大于0,最大值可以无限的数字,即便量纲不统一,我们也不对数据进行标准化处理。
(5)样本不均衡问题
x = df.iloc[:, 1:]
y = df.iloc[:, 0]
y.value_counts() #将y标签的0和1的个数进行统计查询
根据统计结果可以看出, 标签为1和0的样本明显不均衡,所以通过上采样来补充样本,使标签不同的样本量变得相等。
n_1_sample = y.value_counts()[1]
n_0_sample = y.value_counts()[0]
n_sample = x.shape[0]
print('样本个数:{}; 不良用户占比:{: .2%}; 良好用户占比: {:.2%}'.format(n_sample, n_1_sample/n_sample, n_0_sample/n_sample))
样本个数:149165; 不良用户占比: 6.62%; 良好用户占比: 93.38%
import imblearn
from imblearn.over_sampling import SMOTE
sm = SMOTE(random_state = 42) #实例化
x,y = sm.fit_resample(x, y) #训练得到新样本
from sklearn.model_selection import train_test_splitx_train
x_vali, y_train, y_vali = train_test_split(x, y, test_size=0.3, random_state=420)
model_data = pd.concat([y_train, x_train], axis=1)
model_data.index = range(model_data.shape[0])
model_data.columns = df.columns
vali_data = pd.concat([y_vali, x_vali], axis=1)
vali_data.index = range(vali_data.shape[0])
vali_data.columns = df.columns
model_data.to_csv(r'D:\model_data.csv')
vali_data.to_csv(r'D:\vali_data.csv')
#将训练数据和测试数据都保存起来
2、分箱
前面提到过,我们要制作评分卡,是要给各个特征进行分档,以便业务人员能够根据新客户填写的信息为客户打分。因此在评分卡制作过程中,一个重要的步骤就是分箱。可以说,分箱是评分卡最难,也是最核心的思路,分箱的本质,其实就是离散化连续变量,好让拥有不同属性的人被分成不同的类别(打上不同的分数),其实本质比较类似于聚类。那我们在分箱中要回答几个问题:
(1)等频分箱
#pd.qcut,基于分位数的分箱函数,本质是将连续型变量离散化 只能够处理一维数据。返回箱子的上限和下限
#参数q:要分箱的个数
#参数retbins=True来要求同时返回结构为索引为样本索引,元素为分到的箱子的Series 现在返回两个值:每个样本属于哪个箱子,以及所有箱子的上限和下限
import pandas as pd
model_data["qcut"], updown = pd.qcut(model_data["age"], retbins=True, q=20)
#在这里时让model_data新添加一列叫做“分箱”,这一列其实就是每个样本所对应的箱子
model_data["qcut"]
# 统计每个分箱中0和1的数量
# 这里使用了数据透视表的功能groupby
coount_y0 = model_data[model_data["SeriousDlqin2yrs"] == 0].groupby(by="qcut").count()["SeriousDlqin2yrs"]
#得到每个箱子里标签为0的样本量的个数
coount_y1 = model_data[model_data["SeriousDlqin2yrs"] == 1].groupby(by="qcut").count()["SeriousDlqin2yrs"]
#得到每个箱子里标签为1的样本量的个数
print(coount_y0)
#num_bins值分别为每个区间的上界,下界,0出现的次数,1出现的次数
num_bins = [*zip(updown, updown[1:],coount_y0 ,coount_y1 )]
print(num_bins)
(2)确保每个箱中都有0和1
for i in range(20):
#如果第一个组没有包含正样本或负样本,向后合并
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[0][3]+num_bins[1][3])]
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
else:
break
(3)定义WOE和IV函数
columns = ['min', 'max', 'count_0', 'count_1']
df = pd.DataFrame(num_bins,columns=columns)
print(df.count_0+df.count_1)
#计算WOE和BAD RATE
#BAD RATE与bad%不是一个东西
#BAD RATE是一个箱中,坏的样本所占的比例 (bad/total)
#而bad%是一个箱中的坏样本占整个特征中的坏样本的比例
def get_woe(num_bins):
# 通过 num_bins 数据计算 woe
columns = ["min","max","count_0","count_1"]
df = pd.DataFrame(num_bins,columns=columns)
df["total"] = df.count_0 + df.count_1
df["percentage"] = df.total / df.total.sum()
df["bad_rate"] = df.count_1 / df.total
df["good%"] = df.count_0/df.count_0.sum()
df["bad%"] = df.count_1/df.count_1.sum()
df["woe"] = np.log(df["good%"] / df["bad%"])
return df
#计算IV值
def get_iv(df):
rate = df["good%"] - df["bad%"]
iv = np.sum(rate * df.woe)
return iv
(4)卡方检验,合并箱体,画出IV曲线
num_bins_ = num_bins.copy()
import matplotlib.pyplot as plt
import scipy
IV = []
axisx = []
while len(num_bins_) > 2:
pvs = []
# 获取 num_bins_两两之间的卡方检验的置信度(或卡方值)
for i in range(len(num_bins_)-1):
x1 = num_bins_[i][2:]
x2 = num_bins_[i+1][2:]
# 0 返回 chi2 值,1 返回 p 值。
pv = scipy.stats.chi2_contingency([x1,x2])[1]
# chi2 = scipy.stats.chi2_contingency([x1,x2])[0]
pvs.append(pv)
#通过p值处理,合并p值最大的两组
i = pvs.index(max(pvs))
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 = get_woe(num_bins_)
axisx.append(len(num_bins_))
IV.append(get_iv(bins_df))
plt.figure()
plt.plot(axisx, IV)
plt.xticks(axisx)
plt.xlabel("number of box")
plt.ylabel("IV")
plt.show()
(5)用最佳分箱个数分箱,并验证分箱结果
def get_bin(num_bins_,n):
while len(num_bins_) > n:
pvs = []
for i in range(len(num_bins_)-1):
x1 = num_bins_[i][2:]
x2 = num_bins_[i+1][2:]
pv = scipy.stats.chi2_contingency([x1,x2])[1]
# chi2 = scipy.stats.chi2_contingency([x1,x2])[0]
pvs.append(pv)
i = pvs.index(max(pvs))
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_
afterbins = get_bin(num_bins,4)
print(afterbins)
'''
[(21.0, 54.0, 53867, 76312), (54.0, 61.0, 15743, 12222), (61.0, 74.0, 20344, 7387), (74.0, 107.0, 7737, 1396)]
'''
(6)将选取最佳分箱个数的过程包装为函数,对所有特征进行分箱选择
def graphforbestbin(DF, X, Y, n=5,q=20,graph=True):
"""
自动最优分箱函数,基于卡方检验的分箱
参数:
DF: 需要输入的数据
X: 需要分箱的列名
Y: 分箱数据对应的标签 Y 列名
n: 保留分箱个数
q: 初始分箱的个数
graph: 是否要画出IV图像
区间为前开后闭 (]
"""
DF = DF[[X,Y]].copy()
DF["qcut"],bins = pd.qcut(DF[X], retbins=True, q=q,duplicates="drop")
coount_y0 = DF.loc[DF[Y]==0].groupby(by="qcut").count()[Y]
coount_y1 = DF.loc[DF[Y]==1].groupby(by="qcut").count()[Y]
num_bins = [*zip(bins,bins[1:],coount_y0,coount_y1)]
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[0][3]+num_bins[1][3])]
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
else:
break
def get_woe(num_bins):
columns = ["min","max","count_0","count_1"]
df = pd.DataFrame(num_bins,columns=columns)
df["total"] = df.count_0 + df.count_1
df["percentage"] = df.total / df.total.sum()
df["bad_rate"] = df.count_1 / df.total
df["good%"] = df.count_0/df.count_0.sum()
df["bad%"] = df.count_1/df.count_1.sum()
df["woe"] = np.log(df["good%"] / df["bad%"])
return df
def get_iv(df):
rate = df["good%"] - df["bad%"]
iv = np.sum(rate * df.woe)
return iv
IV = []
axisx = []
while len(num_bins) > n:
pvs = []
for i in range(len(num_bins)-1):
x1 = num_bins[i][2:]
x2 = num_bins[i+1][2:]
pv = scipy.stats.chi2_contingency([x1,x2])[1]
pvs.append(pv)
i = pvs.index(max(pvs))
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.append(get_iv(bins_df))
if graph:
plt.figure()
plt.plot(axisx,IV)
plt.xticks(axisx)
plt.show()
return bins_df
model_data.columns
for i in model_data.columns[1:-1]:
print(i)
graphforbestbin(model_data,i,"SeriousDlqin2yrs",n=2,q=20)
至于有的字段画不出图片来时因为字段分类个数少于20种,例如家庭成员最多也就五六个
auto_col_bins = {"RevolvingUtilizationOfUnsecuredLines":6,
"age":5,
"DebtRatio":4,
"MonthlyIncome":3,
"NumberOfOpenCreditLinesAndLoans":5}
#不能使用自动分箱的变量
hand_bins = {"NumberOfTime30-59DaysPastDueNotWorse":[0,1,2,13]
,"NumberOfTimes90DaysLate":[0,1,2,17]
,"NumberRealEstateLoansOrLines":[0,1,2,4,54]
,"NumberOfTime60-89DaysPastDueNotWorse":[0,1,2,8]
,"NumberOfDependents":[0,1,2,3]}
#保证区间覆盖使用 np.inf替换最大值,用-np.inf替换最小值
hand_bins = {k:[-np.inf,*v[:-1],np.inf] for k,v in hand_bins.items()}
接下来对所有特征按照选择的箱体个数和手写的分箱范围进行分箱:
bins_of_col = {}
# 生成自动分箱的分箱区间和分箱后的 IV 值
for col in auto_col_bins:
bins_df = graphforbestbin(model_data,col
,"SeriousDlqin2yrs"
,n=auto_col_bins[col]
#使用字典的性质来取出每个特征所对应的箱的数量
,q=20
,graph=False)
bins_list = sorted(set(bins_df["min"]).union(bins_df["max"]))
#保证区间覆盖使用 np.inf 替换最大值 -np.inf 替换最小值
bins_list[0],bins_list[-1] = -np.inf,np.inf
bins_of_col[col] = bins_list
#合并手动分箱数据
bins_of_col.update(hand_bins)
bins_of_col
(7)计算各箱的WOE并映射到数据中
data = model_data.copy()
data = data[["age","SeriousDlqin2yrs"]].copy()
data
#pd.cut,可以根据已知的分箱间隔把数据分箱 参数为 pd.cut(数据,以列表表示的分箱间隔)
data["cut"]=pd.cut(data["age"],[-np.inf, 48.49986200790144, 58.757170160044694, 64.0,
74.0, np.inf])
data
将数据按分箱结果聚合,并取出其中的标签值
将数据按分箱结果聚合,并取出其中的标签值
data.groupby("cut")["SeriousDlqin2yrs"].value_counts()
#使用unstack()来将树状结构变成表状结构
data.groupby("cut")["SeriousDlqin2yrs"].value_counts().unstack()
#计算woe值
bins_df["woe"] = np.log((bins_df[0]/bins_df[0].sum())/(bins_df[1]/bins_df[1].sum()))
把以上过程包装成函数:
def get_woe(df,col,y,bins):
df = df[[col,y]].copy()
df["cut"] = pd.cut(df[col],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
#将所有特征的WOE存储到字典当中
woeall = {}
for col in bins_of_col:
woeall[col] = get_woe(model_data,col,"SeriousDlqin2yrs",bins_of_col[col])
woeall
(8)接下来,把所有的WOE映射到原始数据中
#不希望覆盖掉原本的数据,创建一个新的DataFrame,索引和原始数据model_data一模一样
model_woe = pd.DataFrame(index=model_data.index)
#将原数据分箱后,按箱的结果把WOE结构用map函数映射到数据中
model_woe["age"]=pd.cut(model_data["age"], bins_of_col["age"]).map(woeall["age"])
model_woe["age"]
#对所有特征都能这么写
for col in bins_of_col:
model_woe[col] = pd.cut(model_data[col], bins_of_col[col]).map(woeall[col])
#将标签补充到数据中
model_woe["SeriousDlqin2yrs"] = model_data["SeriousDlqin2yrs"]
该表并不完全,仅展示了一部分内容
3、建模与模型验证
#处理测试集
vali_woe = pd.DataFrame(index=vali_data.index)
for col in bins_of_col:
vali_woe[col]=pd.cut(vali_data[col], bins_of_col[col]).map(woeall[col])
vali_woe["SeriousDlqin2yrs"]=vali_data["SeriousDlqin2yrs"]
vali_x = vali_woe.iloc[:, :-1]
vali_y = vali_woe.iloc[:, -1]
x = model_woe.iloc[:, :-1]
y = model_woe.iloc[:, -1]
from sklearn.linear_model import LogisticRegression as LR
lr = LR().fit(x, y)
lr.score(vali_x, vali_y)
#0.774959318464631
画c学习曲线
c_1 = np.linspace(0.01,1,20)
c_2 = np.linspace(0.01,0.2,20)
score = []
for i in c_2:
lr = LR(solver='liblinear',C=i).fit(x,y)
score.append(lr.score(vali_x,vali_y))
plt.figure()
plt.plot(c_2,score)
plt.show()
并没有什么提升,在C=0.025的时候达到最大分数
lr.n_iter_ #查看迭代次数
#array([5], dtype=int32) 迭代次数为5
score=[]
for i in [1, 2, 3, 4, 5, 6]:
lr=LR(solver='liblinear', C=0.025, max_iter=i).fit(x,y)
score.append(lr.score(vali_x, vali_y))
plt.figure()
plt.plot([1,2,3,4,5,6], score)
plt.show()
我们可以认为不管是最大迭代次数还是C在最佳位置时也无法得到很大的提升,所以就不再尝试了
那就画ROC曲线看看
import scikitplot as skplt
vali_proba_df = pd.DataFrame(lr.predict_proba(vali_x))
skplt.metrics.plot_roc(vali_y, vali_proba_df,
plot_micro=False,figsize=(6,6),
plot_macro=False)
ROC曲线极偏左,说明模型效果不错
4、制作评分卡
B = 20/np.log(2)
A = 600 + B*np.log(1/60)
B,A
#(28.85390081777927, 481.8621880878296)
base_score = A - B*lr.intercept_
print(base_score)
#array([482.0125967])
score_age = woeall["age"] * (-B*lr.coef_[0][0])
print(score_age)
file = r"D:\ScoreData.csv"
with open(file,"w") as fdata:
fdata.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(file,header=True,mode="a")
print(score)
评分卡如上表所示