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

简介

本文要介绍的是FNN模型,出自于张伟楠老师于2016年发表的论文《Deep Learning over Multi-field Categorical Data》。论文提出了两种深度学习模型,分别叫做FNN(Factorisation Machine supported Neural Network)和SNN(Sampling-based Neural Network),本文只介绍FNN模型。其实学习FNN模型之前,强烈建议先学习FM模型,因为FNN模型其实可以看做是由一个FM模型和一个MLP组成的。FM的引入是为了高效地将输入稀疏特征映射到稠密特征,从而加快FNN模型的训练。关于FM模型的相关内容,可以参考推荐系统之FM(因子分解机)模型原理以及代码实践

介绍

用户行为预测在许多网页级应用上发挥着重要的作用,比如网页搜索、推荐系统、赞助搜索、以及广告展示等。在在线广告中,举个例子,对目标用户群体的定位能力是区别于传统线下广告的关键优势。所有的定位技术,都依赖于预测是否特定的用户认为这个广告是相关的,给出用户在特定的场景中点击的概率。目前大部分的CTR预测都是线性模型,如逻辑回归,朴素贝叶斯,FTRL逻辑回归和贝叶斯逻辑回归等。所有的这些都是基于使用one-hot编码的大量稀疏特征。线性模型简单,有效,但是性能偏差,因为无法学习到特征之间的相互关系。非线性模型可以通过特征间的组合提高模型的能力。如FMs,将二值化的特征映射成连续的低维空间,通过内积获取特征间的相互关系。GBDT梯度提升树,通过树的构建过程,自动的学习特征的组合。然而,这些方法并不能利用所有可能的组合。此外,许多模型仍然需要依靠手工进行特征工程,来决定如何进行特征的组合。另一个问题是,已有的CTR模型在对复杂数据间的潜在的模式上的表达能力是非常有限的。所以,它们的泛化能力是非常受限的。
​深度学习在CV和NLP上取得了很大的成功,在非监督的训练中,神经网络可以从原始的特征中学习到高维的特征表示,这个能力也可以用在CTR上。在CTR中,大部分的输入特征是来自各个领域的,而且是离散的类别特征。比如用户所在的城市信息(London,Paris,Beijing),设备类型(PC,Mobile),广告类别(Sports, Electronics)等等,并且特征之间的相互依赖是未知的。因此,我们抱着极大地兴趣想了解一下,深度学习方法是如何在大规模的多特征域的离散类别数据上通过学习特征表示来提高CTR任务的估计准确度的。然而,大规模的输入特征空间需要调整大量的参数,这毫无疑问在计算上是非常昂贵的。与物理世界的图像或者音频数据不同,在推荐系统或者在线搜索等系统中,输入数据都是及其稀疏的。举个例子,假如我们有100万个二进制输入特征,以及100个隐层,那么这大概需要1亿个连接才能构建第一层神经网络。

模型

先直接给出论文中的FNN模型图,如下:
FNN模型图

上图的右侧已经标明了每一层的含义,下面从模型自顶向下的角度详细解释一下。

  • 输出层
    模型的输出是一个实数\hat y \in (0,1)作为预测的CTR,即特定用户在指定上下文的条件下点击给定广告的概率。
    其中W_3,b_3是对应的权重和偏置参数。
  • l2
    l_2的计算如下:
    其中tanh(x) = (1-e^{-2x})/(1+e^{-2x}),其中W_2 \in \mathbb R^{L \times M}, b_2 \in \mathbb R^L,l_1 \in \mathbb R^M。作者尝试了线性函数,sigmoid函数,tanh函数,发现tanh的函数的效果是最好的,因此这一层选用了tanh函数。
  • l1
    同样地,l_1的计算如下:
    其中W_1 \in \mathbb R^{M \times J}, b_1 \in \mathbb R^M, z \in \mathbb R^J,其中z是下一层的输出。
  • Dense\ Real层
    z定义如下:
    w_0是一个全局的偏置标量,n是特征域的总数,z_i \in \mathbb R^{K+1}是第i个特征域在FM中的参数向量,定义如下:
    其中start_i 和 end_i是第i个特征域的起止下标,\boldsymbol{W}_{0}^{i} \in \mathbb{R}^{(K+1) \times\left(\mathrm{end}_{i}-\mathrm{start}_{i}+1\right)}x在上面已经描述过了。所有的权重W_0^i都是通过偏置项w_i和向量v_i分别初始化。(举个例子,W_0^i[0]w_i初始化,W_0^i[1]v_i^1初始化,W_0^i[2]v_i^2初始化等等。)通过这种方式,FNN中的第一层的参数向量z就是通过预训练好的FM模型来初始化的。所以,现在大家也能够理解为什么说FNN就是一个FM+MLP了吧。

这里给出FM的方程:

FM方程

每一个特征i都有一个偏置向量w_i以及一个K维隐向量v_i,特征交互是通过将它们的隐向量进行内积<v_i,v_j>操作得到的。
为了进一步展示FM与FNN的关系,这里再补上一张图加以说明:
FM和FNN Embedding层各参数的对应关系

需要注意的是,虽然上图中FM中的参数直接指向了FNN的Embedding层各神经元,但其具体意义是初始化Embedding神经元与输入神经元之间的连接权重。假设FM的隐向量的维度为m,第i个特征域的第k维特征的隐向量v_{i,k} = (v_{i,k}^1,v_{i,k}^2,...,v_{i,k}^l,...,v_{i,k}^m),那么隐向量的第l维度v_{i,k}^l就会成为连接输入神经元kEmbedding神经元l之间连接权重的初始值。
需要说明的是,在训练FM的过程中,并没有对特征域进行区分,但在FNN中,特征被分成了不同特征域,因此每个特征域具有对应的Embedding层,并且每个特征域Embedding的维度都应该与FM隐向量维度保持一致。

使用预训练的FM来初始化FNN模型的第一层参数可以有效地学习特征表示,并且绕开了高维二值输入带来的计算复杂度高问题。​更进一步,隐含层的权重(除了FM层)可以通过预训练的RBM来进行初始化。FM的权重可以通过SGD来进行更新,我们只需要更新那些不为0的单元,这样可以减少大量的计算。通过预训练FM层和其他的层进行初始化之后,再通过监督学习的方法进行微调,使用交叉熵的损失函数:

使用反向传播的链式法则,FNN模型(包含FM)的权重可以被高效地更新,举个例子,FM层的权重可以通过下述公式来进行更新:
基于输入\boldsymbol{x}\left[\right. start _{i}: end \left._{i}\right]中大多数元素都为0这样一个事实,我们可以通过只更新那些非零的权重来加快fine-tuning。

FNN模型分析

FNN模型的特点:

  1. 采用FM预训练得到的隐含层及其权重作为神经网络的第一层的初始值,之后再不断堆叠全连接层,最终输出预测的点击率。
  2. 可以将FNN理解成一种特殊的embedding+MLP,其要求第一层嵌入后的各特征域特征维度一致,并且嵌入权重的初始化是FM预训练好的。
  3. 这不是一个端到端的训练过程,有贪心训练的思路。而且如果不考虑预训练过程,模型网络结构也没有考虑低阶特征组合。

FNN模型的优缺点:

  • 优点
    1.引入DNN对特征进行更高阶组合,减少特征工程,能在一定程度上增强FM的学习能力,这种尝试为后续深度推荐模型的发展提供了新的思路。

  • 缺点
    1.两阶段训练模式,在应用过程中不方便,且模型能力受限于FM表征能力的上限。
    2.FNN专注于高阶组合特征,但是却没有对低阶特征进行建模。

代码实践

模型部分代码:

import torch
import torch.nn as nn
from BaseModel.basemodel import BaseModel

class FNN(BaseModel):
    def __init__(self, config, dense_features_cols, sparse_features_cols):
        super(FNN, self).__init__(config)
        # 稠密和稀疏特征的数量
        self.num_dense_feature = dense_features_cols.__len__()
        self.num_sparse_feature = sparse_features_cols.__len__()

        # FNN的线性部分,对应 ∑WiXi
        self.embedding_layers_1 = nn.ModuleList([
            nn.Embedding(num_embeddings=feat_dim, embedding_dim=1)
                for feat_dim in sparse_features_cols
        ])

        # FNN的Interaction部分,对应∑∑<Vi,Vj>XiXj
        self.embedding_layers_2 = nn.ModuleList([
            nn.Embedding(num_embeddings=feat_dim, embedding_dim=config['embed_dim'])
                for feat_dim in sparse_features_cols
        ])

        # FNN的DNN部分
        self.hidden_layers = [self.num_dense_feature + self.num_sparse_feature*(config['embed_dim']+1)] + config['dnn_hidden_units']
        self.dnn_layers = nn.ModuleList([
            nn.Linear(in_features=layer[0], out_features=layer[1])\
                for layer in list(zip(self.hidden_layers[:-1], self.hidden_layers[1:]))
        ])
        self.dnn_linear = nn.Linear(self.hidden_layers[-1], 1, bias=False)

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

        # 求出线性部分
        linear_logit = [self.embedding_layers_1[i](sparse_inputs[:, i]) for i in range(sparse_inputs.shape[1])]
        linear_logit = torch.cat(linear_logit, axis=-1)

        # 求出稀疏特征的embedding向量
        sparse_embeds = [self.embedding_layers_2[i](sparse_inputs[:, i]) for i in range(sparse_inputs.shape[1])]
        sparse_embeds = torch.cat(sparse_embeds, axis=-1)

        dnn_input = torch.cat((dense_input, linear_logit, sparse_embeds), dim=-1)

        # DNN 层
        dnn_output = dnn_input
        for dnn in self.dnn_layers:
            dnn_output = dnn(dnn_output)
            dnn_output = torch.tanh(dnn_output)
        dnn_logit = self.dnn_linear(dnn_output)

        # Final
        y_pred = torch.sigmoid(dnn_logit)

        return y_pred

注意这里实现的FNN模型跟论文中的并不完全一样。论文中描述的FNN模型的第一层的参数是通过预训练好的FM来初始化的,因此模型需要分为两个阶段来训练。这里为了简化,直接使用了两个Embedding层来代替FM中应该学习得到的参数,使得网络可以以端到端的方式训练,简化代码实现。
测试部分代码:

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

fnn_config = \
{
    'embed_dim': 8, # 用于控制稀疏特征经过Embedding层后的稠密特征大小
    'dnn_hidden_units': [128, 128],
    'num_epoch': 150,
    'batch_size': 64,
    '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/FNN.model'
}

if __name__ == "__main__":
    ####################################################################################
    # FNN 模型
    ####################################################################################
    training_data, training_label, dense_features_col, sparse_features_col = getTrainData(fnn_config['train_file'], fnn_config['fea_file'])
    train_dataset = Data.TensorDataset(torch.tensor(training_data).float(), torch.tensor(training_label).float())

    test_data = getTestData(fnn_config['test_file'])
    test_dataset = Data.TensorDataset(torch.tensor(test_data).float())

    fnn = FNN(fnn_config, dense_features_cols=dense_features_col, sparse_features_cols=sparse_features_col)
    print(fnn)
    ####################################################################################
    # 模型训练阶段
    ####################################################################################
    # # 实例化模型训练器
    trainer = Trainer(model=fnn, config=fnn_config)
    # 训练
    trainer.train(train_dataset)
    # 保存模型
    trainer.save()

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

    y_pred_probs = fnn(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))

在criteo数据集上的部分测试结果,输出的是每一个测试数据的点击率预估:
测试结果

完整代码见:https://github.com/HeartbreakSurvivor/RsAlgorithms/tree/main/FNN

参考

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

推荐阅读更多精彩内容