Torch-Rechub学习分享2

推荐系统开源学习 TASK 02 :

1. DeepFM:

1.1 DeepFM的提出动机:

CTR预估是目前推荐系统的核心技术,其目标是预估用户点击推荐内容的概率。在CTR预估任务中,用户行为的隐式low-order和high-order特征都起到十分重要。有些特征是易于理解的,可以通过领域专家进行人工特征工程抽取特征。但是对于不易于理解的特征,如“啤酒和尿布”,则只能通过机器学习的方法得到。同样的对于需要特别巨量特征的模型,人工特征工程也是难以接受的。所以特征的自动学习显的十分重要。简单线性模型,缺乏学习high-order特征的能力,很难从训练样本中学习到从未出现或极少出现的重要特征。FM模型可以通过点积和隐向量的形式学习交叉特征。由于复杂度的约束,FM通常只应用order-2的2重交叉特征。深层模型善于捕捉high-order复杂特征。
现有模型用于CTR预估的有很多尝试,如CNN/RNN/FNN/PNN/W&D等,但都有各自的问题。FNN和PNN缺乏low-order特征,W&D虽然将整个模型的结构调整为了并行结构,但在实际的使用中Wide Module中的部分需要较为精巧的特征工程,同时存在问题:在output Units阶段直接将低阶和高阶特征进行组合,很容易让模型最终偏向学习到低阶或者高阶的特征,而不能做到很好的结合。(如下图所示)

Wide&Deep模型结构图
基于解决以上问题提出的DeepFM模型是一种可以从原始特征中抽取到各种复杂度特征的端到端模型,没有人工特征工程的困扰。DeepFM主要有以下贡献:(1)DeepFM模型包含FM和DNN两部分,FM模型可以抽取low-order特征,DNN可以抽取high-order特征。(2)无需像Wide&Deep模型一样进行人工特征工程。由于输入仅为原始特征,而且FM和DNN共享输入向量特征,DeepFM模型训练速度很快。

1.2 DeepFM的算法原理:

DeepFM模型一共包含两个部分,以下分别介绍两个部分的模型原理

1.2.1 Deep模型部分:

Deep Module

该部分和Wide&Deep模型类似,是简单的前馈网络。在输入特征部分,由于原始特征向量多是高维度且高度稀疏,连续和类别混合的分域特征,为了更好的发挥DNN模型学习high-order特征的能力,文中设计了一套子网络结构,将原始的稀疏表示特征映射为稠密的特征向量。子网络的输出层公式为:

DNN网络第l层表示成:

假设一共有H个隐藏层,DNN部分对CTR预测结果可以表示为:

1.2.2 FM模型部分

下图是FM的一个结构图,从图中大致可以看出FM Layer是由一阶特征和二阶特征Concatenate到一起在经过一个Sigmoid得到logits,所以在实现的时候需要单独考虑linear部分和FM交叉特征部分。

FM Module

1.3 基于criteo数据集的Pytorch代码实现

该部分代码来源于Torch-Rechub中的run_criteo.py,首先简单介绍criteo数据集:该数据集是Criteo Labs发布的在线广告数据集。 它包含数百万个展示广告的点击反馈记录,该数据可作为点击率(CTR)预测的基准。 数据集具有40个特征,第一列是标签,其中值1表示已点击广告,而值0表示未点击广告。 其他特征包含13个dense特征和26个sparse特征。这里仅采用原数据集中的115个样本作为数据集进行训练与预测。

import numpy as np
import pandas as pd
import torch
from torch_rechub.models.ranking import WideDeep, DeepFM, DCN
from torch_rechub.trainers import CTRTrainer
from torch_rechub.basic.features import DenseFeature, SparseFeature
from torch_rechub.utils.data import DataGenerator
from tqdm import tqdm
from sklearn.preprocessing import MinMaxScaler, LabelEncoder
torch.manual_seed(2022) #固定随机种子
data_path = '../examples/ranking/data/criteo/criteo_sample.csv'
data = pd.read_csv(data_path)  
#data = pd.read_csv(data_path, compression="gzip") #if the raw_data is .gz file
data.head()
criteo数据集

特征工程部分:读取原始数据集后,需要对数据集中的连续特征和离散特征进行处理,Torch-Rechub中对Dense特征进行两种操作:MinMaxScaler归一化,使其取值在[0,1]之间;将其离散化成新的Sparse特征。对Sparse特征直接进行LabelEncoder编码操作,将原始的类别字符串映射为数值,在模型中将为每一种取值生成Embedding向量。同时通过调用Torch-Rechub中的DataGenerator类中的generate_dataloader函数,利用给定数据集的划分比例得到训练集,验证集以及测试集的数据迭代器作为训练的输入。

dense_cols= [f for f in data.columns.tolist() if f[0] == "I"] #以I开头的特征名为dense特征
sparse_cols = [f for f in data.columns.tolist() if f[0] == "C"]  #以C开头的特征名为sparse特征

data[dense_cols] = data[dense_cols].fillna(-996) #填充空缺值
data[sparse_cols] = data[sparse_cols].fillna('-996')


def convert_numeric_feature(val):
    v = int(val)
    if v > 2:
        return int(np.log(v)**2)
    else:
        return v - 2
        
for col in tqdm(dense_cols):  #将离散化dense特征列设置为新的sparse特征列
    sparse_cols.append(col + "_sparse")
    data[col + "_sparse"] = data[col].apply(lambda x: convert_numeric_feature(x))

scaler = MinMaxScaler()  #对dense特征列归一化
data[dense_cols] = scaler.fit_transform(data[dense_cols])

for col in tqdm(sparse_cols):  #sparse特征编码
    lbe = LabelEncoder()
    data[col] = lbe.fit_transform(data[col])

#重点:将每个特征定义为torch-rechub所支持的特征基类,dense特征只需指定特征名,sparse特征需指定特征名、特征取值个数(vocab_size)、embedding维度(embed_dim)
dense_features = [DenseFeature(feature_name) for feature_name in dense_cols]
sparse_features = [SparseFeature(feature_name, vocab_size=data[feature_name].nunique(), embed_dim=16) for feature_name in sparse_cols]
y = data["label"]
del data["label"]
x = data
dg = DataGenerator(x, y) 
train_dataloader, val_dataloader, test_dataloader = dg.generate_dataloader(split_ratio=[0.7, 0.1], batch_size=256, num_workers=8)
对连续型特征进行离散化并进行特征编码的结果

根据上述对DeepFM模型的分析,我们了解到模型大致由两部分组成,一部分是FM,还有一部分就是DNN, 而FM又由一阶特征部分与二阶特征交叉部分组成,所以可以将整个模型拆成三部分,分别是一阶特征处理linear部分,二阶特征交叉FM以及DNN的高阶特征交叉。在下面的DeepFM的代码中也能够清晰的看到这个结构。

class DeepFM(torch.nn.Module):
    """Deep Factorization Machine Model

    Args:
        deep_features (list): the list of `Feature Class`, training by the deep part module.
        fm_features (list): the list of `Feature Class`, training by the fm part module.
        mlp_params (dict): the params of the last MLP module, keys include:`{"dims":list, "activation":str, "dropout":float, "output_layer":bool`}
    """

    def __init__(self, deep_features, fm_features, mlp_params):
        super(DeepFM, self).__init__()
        self.deep_features = deep_features
        self.fm_features = fm_features
        self.deep_dims = sum([fea.embed_dim for fea in deep_features])
        self.fm_dims = sum([fea.embed_dim for fea in fm_features])
        self.linear = LR(self.fm_dims)  # 1-odrder interaction
        self.fm = FM(reduce_sum=True)  # 2-odrder interaction
        self.embedding = EmbeddingLayer(deep_features + fm_features)
        self.mlp = MLP(self.deep_dims, **mlp_params)

    def forward(self, x):
        input_deep = self.embedding(x, self.deep_features, squeeze_dim=True)  #[batch_size, deep_dims]
        input_fm = self.embedding(x, self.fm_features, squeeze_dim=False)  #[batch_size, num_fields, embed_dim]

        y_linear = self.linear(input_fm.flatten(start_dim=1))
        y_fm = self.fm(input_fm)
        y_deep = self.mlp(input_deep)  #[batch_size, 1]
        y = y_linear + y_fm + y_deep
        return torch.sigmoid(y.squeeze(1))

为了训练一个DeepFM模型,需要指定DeepFM的模型结构参数,学习率等训练参数。 对于DeepFM而言,主要参数如下:deep_features指用deep模块训练的特征(兼容dense和sparse);fm_features指用fm模块训练的特征,只能传入sparse类型;mlp_params指定deep模块中MLP层的参数,运行代码如下:

from torch_rechub.models.ranking import DeepFM
from torch_rechub.trainers import CTRTrainer

#定义模型
model = DeepFM(
        deep_features=dense_features+sparse_features,
        fm_features=sparse_features,
        mlp_params={"dims": [256, 128], "dropout": 0.2, "activation": "relu"},
    )

# 模型训练,需要学习率、设备等一般的参数,此外我们还支持earlystoping策略,及时发现过拟合
ctr_trainer = CTRTrainer(
    model,
    optimizer_params={"lr": 1e-4, "weight_decay": 1e-5},
    n_epoch=1,
    earlystop_patience=3,
    device='cpu', #如果有gpu,可设置成cuda:0
    model_path='./', #模型存储路径
)
ctr_trainer.fit(train_dataloader, val_dataloader)

# 查看在测试集上的性能
auc = ctr_trainer.evaluate(ctr_trainer.model, test_dataloader)
print(f'test auc: {auc}')
DeepFM训练两个epoch的结果

为了探究在Deep层输入离散特征的作用,这里仅将连续特征输入Deep模块,与上述实验结果进行对比,结果如下所示,可以看到相比于将连续特征和离散特征同时输入,仅输入连续特征在测试集上的auc有了明显的降低。

对比试验:在Deep模块中只输入连续特征的DeepFM训练两个epoch的结果

2. DIN:

2.1 DIN提出的动机:

传统的基于深度学习的模型在个性化广告点击预测任务中存在的问题就是无法表达用户广泛的兴趣,因为这些模型在得到各个特征的embedding之后,就蛮力拼接了,然后就各种交叉等。这时候根本没有考虑之前用户历史行为商品具体是什么,究竟用户历史行为中的哪个会对当前的点击预测带来积极的作用。 而实际上,对于用户点不点击当前的商品广告,很大程度上是依赖于他的历史行为。Deep Interest Network(DIIN)是2018年阿里巴巴提出来的模型,模型的应用场景是阿里巴巴的电商广告推荐业务, 这样的场景下一般会有大量的用户历史行为信息, DIN模型的创新点或者解决的问题就是使用了注意力机制来对用户的兴趣动态模拟, 而这个模拟过程存在的前提就是用户之前有大量的历史行为了,这样我们在预测某个商品广告用户是否点击的时候,就可以参考他之前购买过或者查看过的商品,猜测出用户的大致兴趣。所以这个模型的使用场景是非常注重用户的历史行为特征(历史购买过的商品或者类别信息)

2.2 DIN模型结构及原理

2.2.1 Base模型架构

在了解DIN模型前,需要首先了解Base模型,DIN模型只不过在Base的基础上添加了一个新结构(注意力网络)来学习当前候选广告与用户历史行为特征的相关性,从而动态捕捉用户的兴趣。Base模型就是现在比较常见的多层神经网络,即先对特征进行Embedding操作,得到一系列Embedding向量之后,将不同group的特征concate起来之后得到一个固定长度的向量,然后将此向量喂给后续的全连接网络,最后输出pCTR值。具体网络结构如下:

base Model

Loss: 由于这里是点击率预测任务, 二分类的问题,所以这里的损失函数用的负的log对数似然(DIN同理):

Base 存在的问题:(1)每个历史商品的信息会丢失了一部分,对于预测当前广告点击率,并不是所有历史商品都有用,综合所有的商品信息反而会增加一些噪声性的信息。(2)没法看出到底用户历史行为中的哪个商品与当前商品比较相关,也就是丢失了历史行为中各个商品对当前预测的重要性程度。(3)如果所有用户浏览过的历史行为商品都通过embedding和pooling转换成了固定长度的embedding,这样会限制模型学习用户的多样化兴趣。

2.2.2 DIN模型架构

DIN采用了基模型的结构,只不过是在这个的基础上加了一个注意力机制来学习用户兴趣与当前候选广告间的关联程度, 用论文里面的话是,引入了一个新的local activation unit, 这个东西用在了用户历史行为特征上面, 能够根据用户历史行为特征和当前广告的相关性给用户历史行为特征embedding进行加权

DIN Model

相比于base model, 这里加了一个local activation unit(前馈神经网络),输入包括两个部分,一个是原始的用户行为embedding向量、广告embedding向量;另外一个是两者Embedding向量经过外积计算后得到的向量,文章指出这种方式有利于relevance modeling。

Activation unit

2.3 基于Amazon-Electronics数据集的代码实现

Amazon-Electronics数据集是 2014 年亚马逊发布的评论数据集。该数据集包括评论(评分、文本、帮助投票)、产品元数据(描述、类别信息、价格、品牌和图像特征) 和链接(查看)。该数据集对不同种类商品进行了分类,Electronics数据集该类目包含19W用户、6W商品的信息。因为原始数据含有较多评论信息,数据较大,Torch-Rechub提供了预处理之后的数据,以csv格式保存,并选取了前100条数据放在data/sample中。首先通过代码读取数据路径得到小型数据集如下:

Amazon-Electronics前99条样本组成的小型数据集

特征工程部分:由于DIN模型是将用户的历史点击物品序列,用户的ID,以及候选物品的相关特征作为Deep阶段的输入,因此可能要用的特征一共包含三种类型:

  • Dense特征:又称数值型特征,例如薪资、年龄,在Amazon-Electronics数据中没有用到这个类型的特征。
  • Sparse特征:又称类别型特征,例如性别、学历。Torch-Rechub中对Sparse特征直接进行LabelEncoder编码操作,将原始的类别字符串映射为数值,在模型中将为每一种取值生成Embedding向量。
  • Sequence特征:序列特征,比如用户历史点击item_id序列、历史商铺序列等,序列特征如何抽取,是我们在DIN中学习的一个重点,也是DIN主要创新点之一。
    如下代码所示,Torch-Rechub调用feature.py中封装好的SequenceFeature和SparseFeature类来区分用户的历史点击物品特征和目标物品特征和用户ID,通过create_seq_features函数将原始数据集根据历史的点击顺序划分为训练集,验证集和测试集。
def get_amazon_data_dict(dataset_path):
    data = pd.read_csv(dataset_path)
    print('========== Start Amazon ==========')
    n_users, n_items, n_cates = data["user_id"].max(), data["item_id"].max(), data["cate_id"].max()

    # features = [SparseFeature("target_item", vocab_size=n_items + 2, embed_dim=8), SparseFeature("target_cate", vocab_size=n_cates + 2, embed_dim=8), SparseFeature("user_id", vocab_size=n_users + 2, embed_dim=8)]
    # target_features = features
    features = [SparseFeature("user_id", vocab_size=n_users + 2, embed_dim=8)]
    target_features = [SparseFeature("target_item", vocab_size=n_items + 2, embed_dim=8),
                       SparseFeature("target_cate", vocab_size=n_cates + 2, embed_dim=8)]
    history_features = [
        SequenceFeature("history_item", vocab_size=n_items + 2, embed_dim=8, pooling="concat", shared_with="target_item"),
        SequenceFeature("history_cate", vocab_size=n_cates + 2, embed_dim=8, pooling="concat", shared_with="target_cate")
    ]

    print('========== Create sequence features ==========')
    train, val, test = create_seq_features(data, seq_feature_col=['item_id', 'cate_id'], drop_short=3)

    print('========== Generate input dict ==========')
    train = df_to_dict(train)
    val = df_to_dict(val)
    test = df_to_dict(test)

    train_y, val_y, test_y = train["label"], val["label"], test["label"]

    del train["label"]
    del val["label"]
    del test["label"]
    train_x, val_x, test_x = train, val, test
    return features, target_features, history_features, (train_x, train_y), (val_x, val_y), (test_x, test_y)

DIN部分的代码如下所示:

class DIN(nn.Module):
    """Deep Interest Network
    Args:
        features (list): the list of `Feature Class`. training by MLP. It means the user profile features and context features in origin paper, exclude history and target features.
        history_features (list): the list of `Feature Class`,training by ActivationUnit. It means the user behaviour sequence features, eg.item id sequence, shop id sequence.
        target_features (list): the list of `Feature Class`, training by ActivationUnit. It means the target feature which will execute target-attention with history feature.
        mlp_params (dict): the params of the last MLP module, keys include:`{"dims":list, "activation":str, "dropout":float, "output_layer":bool`}
        attention_mlp_params (dict): the params of the ActivationUnit module, keys include:`{"dims":list, "activation":str, "dropout":float, "use_softmax":bool`}
    """

    def __init__(self, features, history_features, target_features, mlp_params, attention_mlp_params):
        super().__init__()
        self.features = features
        self.history_features = history_features
        self.target_features = target_features
        self.num_history_features = len(history_features)
        self.all_dims = sum([fea.embed_dim for fea in features + history_features + target_features])

        self.embedding = EmbeddingLayer(features + history_features + target_features)
        self.attention_layers = nn.ModuleList(
            [ActivationUnit(fea.embed_dim, **attention_mlp_params) for fea in self.history_features])
        self.mlp = MLP(self.all_dims, activation="dice", **mlp_params)

    def forward(self, x):
        embed_x_features = self.embedding(x, self.features)  #(batch_size, num_features, emb_dim)
        embed_x_history = self.embedding(
            x, self.history_features)  #(batch_size, num_history_features, seq_length, emb_dim)
        embed_x_target = self.embedding(x, self.target_features)  #(batch_size, num_target_features, emb_dim)
        attention_pooling = []
        for i in range(self.num_history_features):
            attention_seq = self.attention_layers[i](embed_x_history[:, i, :, :], embed_x_target[:, i, :])
            attention_pooling.append(attention_seq.unsqueeze(1))  #(batch_size, 1, emb_dim)
        attention_pooling = torch.cat(attention_pooling, dim=1)  #(batch_size, num_history_features, emb_dim)

        mlp_in = torch.cat([
            attention_pooling.flatten(start_dim=1),
            embed_x_target.flatten(start_dim=1),
            embed_x_features.flatten(start_dim=1)
        ],
                           dim=1)  #(batch_size, N)

        y = self.mlp(mlp_in)
        return torch.sigmoid(y.squeeze(1))


class ActivationUnit(nn.Module):
    """Activation Unit Layer mentioned in DIN paper, it is a Target Attention method.

    Args:
        embed_dim (int): the length of embedding vector.
        history (tensor):
    Shape:
        - Input: `(batch_size, seq_length, emb_dim)`
        - Output: `(batch_size, emb_dim)`
    """

    def __init__(self, emb_dim, dims=[36], activation="dice", use_softmax=False):
        super(ActivationUnit, self).__init__()
        self.emb_dim = emb_dim
        self.use_softmax = use_softmax
        self.attention = MLP(4 * self.emb_dim, dims=dims, activation=activation)

    def forward(self, history, target):
        seq_length = history.size(1)
        target = target.unsqueeze(1).expand(-1, seq_length, -1)  #batch_size,seq_length,emb_dim
        att_input = torch.cat([target, history, target - history, target * history],
                              dim=-1)  # batch_size,seq_length,4*emb_dim
        att_weight = self.attention(att_input.view(-1, 4 * self.emb_dim))  #  #(batch_size*seq_length,4*emb_dim)
        att_weight = att_weight.view(-1, seq_length)  #(batch_size*seq_length, 1) -> (batch_size,seq_length)
        if self.use_softmax:
            att_weight = att_weight.softmax(dim=-1)
        # (batch_size, seq_length, 1) * (batch_size, seq_length, emb_dim)
        output = (att_weight.unsqueeze(-1) * history).sum(dim=1)  #(batch_size,emb_dim)
        return output

通过上述代码可以看到,这里的ActivationUnit的输入除了历史物品特征和候选物品特征本身,还包括两个向量的差和元素积。历史物品的特征一共包含两个:物品ID和物品类别,因此ActivationUnit分别对这两个特征进行计算后拼接作为下一阶段MLP的输入。最终通过main函数生成数据迭代器,并将DIN的模型参数传入进行训练,代码如下所示:

def main(dataset_path, epoch, learning_rate, batch_size, weight_decay, device, save_dir, seed):
    torch.manual_seed(seed)
    features, target_features, history_features, (train_x, train_y), (val_x, val_y), (test_x, test_y) = \
        get_amazon_data_dict(dataset_path)
    dg = DataGenerator(train_x, train_y)

    train_dataloader, val_dataloader, test_dataloader = dg.generate_dataloader(x_val=val_x, y_val=val_y, x_test=test_x, y_test=test_y, batch_size=batch_size)
    model = DIN(features=features, history_features=history_features, target_features=target_features, mlp_params={"dims": [256, 128]}, attention_mlp_params={"dims": [256, 128]})

    ctr_trainer = CTRTrainer(model, optimizer_params={"lr": learning_rate, "weight_decay": weight_decay}, n_epoch=epoch, earlystop_patience=4, device=device, model_path=save_dir)
    ctr_trainer.fit(train_dataloader, val_dataloader)
    auc = ctr_trainer.evaluate(ctr_trainer.model, test_dataloader)
    print(f'test auc: {auc}')


if __name__ == '__main__':
    import argparse
    parser = argparse.ArgumentParser()
    parser.add_argument('--dataset_path', default="./data/amazon-electronics/amazon_electronics_sample.csv")
    parser.add_argument('--epoch', type=int, default=2)
    parser.add_argument('--learning_rate', type=float, default=1e-3)
    parser.add_argument('--batch_size', type=int, default=4096)
    parser.add_argument('--weight_decay', type=float, default=1e-3)
    parser.add_argument('--device', default='cpu')  #cuda:0
    parser.add_argument('--save_dir', default='./')
    parser.add_argument('--seed', type=int, default=2022)

    args = parser.parse_args()
    main(args.dataset_path, args.epoch, args.learning_rate, args.batch_size, args.weight_decay, args.device, args.save_dir, args.seed)

最终模型的运行结果如下所示,可以看到在小数据集上DIN的训练速度很快,后续会用全量数据集进行实验,验证模型的收敛效果


DIN模型运行两个epoch的训练结果

你可能感兴趣的:(Torch-Rechub学习分享2)