【有監督分箱】方法二: Best-KS分箱
阿新 • • 發佈:2018-12-04
銜接上一篇工作:https://blog.csdn.net/hxcaifly/article/details/80203663
變數的KS值
KS(Kolmogorov-Smirnov)用於模型風險區分能力進行評估,指標衡量的是好壞樣本累計部分之間的差距 。KS值越大,表示該變數越能將正,負客戶的區分程度越大。通常來說,KS>0.2即表示特徵有較好的準確率。強調一下,這
裡的KS值是變數的KS值,而不是模型的KS值。(後面的模型評估裡會重點講解模型的KS值)。
KS的計算方式:
- 計算每個評分割槽間的好壞賬戶數。
- 計算各每個評分割槽間的累計好賬戶數佔總好賬戶數比率(good%)和累計壞賬戶數佔總壞賬戶數比率(bad%)。
- 計算每個評分割槽間累計壞賬戶比與累計好賬戶佔比差的絕對值(累計good%-累計bad%),然後對這些絕對值取最大值記得到KS值。
Best-KS分箱
Best-KS分箱的演算法執行過程是一個逐步拆分的過程:
- 將特徵值值進行從小到大的排序。
- 計算出KS最大的那個值,即為切點,記為D。然後把資料切分成兩部分。
- 重複步驟2,進行遞迴,D左右的資料進一步切割。直到KS的箱體數達到我們的預設閾值即可。
Best-KS分箱的特點: - 連續型變數:分箱後的KS值<=分箱前的KS值
- 分箱過程中,決定分箱後的KS值是某一個切點,而不是多個切點的共同作用。這個切點的位置是原始KS值最大的位置。
整體程式碼
# -*- coding: utf-8 -*-
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
#import missingno as msno
plt.style.use('fivethirtyeight')
import warnings
import datetime
warnings.filterwarnings('ignore')
#%matplotlib inline
#from tqdm import tqdm
import re
import math
import time
import itertools
import random
from logging import Logger
from logging.handlers import TimedRotatingFileHandler
import os
#######################################################KS分箱的主體邏輯##############################################
def init_logger(logger_name,logging_path):
if not os.path.exists(logging_path):
os.makedirs(logging_path)
if logger_name not in Logger.manager.loggerDict:
logger = logging.getLogger(logger_name)
logger.setLevel(logging.DEBUG)
handler = TimedRotatingFileHandler(filename=logging_path+"/%sAll.log"%logger_name,when='D',backupCount = 7)
datefmt = '%Y-%m-%d %H:%M:%S'
format_str = '[%(asctime)s]: %(name)s %(filename)s[line:%(lineno)s] %(levelname)s %(message)s'
formatter = logging.Formatter(format_str,datefmt)
handler.setFormatter(formatter)
handler.setLevel(logging.INFO)
logger.addHandler(handler)
console= logging.StreamHandler()
console.setLevel(logging.INFO)
console.setFormatter(formatter)
logger.addHandler(console)
handler = TimedRotatingFileHandler(filename=logging_path+"/%sError.log"%logger_name,when='D',backupCount=7)
datefmt = '%Y-%m-%d %H:%M:%S'
format_str = '[%(asctime)s]: %(name)s %(filename)s[line:%(lineno)s] %(levelname)s %(message)s'
formatter = logging.Formatter(format_str,datefmt)
handler.setFormatter(formatter)
handler.setLevel(logging.ERROR)
logger.addHandler(handler)
logger = logging.getLogger(logger_name)
return logger
def get_max_ks(date_df, start, end, rate, factor_name, bad_name, good_name, total_name,total_all):
'''
計算最大的ks值
:param date_df: 資料來源
:param start: 第一條資料的index
:param end: 最後一條資料的index
:param rate:
:param factor_name:
:param bad_name:
:param good_name:
:param total_name:
:param total_all:
:return:最大ks值切點的index
'''
ks = ''
#獲取黑名單資料
bad = date_df.loc[start:end,bad_name]
#獲取白名單資料
good = date_df.loc[start:end,good_name]
#np.cumsum累加。計算黑白的數量佔比,累計差
bad_good_cum = list(abs(np.cumsum(bad/sum(bad)) - np.cumsum(good/sum(good))))
if bad_good_cum:
#找到最大的ks
max_ks = max(bad_good_cum)
#找到最大ks的切點index。
index_max = bad_good_cum.index(max_ks)
t = start + index_max
len1 = sum(date_df.loc[start:t,total_name])
len2 = sum(date_df.loc[t+1:end,total_name])
#這個就是rate起的效果,一旦按照最大ks切點切割資料,要保證兩邊的資料量都不能小於一個閾值
if len1 >= rate*total_all:
if len2 >= rate*total_all:
ks = t
#如果分割之後,任意一部分資料的數量小於rate這個閾值,那麼ks就返回為空了。
return ks
def cut_fun(x,date_df,types,rate,factor_name,bad_name,good_name,total_name,total_all):
'''
:param x: List,就是儲存了date_df的第一條index和最後一條index的List。
:param date_df: 資料來源
:param types: 不知道是什麼意思
:param rate: rate的含義也是一直不清楚
:param factor_name: 待分箱的特徵欄位
:param bad_name:
:param good_name:
:param total_name:
:param total_all:
:return: 資料的start index,切點index,end index。
'''
if types == 'upper':
#起始從date_df的第一條開始
start = x[0]
else:
start = x[0]+1
#結束時date_df的最後一條
end = x[1]
t = ''
#很明顯start != end,所以就執行這個函式體
if start != end:
#計算得到最大ks切點index的值,並且把值存入t。
t = get_max_ks(date_df,start,end,rate,factor_name,bad_name,good_name,total_name,total_all)
if t:
#把t存入x。
x.append(t)
#這個時候x存著[start,切點,end]
x.sort()
if t == 0:
x.append(t)
x.sort()
return x
def cut_while_fun(t_list,date_df,rate,factor_name,bad_name,good_name,total_name,total_all):
'''
:param t_list: start_index,分箱切點 ,end_index
:param date_df:
:param rate:
:param factor_name:
:param bad_name:
:param good_name:
:param total_name:
:param total_all:
:return:
'''
if len(t_list) != 2:
#切點左邊資料
t_up = [t_list[0],t_list[1]]
#切點右邊資料
t_down = [t_list[1],t_list[2]]
#遞迴對左邊資料進行切割
if t_list[1]-t_list[0] > 1 and sum(date_df.loc[t_up[0]:t_up[1],total_name]) >= rate * sum(date_df[total_name]):
t_up = cut_fun(t_up,date_df,'upper',rate,factor_name,good_name,bad_name,total_name,total_all)
else:
t_up = []
#遞迴對右邊資料進行切割
if t_list[2]-t_list[1] > 1 and sum(date_df.loc[t_down[0]+1:t_down[1],total_name]) >= rate * sum(date_df[total_name]):
t_down = cut_fun(t_down,date_df,'down',rate,factor_name,good_name,bad_name,total_name,total_all)
else:
t_down = []
else:
t_up = []
t_down = []
return t_up,t_down
def ks_auto(date_df,piece,rate,factor_name,bad_name,good_name,total_name,total_all):
'''
:param date_df: 資料來源
:param piece: 分箱數目
:param rate: 最小數量佔比,就是把資料通過切點分成兩半部分之後,要保證兩部分的數量都必須不能小於這個佔比rate。
:param factor_name: 待分箱的特徵名稱
:param bad_name: 黑名單特徵名稱
:param good_name: 白名單特徵名稱
:param total_name: 總和的特診名稱
:param total_all: 總共資料量
:return: 返回整個分箱的間隔點,用List儲存。這裡是以date_df的index為分割點的。
'''
t1 = 0
#資料來源的大小,條數
t2 = len(date_df)-1
num = len(date_df)
#還不知道這樣做的目的是什麼。
if num > pow(2,piece-1):
num = pow(2,piece-1)
#新定義一個list,這個list是什麼含義
t_list = [t1,t2]
tt =[]
i = 1
#如果資料來源的條數大於1,就表示有分箱的資格
if len(date_df) > 1:
#這個是為了獲取date_df資料的[start_index,切點_index, end_index]
#將資料根據ks最大處進行二分
t_list = cut_fun(t_list,date_df,'upper',rate,factor_name,bad_name,good_name,total_name,total_all)
tt.append(t_list)
for t_new in tt:
#>2說明,分箱是成功的。
if len(t_new) > 2:
#
up_down = cut_while_fun(t_new,date_df,rate,factor_name,bad_name,good_name,total_name,total_all)
t_up = up_down[0]
if len(t_up) > 2:
#
t_list = list(set(t_list+t_up))
tt.append(t_up)
t_down = up_down[1]
if len(t_down) > 2:
t_list = list(set(t_list+t_down))
tt.append(t_down)
i += 1
#注意迴圈的停止條件
#1. i表示通過箱數限制break
#2. len(t_list)還不是很清楚
if len(t_list)-1 > num:
break
if i >= piece:
break
if len(date_df) > 0:
#這裡有個疑問,我感覺有問題
#這裡為啥要獲取第一條資料,total的數量
length1 = date_df.loc[0,total_name]
if length1 >= rate*total_all:
if 0 not in t_list:
t_list.append(0)
else:
t_list.remove(0)
t_list.sort()
return t_list
def get_combine(t_list, date_df, piece):
'''
:param t_list: 這個值分箱間隔點
:param date_df: 資料來源
:param piece: 分箱的箱數,表示第幾箱。
:return: 列舉所有的分箱可能組合
'''
t1 = 0
t2 = len(date_df)-1
list0 = t_list[1:len(t_list)-1]
combine = []
if len(t_list)-2 < piece:
c = len(t_list)-2
else:
c = piece-1
#獲取list0的所有子序列。子序列長度是c
list1 = list(itertools.combinations(list0, c))
if list1:
#向list1收尾新增資料,頭部新增t1-1,尾部新增t2
combine = map(lambda x: sorted(x + (t1-1,t2)),list1)
return combine
def cal_iv(date_df,items,bad_name,good_name,total_name):
'''
:param date_df:
:param items:
:param bad_name:
:param good_name:
:param total_name:
:return: 返回計算的IV值
'''
iv0 = 0
bad0 = np.array(map(lambda x: sum(date_df.ix[x[0]:x[1],bad_name]),items))
good0 = np.array(map(lambda x: sum(date_df.ix[x[0]:x[1],good_name]),items))
bad_rate0 = np.array(map(lambda x: sum(date_df.ix[x[0]:x[1],bad_name])*1.0/sum(date_df.ix[x[0]:x[1],total_name]),items))
if 0 in bad0:
return iv0
if 0 in good0:
return iv0
good_per0 = good0*1.0/sum(date_df[good_name])
bad_per0 = bad0*1.0/sum(date_df[bad_name])
woe0 = map(lambda x: math.log(x,math.e),good_per0/bad_per0)
if sorted(woe0, reverse=False) == list(woe0) and sorted(bad_rate0, reverse=True) == list(bad_rate0):
iv0 = sum(woe0*(good_per0-bad_per0))
elif sorted(woe0, reverse=True) == list(woe0) and sorted(bad_rate0, reverse=False) == list(bad_rate0):
iv0 = sum(woe0*(good_per0-bad_per0))
return iv0
def choose_best_combine(date_df,combine,bad_name,good_name,total_name):
'''
:param date_df: 資料來源
:param combine: 所有的分箱可能
:param bad_name:
:param good_name:
:param total_name:
:return: 通過最大IV值,來得到最優的分箱方法
'''
z = [0]*len(combine)
for i in range(len(combine)):
item = combine[i]
z[i] = (zip(map(lambda x: x+1,item[0:len(item)-1]),item[1:]))
#計算最大的IV值
iv_list = map(lambda x: cal_iv(date_df,x,bad_name,good_name,total_name),z)
iv_max = max(iv_list)
if iv_max == 0:
return ''
index_max = iv_list.index(iv_max)
combine_max = z[index_max]
#返回最好的分箱組合
#[(0, 180), (181, 268), (269, 348), (349, 450), (451, 605)] 類似於這種資料
return combine_max
def verify_woe(x):
if re.match('^\d*\.?\d+$', str(x)):
return x
else:
return 0
def best_df(date_df, items, na_df, rate, factor_name, total_name, bad_name, good_name,total_all,good_all,bad_all):
'''
:param date_df:
:param items: 分箱間隔,陣列[(0, 180), (181, 268), (269, 348), (349, 450), (451, 605)]
:param na_df:
:param rate:
:param factor_name:
:param total_name:
:param bad_name:
:param good_name:
:param total_all:
:param good_all:
:param bad_all:
:return:分箱之後的指標儲存為dataframe,並返回。
'''
df0 = pd.DataFrame()
if items:
piece0 = map(lambda x: '['+str(date_df.ix[x[0],factor_name])+','+str(date_df.ix[x[1],factor_name])+']',items)
bad0 = map(lambda x: sum(date_df.ix[x[0]:x[1],bad_name]),items)
good0 = map(lambda x: sum(date_df.ix[x[0]:x[1],good_name]),items)
if len(na_df) > 0:
piece0 = np.array(list(piece0) + map(lambda x: '['+str(x)+','+str(x)+']',list(na_df[factor_name])))
bad0 = np.array(list(bad0) + list(na_df[bad_name]))
good0 = np.array(list(good0) + list(na_df[good_name]))
else:
piece0 = np.array(list(piece0))
bad0 = np.array(list(bad0))
good0 = np.array(list(good0))
#bad0,good0都是list資料結構
total0 = bad0 + good0
#計算每一個箱子的總數量佔比
total_per0 = total0*1.0/total_all
#當前箱子的黑名單比例
bad_rate0 = bad0*1.0/total0
#當前箱子的白名單比例
good_rate0 = 1 - bad_rate0
#當前箱子的白名單在整體白名單資料的比例
good_per0 = good0*1.0/good_all
#當前箱子黑名單在在整體黑名單資料的比例
bad_per0 = bad0*1.0/bad_all
#先將這些資料儲存為數框
df0 = pd.DataFrame(zip(piece0,total0,bad0,good0,total_per0,bad_rate0,good_rate0,good_per0,bad_per0),columns=['Bin','Total_Num','Bad_Num','Good_Num','Total_Pcnt','Bad_Rate','Good_Rate','Good_Pcnt','Bad_Pcnt'])
#通過bad_rate進行排序
df0 = df0.sort_values(by='Bad_Rate',ascending=False)
df0.index = range(len(df0))
bad_per0 = np.arr