赛题介绍
智能营销工具可以帮助商家预测用户购买的行为,本次比赛提供了一份品牌商家的历史订单数据,参赛选手需构建一个预测模型,预估用户人群在规定时间内产生购买行为的概率。该模型可应用于各种电商数据分析,以及百度电商开放平台, 不仅可以帮助商家基于平台流量,进行商品售卖、支付,还可以通过MarTech技术更精准地锁定核心用户,对用户的购买行为进行预测。
本文为大家介绍参赛思路和PaddleRec构建基线模型,欢迎大家踊跃参赛!本基线项目地址:
https://aistudio.baidu.com/aistudio/projectdetail/1189943
赛题页面:
https://aistudio.baidu.com/aistudio/competition/detail/51
基线介绍
运行方式
本次基线基于飞桨PaddlePaddle1.8和PaddleRec V1.8.5版本,若本地运行则可能需要额外安装jupyter notebook环境、pandas模块等。AI Studio上运行建议使用32G内存的高级版,本地运行同样建议配置较大的内存空间。
PaddleRec GitHub开源项目地址:
https://github.com/PaddlePaddle/PaddleRec
AI Studio (Notebook)的运行
关于AI Studio (Notebook)的运行,依次运行下方的cell即可,若运行时修改了cell,推荐在右上角重启执行器后再以此运行,避免因内存未清空而产生报错。
本地运行
Fork本项目后点击右上角的“文件”——“导出Notebook为ipynb”,下载到本地后在jupyter notebook环境即可开始训练,生成的推理结果文件为submission.csv。
设计思想
执行流程:
- 配置预处理数据方案
- 开始训练
- 执行预测并产生结果文件
技术方案:
在本次赛题中,虽然赛题是一个二分类任务(用户购买、未购买),但从赛题数据看,属于比较典型的时间序列数据,也可以参照以往的线性回归任务的做法处理。接下来将介绍技术方案中的一些细节问题以及method流程。
label设计:
本次赛题反映了一个客观事实——在真实场景应用机器学习/深度学习技术时,通常是没有已经整理好的训练集、验证集、测试集,需要自己设计。
比如赛题中提到,在比赛任务是预测下个月用户是否购买,下个月是哪个月?我们不妨设想自己是个业务经理,现在领导说做个模型,预测下个月你手上的客户是否会流失。所以在这类题目中,下个月就是提供的数据集截止日期之后的一个月。当然,如果比赛要求预测未来7天、未来15天的销售情况,道理也是一样的。
在此类比赛的解决方案中,通常会有个时间滑窗的概念。比如按月进行时间滑窗,本题中数据到2013.8.31,默认提供的数据集划分设计如下(选手也可以自行设计数据集的划分):
- 训练集:选择某一天为截止时间,用截止时间前的3个月预测用户截止时间后的一个月是否购买;(保证截止时间后还存在一个月的数据)
- 验证集:选择某一天为截止时间,用截止时间前的3个月预测用户截止时间后的一个月是否购买;(保证截止时间后还存在一个月的数据)
- 测试集:用2013年6-8月的数据预测用户在9月是否购买(其实就是预测的目标)。
时间滑窗特征构建:
注:更详细的时间滑窗特征工程的方法请参考《用户购买预测时间滑窗特征构建》,本文做了大幅缩减:
https://aistudio.baidu.com/aistudio/projectdetail/276829
代码语言:javascript复制# 这是一个时间滑窗函数,获得dt之前minus天以来periods的dataframe,以便进一步计算
def get_timespan(df, dt, minus, periods, freq='D'):
return df[pd.date_range(dt - timedelta(days=minus), periods=periods, freq=freq)]
时间滑窗在业务应用上被称为RFM模型,RFM模型最早是用来衡量客户价值和客户创利能力。理解RFM框架的思想是构造统计类特征的基础,其含义为:
- R(Recency):客户最近一次交易消费时间的间隔。R值越大,表示客户交易发生的日期越久,反之则表示客户交易发生的日期越近。
- F(Frequency):客户在最近一段时间内交易消费的次数。F值越大,表示客户交易越频繁,反之则表示客户交易不够活跃。
- M(Monetary):客户在最近一段时间内交易消费的金额。M值越大,表示客户价值越高,反之则表示客户价值越低。
也就是说,时间滑窗特征本身是与业务紧密联系的,而在这类时间序列数据的比赛中,滑动时间窗口内的统计指标可以更加丰富,统计值一般会有最大值、最小值、均值、标准差、中位数、极差等。
代码语言:javascript复制# 要计算统计指标特征的时间窗口
for i in [14,30,60,91]:
tmp = get_timespan(df_payment, t2018, i, i)
# 削去峰值的均值特征
X['mean_%s_decay' % i] = (tmp * np.power(0.9, np.arange(i)[::-1])).sum(axis=1).values
# 中位数特征,在本赛题中基本不适用
# X['median_%s' % i] = tmp.median(axis=1).values
# 最小值特征,在本赛题中基本不适用
# X['min_%s' % i] = tmp_1.min(axis=1).values
# 最大值特征
X['max_%s' % i] = tmp.max(axis=1).values
# 标准差特征
# X['std_%s' % i] = tmp_1.std(axis=1).values
# 求和特征
X['sum_%s' % i] = tmp.sum(axis=1).values
深度学习模型搭建:
这里搭建三层深度神经网络。需要注意的是,由于神经网络对缺失值和稀疏数据敏感,对送入神经网络的特征需要做筛选。另外,选择哪种神经网络结构效果更好,需要参赛选手进一步探索。
数据预处理 - 数据集划分与特征工程
代码语言:javascript复制# 处理id字段
train['order_detail_id'] = train['order_detail_id'].astype(np.uint32)
train['order_id'] = train['order_id'].astype(np.uint32)
train['customer_id'] = train['customer_id'].astype(np.uint32)
train['goods_id'] = train['goods_id'].astype(np.uint32)
train['goods_class_id'] = train['goods_class_id'].astype(np.uint32)
train['member_id'] = train['member_id'].astype(np.uint32)
# 处理状态字段,这里同时处理空值,将空值置为0
train['order_status'] = train['order_status'].astype(np.uint8)
train['goods_has_discount'] = train['goods_has_discount'].astype(np.uint8)
train["is_member_actived"].fillna(0, inplace=True)
train["is_member_actived"]=train["is_member_actived"].astype(np.int8)
train["member_status"].fillna(0, inplace=True)
train["member_status"]=train["member_status"].astype(np.int8)
train["customer_gender"].fillna(0, inplace=True)
train["customer_gender"]=train["customer_gender"].astype(np.int8)
train['is_customer_rate'] = train['is_customer_rate'].astype(np.uint8)
train['order_detail_status'] = train['order_detail_status'].astype(np.uint8)
# 处理日期
train['goods_list_time']=pd.to_datetime(train['goods_list_time'],format="%Y-%m-%d")
train['order_pay_time']=pd.to_datetime(train['order_pay_time'],format="%Y-%m-%d")
train['goods_delist_time']=pd.to_datetime(train['goods_delist_time'],format="%Y-%m-%d")
构造时间滑窗特征:
1.每日付款金额
代码语言:javascript复制# 将用户下单金额按天进行汇总
df = train[train.order_pay_time>'2013-02-01']
df['date'] = pd.DatetimeIndex(df['order_pay_time']).date
df_payment = df[['customer_id','date','order_total_payment']]
len(df_payment['customer_id'].unique())
685471
注意,成功交易的客户数量不等于全部客户数量,说明有相当一部分客户虽然下过单,但是没有成功的订单,那么这些客户自然应当算在训练集之外。数据合并时,由于test.csv中,已经设置了默认0值,只需要和训练后的预测标签做一个left join就可以了
代码语言:javascript复制df_payment = df_payment.groupby(['date','customer_id']).agg({'order_total_payment': ['sum']})
df_payment.columns = ['day_total_payment']
df_payment.reset_index(inplace=True)
df_payment = df_payment.set_index(
["customer_id", "date"])[["day_total_payment"]].unstack(level=-1).fillna(0)
df_payment.columns = df_payment.columns.get_level_values(1)
2.每日购买商品数量
代码语言:javascript复制df_goods = df[['customer_id','date','order_total_num']]
df_goods = df_goods.groupby(['date','customer_id']).agg({'order_total_num': ['sum']})
df_goods.columns = ['day_total_num']
df_goods.reset_index(inplace=True)
df_goods = df_goods.set_index(
["customer_id", "date"])[["day_total_num"]].unstack(level=-1).fillna(0)
df_goods.columns = df_goods.columns.get_level_values(1)
该场景每天都有成交记录,这样就不需要考虑生成完整时间段填充的问题
代码语言:javascript复制# 这是一个时间滑窗函数,获得dt之前minus天以来periods的dataframe,以便进一步计算
def get_timespan(df, dt, minus, periods, freq='D'):
return df[pd.date_range(dt - timedelta(days=minus), periods=periods, freq=freq)]
构造dataset这里有个取巧的地方,因为要预测的9月份除了开学季以外不是非常特殊的月份,因此主要考虑近期的因素,数据集的开始时间也是2月1日,尽量避免了双十一、元旦假期的影响,当然春节假期继续保留。同时,构造数据集的时候保留了customer_id,主要为了与其它特征做整合。
通过一个函数整合付款金额和商品数量的时间滑窗,主要是因为分开做到时候合并占用内存更大,并且函数最后在返回值处做了内存优化,用时间代价尽可能避免内存溢出。
代码语言:javascript复制def prepare_dataset(df_payment, df_goods, t2018, is_train=True):
X = {}
# 整合用户id
tmp = df_payment.reset_index()
X['customer_id'] = tmp['customer_id']
# 消费特征
print('Preparing payment feature...')
for i in [14,30,60,91]:
tmp = get_timespan(df_payment, t2018, i, i)
X['mean_%s_decay' % i] = (tmp * np.power(0.9, np.arange(i)[::-1])).sum(axis=1).values
X['max_%s' % i] = tmp.max(axis=1).values
X['sum_%s' % i] = tmp.sum(axis=1).values
for i in [14,30,60,91]:
tmp = get_timespan(df_payment, t2018 timedelta(days=-7), i, i)
X['mean_%s_decay_2' % i] = (tmp * np.power(0.9, np.arange(i)[::-1])).sum(axis=1).values
X['max_%s_2' % i] = tmp.max(axis=1).values
for i in [14,30,60,91]:
tmp = get_timespan(df_payment, t2018, i, i)
X['has_sales_days_in_last_%s' % i] = (tmp != 0).sum(axis=1).values
X['last_has_sales_day_in_last_%s' % i] = i - ((tmp != 0) * np.arange(i)).max(axis=1).values
X['first_has_sales_day_in_last_%s' % i] = ((tmp != 0) * np.arange(i, 0, -1)).max(axis=1).values
# 对此处进行微调,主要考虑近期因素
for i in range(1, 4):
X['day_%s_2018' % i] = get_timespan(df_payment, t2018, i*30, 30).sum(axis=1).values
# 商品数量特征,这里故意把时间和消费特征错开,提高时间滑窗的覆盖面
print('Preparing num feature...')
for i in [21,49,84]:
tmp = get_timespan(df_goods, t2018, i, i)
X['goods_mean_%s' % i] = tmp.mean(axis=1).values
X['goods_max_%s' % i] = tmp.max(axis=1).values
X['goods_sum_%s' % i] = tmp.sum(axis=1).values
for i in [21,49,84]:
tmp = get_timespan(df_goods, t2018 timedelta(weeks=-1), i, i)
X['goods_mean_%s_2' % i] = tmp.mean(axis=1).values
X['goods_max_%s_2' % i] = tmp.max(axis=1).values
X['goods_sum_%s_2' % i] = tmp.sum(axis=1).values
for i in [21,49,84]:
tmp = get_timespan(df_goods, t2018, i, i)
X['goods_has_sales_days_in_last_%s' % i] = (tmp > 0).sum(axis=1).values
X['goods_last_has_sales_day_in_last_%s' % i] = i - ((tmp > 0) * np.arange(i)).max(axis=1).values
X['goods_first_has_sales_day_in_last_%s' % i] = ((tmp > 0) * np.arange(i, 0, -1)).max(axis=1).values
# 对此处进行微调,主要考虑近期因素
for i in range(1, 4):
X['goods_day_%s_2018' % i] = get_timespan(df_goods, t2018, i*28, 28).sum(axis=1).values
X = pd.DataFrame(X)
reduce_mem_usage(X)
if is_train:
# 这样转换之后,打标签直接用numpy切片就可以了
# 当然这里前提是确认付款总额没有负数的问题
X['label'] = df_goods[pd.date_range(t2018, periods=30)].max(axis=1).values
X['label'][X['label'] > 0] = 1
return X
return X
num_days = 4
t2017 = date(2013, 7, 1)
X_l, y_l = [], []
for i in range(num_days):
delta = timedelta(days=7 * i)
X_tmp = prepare_dataset(df_payment, df_goods, t2017 delta)
X_tmp = pd.concat([X_tmp], axis=1)
X_l.append(X_tmp)
X_train = pd.concat(X_l, axis=0)
del X_l, y_l
X_test = prepare_dataset(df_payment, df_goods, date(2013, 9, 1), is_train=False)
X_test = pd.concat([X_test], axis=1)
使用PaddleRec构建模型
基本文件结构
在刚接触PaddleRec的时候,您需要了解其中每个模型最基础的组成部分。
1.data
PaddleRec的模型下面通常都会配有相应的样例数据,供使用者一键启动快速体验。而数据通常放在模型相应目录下的data目录中。有些模型中还会在data目录下更详细的分为训练数据目录和测试数据目录。同时一些下载数据和数据预处理的脚本通常也会放在这个目录下。
2.model.py
model.py为模型文件,在其中定义模型的组网。如果您希望对模型进行改动或者添加自定义的模型,可以打开我们的教程查看更详细的介绍。
3.config.yaml
config.yaml中存放着模型的各种配置。其中又大体分为几个模块:
- workspace 指定model/reader/data所在位置
- dataset 指定数据输入的具体方式
- hyper_parameters 模型中需要用到的超参数
- mode 指定当次运行使用哪些runner
- runner&phase 指定运行的具体方式和参数
构建Reader
在PaddleRec中,我们有两种数据输入的方式。您可以选择直接使用PaddleRec内置的Reader,或者为您的模型自定义Reader。
1. 使用PaddleRec内置的Reader
当您的数据集格式为slot:feasign这种模式,或者可以预处理为这种格式时,可以直接使用PaddleRec内置的Reader
Slot : Feasign 是什么?
Slot直译是槽位,在推荐工程中,是指某一个宽泛的特征类别,比如用户ID、性别、年龄就是Slot,Feasign则是具体值,比如:12345,男,20岁。
在实践过程中,很多特征槽位不是单一属性,或无法量化并且离散稀疏的,比如某用户兴趣爱好有三个:游戏/足球/数码,且每个具体兴趣又有多个特征维度,则在兴趣爱好这个Slot兴趣槽位中,就会有多个Feasign值。
PaddleRec在读取数据时,每个Slot ID对应的特征,支持稀疏,且支持变长,可以非常灵活的支持各种场景的推荐模型训练。
将数据集处理为slot:feasign这种模式的数据后,在相应的配置文件config.yaml中填写以空格分开的sparse_slots表示稀疏特征的列表,以空格分开dense_slots表示稠密特征的列表,模型即可从数据集中按slot列表读取相应的特征。
例如本教程中的yaml配置:
代码语言:javascript复制#读取从logid到label的10个稀疏特征特征
sparse_slots: "logid time userid gender age occupation movieid title genres label"
dense_slots: ""
配置好了之后,这些slot对应的variable在model中可以使用如下方式调用:
self._sparse_data_var
self._dense_data_var
若要详细了解这种输入方式,点击这里了解更多
2. 使用自定义Reader
当您的数据集格式并不方便处理为slot:feasign这种模式,PaddleRec也支持您使用自定义的格式进行输入。不过您需要一个单独的python文件进行描述。
实现自定义的reader具体流程如下,首先我们需要引入Reader基类:
代码语言:javascript复制from paddlerec.core.reader import ReaderBase
创建一个子类,继承Reader的基类。
代码语言:javascript复制class Reader(ReaderBase):
def init(self):
pass
def generator_sample(self, line):
pass
在init(self)函数中声明一些在数据读取中会用到的变量,必要时可以在config.yaml文件中配置变量,利用env.get_global_env()拿到配置的变量。
继承并实现基类中的generate_sample(self, line)函数,逐行读取数据。
该函数应返回一个可以迭代的reader方法(带有yield的函数不再是一个普通的函数,而是一个生成器generator,成为了可以迭代的对象,等价于一个数组、链表、文件、字符串etc.)。在这个可以迭代的函数中,我们定义数据读取的逻辑。以行为单位将数据进行截取,转换及预处理。
最后,我们需要将数据整理为特定的格式,才能够被PaddleRec的Reader正确读取,并灌入的训练的网络中。简单来说,数据的输出顺序与我们在网络中创建的inputs必须是严格一一对应的,并转换为类似字典的形式。
至此,我们完成了Reader的实现。最后,在配置文件config.yaml中,加入自定义Reader的路径。
代码语言:javascript复制dataset:
- name: train_dataset
batch_size: 4096
type: DataLoader # or QueueDataset
data_path: "{workspace}/data/train"
data_converter: "{workspace}/reader.py"
如介绍所说,我们需要添加模型文件model.py,在其中定义模型的组网。
构建模型
如介绍所说,我们需要添加模型文件model.py,在其中定义模型的组网。
1. 基类的继承:
继承paddlerec.core.model的ModelBase,命名为Class Model
代码语言:javascript复制from paddlerec.core.model import ModelBase
class Model(ModelBase):
# 构造函数无需显式指定
# 若继承,务必调用基类的__init__方法
def __init__(self, config):
ModelBase.__init__(self, config)
# ModelBase的__init__方法会调用_init_hyper_parameter()
2. 超参的初始化:
继承并实现_init_hyper_parameter方法(必要),可以在该方法中,从yaml文件获取超参或进行自定义操作。所有的envs调用接口在_init_hyper_parameters()方法中实现,同时类成员也推荐在此做声明及初始化。如下面的示例:
代码语言:javascript复制def _init_hyper_parameters(self):
self.fc1_size = envs.get_global_env("hyper_parameters.fc1_size")
self.fc2_size = envs.get_global_env("hyper_parameters.fc2_size")
3. 数据输入的定义:
ModelBase中的input_data默认实现为slot_reader,在config.yaml中分别配置dataset.sparse_slots及dataset.dense_slots选项实现slog:feasign模式的数据读取。配置好了之后,这些slot对应的variable在model中可以使用如下方式调用:
self._sparse_data_var
self._dense_data_var
如果您不想使用slot:feasign模式,则需继承并实现input_data接口,在模型组网中加入输入占位符。接口定义:def input_data(self, is_infer=False, **kwargs)
Reader读取文件后,产出的数据喂入网络,需要有占位符进行接收。占位符在Paddle中使用fluid.data或fluid.layers.data进行定义。data的定义可以参考fluid.data以及fluid.layers.data。
代码语言:javascript复制def input_data(self, is_infer=False, **kwargs):
data = fluid.data(name="data", shape=[None,41], dtype='float32')
label = fluid.data(name="label", shape=[None,1], dtype='int64')
return [data, label]
构建配置文件config.yaml
config.yaml中存放着模型的各种配置。其中又大体分为几个模块:
- workspace 指定model/reader/data所在位置
- dataset 指定数据输入的具体方式
- hyper_parameters 模型中需要用到的超参数
- mode 指定当次运行使用哪些runner
- runner&phase 指定运行的具体方式和参数
更加具体的参数请点击这里查看更加详细的教程
开始训练
执行如下命令启动训练。
代码语言:javascript复制# 模型训练
# 模型训练10个epoch
!cd PaddleRec/models/demo/competition && python -m paddlerec.run -m ./config_train.yaml
生成提交文件
在config_train.yaml中可以设定训练时参数文件的保存目录。
进入这个目录,可以看到其中保存了0到9共10个目录。这就是训练时保存下来的参数文件。我们可以选择其中一个目录用来初始化模型进行预测。
代码语言:javascript复制# 开始预测,并将结果重定向到infer_log.txt文件中
!cd PaddleRec/models/demo/competition && python -m paddlerec.run -m ./config_infer.yaml 2>log.txt
写在最后
本次比赛可调优空间非常大,可尝试且不限于从以下方面来进行调优。
数据处理
- 归一化方案 - 直接拉伸是最佳方式吗?
- 离散值与连续值 - 哪种方式更适合处理这些方式?是否有较为通用的方法可以尝试?是否可以使用Embedding?
- 特征工程 - 除了时间滑窗是否可以有其它特征?有没有不使用特征工程的解决方案?
- 特征选择 - 输入特征真的是越多越好吗?如何选择特征以克服神经网络训练的不稳定性?
- 数据集划分比例 - 训练集、验证集、测试集应该怎样划分效果更好?
首层网络选择
- Embedding还是Linear、Conv?- 如果使用卷积应该怎样处理shape?
- 多字段合并输入还是分开输入?- 分开输入效果一定好吗?哪些字段更适合合并输入?
网络(Backbone)部分搭建
- 隐层大小选择 - 宽度和层数
- 尝试复杂网络构建 - 是否可以尝试简单CNN、RNN?如何使用飞桨复现经典解决方案?是否可以尝试使用图神经网络?如何使用PGL构建本赛题的异构图?
- 选择更合适的激活函数
- 尝试正则化、dropout等方式避免过拟合
- 尝试非Xavier初始化方案
模型(Model)搭建以及训练相关
- 选择学习率等超参数
- 选择合适的损失函数 - 如何处理数据不平衡问题?
- 尝试不同的优化器
- 尝试使用学习率调度器
- 避免脏数据干扰(用深度学习的方式更优更方便)
模型融合
- 深度学习模型自身是否需要进行模型融合?模型融合是否能克服神经网络训练的不稳定性?
- 是否能使用不同深度学习模型进行融合?
提交相关
- 测试集表现最好的模型一定是最优秀的吗?
- 用准确率来衡量二分类模型的能力是最好选择吗?
飞桨常规赛致力于基于真实场景提供轻量级赛题,以赛促学,和开发者共成长。欢迎更多炼丹师艺术创想,使用创新方法解题。超过评奖分数并排名前十的选手可以获得飞桨精美周边一份及100小时GPU算力卡,使用创新方法解的魔改大师可以get更加丰厚的周边礼包!突破历史最高分的选手更有小度在家等你来拿!
快来看看历史大佬们的奖品