前沿科技探索家 · 2021年09月09日

MarTech用户购买预测赛Baseline(基于深度学习方法)官方解析

赛题介绍

智能营销工具可以帮助商家预测用户购买的行为,本次比赛提供了一份品牌商家的历史订单数据,参赛选手需构建一个预测模型,预估用户人群在规定时间内产生购买行为的概率。该模型可应用于各种电商数据分析,以及百度电商开放平台, 不仅可以帮助商家基于平台流量,进行商品售卖、支付,还可以通过MarTech技术更精准地锁定核心用户,对用户的购买行为进行预测。

本文为大家介绍参赛思路和PaddleRec构建基线模型,欢迎大家踊跃参赛!本基线项目地址:

https://aistudio.baidu.com/ai...

赛题页面:

https://aistudio.baidu.com/ai...

基线介绍
运行方式

本次基线基于飞桨PaddlePaddle1.8和PaddleRec V1.8.5版本,若本地运行则可能需要额外安装jupyter notebook环境、pandas模块等。AI Studio上运行建议使用32G内存的高级版,本地运行同样建议配置较大的内存空间。

PaddleRec GitHub开源项目地址:

https://github.com/PaddlePadd...

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/aist

# 这是一个时间滑窗函数,获得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值越大,表示客户价值越高,反之则表示客户价值越低。
也就是说,时间滑窗特征本身是与业务紧密联系的,而在这类时间序列数据的比赛中,滑动时间窗口内的统计指标可以更加丰富,统计值一般会有最大值、最小值、均值、标准差、中位数、极差等。

# 要计算统计指标特征的时间窗口
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

深度学习模型搭建:

这里搭建三层深度神经网络。需要注意的是,由于神经网络对缺失值和稀疏数据敏感,对送入神经网络的特征需要做筛选。另外,选择哪种神经网络结构效果更好,需要参赛选手进一步探索。

数据预处理 - 数据集划分与特征工程

# 处理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.每日付款金额

# 将用户下单金额按天进行汇总
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就可以了

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.每日购买商品数量

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)

该场景每天都有成交记录,这样就不需要考虑生成完整时间段填充的问题

# 这是一个时间滑窗函数,获得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,主要为了与其它特征做整合。

通过一个函数整合付款金额和商品数量的时间滑窗,主要是因为分开做到时候合并占用内存更大,并且函数最后在返回值处做了内存优化,用时间代价尽可能避免内存溢出。

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配置:

#读取从logid到label的10个稀疏特征特征
sparse_slots: "logid time userid gender age occupation movieid title genres label"
dense_slots: ""

配置好了之后,这些slot对应的variable在model中可以使用如下方式调用:self._sparse_data_varself._dense_data_var

若要详细了解这种输入方式,点击这里了解更多

  1. 使用自定义Reader

当您的数据集格式并不方便处理为slot:feasign这种模式,PaddleRec也支持您使用自定义的格式进行输入。不过您需要一个单独的python文件进行描述。

实现自定义的reader具体流程如下,首先我们需要引入Reader基类:

from paddlerec.core.reader import ReaderBase

创建一个子类,继承Reader的基类。

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的路径。

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

from paddlerec.core.model import ModelBase

class Model(ModelBase):

    # 构造函数无需显式指定
    # 若继承,务必调用基类的__init__方法
    def __init__(self, config):
        ModelBase.__init__(self, config)
        # ModelBase的__init__方法会调用_init_hyper_parameter()
  1. 超参的初始化:

继承并实现_init_hyper_parameter方法(必要),可以在该方法中,从yaml文件获取超参或进行自定义操作。所有的envs调用接口在_init_hyper_parameters()方法中实现,同时类成员也推荐在此做声明及初始化。如下面的示例:

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")
  1. 数据输入的定义:

ModelBase中的input_data默认实现为slot_reader,在config.yaml中分别配置dataset.sparse_slots及dataset.dense_slots选项实现slog:feasign模式的数据读取。配置好了之后,这些slot对应的variable在model中可以使用如下方式调用:self._sparse_data_varself._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。

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 指定运行的具体方式和参数
更加具体的参数请点击这里查看更加详细的教程

开始训练

执行如下命令启动训练。

# 模型训练
# 模型训练10个epoch
!cd PaddleRec/models/demo/competition && python -m paddlerec.run -m ./config_train.yaml

生成提交文件

在config_train.yaml中可以设定训练时参数文件的保存目录。

1.jpg

进入这个目录,可以看到其中保存了0到9共10个目录。这就是训练时保存下来的参数文件。我们可以选择其中一个目录用来初始化模型进行预测。

1.jpg

# 开始预测,并将结果重定向到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更加丰厚的周边礼包!突破历史最高分的选手更有小度在家等你来拿!

推荐阅读
关注数
12978
内容数
325
带你捕获最前沿的科技信息,了解最新鲜的科技资讯
目录
极术微信服务号
关注极术微信号
实时接收点赞提醒和评论通知
安谋科技学堂公众号
关注安谋科技学堂
实时获取安谋科技及 Arm 教学资源
安谋科技招聘公众号
关注安谋科技招聘
实时获取安谋科技中国职位信息