A卡制作
1 前言
1.1 A卡是什么?
A卡即Application score card 又称申请评分卡,是用于贷前使用
2 A卡制作流程
- 数据获取
- 数据预处理
- 探索性分析
- 特征选择
- 模型开发
- 模型评估
- 计算基础分,和分值
- 建立评分系统
- 模型上线
- 监测与报告
2.1数据获取
这里我的数据来自kaggle,give me some credict, 实际过程中数据来自公司的存量客户
首先我们看看字段
标号 | 特征名称 | 特征解释 |
---|---|---|
0 | SeriousDlqin2yrs | 出现90天或更长时间的逾期客户, 好客户(0)坏客户(1) |
1 | RevolvingUtilizationOfUnsecuredLines | 贷款以及信用卡可用额度与总额度的比例 |
2 | age | 借款人的年龄 |
3 | NumberOfTime30-59DaysPastDueNotWorse | 过去两年内出现30~59天逾期 但未发展到更坏的次数 |
4 | DebtRatio | 每月偿还债务除以月总收入 |
5 | MonthlyIncome | 月收入 |
6 | NumberOfOpenCreaditLinesAndLoans | 开放式贷款和信贷数量 |
7 | NumberOfTimes90DaysLate | 过去两年内出现90天逾期或更坏的次数 |
8 | NumberRealEstateLoansOrLines | 抵押贷款与房地产贷款数量,包括房屋净值信贷额度 |
9 | NumberOfTime60-89DaysPastDueNotWorse | 过去两年内出现60~89天逾期但是没有发展到更坏的次数 |
10 | NumberOfDependents | 家庭中不包括自身的家属人数(子女,配偶) |
2.2 数据预处理
主要工作有数据清洗,缺失值处理,异常值处理,重复值处理,将数据清洗为可建模数据
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression as LR
from sklearn.ensemble import RandomForestRegressor as RFR
data = pd.read_csv('cs-training.csv', index_col=0)
# 粗略看一下数据量有多大,有哪些特征,及其缺失值个数
data.info()
结果如下

2.2.1 去除重复值
在银行业中,可能会出现样本从夫的问题,即所有的特征的一样的行。这种情况可能是人工输入的错误,也可能是系统重复录入。这样我们可以考虑删除重复值。虽然说两个人的所有特征都一样不是没有可能性,即姓名收入学历等都一样,这也是有可能的,但是这种可能性几乎是为零的,即使出现这种极端情况我们也可以当作少量信息的损失,所以考虑删除重复值记录删去。
data.drop_duplicates(inplace=True)
# 重新索引
data.index = range(data.shape[0])
2.2.2 填补缺失值
查看有多少缺失行
data.isnull().sum()

可见家庭人数NumberOfDependents缺失也挺多的,15w行数据,缺了3828,也是挺大的一笔数据,那么考虑不删除,那首先先看看这个特征的数据分布
data.NumberOfDependents.\
describe([0.01, 0.1, 0.25, 0.5, 0.75, 0.9, 0.99])

其实从分位数据也可以看出这是个左偏数据
这里采用用中值填补
data['NumberOfDependents'].fillna(data['NumberOfDependents'].median(), inplace=True)
月收入MonthlyIncome特征差了太多数据
删除这么多空值不合适,我们采用随机森林进行填补
from sklearn.ensemble import RandomForestRegressor as RFR
# 定义一个函数
def fill_missing(dataframe, y, to_fill):
"""
使用随机森林填补一个特征的缺失值
params:
dataframe: 要填补的特征矩阵
y: 完整的没有缺失的标签
to_fill: 字符串,要填补的那一列的名称
"""
df = dataframe.copy()
y_fill = df.loc[:,to_fill]
df = pd.concat([df.loc[:, df.columns != to_fill], pd.DataFrame(y)], axis=1) # 将标签和特征并起来
# 随机森林本来就可以处理自带数据缺失的数据集,所以可以不用选取特征完全的数据
# 选择我们的训练集和测试
Ytest = w_fill[df.notnull().all(axis=1) & y_fill.
Ytrain = w_fill[df.notnull().all(axis=1) & y_fill.
Xtest = df.iloc[Ytest.
Xtrain = df.iloc[Ytrain.index,:]
# 使用随机森林回归进行填补
rfr = RFR(n_estimators=100, n_jobs=4)
rfr = rfr.fit(Xtrain, Ytrain)
Ypredict = rfr.predict(Xtest)
Ypredict = pd.Series(Ypredict, index=Xtest.index)
return Ypredict
X = data.iloc[:, 1:]
y = data['SeriousDlqin2yrs']
new_col = fill_missing(X,y, 'MonthlyIncome')
data.loc[new_col.index, 'MonthlyIncome'] = new_col
data.info()
填补缺失值后结果如下

2.2.3 异常值处理
现实数据总会有异常值,但是请注意我们并不是说异常值就是错误数据,需要排除所有异常值 ,相反有的时候我们还需要重点关注
处理异常值的方法:
- 箱线图
- 三西塔准则
可是在银行业中我们要排除的不是超高和超低的值,比如收入。我们需要排除的是不符合常理的数据。比如银行规定信用卡发放的最低年龄是18,那么低于18的是异常数据,这个应该和业务人员了解清楚,什么原因产生的这个数据,在这里我们就考虑把他去除.所以在银行
业中,我们往往就使用普通的描述性统计来观察数据的异常与否与数据的分布情况。注意,这种方法只能在特征量
有限的情况下进行,如果有几百个特征又无法成功降维或特征选择不管用,那还是用 3西塔比较好
# 描述性统计
data.describe([0.01, 0.1, 0.25, 0.5, 0.75, 0.9, 0.99])

RevolvingUtilizationOfUnsecuredLines贷款以及信用卡可用额度与总额度的比例,按照正常逻辑应该小于等于1,所以大于1的令其为1。
age年龄低于18岁将其视为异常。
NumberOfTime30-59DaysPastDueNotWorse过去两年内出现30~59天逾期 但未发展到更坏的次数,两年内最多12次,所以大于12次则为异常数据。
DebtRatio每月偿还债务除以月总收入,不能为负数
MonthlyIncome月收入不能为负数
NumberOfOpenCreaditLinesAndLoans开放式贷款和信贷数量,不能为负数
NumberOfTimes90DaysLate 过去两年内出现90天逾期或更坏的次数,不能为负数,且小于等于8次
NumberRealEstateLoansOrLines 抵押贷款与房地产贷款数量,包括房屋净值信贷额度
NumberOfTime60-89DaysPastDueNotWorse 过去两年内出现60~89天逾期但是没有发展到更坏的次数,不能为负数,且小于等于7次
NumberOfDependents 家庭中不包括自身的家属人数(子女,配偶),不能为负数
# 在这里我们要去除年龄小于18的用户,因为他们没有开卡的权利
data.drop(index=data[ data['age'] <18].index, inplace=True)
data[data['RevolvingUtilizationOfUnsecuredLines']>1]['RevolvingUtilizationOfUnsecuredLines'] = 1
data.loc[data['NumberOfTime30-59DaysPastDueNotWorse']>24, 'NumberOfTime30-59DaysPastDueNotWorse'] = 24
data = data[(data['DebtRatio']>=0)&(data['MonthlyIncome']>=0)]
data = data[data['NumberOfOpenCreditLinesAndLoans']>=0]
data = data[data['NumberOfTimes90DaysLate']>=0]
data.loc[data['NumberOfTimes90DaysLate']>=8, 'NumberOfTimes90DaysLate'] = 8
data = data[data['NumberOfTime60-89DaysPastDueNotWorse']>=0]
data.loc[data['NumberOfTime60-89DaysPastDueNotWorse']>6, 'NumberOfTime60-89DaysPastDueNotWorse'] = 6
data = data[data['NumberOfDependents'] >=0]
data.index = range(data.shape[0])
虽然还是会有异常值的存在,比如家庭人口达到20,但是其实在后期分箱时可以减小异常值的影响
2.2.4 异常值处理
2.2.5 数据标准化,归一化吗?
不需要,首先逻辑回归不要求归一化和标准化,虽然说数据付出正态分布的话梯度下降能收敛得更加快。但是我们也不要去进行标准化,也不进行量纲统一。
因为评分卡是给业务人员用的一张打分的卡片,我们需要的是将每个特征的数据进行分档,比如收入1000到3000一个档次,3000-6000一个档次,等。每个档次对于的分数不一样。我们如果进行无量纲化制作上虽然在统计上是美的,但是分档的数据是完全看不出来是什么的,业务人员也看不懂这个数字是什么。所以为了业务需要,不进行无量纲化
2.2.6 样本不平衡问题
在现实中不还款的毕竟是少数,所以这种样本和还款样本相比是非常小的。逻辑回归学习是会偏向多数样本的,而我们关注的就是少数类。所以我们要解决样本不平衡问题。
解决样本不均衡两种方法:
- 上采样,增加少数类样本(比如SMOTE)
- 下采样 ,减少多数类的样本
逻辑回归中使用最多的就是上采样法来平衡样本
# 探索标签的分布
X = data.iloc[:,1:]
y = data.iloc[:, 0]
y.value_counts()
display(X.shape)
display(y.shape)
data.shape
# smote在imblearn里,使用前pip安装,anaconda不自带
import imblearn
from imblearn.over_sampling import SMOTE
sm = SMOTE(random_state=2)
X, y = sm.fit_sample(X,y)
X = pd.DataFrame(X)
y = pd.Series(y)
X.shape
y.shape
data_ = pd.concat([X,y], axis=1) # 不加axis=1你都不知道他少了一列
data_.dropna(inplace=True)
data_.index = range(a.shape[0])
2.2.7 分测试集和训练集
from sklearn.model_selection import train_test_split
X_ = data_.iloc[:, :-1]
y_ = data_.iloc[:, -1]
Xtrain, Xtest, Ytrain, Ytest = train_test_split(X_, y_, test_size=0.3, random_state=7)
model_train_data = pd.concat([Ytrain, Xtrain], axis=1)
model_train_data.index = range(model_train_data.shape[0])
model_train_data.columns = data.columns
model_test_data = pd.concat([Ytest, Xtest], axis=1)
model_test_data.index = range(model_test_data.shape[0])
model_test_data.columns = data.columns
model_test_data.to_csv('test_data.csv')
model_train_data.to_csv('train_data.csv')
model_train_data
3.1 分箱
分箱即把连续变量离散化,肯定箱体数量不能太多,用来做评分卡4到5个最好。离散化连续变量必然会伴随着信息的损失,为了衡量特征上的信息量以及对预测函数的贡献。IV值成为衡量。
IV值 | 特征对预测函数的贡献 |
---|---|
<0.03 | 特征几乎不带有效信息,该特征可删 |
0.03~0.09 | 有效信息非常少, 对模型贡献低 |
0.1~0.3 | 有效信息一般,对模型贡献一般 |
0.31~0.5 | 有效信息较多, 对模型贡献较高 |
>0.51 | 有效信息非常多,对模型贡献超高且可疑,(有的会以超过1.2而可疑,标准不一致) |
3.1.1 初步等频分箱
这里我们先试试把age特征进行分箱
先等频分箱,为什么不用等宽分箱应该不用说了吧。
# 等频分箱
model_train_data['qcut'], updown = pd.qcut(model_train_data['age'], retbins=True, q=20)
# 每个箱体上下界
print(updown)
# 统计每个箱体中正负样本的个数
count_0 = model_train_data[model_train_data['SeriousDlqin2yrs']==0].groupby('qcut').count()['SeriousDlqin2yrs']
count_1 = model_train_data[model_train_data['SeriousDlqin2yrs']==1].groupby('qcut').count()['SeriousDlqin2yrs']
# num_bins值为每个区间的下界,上界,0出现的次数1出现的次数
num_bins = [*zip(updown, updown[1:], count_0, count_1)]
num_bins
分箱要保证每个箱体中都有正负样本,计算woe的时候ln(good rate/bad rate)分母bad rate 不能为零。·
# 确保每个箱子里都有0和1
i_n = 0 # 索引数字
for i in range(len(num_bins)):
if i_n >= len(num_bins):
break
if i_n == (len(num_bins)-1):
if 0 in num_bins[i_n][2:]:
num_bins[i_n-1:i_n+1] = [(num_bins[i_n-1][0],
num_bins[i_n][1],
num_bins[i_n-1][2]+num_bins[i_n][2],
num_bins[i_n-1][3]+num_bins[i_n][3]
)]
break
if 0 in num_bins[i_n][2:]:
num_bins[i_n:i_n+2] = [(num_bins[i_n][0],
num_bins[i_n+1][1],
num_bins[i_n][2]+num_bins[i_n+1][2],
num_bins[i_n][3]+num_bins[i_n+1][3]
)]
else:
i_n += 1
# 定义woe和IV函数
def get_woe(num_bins):
"""
通过num_bins数据计算woe
parames:
num_bins: list, 每个分箱的上下界和正负样本
"""
columns = ['floor', 'top', '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['bad%'] = df.count_0/df.count_0.sum()
df['good%'] = df.count_1/df.count_1.sum()
df['woe'] = np.log(df['good%']/df['bad%'])
return df
def get_iv(df):
"""
计算iv值
df: dataFrame, [floor, top, count_0, count_1]
return iv值
"""
rate = df['good%'] - df['bad%']
iv = np.sum(rate*df.woe)
return iv
3.1.2 卡方检验,合并箱体
我们希望组内差异小,组间差异大
import matplotlib.pyplot as plt
import scipy
IV = []
axis = []
def get_bin(num_bins, n):
"""
获得分箱,卡方合并相关性大的箱体
参数:
num_bins:原有分箱
n:目标分箱个数
"""
while len(num_bins) > n:
pvs = [] # p值
# 获取num_bins两两之间的卡方检验的置信度
for i in range(len(num_bins)-1):
x1 = num_bins[i][2:]
x2 = num_bins[i+1][2:]
# 卡方值,p-value
chi2, pv = scipy.stats.chi2_contingency([x1, x2])
pvs.append(pv)
# 合并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])]
return num_bins
def searchForBestbins(DF, x_label, y_label, n=5, q=20, graph=False):
"""
自动最优分箱函数,基于卡方检验的分箱
参数:
DF: 输入数据(包含标签)
x_label: 需要分箱的列名
y_label: 分箱数据对应的标签列名
n: 保留箱体个数
q: 初始分箱个数
graph: 是否画出iv图像
"""
df = DF[[x_label, y_label]].copy()
# 等频分箱
df['qcut'], updown = pd.qcut(df[x_label], retbins=True, q=q, duplicates='drop')
# 统计每个箱中0和1的个数
count_0 = df[df[y_label]==0].groupby('qcut').count()[y_label]
count_1 = df[df[y_label]==1].groupby('qcut').count()[y_label]
# num_bins值为每个区间的上界,下界,0出现的次数1出现的次数
num_bins = [*zip(updown, updown[1:], count_0, count_1)]
# 确保每个箱子里都有0和1
i_n = 0 # 索引数字
for i in range(len(num_bins)):
if i_n >= len(num_bins):
break
if i_n == (len(num_bins)-1):
if 0 in num_bins[i_n][2:]:
num_bins[i_n-1:i_n+1] = [(num_bins[i_n-1][0],
num_bins[i_n][1],
num_bins[i_n-1][2]+num_bins[i_n][2],
num_bins[i_n-1][3]+num_bins[i_n][3]
)]
break
if 0 in num_bins[i_n][2:]:
num_bins[i_n:i_n+2] = [(num_bins[i_n][0],
num_bins[i_n+1][1],
num_bins[i_n][2]+num_bins[i_n+1][2],
num_bins[i_n][3]+num_bins[i_n+1][3]
)]
else:
i_n += 1
# 合并箱体
IV = []
axisx = []
bins_num_before = len(num_bins)
for i in range(len(num_bins)-n):
bins = get_bin(num_bins, n=bins_num_before-i)
bins_df = pd.DataFrame(get_woe(bins))
axisx.append(bins_num_before-i)
IV.append(get_iv(bins_df))
# 画出箱体与iv值的折线图
if graph:
plt.figure()
plt.plot(axisx, IV)
plt.xticks(axisx)
plt.xlabel('number of box')
plt.ylabel('IV')
plt.show()
return bins_df
我们就计算一下age的iv值与箱体数量的关系
searchForBestbins(model_train_data, x_label='age', y_label='SeriousDlqin2yrs', n=2, q=20, graph=True)

对所有的特征进行分箱
上述的分箱是针对连续变量的,对于离散的变量我们要手动分箱
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.update(hand_bins)
bins_of_col
3.1.3 映射每个样本的每个特征的WOE
计算各箱的WOE并映射到数据中
## 计算各箱的WOE并映射到数据中
data = model_train_data.copy()
# 函数pd.cut 可以根据已知的分箱间隔把数据分箱
# 参数为pd.cut(数据,以列表表示的分箱间隔)
def df_get_woe(df, col, y, bins):
"""
映射woe到每个箱体上
"""
df = df[[col, y]].copy()
df['cut'] = pd.cut(df[col], bins)
bins_df = df.groupby('cut')[y].value_counts().unstack()
bins_df['woe'] = np.log((bins_df[0]/bins_df[0].sum())/(bins_df[1]/bins_df[1].sum()))
return bins_df['woe']
# 把所用woe映射到原始数据中去
model_woe = pd.DataFrame(index=model_train_data.index)
# 对所有特征操作可以写成
for col in bins_of_col:
model_woe[col] = pd.cut(model_train_data[col], bins_of_col[col]).map(woeall[col])
# 将标签补充到数据中
model_woe['SeriousDlqin2yrs'] = model_train_data['SeriousDlqin2yrs']
# 这就是我们的建模数据了
model_woe.head()
3.1.4 VIF检验
# vif消除共线性
from statsmodels.stats.outliers_influence import variance_inflation_factor
import numpy as np
X = np.matrix(model_woe)
VIF_list = [variance_inflation_factor(X, i) for i in range(X.shape[1])]
max_VIF = max(VIF_list)
print(max_VIF)
# 最大的VIF是1.6343915415290946(小于10),因此这一步认为没有多重共线性
VIF_list
此时最大vif小于10所以我们认为没有多重共线性
3.1.5 建模与模型验证
test_data_woe = pd.DataFrame(index=model_test_data.index)
for col in bins_of_col:
test_data_woe[col] = pd.cut(model_test_data[col], bins_of_col[col]).map(woeall[col])
test_data_woe['SeriousDlqin2yrs'] = model_test_data['SeriousDlqin2yrs']
test_X = test_data_woe.iloc[:, :-1]
test_y = test_data_woe.iloc[:, -1]
# 训练集
X = model_woe.iloc[:, :-1]
y = model_woe.iloc[:, -1]
embedded选择特征
from sklearn.linear_model import LogisticRegression as LR
from sklearn.feature_selection import SelectFromModel
estimator = LR()
X_embedded = SelectFromModel(estimator=estimator
,threshold=0.1
).fit_transform(X,y)
X_embedded.shape
经过选择一个特征都没删去,所以直接用吧
lr = LR().fit(X,y)
lr.score(test_X, test_y)
此时分数为0.8594572604575477
c_2 = np.linspace(0.01, 0.2, 20) # l2正则惩罚
score = []
for i in c_2:
lr = LR(solver='liblinear', C=i).fit(X,y)
score.append(lr.score(test_X, test_y))
plt.figure()
plt.plot(c_2, score)
plt.show()
lr.n_iter_
画出图可见0.025左右的时候最大

score = []
for i in [1,2,3,4,5,6,10,20,30]:
lr = LR(solver='liblinear', C=0.025, max_iter=i).fit(X,y)
score.append(lr.score(test_X, test_y))
plt.figure()
plt.plot([1,2,3,4,5,6,10,20,30], score)
plt.show()
最大迭代次数

# 看看roc曲线上的结果
import scikitplot as skplt
test_proba_df = pd.DataFrame(lr.predict_proba(test_X))
skplt.metrics.plot_roc(test_y, test_proba_df, plot_micro=False, figsize=(7,7), plot_macro=False)

制作评分卡
Score = A - B*log(odds)
A叫做补偿
B叫刻度
log(odds)代表一个人的违约可能性
两个常数可以通过两个假设的分值带入公式求出:
- 某个特定违约概率下的预期分值
- 指定的违约概率翻倍的分数(PDO)
假如对数几率为1/60时设定的特定分数为600,PDO为20那么对数几率为1/30时的分数就是620.
600 = A - B * log(1/60)
620 = A - B * log(1/30)
B = 20/np.log(2)
A = 600 + B*np.log(1/60)
A,B
lr.intercept_
# 基本分-不受评分卡中各种特征影响的基础分
base_score = A - B*lr.intercept_
base_score
# 比如要算年龄这特征的每个箱体对应的分数
score_age = woeall['age']*(-B*lr.coef_[0][0])
score_age
file = 'score.csv'
with open(file, 'w') as f:
f.write(f"base score:{base_score}\n")
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')
网友评论