1. 问题背景
所要解决的问题是找到目标人群,精准投放营销广告。
用户画像,即用户信息标签化,就是企业通过收集与分析消费者社会属性、生活习惯、消费行为等主要信息的数据之后,完美地抽象出一个用户的商业全貌。所以基于用户画像建立分类模型来预测短信投放的目标用户。
2. 构造训练数据
有三部分数据:用户画像数据(199737765条)、营销汇总数据(472616840条)、登录数据(495517967条),最后要筛选出选定的一次短信营销活动中的用户,用该用户的用户画像数据作为训练集,目标变量为观察期内是否登录,登录为1,未登录为0。
2.1 用户画像数据
先来看看用户画像数据,存放在hive中dim_customer_marking表内。用户画像表共有368个字段,剔除已下线和停止刷新项目如大学生项目,并选择用户的固有属性,如:是否购买基金、是否是陆金所用户、是否是平安内勤员工,剔除金融产品中的细分类别,并根据业务逻辑选择重要特征。因为要将静默用户和近期活跃用户按一定比例选择作为目标投放用户,并且响应指标是看用户在观察期内是否登录,所以剔除用户画像表中登录信息。对于生活类消费和购买的理财产品有很多细分类别,也先剔除,只保留对这些大类是否交易过,最后筛选出认为比较重要的特征56个。
from pyspark import SparkConf, SparkContext
from pyspark.sql import HiveContext,Row
hiveContext = HiveContext(sc)
dim_data=sc.sequenceFile('/user/hive/warehouse/i8ji.db/dim_customer_marking')
dim_data=dim_data.map(lambda x:x[1].split("\x01")).map(lambda x:(x[0],x[4],x[7],x[14],x[23],x[24],x[26],x[29],x[31],x[33],x[46],x[47],x[48],x[49],x[50],x[51],x[52],x[53],x[54],x[55],x[56],x[57],x[66],x[89],x[94],x[96],x[117],x[118],x[122],x[127],x[141],x[142],x[143],x[144],x[146],x[148],x[149],x[180],x[181],x[261],x[262],x[263],x[275],x[276],x[277],x[278],x[279],x[280],x[289],x[306],x[335],x[336],x[337],x[340],x[341],x[342],x[343]))
dim_data1=hiveContext.createDataFrame(dim_data)
部分特征如下:
2.2 营销汇总数据
然后在营销数据汇总表dim_yx_customer_base_new中,选择短信营销,即令channel=’S’,并选择一次营销活动——【822砍价——短信营销第二波】,活动id为39010,即令campaign_id=39010。
营销数据汇总表为orc格式,用hiveContext.read.format(“orc”).load(path)来读写。
data=hiveContext.read.format("orc").load("/user/hive/warehouse/i8ji.db/dim_yx_customer_base_new")
train_data=data.filter("channel='S' and campaign_id=39010").select("customer_id")
2.3 登录数据
在用户登录表mid_dim_cust_ex_login中取出在2017年8月22日至9月5日(观察期)内登录的用户,去重,并赋值为1(为后面构造目标变量做准备)。
login_data=sc.textFile("/user/hive/warehouse/i8ji.db/mid_dim_cust_ex_login")
login_data=login_data.map(lambda x:x.split("\x01"))
login_user=login_data.map(lambda x:Row(customer_id=x[0],login_time=x[1]))
login_user1=hiveContext.createDataFrame(login_user)
login_user2=login_user1.filter("login_time>'2017-08-22' and login_time<'2017-09-05'").select("customer_id")
login_user3=login_user2.distinct()
login_user4=login_user3.rdd.map(lambda x:Row(customer_id=x[0],y=1))
login_user5=hiveContext.createDataFrame(login_user4)
2.4 构造训练集
先取出该次营销活动用户的用户画像数据,即用户两个数据表按customer_id做“inner” join。
train=dim_data1.join(train_data,train_data.customer_id==dim_data1._1,'inner')
train1=train.drop('customer_id')
然后构造目标变量,将上述数据与登录数据按customer_id做left_join,空值填充为0。
train2=train1.join(login_user5,train1._1==login_user5.customer_id,'left_outer')
train3=train2.na.fill({'y':0})
train4=train3.drop('customer_id')
最后将训练集保存到hive。
train4.saveAsTable('trainmm',mode='overwrite')
3. 数据处理
先来看下训练集大小和正负样本数量。得到训练集有284681条
df=hiveContext.read.load('/user/hive/warehouse/trainmm')
df.count()
df.groupBy('y‘).count().show()
如下表所示,正负样本分布比较均衡:
y | count |
---|---|
0 | 170103 |
1 | 114578 |
静默用户分布:
_40 | count |
---|---|
0 | 245213 |
1 | 39468 |
将训练集中56个特征按照离散变量和连续变量分类处理。基本方法是做缺失值、异常值处理、离散化、数据变换(标准化、归一化、对数变换)等。
3.1 离散变量
首先看各变量的缺失情况(注意,实际上不是空值,而是空字符串“)。
缺失变量 | count |
---|---|
_2 | 55020 |
_3 | 55022 |
_10 | 39645 |
_24 | 117465 |
_29 | 13 |
_30 | 13 |
_31 | 258194 |
_32 | 258194 |
_33 | 258217 |
_34 | 258217 |
_35 | 258217 |
_36 | 284681 |
_37 | 284681 |
_43 | 74610 |
_44 | 74610 |
_45 | 74610 |
_46 | 74610 |
_47 | 74610 |
_48 | 74610 |
_49 | 281167 |
_51 | 9180 |
_52 | 9180 |
_53 | 9180 |
_54 | 12650 |
_55 | 84406 |
_56 | 84406 |
_57 | 84406 |
_31、_32、_33、_34、_35、_36、_37、_49,这8个变量的缺失数量在90%以上,所以删除这些变量,其他有缺失值的变量中一般将缺失值填充为-1,还有下面的特殊情况。
将生日所在年份、注册时间、首次登陆时间转变为年龄或距营销日期天数。并且,将定性的特征变成数值型,定性特征如下:
_2 | count |
---|---|
F | 124404 |
M | 105257 |
55020 |
_44 | count |
---|---|
00.积分段0元 | 80185 |
09.积分段[50,100)元 | 7309 |
13.积分段大于等于1000元以上 | 3729 |
06.积分段[10,20)元 | 8870 |
07.积分段[20,30)元 | 5335 |
08.积分段[30,50)元 | 6148 |
05.积分段[5,10)元 | 11462 |
11.积分段[200,500)元 | 6563 |
01.积分段(0,0.2)元 | 27229 |
12.积分段[500,1000)元 | 3457 |
10.积分段[100,200)元 | 6700 |
04.积分段[2,5)元 | 12442 |
02.积分段[0.2,1)元 | 21785 |
74610 | |
03.积分段[1,2)元 | 8857 |
_48 | count |
---|---|
MOB_m0 | 502 |
MOB_m1 | 31166 |
MOB_m2 | 18811 |
old | 159592 |
74610 |
连续变量
所有连续变量的描述统计信息,可以看到有几个特征的最大值非常大,该特征的含义均为多天的交易金额或理财账户余额,所以比较合理,不认为是异常值。
summary | _8 | _9 | _11 | _12 | _13 | _14 | _15 | _16 | _17 | _18 |
---|---|---|---|---|---|---|---|---|---|---|
count | 284681 | 284681 | 284681 | 284681 | 284681 | 284681 | 284681 | 284681 | 284681 | 284681 |
mean | 4.88 | 0.18 | 0.65 | 2840.11 | 884.99 | 0.46 | 2.63 | 12087.80 | 1852.43 | 1.84 |
stddev | 7.36 | 0.74 | 5.13 | 29635.17 | 9656.47 | 4.94 | 15.69 | 81661.25 | 15482.61 | 14.99 |
min | 0 | 0 | 0 | 0.0 | 0.0 | 0 | 0 | 0.0 | 0.0 | 0 |
max | 99 | 9 | 99 | 9999.91 | 9999.66 | 99 | 99999999 | 99993.74 | 99 |
summary | _19 | _20 | _21 | _22 | _25 | _26 | _27 | _28 | _50 |
---|---|---|---|---|---|---|---|---|---|
count | 284681 | 284681 | 284681 | 284681 | 284681 | 284681 | 284681 | 284681 | 284681 |
mean | 8.07 | 36595.32 | 3059.08 | 5.90 | 38021.46 | 69.24 | 2913.35 | 5.91 | |
stddev | 40.34 | 188195.69 | 22527.65 | 37.97 | 123498.09 | 86.66 | 29701.63 | 422.50 | 766.76 |
min | 0 | 0.0 | 0.0 | 0 | |||||
max | 993 | 99998.0 | 9999.87 | 993 | 9999 | 99 | 99967.03 | 996.0 | 8027.38 |
然后查看缺失值,只有5个特征有缺失值,缺失数量如下,删除_25、_26和_50特征。_27和_28因为是余额,所以缺失可能是因为用户没有办理这项业务,所以将缺失值填充为0。
缺失变量 | count |
---|---|
_25 | 233374 |
_26 | 271181 |
_27 | 61383 |
_28 | 39645 |
_50 | 260242 |
数据处理如下:
df1=df.drop('_25')
df1=df1.drop('_26')
df1=df1.drop('_31')
df1=df1.drop('_32')
df1=df1.drop('_33')
df1=df1.drop('_34')
df1=df1.drop('_35')
df1=df1.drop('_36')
df1=df1.drop('_37')
df1=df1.drop('_49')
df1=df1.drop('_50')
from datetime import datetime
d=df1.rdd.map(lambda x:[x[0],x[1],x[2],x[3],x[4],x[5],x[6],x[7],x[8],x[9],x[10],x[11],x[12],x[13],x[14],x[15],x[16],x[17],x[18],x[19],x[20],x[21],x[22],x[23],x[24],x[25],x[26],x[27],x[28],x[29],x[30],x[31],x[32],x[33],x[34],x[35],x[36],x[37],x[38],x[39],x[40],x[41],x[42],x[43],x[44],x[45],x[46]])
mm={u'MOB_m0':0,u'MOB_m1':1,u'MOB_m2':2,u'old':3}
m={u'00.积分段0元':0,u'01.积分段(0,0.2)元':1,u'02.积分段[0.2,1)元':2,u'03.积分段[1,2)元':3,u'04.积分段[2,5)元':4,u'05.积分段[5,10)元':5,u'06.积分段[10,20)元':6,u'07.积分段[20,30)元':7,u'08.积分段[30,50)元':8,u'09.积分段[50,100)元':9,u'10.积分段[100,200)元':10,u'11.积分段[200,500)元':11,u'12.积分段[500,1000)元':12,u'13.积分段大于等于1000元以上':13}
def f1(x):
if [1]=='M':
x[1]=0
elif x[1]=='F':
x[1]=1
else:
x[1]=-1
if x[2]=='':
x[2]=1980
if x[9]=='':
x[9]='2017-08-22 00:00:00'
x[2]=2017-int(x[2])
x[3]=(datetime.strptime('2017-08-22','%Y-%m-%d')-datetime.strptime(x[3],'%Y-%m-%d %H:%M:%S')).days
x[9]=(datetime.strptime('2017-08-22','%Y-%m-%d')-datetime.strptime(x[9],'%Y-%m-%d %H:%M:%S')).days
if x[23]=='':
x[23]=-1
for j in range(24,26):
if x[j]=='':
x[j]=0
for j in range(26,46):
if x[j]=='':
x[j]=-1
for k,v in mm.items():
if x[38]==k:
x[38]=v
for k,v in m.items():
if x[34]==k:
x[34]=v
x=list(map(float,x))
return x
data=d.map(lambda x:f1(x))
算法实现
先比较一下后面要用到的两种算法,只看性能方面,不讲具体原理(参考了MLlib文档)
Gradient-Boosted Trees vs. Random Forests:
GDBT和RF都是基于树的集成学习的算法,但训练过程不同,下面是几个它们的不同点:
- GDBT一次训练一棵树,所以训练时间比RF要长。RF可以并行训练多棵树。另外,训练GDBT比RF时使用更少的树是有意义的,并且树越少花费时间越短。
- RF更不易于过拟合。训练越多的树时RF会减少过拟合的可能性,但GDBT会增加过拟合。(换做统计语言就是,RF通过使用更多的树减少方差,而GBDT通过使用更多的树减少偏差。
- RF更容易调参,因为模型性能随树的个数单调递增(但是如果树的个数变得过大,性能可能会开始变差)。
总之,两个算法都很有效,如何选择要基于实际的数据集。
Random Forests
不需要对特征标准化
训练过程引入两个随机性:
- 每次迭代对原始训练集子采样,每次得到不同的训练集(a.k.a. bootstrapping)。
- 每棵树划分节点时随机选择的特征的子集作为候选划分特征
Usage tips
首先看最重要的两个参数,调节它们通常会提高性能:
- numTrees: 树的个数。增加树的个数会减少预测的方差,提高模型的准确率。训练时间呈线性增加。
- maxDepth: 每棵树的最大深度。增加树深度会使模型更美好和强大,但是会花费更多时间去训练,并且容易过拟合。通常,可以接受训练RF时比训练单颗决策树使用更大深度的树。一棵树比RF更易过拟合(因为平均多棵树会减少方差)。
还有两个参数通常不需要调节。但是,可以调节它们来提高训练的速度。 - subsamplingRate: 训练每棵树的训练集大小,是原始训练集的一部分。推荐使用默认值(1.0),减少这个比例可以提高训练的速度。
- featureSubsetStrategy: 每棵树划分节点时使用的候选特征的个数。减少这个数量可以提高训练的速度,但如果值太低有时会影响效果。
Gradient-Boosted Trees
同样不需要对特征标准化
Losses
每个损失函数的适用情况,注意:每个损失函数只适应于分类或回归中的一种。
Loss | Task | Description |
---|---|---|
Log Loss | Classification | Twice binomial negative log likelihood. |
Squared Error | Regression | Also called L2 loss. Default loss for regression tasks. |
Absolute Error | Regression | Also called L1 loss. Can be more robust to outliers than Squared Error. |
Usage tips:
- loss: 不同的损失函数会得到不同的结果,具体用哪个取决于数据集。
- numIterations: 设置集成中树的个数。每次迭代会产生一棵树。增加这个值使模型性能更优,提高训练集准确率。但如果值太大,测试集准确率降低。
- learningRate: 学习速率,即步长。不该调节该参数。如果算法表现不稳定,减小这个值可能会提高稳定性。
模型评估及选择
最后就到建模这一步了,要做交叉验证来看模型效果和模型调优,一般是将训练集分成两部分,一部分用于训练我们需要的模型,另外一部分数据上看我们预测算法的效果。
from pyspark.mllib.regression import LabeledPoint
train=data.map(lambda x:LabeledPoint(x[-1],x[1:46]))
(trainingData, testData) = train.randomSplit([0.7, 0.3])
然后训练模型,这里分别用了随机森林和GDBT,看一下在训练集上预测的准确率。
maxDepth均设为10,RF调节numTrees参数,GBDT调节numIterations参数
from pyspark.mllib.tree import RandomForest, RandomForestModel
from pyspark.mllib.tree import GradientBoostedTrees, GradientBoostedTreesModel
from pyspark.mllib.util import MLUtils
r1=[]
for i in [3,10,40,60,80]:
model = GradientBoostedTrees.trainClassifier(trainingData,categoricalFeaturesInfo={},numIterations=i,maxDepth=10)
predictions = model.predict(testData.map(lambda x: x.features))
labelsAndPredictions = testData.map(lambda lp: lp.label).zip(predictions)
accuracy = 1-labelsAndPredictions.filter(lambda (v, p): v != p).count() / float(l)
r1.append(accuracy)
r2=[]
for i in [50,100,150,200]:
model = RandomForest.trainClassifier(trainingData, numClasses=2, categoricalFeaturesInfo={}, numTrees=i, featureSubsetStrategy="auto",impurity='gini', maxDepth=10, maxBins=32)
predictions = model.predict(testData.map(lambda x: x.features))
labelsAndPredictions = testData.map(lambda lp: lp.label).zip(predictions)
accuracy = 1-labelsAndPredictions.filter(lambda (v, p): v != p).count() / float(l)
r2.append(accuracy)
随机森林调参:
numTrees | 50 | 100 | 150 | 200 |
---|---|---|---|---|
accuracy | 0.8443473768258889 | 0.84653139824338919 | 0.84383072659809311 | 0.84445305528157433 |
GBDT调参:
numIterations | 3 | 10 | 40 |
---|---|---|---|
accuracy | 0.84748250434455874 | 0.8509464092809168 | 0.85233197125546001 |
GDBT以时间换性能。
总结
- 活跃用户和静默用户按一定比例作为目标用户,所以分别训练模型
- 按不同类别的营销活动,分别筛选特征、建模
- 重新筛选特征、构造特征、交叉特征
- 不盲目调参,理解参数对预测的影响,深入理解算法