推荐系统之Deep Crossing模型原理以及代码实践

简介

本文要介绍的Deep Crossing模型是由微软研究院在论文《Deep Crossing: Web-Scale Modeling without Manually Crafted Combinatorial Features》中提出的,它主要是用来解决大规模特征自动组合问题,从而减轻或者避免手工进行特征组合的开销。Deep Crossing可以说是深度学习CTR模型的最典型和基础性的模型。

背景知识

传统机器学习算法充分利用所有的输入特征来对新实例进行预测和分类。但是,仅仅使用原始特征很难获得最佳结果。因此无论是在工业界还是学术界,都进行着大量的工作来对原始特征进行转换。一种有效的特征转换方式是进行多种特征的组合,然后将融合后的特征输入到学习器中去。
组合特征在很多领域已经被证实能发挥强大的功能。在Kaggle社区里,顶级的数据科学家往往都十分擅长特征融合,甚至能横跨3~5个维度进行特征组合,直觉和创造有效组合特征的能力是他们赢得比赛的制胜法宝。同理,在图像识别等领域,类似SIFT的特征提取也是某些算法能够在ImageNet等数据集上取得最佳结果的关键因素。然而,进行高效的特征融合却需要高昂的成本代价。随着特征数量的增加,管理,维护变得充满挑战,尤其是在大规模的网络应用程序中。庞大的搜索空间和样本数量,导致训练和评估变得异常缓慢,因此寻找额外的组合特征来改进现有模型是一项艰巨的任务。
深度学习模型天然就可以从独立特征中进行学习,并且无需人工干预。在计算机视觉以及自然语言处理等领域已经发挥出了它强大的功能,比如基于CNN的模型在图像识别比赛中取得的成绩就已经超过了基于传统手工特征SIFT的相关方法取得的最好成绩。
Deep Crossing模型将深度学习从图像和自然语言处理等领域扩展到了更加广泛的环境中,比如每个输入特征都具有不同的性质。更具体地说,它可以输入诸如文本、类别、ID以及数值信息等特征,并且根据特定任务要求,自动搜索最佳的特征组合。

模型介绍

首先给出Deep Crossing的整体模型架构图,如下:
Deep Crossing模型架构图

模型的输入是一系列的独立特征,模型总共包含4层,分别是Embedding层、Stacking层、Residual Unit层、Scoring层,模型的输出是用户点击率预测值。
注意上图中红色方框部分,输入特征没有经过Embedding层就直接连接到了Stacking层了。这是因为输入特征可能是稠密的也可能是稀疏的,论文中指出,对于维度小于256的特征直接连接到Stacking层。

损失函数

论文中使用的是交叉熵损失函数,但是也可以使用Softmax或者其他损失函数。定义如下:

损失函数

其中 代表的是训练样本的下标, 是训练样本的总数, 是每个样本的标签,在用户点击率预估问题中就是用户点击率, 是模型的预估值,在Deep Crossing中是Sigmoid函数的输出。

Embedding层

Embedding层的主要作用是对输入特征进行特征转换,Embedding层包含了一个单层神经网络,网络定义如下:


其中 代表输入特征的索引, 代表输入特征, 是一个 的矩阵, , 是输出的Embedding特征。当 时,Embedding层的作用就是对输入特征进行降维。 操作在神经网络中代表的就是ReLU激活函数。故Embedding层的主要作用是,首先对输入特征进行一个线性变化,其次通过ReLU激活函数得到最后的Embedding特征。
值得注意的是,Embedding层的大小会对整个模型的整体大小产生很重要的影响。即便输入是稀疏特征,大小为 的权重矩阵也是稠密的。

Stacking层

当得到了所有输入特征的Embedding表示之后(特征维度小于256的除外)。Stacking层所做的事情只是简单把这些特征聚合起来,形成一个向量。表示如下:


其中 是输入特征的数量。注意到这里 和 都属于模型的参数,都是需要经过优化的,这一点也是模型中Embedding的重要特点之一。

Residual层

残差层的是由下图所示的残差单元构建成的。残差单元如下所示:

残差单元

Deep Crossing模型中使用的残差单元与ResNet中使用的不太一样,它不包含卷积操作。残差单元的特有属性就是将输入加到了隐层的输出上,上图所示的残差单元的计算可由下式来表示:

其中 和 分别代表两个全连接层的参数, 代表的是两个全连接层的映射函数,若将 移动到上式的左边之后,可得 ,即 函数学习到的是目标输出和输入之间的残差。

关于ResNet更详细的介绍可以参考我的另外一篇博客PyTorch实现经典网络之ResNet。

Scoring层

Residual层的输出首先连接到全连接层,其次再经过Sigmoid激活函数,最后输出的是一个广告的预测点击率。

代码实践

论文中给出了基于CNTK的伪代码实现,如下:

模型训练和测试使用的是内部的一个数据集。
我使用pytorch对代码进行了改写,并且基于criteo数据集进行训练和测试。模型部分代码如下:

import torch
import torch.nn as nn

class ResidualBlock(nn.Module):
    def __init__(self, input_dim, hidden_dim):
        super(ResidualBlock, self).__init__()
        self.linear1 = nn.Linear(in_features=input_dim, out_features=hidden_dim, bias=True)
        self.linear2 = nn.Linear(in_features=hidden_dim, out_features=input_dim, bias=True)

    def forward(self, x):
        out = self.linear2(torch.relu(self.linear1(x)))
        out += x
        out = torch.relu(out)
        return out

class DeepCrossing(nn.Module):
    def __init__(self, config, dense_features_cols, sparse_features_cols):
        super(DeepCrossing, self).__init__()
        self._config = config
        # 稠密特征的数量
        self._num_of_dense_feature = dense_features_cols.__len__()
        # 稠密特征
        self.sparse_features_cols = sparse_features_cols
        self.sparse_indexes = [idx for idx, num_feat in enumerate(self.sparse_features_cols) if num_feat > config['min_dim']]
        self.dense_indexes = [idx for idx in range(len(self.sparse_features_cols)) if idx not in self.sparse_indexes]

        # 对特征类别大于config['min_dim']的创建Embedding层,其余的直接加入Stack层
        self.embedding_layers = nn.ModuleList([
            # 根据稀疏特征的个数创建对应个数的Embedding层,Embedding输入大小是稀疏特征的类别总数,输出稠密向量的维度由config文件配置
            nn.Embedding(num_embeddings = self.sparse_features_cols[idx], embedding_dim=config['embed_dim'])
                for idx  in self.sparse_indexes
        ])

        self.dim_stack = self.sparse_indexes.__len__()*config['embed_dim'] + self.dense_indexes.__len__() + self._num_of_dense_feature

        self.residual_layers = nn.ModuleList([
            # 根据稀疏特征的个数创建对应个数的Embedding层,Embedding输入大小是稀疏特征的类别总数,输出稠密向量的维度由config文件配置
            ResidualBlock(self.dim_stack, layer)
            for layer in config['hidden_layers']
        ])

        self._final_linear = nn.Linear(self.dim_stack, 1)

    def forward(self, x):
        # 先区分出稀疏特征和稠密特征,这里是按照列来划分的,即所有的行都要进行筛选
        dense_input, sparse_inputs = x[:, :self._num_of_dense_feature], x[:, self._num_of_dense_feature:]
        sparse_inputs = sparse_inputs.long()

        sparse_embeds = [self.embedding_layers[idx](sparse_inputs[:, i]) for idx, i in enumerate(self.sparse_indexes)]
        sparse_embeds = torch.cat(sparse_embeds, axis=-1)

        # 取出sparse中维度小于config['min_dim']的Tensor
        indices = torch.LongTensor(self.dense_indexes)
        sparse_dense = torch.index_select(sparse_inputs, 1, indices)

        output = torch.cat([sparse_embeds, dense_input, sparse_dense], axis=-1)

        for residual in self.residual_layers:
            output = residual(output)

        output = self._final_linear(output)
        output = torch.sigmoid(output)
        return output

    def saveModel(self):
        torch.save(self.state_dict(), self._config['model_name'])

    def loadModel(self, map_location):
        state_dict = torch.load(self._config['model_name'], map_location=map_location)
        self.load_state_dict(state_dict, strict=False)

测试部分代码:

import torch
from DeepCrossing.trainer import Trainer
from DeepCrossing.network import DeepCrossing
from Utils.criteo_loader import getTestData, getTrainData
import torch.utils.data as Data

deepcrossing_config = \
{
    'embed_dim': 8, # 用于控制稀疏特征经过Embedding层后的稠密特征大小
    'min_dim': 256, # 稀疏特征维度小于min_dim的直接进入stack layer,不用经过embedding层
    'hidden_layers': [512,256,128,64,32],
    'num_epoch': 30,
    'batch_size': 32,
    'lr': 1e-3,
    'l2_regularization': 1e-4,
    'device_id': 0,
    'use_cuda': False,
    'train_file': '../Data/criteo/processed_data/train_set.csv',
    'fea_file': '../Data/criteo/processed_data/fea_col.npy',
    'validate_file': '../Data/criteo/processed_data/val_set.csv',
    'test_file': '../Data/criteo/processed_data/test_set.csv',
    'model_name': '../TrainedModels/DeepCrossing.model'
}

if __name__ == "__main__":
    ####################################################################################
    # DeepCrossing 模型
    ####################################################################################
    training_data, training_label, dense_features_col, sparse_features_col = getTrainData(deepcrossing_config['train_file'], deepcrossing_config['fea_file'])
    train_dataset = Data.TensorDataset(torch.tensor(training_data).float(), torch.tensor(training_label).float())
    test_data = getTestData(deepcrossing_config['test_file'])
    test_dataset = Data.TensorDataset(torch.tensor(test_data).float())

    deepCrossing = DeepCrossing(deepcrossing_config, dense_features_cols=dense_features_col, sparse_features_cols=sparse_features_col)

    ####################################################################################
    # 模型训练阶段
    ####################################################################################
    # # 实例化模型训练器
    trainer = Trainer(model=deepCrossing, config=deepcrossing_config)
    # 训练
    trainer.train(train_dataset)
    # 保存模型
    trainer.save()

    ####################################################################################
    # 模型测试阶段
    ####################################################################################
    deepCrossing.eval()
    if deepcrossing_config['use_cuda']:
        deepCrossing.loadModel(map_location=lambda storage, loc: storage.cuda(deepcrossing_config['device_id']))
        deepCrossing = deepCrossing.cuda()
    else:
        deepCrossing.loadModel(map_location=torch.device('cpu'))

    y_pred_probs = deepCrossing(torch.tensor(test_data).float())
    y_pred = torch.where(y_pred_probs>0.5, torch.ones_like(y_pred_probs), torch.zeros_like(y_pred_probs))
    print("Test Data CTR Predict...\n ", y_pred.view(-1))


点击率预估部分测试结果:
测试集预估结果

完整代码见https://github.com/HeartbreakSurvivor/RsAlgorithms/blob/main/Test/deepcrossing_test.py

参考

  • https://www.kdd.org/kdd2016/papers/files/adf0975-shanA.pdf
  • https://zhuanlan.zhihu.com/p/31216915

你可能感兴趣的:(推荐系统之Deep Crossing模型原理以及代码实践)