背景:
随着移动设备的完善和普及,移动互联网+各行各业进入了高速发展的阶段,这其中以O2O(Online to Offline)消费最为吸引眼球。目前需要根据O2O场景的数据通过分析建模,精准预测用户是否会在规定时间(15天)内使用相应优惠券。
从机器学习模型的角度来说,这是一个典型的分类问题,其过程就是根据已有训练集进行训练,得到的模型再对其测试集进行测试并分类。整体流程为:
训练数据-->特征提取-->建立模型-->测试数据-->特征提取-->预测-->结果
评估方式
评估一个机器学习模型有多种方式,最常见的如准确率(Accuracy)、精确率(Precision)、召回率(Recall)。一般使用精确率和召回率结合的方式F1 score能较好地评估模型性能(特别是在正负样本不平衡的情况下)。
这里使用AUC进行评估,AUC是ROC曲线与横坐标围成的面积,使用ROC和AUC的原因在于当测试集中的正负样本的分布变化的时候,ROC曲线能够保持不变,即能够更好地处理正负样本不均的场景。
我们主要用到的是线下测试集(ccf_offline_stage1_test_revised.csv)和线下训练集(ccf_offline_stage1_train.csv);其中,线下训练集主要记录用户线下消费和优惠券领取行为,字段有:
Field | Description |
---|---|
User_id | 用户id |
Merchant_id | 商户id |
Coupon_id | 优惠券id:null表示无优惠券消费,此时Discount_rate和Date_received字段无意义 |
Discount_rate | 优惠率:x\in [0,1]代表折扣率;x:y表示满x减y。单位是元 |
Distance | user经常活动的地点距离该merchant的最近门店距离是x*500米(如果是连锁店,则取最近的一家门店),x\in[0,10];null表示无此信息,0表示低于500米,10表示大于5公里; |
Date_received | 领取优惠券日期 |
Date | 消费日期:如果Date=null&Coupon_id!=null,该记录表示领取优惠券但没有使用,即负样本;如果Date!=null&Coupon_id=null,则表示普通消费日期;如果Date!=null&Coupin_id!=null,则表示优惠券消费日期,即正样本; |
线下测试集记录的是用户O2O线下优惠券使用预测样本
Field | Description |
---|---|
User_id | 用户ID |
Merchant_id | 商户ID |
Coupon_id | 优惠券ID |
Discount_rate | 优惠率:x\in [0,1]代表折扣率;x:y表示满x减y。单位是元 |
Distance | user经常活动的地点距离该merchant的最近门店距离是x*500米(如果是连锁店,则取最近的一家门店),x\in[0,10];null表示无此信息,0表示低于500米,10表示大于5公里; |
Date_received | 领取优惠券日期 |
最后我们需要根据线下测试集中的user_id,coupon_id以及date_received进行概率probability预测,即得到用户在15天内使用优惠券的概率值。
记住:Date_received是领取优惠券日期,Date是消费日期。
导入必要的库:
import os,sys,pickle
import numpy as np
import pandas as pd
from datetime import date
from sklearn.model_selection import KFold,train_test_split,StratifiedKFold,cross_val_score,GridSearchCV
from sklearn.pipeline import Pipeline
from sklearn.linear_model import SGDClassifier,LogisticRegression
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import log_loss,roc_auc_score,auc,roc_curve
from sklearn.preprocessing import MinMaxScaler
%matplotlib inline
%config InlineBackend.figure_format='retina'
读取数据:
dfoff=pd.read_csv('/ccf_offline_stage1_train.csv')
dftest=pd.read_csv('/ccf_offline_stage1_test_revised.csv')
print('有优惠券,购买商品:%d'%dfoff[(~dfoff['Date_received'].isnull())&(~dfoff['Date'].isnull())].shape[0])
print('有优惠券,未购买商品:%d'%dfoff[(~dfoff['Date_received'].isnull())&(dfoff['Date'].isnull())].shape[0])
print('无优惠券,购买商品:%d'%dfoff[(dfoff['Date_received'].isnull())&(~dfoff['Date'].isnull())].shape[0])
print('无优惠券,未购买商品:%d'%dfoff[(dfoff['Date_received'].isnull())&(dfoff['Date'].isnull())].shape[0])
有优惠券,购买商品:75382
有优惠券,未购买商品:977900
无优惠券,购买商品:701602
无优惠券,未购买商品:0
可以看出,很多人(701602)购买商品却没有使用优惠券,也有很多人(977900)有优惠券但却没有使用,真正使用优惠券购买商品的人(75382)很少,所以我们的目的就是把优惠券送给真正可能会购买商品的人。
1. 特征提取
构建机器学习模型,特征工程可能比选择哪种算法更加重要
1.1. Discount_rate(打折率)
常识来讲,优惠的越多,用户就越有可能使用优惠券。
print('Discount_rate 类型:\n',dfoff['Discount_rate'].unique())
根据打印的结果来看,打折率分为三种情况,nan表示没有打折;[0,1]表示折扣率;x:y表示满x减y
我们的处理方式可以构建四个函数,分别提取4种特征,分别是:
- 满多少: getDiscountMan()
- 减多少:getDiscountJian()
- 打折类型:getDiscountType()
- 折扣率: convertRate()
首先定义满多少和减多少的函数
def getDiscountMan(row):
if ':' in row:
rows=row.split(':')
return int(rows[0])
else:
return 0
def getDiscountJian(row):
if ':' in row:
rows=row.split(':')
return int(rows[1])
else:
return 0
def processData(df):
df['discount_man']=df['Discount_rate'].astype(str).apply(getDiscountMan)
df['discount_jian']=df['Discount_rate'].astype(str).apply(getDiscountJian)
return df
对训练集和测试集分别进行processData()函数处理:
dfoff=processData(dfoff)
dftest=processData(dftest)
其次,定义打折类型和折扣率
先解决字段中的nan,将其转化为null
dfoff['Discount_rate']=dfoff['Discount_rate'].fillna('null')
dftest['Discount_rate']=dftest['Discount_rate'].fillna('null')
#Convert Discount_rate and Distance
def getDiscountType(row):
if ':' in row:
return 1
else:
return 0
def convertRate(row):
"""Convert discount to rate"""
if ':' in row:
if row!='null':
rows=row.split(':')
return 1.0-float(rows[1])/float(rows[0])
else:
return float(row)
def processData1(df):
#df['Discount_rate']=df['Discount_rate'].fillna('null')
df['discount_type']=df['Discount_rate'].apply(getDiscountType)
df['discount_rate']=df['Discount_rate'].apply(convertRate)
return df
dftest=processData1(dftest)
dfoff=processData1(dfoff)
1.2. 距离
距离字段表示用户与商店的地理距离,距离的远近也会营销优惠券的使用与否。
#首先看下距离的取值有哪几种
print('Distance 类型:', dfoff['Distance'].unique())
#convert distance
dfoff['distance']=dfoff['Distance'].fillna(-1).astype(int)
dftest['distance']=dftest['Distance'].fillna(-1).astype(int)
1.3. 领券日期(Date_received)
一般而言,用户在周末领取优惠券去消费的可能性更大一些,因此可以构建关于领券日期的一些特征。
weekday:{null,1,2,3,4,5,6,7}
weekday_type:{1,0}(周六和周日为1,其它为0)
Weekday_1:{1,0,0,0,0,0,0}
Weekday_2:{0,1,0,0,0,0,0}
Weekday_3:{0,0,1,0,0,0,0}
Weekday_4:{0,0,0,1,0,0,0}
Weekday_5:{0,0,0,0,1,0,0}
Weekday_6:{0,0,0,0,0,1,0}
Weekday_7:{0,0,0,0,0,0,1}
用到了独热编码,让特征更加丰富,相应的特征提取函数为:
def getWeekday(row):
if row!='null':
return date(int(row[0:4]),int(row[4:6]),int(row[6:8])).weekday()+1
#WEEKDAYI函数的功能是返回某日期的星期数。在默认情况下,它的值为1(星期天)-7(星期六)之间的一个整数
"""
WEEKDAY(参数1,参数2)
含义:“参数1”代表要查找的那一天的日期;“参数2”为确定返回值类型的数字,当其值为2时返回的数字1代表星期一,7代表星期日。
"""
def process_Data2(df):
df['Date_received']=df['Date_received'].fillna('null')
df['weekday']=df['Date_received'].astype(str).apply(getWeekday)
return df
dfoff=process_Data2(dfoff)
dftest=process_Data2(dftest)
weekday_type:周六和周日为1,其它为0
dfoff['weekday_type']=dfoff['weekday'].apply(lambda x:1 if x in [6,7] else 0)
dftest['weekday_type']=dftest['weekday'].apply(lambda x:1 if x in [6,7] else 0)
# change weekday to one-hot encoding
weekdaycols=['weekday_'+str(i) for i in range(1,8)]
tmpdf=pd.get_dummies(dfoff['weekday'])
#默认情况下,get_dummies()不处理缺失值
tmpdf.columns=weekdaycols
dfoff[weekdaycols]=tmpdf
#对测试集进行同样的操作
tmpdf=pd.get_dummies(dftest['weekday'])
tmpdf.columns=weekdaycols
dftest[weekdaycols]=tmpdf
这样我们就会在训练集和测试集上发现增加了9个关于领券日期的特征
至此,我们共新增14个特征
2. 标注标签Label
需要对训练样本进行label标注,即确定哪些是正样本(y=1),哪些是负样本(y=0)。因为我们需要的预测的是用户在领取优惠券之后15日之内的消费情况,所以总共有三种情况
Date_received 是领取优惠券日期,Date 是消费日期。
Date_received=='null' 表示没有领到优惠券,无需考虑,y=-1
(Date_received!='null')&(Date!='null')&(Date-Date_received<=15) 表示领取优惠券且在15天内使用,即正样本,y=1
(Date_received!='null')&((Date=='null')|(Date-Date_received>15)) 表示领取优惠券未在15天内使用,即负样本,y=0
明确规则后,定义标签备注函数
如果用户领券时间为null,则标注为-1;
如果用户消费时间不为null,则判断用户消费时间减去领券时间的差值是否在15天之内,如果是,则标注为1,否则为0
def label(row):
if row['Date_received']=='null':
return -1
if row['Date']!='null':
td=pd.to_datetime(row['Date'],format='%Y%m%d')-pd.to_datetime(row['Date_received'],format='%Y%m%d')
if td<=pd.Timedelta(15,'D'):
return 1
return 0
def processdf1(df):
df['Date_received']=df['Date_received'].fillna('null')
df['Date']=df['Date'].fillna('null')
df['label']=df.apply(label,axis=1)
return df
有时候会遇到定义函数使用失灵的情况,就单独拆解来处理;
分别对训练集和测试集进行处理;
dfoff['Date_received']=dfoff['Date_received'].fillna('null')
dfoff['Date']=dfoff['Date'].fillna('null')
dfoff['label']=dfoff.apply(label,axis=1)
dftest['Date_received']=dftest['Date_received'].fillna('null')
dftest['Date']=dftest['Date'].fillna('null')
dftest['label']=dftest.apply(label,axis=1)
print(dfoff['label'].value_counts())
-1.0 701602
1.0 64395
0.0 10987
可以看到正样本共有64395例,负样本共有10987例,显然,正负样本差别很大,这也是为什么会使用AUC作为模型性能评估标准的原因。
3. 建立模型
接下来是建立机器学习模型,首先确定的是我们选择的是上面提取的14个特征,为了验证模型的性能,需要划分验证集进行模型验证,划分方式是按照领券日期,即训练集:20160101-20160515,验证集:20160516-02160615
采用的模型是SGDClassifier
3.1. 划分训练集和验证集
剔除没有领优惠券的样本后,划分训练集和测试集。
#data split
df=dfoff[dfoff['label']!=-1].copy()
#除去含有null值的字段
df=df[~np.isnan(dfoff['label'])]
df=df[~np.isnan(dfoff['Distance'])]
df=df[~np.isnan(dfoff['discount_rate'])]
train=df[(pd.to_datetime(df['Date_received'],format='%Y%m%d')<'20160516')].copy()
valid=df[(pd.to_datetime(df['Date_received'],format='%Y%m%d')>='20160516')&(pd.to_datetime(df['Date_received'],format='%Y%m%d')<='20160615')].copy()
print('Train Set:\n',train['label'].value_counts())
print('Valid Set:\n',valid['label'].value_counts())
Train Set:
1.0 41524
0.0 7852
Name: label, dtype: int64
Valid Set:
1.0 22871
0.0 3135
Name: label, dtype: int64
3.2. 构建模型
def check_model(data, predictors):
classifier = lambda: SGDClassifier(
loss='log', # loss function: logistic regression
penalty='elasticnet', # L1 & L2
fit_intercept=True, # 是否存在截距,默认存在
max_iter=100,
shuffle=True, # Whether or not the training data should be shuffled after each epoch
n_jobs=1, # The number of processors to use
class_weight=None) # Weights associated with classes. If not given, all classes are supposed to have weight one.
# 管道机制使得参数集在新数据集(比如测试集)上的重复使用,管道机制实现了对全部步骤的流式化封装和管理。
model = Pipeline(steps=[
('ss', StandardScaler()), # transformer
('en', classifier()) # estimator
])
parameters = {
'en__alpha': [ 0.001, 0.01, 0.1],
'en__l1_ratio': [ 0.001, 0.01, 0.1]
}
# StratifiedKFold用法类似Kfold,但是他是分层采样,确保训练集,测试集中各类别样本的比例与原始数据集中相同。
folder = StratifiedKFold(n_splits=3, shuffle=True)
# Exhaustive search over specified parameter values for an estimator.
grid_search = GridSearchCV(
model,
parameters,
cv=folder,
n_jobs=-1, # -1 means using all processors
verbose=1)
grid_search = grid_search.fit(data[predictors],
data['label'])
return grid_search
3.3. 训练
original_feature=['discount_rate','discount_type','discount_man','discount_jian','distance','weekday_type','weekday']+weekdaycols
predictors=original_feature
model=check_model(train,predictors)
4. 验证
对验证集中每个优惠券预测的结果计算AUC,再对所有的优惠券的AUC求平均,计算AUC的时候,如果label只有一类,就直接跳过,因为AUC无法计算。
#valid predict
y_valid_pred=model.predict_proba(valid[predictors])
valid1=valid.copy()
valid['pred_prob']=y_valid_pred[:,1]
valid1.head()
注意:这里得到的结果pred_prob是概率值(预测概率属于正类的概率)
最后对验证集计算AUC。直接调用sklearn库自带的AUC函数即可。
#avgAUC calculation
vg=valid.groupby(['Coupon_id'])
aucs=[]
for i in vg:
tmpdf=i[1]
if len(tmpdf['label'].unique())!=2:
continue
fpr,tpr,thresholds=roc_curve(tmpdf['label'],tmpdf['pred_prob'],pos_label=1)
aucs.append(auc(fpr,tpr))
print(np.average(aucs))
可以看到模型的精度一般,可以用多种手段优化模型
- 特征工程
- 机器学习
- 模型融合
5. 测试
使用训练好的模型对测试集进行测试,得到测试的结果---概率值;;由于我们不知道真正的Label,所以无法计算AUC
#同样地,需要去掉null值字段记录
dftest2=dftest[~np.isnan(dftest['discount_rate'])]
#test prediction for submission
y_test_pred=model.predict_proba(dftest2[predictors])
dftest1=dftest2[['User_id','Coupon_id','Date_received']].copy()
dftest1['Porbability']=y_test_pred[:,1]
dftest1.head()