推荐系统之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或者其他损失函数。定义如下:

损失函数

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

Embedding层

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


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

Stacking层

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


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

Residual层

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

残差单元

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

其中W_{\{0,1\}}b_{\{0,1\}}分别代表两个全连接层的参数,F代表的是两个全连接层的映射函数,若将X^I移动到上式的左边之后,可得F(\cdot) = X^O - X^I,即F函数学习到的是目标输出和输入之间的残差。

关于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

参考

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,324评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,303评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,192评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,555评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,569评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,566评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,927评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,583评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,827评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,590评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,669评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,365评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,941评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,928评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,159评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,880评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,399评论 2 342

推荐阅读更多精彩内容