评分卡类模型的概率对齐和分数映射,代码实现
摘要:评分卡
,机器学习
为什么要做概率对齐和分数映射
一般的机器学习二分类问题输出为概率在0-1之间的小数值,概率对齐指将模型输出的概率和真实的事件概率进行比对和对齐,尤其对一些极端模型概率
进行修正,分数映射指的对模型的概率输出再做一层变换使结果更加契合业务理解和分析,一般是线性映射
,不影响排序
,不影响分布
。
需要对模型概率值做二次加工的原因:
- 概率对齐:
(1) 评分卡模型的输出要好看,不论得分高低都需要输出给用户体验,区别于一般的机器学习模型只需要一刀切取阈值以上并且一视同仁,评分卡的模型每一个输入数据的预测输出都需要合理,需要连续稠密饱满
而不能极高极低过于离散
(2) 训练样本和实际样本的标签分布
差异较大。一般评分卡都是对好坏客户的预测,坏客户的自然比率可能只有几个百分点
,但是机器学习模型都要求样本的正负比率接近1:1,而测试和应用用真实比率,这必然导致实际应用过程中概率失真,虽然不影响结果的排序 - 分数映射
(1) 分数映射主要是处于业务考虑
,主要来修正分数的上下限
以及分数的间隔
的大小,不影响分数的分布,目的是将分数映射到一个业务需要的范围,并且将分数的上下浮动和概率的成倍变化
联系起来,增加一定的可解释性和比较依据
概率对齐
概率对齐参考度小满的一篇分享
概率标准化.png
思路是直接对模型的预测坏概率和真实坏比率在做一次拟合,几个细节:
- 排序并分段:理论上拟合的数据是一对一对的模型概率和真实比率,分段的目的是
平滑
,先排序将相邻
的进行分段,每一段各自的均值
形成一对拟合样本,防止某个点的单个类别的占比太大 - 概率取logit:此处将概率转化为log(odds)
ln(p/(1-p))
,评分卡出现了,不管什么算法在这一步全部手动
转化为odds
做对齐,以为下一步做分数映射做铺垫,换句话说如果本身模型就能输出odds就可以直接使用跳过此步 - 尝试一次或二次拟合:个人建议至少二次拟合,一次拟合相当于还是线性映射,确实能修正到真实odds,但是对修正极值几乎没有效果,二次拟合才能其他改变分数形状的能力,由于下一步分数映射已经确定是线性映射,因此此步骤应该用至少二次拟合
代码实现
模型使用逻辑回归,并且弃用框架的predict方法,将模型的权重和偏置拿出来写入配置,预测直接wx+b得到log(odds)
也就是预测概率的logit
,先写一个函数实现wx+b输出log(odds),其中params和feature分别是字段为key,权重和特征值为value的字典
def get_odds(feature):
res = 0
for i in feature_columns:
res += params[i] * feature[i]
res += params["intercept"]
return res
取测试集,测试集
的正负样本比例应该等于自然比例
,输出模型预测的logit和实际的label,这份测试集的质量非常重要,因为需要基于他做事实的拟合
test = pd.read_csv("data/tmp_test.txt")
res6 = []
for i in range(len(test)):
score = [get_odds(test.loc[i].to_dict()), test.loc[i]["标签"]]
res6.append(score)
下一步通过模型预测logit和实际label的结果计算每个测试点的实际坏率
,可以采用等频分箱
# 等频分箱
bin_num = int(len(res6) / 100) # 分为100箱
bin_data = []
pair_data = []
# 根据模型预测输出排序
for line in sorted(res6, key=lambda x: x[0], reverse=True):
bin_data.append(line)
if len(bin_data) == bin_num:
# 计算平均预测值
mean_predict = np.mean([x[0] for x in bin_data])
# 真实坏率
bad = len([x for x in bin_data if x[1] == 1])
good = len([x for x in bin_data if x[1] == 0])
actual_odds = np.log(bad / good)
pair_data.append([mean_predict, actual_odds])
bin_data.clear()
也可以采用等距分箱
# 等距分箱
max_predict = max([x[0] for x in res6])
min_predict = min([x[0] for x in res6])
bin = (max_predict - min_predict) / 30 # 等距离分为30箱
pair_data = []
for i in range(30):
bin_start = min_predict + i * bin
bin_end = bin_start + bin
bin_data = [x for x in res6 if bin_start <= x[0] < bin_end]
# 平均预测值
mean_predict = np.mean([x[0] for x in bin_data])
# 真实坏率
bad = len([x for x in bin_data if x[1] == 1 ])
good = len([x for x in bin_data if x[1] == 0])
actual_odds = np.log(bad / good)
pair_data.append([mean_predict, actual_odds])
以上两种分箱方法都是为了平滑,需要注意某分箱可能存在分子或者分母为0的情况,需要剔除或者调整分箱或者其他处理,本例中采用另一种即不做分箱不做平滑处理,每个点都生成一对模型结果和实际坏率进行拟合,但是对每个点阈值以上
的数据量
有数据要求,如果数据量太小不作为拟合样本,代码如下
# 每个点都拟合
res7 = []
count = 0
for score, label in res6:
count += 1
threshold_data = [x for x in res6 if x[0] >= score]
# 限制数据量
if len(threshold_data) >= 50:
bad = len([x for x in threshold_data if x[1] == 1])
good = len([x for x in threshold_data if x[1] == 0])
# 限制都非0
if bad and good:
odds = np.log(bad / good)
res7.append([score, odds])
以模型的输出logit作为x,实际坏率的logit(odds)作为y,做散点图,观察两者的关系
x = []
y = []
for line in res7:
if -6.5 <= line[0] <= 6.5:
x.append(line[0])
y.append(line[1])
plt.scatter(x, y, s=2)
plt.show()
模型概率和实际坏率的关系.png
- 整体呈
线性
,可见模型的预测效果不错如果只看排序模型的结果可以直接使用 - 两边呈现向
水平方向收敛
,可见此时模型的预测结果在走向极值
,但是实际坏率并没有变极端而是趋于不再变化
,因此至少要对模型输出的极大极小点进行对齐修正 -
logit(odds)失真
,横轴是模型结果在[-6, 6]之间,纵轴是真实比例的坏率结果在[-4.5, 0.5]之间,因此需要拟合修正,另一方面修正后的odds并不对称
,因此分数映射的基础分可能不是中间分
进行拟合,尝试多次后使用三次拟合
# 拟合三次多项式
np.plotfit(x, y, 2) # 系数 array([-0.00282885, 0.00503271, 0.50497722, -2.06355245])
p = np.plot1d(np.plotfit(x, y, 2))
查看拟合曲线和之前的散点图,可以看到拟合了线性关系,将概率修正到真实坏率,并且在头尾处有修正
plt.scatter(x, y, s=2)
plt.scatter(x, [p(i) for i in x], s=2)
plt.show()
拟合图.png
获得系数后编写一个函数获得修正后的log(odds)
plot_params = [-0.00282885, 0.00503271, 0.50497722, -2.06355245]
def get_plotfit_odds(x):
# 把原输出修正到[-9, 9],防止三次函数作怪
if x < -9:
x = -9
if x > 9:
x = 9
return polt_params[0] * x ** 3 + poly_params[1] * x ** 2 + poly_params[2] * x + poly_params[3]
测试修正之后的logit(odds)
get_plotfit_odds(5) # 0.2335451499
get_plotfit_odds(-1) # -2.56066811
get_plotfit_odds(3) # -0.57970535
分数映射
完成了第一步分数对齐,下一步分数映射就比较固定了,此步骤是一个线性映射,即确定w和b,度小满给出的公式中:
- b=400代表
基础分
,可以理解为中间分,平均水平,但是当映射之前logit(odds)不两边对称
时可能这个值不能是中间分 - w= -35 / ln2,35代表每提升35分
好坏比数比上升为原来的2倍
,基本等同于风险上下降2倍
,负号
的原因是分数越低坏率越高,而后面的因子中的p是坏率,后面的因子越大坏率越大,如果希望分数越高坏率越高,不需要负号 - 分数的总区间在
[0, 800]
,这就要求logit(odds)必须在[-8, 8]
之间否则就会出现负分,一般而言logit(odds)不会超过这两个界限,但是为了分数异常还需要对天花板做一下限制
编写分数映射函数,此处期望分数在[0, 100]
之间,并且分数越大风险越高
,由于在上一步已知修正后的logit(odds)在[-4.5, 1]之间,所以计算可得要把分数放到100的区间,w=100 / (4.5 + 1) = 18,此时b=80差不多可以把100分填满,分数上升18分风险变为原来的2倍
def get_score(feature, w=18, b=80):
odds = get_plotfit_odds(get_od ds(feature))
score = int(round(w * odds + b, 1))
if score > 100:
score = 100
if score < 0:
score = 0
return score
这个函数将模型预测,概率对齐,分数映射,异常修正全部包装在一起,现在应用到test数据集中查看分数结果
res4 = []
for i in range(len(test)):
score1 = get_odds(test.loc[i].to_dict()) # 模型输出
score2 = get_plotfit_odds(score1) # 概率对齐
score3 = get_score(test.loc[i].to_dict()) # 分数映射
res4.append([score1, score2, score3])
查看原始概率的分布
plt.hist([x[0] for x in res4])
plt.show()
原始模型输出.png
查看概率对齐+分数映射后的最终得分
plt.hist([x[-1] for x in res4])
plt.show()
分数二次加工后的最终得分.png