Press "Enter" to skip to content

CTR预估赛冠军项目开源,国产框架也这幺牛!

本文使用飞桨PaddlePaddle框架复现一点资讯技术编程大赛CTR预估赛题第一名方案,项目在DeepFM模型中加入“个性化学习组件”解决数据不充分的问题,点击 阅读原文 即可前往AI Studio平台体验学习, 一键运行 。

 

项目背景

 

点击率(Click-Through Rate,简称CTR)预估是推荐算法的重要模块,通常用于在用户请求推荐系统时对内容进行排序,其结果直接影响产品的核心指标和用户的消费体验。在真实环境里,用户对某条内容是否产生点击行为的原因非常复杂,既包含内容本身的信息呈现和优质程度,又包含用户的基础属性(性别、年龄等)和个体偏好,甚至与当前所处的网络环境、地理位置也息息相关。如何抽取出这些复杂的诱因并对其进行建模学习,精准预估出海量用户对不同内容的点击概率,一直是推荐算法的一个重要研究方向。

 

比赛地址:

 

https://tech.yidianzixun.com/competition/

 

项目地址:

 

https://aistudio.baidu.com/aistudio/projectdetail/2390780

 

思路想法

 

该比赛是典型的点击率预估问题。本次项目构造user测和item测特征,计算一阶、二阶下的历史ctr,duration统计值以及全局热度特征。以DeepFM为基础模型结构,引入“个性化学习组件”对个性化特征的后验分布进行拟合。

 

数据说明

 

抽样用户过去一段时间内在某APP上的真实曝光和点击记录,以及所涉及用户和文章的基础属性,根据这些数据进行分析和建模。同时,依靠提供部分用户之后一段时间的曝光文章列表,最终预测每个用户在之后曝光文章上的点击概率预估值(0-1之间的浮点数)。系统根据点击概率预估值和用户真实点击情况的差异,来评估预估任务的准确程度。数据已经隐去能代表用户身份的所有信息,对部分必要的敏感信息也进行了加密处理。

 

步骤概述

 

数据预处理

 

这一步主要做数据清洗和数据格式化。age和gender给出的数据形式为多值概率,所以选取概率最大的作为用户的基本属性。picnum和pubtime存在一些不符合预期的特征值,需要先做特征清洗。受限于资源限制,所以训练集只保留了两天的数据,而且按照0.01的比例进行了下采样。

 

raw_data_path = '/home/aistudio/data/data109207'
data_path = '/home/aistudio/data/data'


os.makedirs(f'{data_path}', exist_ok=True)


df_train = pd.read_csv(f'{raw_data_path}/train.csv', low_memory=False)
df_train = df_train.sample(frac=0.01)
df_train['dt'] = pd.to_datetime(df_train['timestamp'], utc=True,
                                unit='ms').dt.tz_convert('Asia/Shanghai')
df_train['date'] = df_train['dt'].dt.date
df_train['date'] = df_train['date'].astype('str')
print('train data')
print(df_train.head())
df_train.to_pickle(f'{data_path}/train.pkl')


df_test = pd.read_csv(f'{raw_data_path}/test_data.txt', sep='\t', header=None)
df_test.columns = ['id', 'userid', 'docid', 'timestamp', 'network', 'refresh']
df_test['dt'] = pd.to_datetime(df_test['timestamp'], utc=True,
                               unit='ms').dt.tz_convert('Asia/Shanghai')
                            
…………

 

特征工程

 

特征工程只有两组,基于用户历史记录统计和基于全局统计。用户历史统计主要针对预测目标click和强相关的duration(消费时长)展开。这两个特征和预测目标强相关,所以基于历史信息统计,避免标签泄漏。全局信息下可以计算各组count,反应热度。

 

 

这个项目有个明显的特点就是测试集和训练集分布不一致,训练集中每天的数据以百万记,但测试集当天的数据只有5w,明显测试集经过了采样,所以很多抽取的特征(譬如很有用的时间特征)都无法取得线上收益。根据测试集的规模对训练集进行了分组,在组内进行特征抽取,从而做到抽取的特征分布一致,效果就会有很大的转变。

 

!python work/feat_basic.py
!python work/feat_basic_history_all.py
!python work/feat_global_statis.py
!python work/feature.py

 

数据集定义

 

参考官方示例进行数据集定义与加载。在加载数据集的时候对keywords这个序列特征进行了补全。

 

class BuildDataSet(Dataset):
    def __init__(self, df, dense_features, is_test=False):
        # 离散 id
        self.userid_list = df['userid'].values
        self.docid_list = df['docid'].values
        self.network_list = df['network'].values
        self.device_list = df['device'].values
        self.os_list = df['os'].values
        self.province_list = df['province'].values
        self.city_list = df['city'].values
        self.age_list = df['age'].values
        self.gender_list = df['gender'].values
        self.category1st_list = df['category1st'].values
        self.category2nd_list = df['category2nd'].values
        ………………


    def _pad_seq(self, seq, max_len, truncation='pre', dtype='int'):
        if type(seq) == float:
            seq = []


        if truncation == 'post':
            seq = seq[-max_len:]
        else:
            seq = seq[:max_len]
       …………
       
    def __getitem__(self, index):
        # 离散 id
        userid = self.userid_list[index]
        docid = self.docid_list[index]
        network = self.network_list[index]
        device_t = self.device_list[index]
        os = self.os_list[index]
        province = self.province_list[index]
        city = self.city_list[index]
        age = self.age_list[index]
   ………………
        return userid, docid, network, device_t, os, province, city, age, gender, category1st, category2nd, dense_features, keywords, click


    def __len__(self):
        return len(self.userid_list)

 

网络构建

 

主体结构为DeepFM,fm做显式二阶交叉,deep做高阶交叉。在做数据分析的时候,有些特征不同特征值的后验ctr相差较大,针对每个离散特征计算特征值对应的ctr的方差,以此来衡量后验ctr的分布差异。可以发现provice,device和city对应的特征值后ctr分布差异明显。虽说基于对深度学习的假设,NN是可以自动学习这种分布,但是受限于数据不充分,NN无法充分学习到这种差异。因此我们将先验知识强加给NN,在现有的网络结构中显式加入“个性化学习组件”,类比FM结构让网络显式做二阶交叉。

 

 

如上图所示,原始DNN的每层输入值乘上一个同维度的 scale 向量,该scale向量由一个独立的小网络得到,该小网络最后一层的激活函数是sigmoid,经过一次sigmoid,并将输出的数据*2,从而保证scale向量的值既能做到提升也能做到打压,拟合不同特征值巨大的分布差异。

 

前面做特征的时候也说到,click和duration是强相关的,duration是click之后的延伸,有点类似click和convert的关系。所以自然想到使用多任务模型结构,将duration信息迁移到click主任务。但实际尝试share bottom和mmoe均对click主任务无提升,其他队伍也尝试过ESMM。从一点资讯的实操结果看,多任务模型结构对click无提升,甚至还有损,在实际场景中使用多任务往往是出于推荐场景下的多指标综合提升。

 

def activation_layer(act_name):
    if isinstance(act_name, str):
        if act_name.lower() == 'sigmoid':
            act_layer = nn.Sigmoid()
        elif act_name.lower() == 'relu':
            act_layer = nn.ReLU()
        elif act_name.lower() == 'prelu':
            act_layer = nn.PReLU()
    elif issubclass(act_name, nn.Module):
        act_layer = act_name()
    else:
        raise NotImplementedError


    return act_layer
    
class DocRec(nn.Layer):
    def __init__(self,
                 sparse_features,
                 dense_features,
                 embedding_dim,
                 weights=None):


        super(DocRec, self).__init__()


        self.embedding_layers = EmbeddingLayer(sparse_features, embedding_dim,
                                               weights)


        # DNN
        dnn_input_size = len(sparse_features) * embedding_dim + len(
            dense_features)
        lhuc_size = 3 * embedding_dim
        self.layer_norm = nn.LayerNorm(dnn_input_size)
        dnn_hidden_units = [1024, 512, 64]
        self.lhuc = LHUC(inputs_dim=dnn_input_size, hidden_units=dnn_hidden_units, lhuc_size=lhuc_size)
        self.dnn_linear = nn.Linear(dnn_hidden_units[-1], 1, bias_attr=False)
        self.fm = FM()


    def forward(self, userid, docid, network, device_t, os, province, city, age, gender, category1st, category2nd, dense_features, keywords, click):
        id_list = [[userid, 'userid'], [docid, 'docid'], [network, 'network'],
                   [device_t, 'device'], [os, 'os'], [province, 'province'],
                   [city, 'city'], [age, 'age'], [gender, 'gender'],
                   [category1st, 'category1st'], [category2nd, 'category2nd'],
                   [keywords, {
                       'name': 'keyword'
                   }]]
        lhuc_id_list = [[device_t, 'device'], [province, 'province'], [city, 'city'], ]


        embeding_input = self.embedding_layers(id_list)
        lhuc_embeding_input = self.embedding_layers(lhuc_id_list)
        
        dnn_input = paddle.concat(
            [paddle.cast(paddle.flatten(embeding_input, start_axis=1), 'float32'), paddle.cast(dense_features, 'float32')], 1)
        lhuc_input = paddle.flatten(lhuc_embeding_input, start_axis=1)
            
        dnn_output = self.lhuc(dnn_input, lhuc_input)
        dnn_logit = self.dnn_linear(dnn_output)


        fm_input = embeding_input
        fm_logit = self.fm(fm_input)


        logit = dnn_logit + fm_logit
        pred = paddle.nn.functional.sigmoid(logit)
        pred = pred.squeeze()
        
        pred = paddle.cast(pred, 'float32')
        click = paddle.cast(click, 'float32')
        loss = F.binary_cross_entropy(pred, click)


        return pred, loss

 

模型训练

 

参考官方示例进行模型训练和预测。

 

model = DocRec(sparse_features=sparse_features,
                dense_features=dense_features,
                embedding_dim=128,
                weights=None)


optim = paddle.optimizer.Adagrad(parameters=model.parameters(), learning_rate=0.01)


for epoch in range(10):
    click_predict_all = []
    click_label_all = []


    for (userid, docid, network, device_t, os, province, city, age, gender,
        category1st, category2nd, dense_features, keywords, click) in tqdm(train_loader):
        
        # 预测值
        predict, loss = model(userid, docid, network, device_t, os, province, city, age, 
                                gender, category1st, category2nd, dense_features, keywords, click)
        
        loss.backward()
        optim.step()
        optim.clear_grad()


        click_predict_all.extend(list(predict.cpu().detach().numpy()))
        click_label_all.extend(list(click.cpu().detach().numpy()))
    
    train_auc = roc_auc_score(click_label_all, click_predict_all)
    print("epoch: {}, loss is: {}, auc is: {}".format(epoch + 1, loss.numpy()[0], train_auc))

 

模型预测

 

对之前训练的模型进行效果预测 。

 

click_predict_all = []


model.eval()
for (userid, docid, network, device_t, os, province, city, age, gender, category1st, category2nd, dense_features, keywords, click) in test_loader():
    predict, loss = model(userid, docid, network, device_t, os,
                          province, city, age, gender, category1st,
                          category2nd, dense_features, keywords, click)
                          
    click_predict_all.extend(list(predict.cpu().detach().numpy()))

 

总结

 

该项目使用的是国产深度学习框架飞桨,更加详细的文章内容和代码解析可以点击 阅读原文 移步AI Studio平台进行查看 。

 

Be First to Comment

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注