前面处理方式见另一篇博文:
1.第三次参加数据挖掘比赛复盘(一)
A.1.分析训练集数据
log表的数据处理方式:
log表由于是该用户购买贷款/理财产品的历史数据,与profile表,与train_flag都是多对一的关系。
那么在梳理过程中可以分为两类,一类的常规数据梳理方式,即同profile表类似的操作。另一类是,对于历史数据进行多对一的数据聚合处理方式。
1.常规数据处理方式:
那么首先是同profile表一样,进行数据初步分析:
1.1 首先看行列数
#数据分析 训练集log:l_t
#行数、列数
print(l_t.shape)
print(l_t['客户编号'].unique().shape)
print(label_t['客户编号'].unique().shape)
print(l_t.columns)
从输出结果可以看出,log历史信息表的客户编号数,是小于给出结果label表/用户信息profile表的编号数的。
这也就是说明,可能有四千条数据,是没有历史办理理财/贷款产品的信息,只能通过他们profile表的信息来预测的。那么将表关联起来后,这四千条信息的log表部分是留空的。
1.2 数据中值的分布
本表的数据分布大致分为三种情况:
1)首先是1的值数量相对较小的列,这种列即使进行历史数据聚合统计,客户历史办理该理财/贷款产品的可能性也相对较低:储蓄账户、担保业务、衍生账号、特级账户、特定账户种类1、家庭账户,共六列
2)剩下是相对办理可能性较高的列:活期账户、工资账户、特定账户种类2、特定账户种类3、电子账户、基金业务、税务业务、信用卡业务、证券业务,共9列
3)养老金业务列,除了空值,其他值全部都是否。该列可以考虑删除。
缺失值统计如下图,从图中可以得到,超过50%缺失的列有:担保业务,活期账户,工资账户。再综合上图分析,活期账户和工资账户虽然缺失值很高,但特征分布较为清晰。而担保业务这一列可以考虑删除。
综上养老金业务、担保业务两列考虑删除。
1.3 具体数据探索
面对log数据,首先思考,“关于是否开办xx业务,xx账户”,此列的1值,是代表已经开办xx业务的状态,还是代表来开办xx业务的行动?
那么通过观察具体数据,由于可以观测的1出现多在连续的年月时间,可以大胆推测,log表的1值,表示的是该客户该月是否开办业务的状态。
那么该表的空缺值很多,此时是否应该进行填充?
当时我考虑的填充策略是:对于某一列,在所有值为1的统计日期集合中,最早日期和最晚日期区间的空缺值,是可以考虑填充为1的。但我没有这么填充,因为我们不能保证在这个日期区间内,该用户不会转而取消产品“1”的购买后重新购买“1”(也就是这个区间内也是有可能插入0的)。
且经过这几次数据挖掘比赛实践,填充空缺值对lgb模型准确度的优化程度,并没有矫正错误数据与造特征来的大(因为LGB树结构对空缺值有自己的处理)。因此我是将它当做后期优化策略,先跑了baseline。
1.4 带时间的log表再处理
由于profile表与预测flag都带有时间,此时笔者考虑了一个方案
该方案是:利用rolling方法将所有数据,对于每一条log表的“客户编号+统计日期”合并作为主键的一行,都求出该“客户编号+统计日期”在该统计日期的上个月、上三个月聚合、上半年聚合、和历史所有聚合 这四种条件下是否开办xx,是否办理xx的状态。
但代码写完了,跑了一下午没出结果。笔者猜想是因为,rolling会加入另一行index“统计日期”,那对于一个有两个index“客户编号“、”统计日期”的表,在做筛选多条件数据处理(非排序操作)时候的效率很低。
那么笔者将上述想法进行删减。考虑到log表有17列特征,如果对其每一列历史信息对客户编号聚合后,求last、count、mean、sum等计算,每计算一次便是一次特征的广泛扩充,**且log表空缺值较多、数据质量并不好。**所以需要考虑如果求哪些计算的实际意义更大。
最终选择了last(“客户编号+统计日期”中统计日期的上个月数据)与历史所有数据(早于“客户编号+统计日期”中统计日期的所有历史数据)的count(0、1)计算。(count(1)=sum=代表办理“1”产品的次数,count(0),可以考虑再加入mean,是因为需要考虑历史数据统计条数的不同,即mean代表办理“1”产品的概率)
**历史一个月、三个月、六个月都能做,为什么偏偏只选择last上个月呢?**是因为有时间序列加入统计的条件下,**如果上一个月为购买“1”产品的状态,那么本月极有可能延续这个状态。**因此对于每一条log表的“客户编号+统计日期”合并字段所代表的的一行,笔者都用如下代码求出了该客户编号在上个月的是否开办xx,是否办理xx的状态
历史所有count(1)、count(0)
到这里,本次比赛数据处理的相关基本思路就说完了。
具体的数据修正与清洗过程,由于不同项目数据质量与待修正点的不同,这里就不多提了,发一些代码吧,处理方式大同小异。**但值得再强调的是:A、B榜训练集的数据理论上分布与质量都不同,需要分别观察,做出不同的处理。**这时肝A榜的兄弟能休息还是休息下吧,如果有团队的话可以换一个兄弟来做数据探索。因为后面还有特征筛选、欠拟合过拟合调整等地方需要处理
可以看出上面代码里,我对A、B榜问题数据处理的方式也不一样
接下来想讲讲本次比赛笔者遭遇到的新的坑:
从A榜前三切到B榜,AUC直接从92.8掉到70,排名当夜直接掉到10+。
为什么模型的泛化性如此重要?
特征筛选又是什么?具体怎么做特征筛选才能提高模型的泛化性?
什么是PSI、WOE、IV?
PSI的适用场景又是什么?
B1
终于到了B榜。
笔者还是经验少,A榜的时候狂肝,以为B榜并不会变化很多。A榜切B榜的当天是个周末,当时还美滋滋还睡了一整个白天。结果晚上起来,原代码跑一轮,AUC直接从93掉到70。
傻眼了。
当时一开始查错的方向就错了,当时开始怀疑B榜数据join错位了,或者是代码里的时间相关对应对错了,先开始怀疑自己代码,等于把代码又在juypter重写了一遍
其实此时可以首先看一下A、B榜的数据分布差异。
比赛数据与真实数据不同。比赛的训练数据,往往都会带有大量人为的故意做出来干扰的误差
当自己代码检查完了还是一头雾水。笔者也不敢问同参赛有竞争的同事,只能咨询了一些其他的同事,经过同事的提醒,再意识到B榜数据集可能分布与A榜不同,待将数据误差都处理完后,AUC回到了79。
与A榜的93还是差很多啊,这是为什么呢?
此时就要引入模型的泛化性这个概念了。
什么是模型的泛化性?
引用一下当时我们比赛群里的讨论。因为以下原因,我们需要提升模型的泛化性。
什么是WOE?
什么是PSI?
群体稳定性指标(population stability index),
所谓特征稳定性,就是关注该特征的取值随着时间的推移会不会发生大的波动。
对特征稳定性的关注,一定一定要在建模之前完成,从一开始就避免将那些本身不太稳定的特征选入模型。遗憾的是,很多做模型的同学并没有留意这一点,而是喜欢在特征ready后立刻开始建模,直到模型临近上线,才意识到应该去看看有没有不太稳定的特征,一旦发现有特征稳定性不满足要求,则需要对其进行剔除后重新建模,导致了不必要的重复性劳动。
通常采用PSI(PopulationStability Index,群体稳定性指数)指标评估特征稳定性。计算公式如下:
PSI是对两个日期的特征数据进行计算,可以任选其一作为base集,另一则是test集(也有其他叫法为expected集和actual集)。
下面介绍特征的PSI是如何计算出来的,有了这个,就可以读懂上面的公式了:
• 特征取值等频分段:对这个特征在base集的取值进行等频划分(通常等频分10份即可),用字母i表示第i个分段区间。
看到经管之家论坛上对PSI解释比较清楚:链接:模型的稳定性用PSI指标来检验 reference1
比如训练一个logistic回归模型,预测时候会有个概率输出p。
测试集上的输出设定为p1吧,将它从小到大排序后10等分,如0-0.1,0.1-0.2,…现在用这个模型去对新的样本进行预测,预测结果叫p2,按p1的区间也划分为10等分。
实际占比就是p2上在各区间的用户占比,预期占比就是p1上各区间的用户占比。
那么如果模型很稳定,那么p1和p2上各区间的用户应该是相近的,占比不会变动很大,即就是预测出来的概率不会差距很大。
一般认为PSI小于0.1时候模型稳定性很高,0.1-0.25一般,大于0.25模型稳定性差,建议重做。
PS:除了按概率值大小等距十等分外,还可以对概率排序后按数量十等分,两种方法计算得到的psi可能有所区别但数值相差不大。
等元旦或者春节再把特征筛选方法仔细学习一下做个总结吧。
以下是赛后代码:
import warnings
warnings.filterwarnings(action='ignore',category=UserWarning,module='gensim')
import pandas as pd
import numpy as np
#from tqdm import tqdm
from multiprocessing import Pool,Process
#import gensim
#from gensim.models import word2vec
import time
import re
import copy
import math
from datetime import datetime
from datetime import timedelta
import gc
#import sys
#import editdistance
#向前逐步回归函数
def forward_selected(data, response):
import statsmodels.formula.api as smf
remaining = set(data.columns)
remaining.remove(response)
selected = []
current_score, best_new_score = 0.0, 0.0
while remaining and current_score == best_new_score:
scores_with_candidates = []
for candidate in remaining:
formula = "{} ~ {} + 1".format(response,
' + '.join(selected + [candidate]))
score = smf.ols(formula, data).fit().rsquared_adj
scores_with_candidates.append((score, candidate))
scores_with_candidates.sort()
best_new_score, best_candidate = scores_with_candidates.pop()
if current_score < best_new_score:
remaining.remove(best_candidate)
selected.append(best_candidate)
current_score = best_new_score
formula = "{} ~ {} + 1".format(response,
' + '.join(selected))
model = smf.ols(formula, data).fit()
result={
'model':model,'params':selected}
return result
def fun_woe(data, colname,classx,data_total,factorname='is_fpd30',max_box=10,min_box=3,max_loss_value=0.01,
min_sample=0.01,min_box_sample=0.05,trim='T'):
#0.对即将处理的列统一命名
rename_eval="data.rename(columns={'"+factorname+"':'remark','"+colname+"':'coly'}, inplace = True)"
eval(rename_eval)
#去除原数据中的缺失值(缺失值会在后面单独处理)
data0=data.loc[data['coly']==data['coly']]
#以实际列值分组
group1=data0.groupby(['coly'])[['remark']].sum()
group2=data0.groupby(['coly'])[['remark']].count()
if len(group1)<3:
classx='class'
colyx=list(group1.index)
if classx=='linear':
x0=pd.DataFrame(colyx,columns=['coly'])
else:
x0=pd.DataFrame(colyx,columns=['coly_name'])
x0['coly']=range(0,len(x0))
#离散数据数据值对应colyid
colyname_info=copy.deepcopy(x0)
x0['nrow1']=list(group1['remark'])
x0['nrow01']=list(group2['remark'])
#总好用户数
sum0=len(data)-sum(data['remark'])
#总坏用户数
sum1=sum(data['remark'])
#1.快速合并初始分箱
x1=copy.deepcopy(x0)
# cumsum1=np.cumsum(list(x1['nrow1']))
# cumsum01=np.cumsum(list(x1['nrow01']))
if classx!='linear':
x1=x1.sort_index(by='nrow01',axis=0,ascending=True)
# x2=pd.DataFrame(columns=['nrow1','nrow01','coly','coly_name'])
# else:
# x2=pd.DataFrame(columns=['nrow1','nrow01','coly'])
x1['cumsum01']=np.cumsum(list(x1['nrow01']))
#本次分箱最小样本数
min_box_x=(sum0+sum1)*min_box_sample
backout=0
x2=pd.DataFrame(columns=['nrow1','nrow01','coly'])
#离散数据用来记录合并过程
coly_set=pd.DataFrame(columns=['coly_from','coly_to'])
while backout==0:
if (min(x1['nrow01'])>=min_box_x):
x2=x2.append(x1[['nrow1','nrow01','coly']])
backout=1
else:
cumsum_1=x1['cumsum01'].loc[x1['cumsum01']>=min_box_x][0:1]
if len(cumsum_1)==0:
x1i=copy.deepcopy(x1)
else:
x1i=x1.loc[x1['cumsum01']<=int(cumsum_1)]
if classx!='linear':
# coly_seti=pd.DataFrame(list(x1i['coly'])[1:len(x1i)],columns=['coly_from'])
# coly_seti['coly_to']=list(x1i['coly'])[0]
# coly_set=coly_set.append(coly_seti)
# s = pd.Series({'coly_from':list(x1i['coly'])[1:len(x1i)], 'coly_to':list(x1i['coly'])[0]})
# coly_set = coly_set.append(s, ignore_index=True)
for i in range(1,len(x1i)):
s = pd.Series({
'coly_from':list(x1i['coly'])[i], 'coly_to':list(x1i['coly'])[0]})
coly_set = coly_set.append(s, ignore_index=True)
s = pd.Series({
'nrow1':sum(x1i['nrow1']), 'nrow01':sum(x1i['nrow01']), 'coly':list(x1i['coly'])[0]})
x2 = x2.append(s, ignore_index=True)
x1=