评分卡类模型的概率对齐和分数映射,代码实现

摘要:评分卡机器学习

为什么要做概率对齐和分数映射

一般的机器学习二分类问题输出为概率在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
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,544评论 6 501
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,430评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,764评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,193评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,216评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,182评论 1 299
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,063评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,917评论 0 274
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,329评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,543评论 2 332
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,722评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,425评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,019评论 3 326
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,671评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,825评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,729评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,614评论 2 353

推荐阅读更多精彩内容