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

简介

本文要介绍的是由新加坡国立大学的研究人员在论文《Neural Factorization Machines for Sparse Predictive Analytics∗》中提出的NFM模型。NFM模型全称是Neural Factorization Machines,通过名字也可以看出,这又是一个基于FM模型改进得到的网络。无论是FM模型还是其改进模型FFM,归根结底是一个二阶特征交叉的模型。受到组合爆炸问题的困扰,FM几乎不可能扩展到三阶及其以上,这就不可避免地限制了FM模型的表达能力。而深度学习网络理论上有拟合任何复杂函数的能力,因此有没有可能使用DNN的更强的表达能力来对FM模型进行扩展呢?这也正是NFM模型出现的缘由。为了更好地理解NFM模型,依旧希望读者能够先熟悉FM模型的原理,可以参考推荐系统之FM(因子分解机)模型原理以及代码实践

现存问题

FM的问题

论文作者分析了在大规模特征组合场景下,传统方法比如LR、GBDT等方法在特征组合时候的缺陷,同时引入了FM算法和DNN方法。忽略应用领域,作者将特征组合的方式分为了两种:

  • 基于FM的线性模型
  • 基于神经网络的非线性模型

作者指出基于FM的方法具有很强的通用性,可以推广到很多领域。它是一种通用的预测器,可以和任何实值特征向量一起进行监督学习。同时作者也分析了FM模型的表达能力有限问题。先忽略效率,作者指出FM实际上仍然属于多变量线性模型家族。换句话说,预测目标\hat y(x)与每个模型参数之间仍然是线性关系。正式地,对于每个模型参数\theta \in\left\{w_{0},\left\{w_{i}\right\},\left\{v_{i f}\right\}\right\},我们可以得到\hat{y}(\mathbf{x})=g+h \theta,其中gh是与\theta独立的。不幸的是,真实世界的数据往往都是高度非线性并且不能够被线性模型准确地模拟出来。正因如此,FM可能缺乏足够强的表达能来对具有复杂固有结构和规则的真实数据进行建模。

DNN的问题

DNN的天然优势是可以以一种隐式的方式来学习任意顺序的组合特征。越深层次的网络结构可以学到更加高阶的特征表示,然而越深的网络结构就越难优化,因为随着网络深度的加深,臭名昭著的梯度消失、爆炸,过度拟合,网络退化等问题就会凸显出来。

为了实际展示DNNs的优化困难问题,作者画出了Wide&Deep和Deep&Cross模型在Frappe数据集上的训练和测试误差随着训练轮次的变化关系图,如下:

可以看到随机初始化网络的参数会导致很糟糕的性能表现。而使用FM预训练好的参数来初始化网络会提高训练网络的效率。

NFM模型

接下来给出作者提出的模型框架图,如下:

NFM模型
NFM模型主要是想结合FM模型以及DNN来对稀疏数据进行建模。与FM类似,NFM也是一个可以使用任意实值特征向量的通用的预测器。对于一个稀疏输入向量x \in \mathbb R^n,NFM通过以下公式来估计目标值:
上式中的前两部分是线性回归部分。第三项f(x)是NFM的核心,它是用一个多层前向神经网络用来对特征交互进行建模,即使用一个表达能力更强的函数来代替FM中二阶隐向量内积的部分。NFM模型和FM模型的关系如下:

接下来自底向上分层介绍NFM模型。

1. Embedding 层

Embedding层是一个全连接层,它将每个稀疏特征映射成一个稠密向量表示。正式地说,令v_i \in \mathbb R^k为第i个特征的embedding向量。在经过Embedding之后,我们可以获得一个embedding向量集合V_{x}=\left\{x_{1} \mathbf{v}_{1}, \ldots, x_{n} \mathbf{v}_{n}\right\}代表输入特征向量x。由于x的稀疏性,我们只包含了embedding向量中非零的部分(注意看NFM模型图中只有输入特征向量中不为零的特征才将对应的embedding向量传入下一层)。请注意,我们根据输入特征值重新调整了embedding向量,而不是简单地查找embedding表,以便覆盖所有实值特征。

这句话没太懂,不过我理解应该也是使用FM模型来构造输入特征的embedding向量。

2.Bi-Interaction 层

我们将embedding向量集合V_x传入Bi-Interaction层,它通过执行池化操作将embedding向量集合转换成单个向量。具体操作如下:

其中\odot代表两个向量的逐元素乘积,即两个长度相同的向量对应维相乘得到元素积向量,其中第k维的操作如下:
\left(\mathbf{v}_{i} \odot \mathbf{v}_{j}\right)_{k}=v_{i k} v_{j k} 显然,Bi-Interaction层的输出是一个k维的向量,此向量编码了embedding空间中任意两个特征之间的交互。
值得指出的是Bi-Interaction层的池化操作并没有引入额外的模型参数,更重要的是,它可以在线性时间内高效计算。这个特性与平均、最大池化操作以及聚合操作类似,这些操作比较简单,被广泛地运用在神经网络中。为了展示Bi-Interaction层的线性时间复杂度,我们可以将上式改写成下面的形式:
其中我们使用v^2来代表v \odot v。考虑到x的稀疏性,我们实际上可以在O(kN_x)的时间复杂度内执行Bi-Interaction池化操作,其中N_x代表x中的非零项。这是一个非常令人欢欣鼓舞的特性,这意味着Bi-Interaction池化操作在对任意一对特征进行特征交互的时候没有增加额外的开销。

3.Hidden Layers

在Bi-Interaction层的上面便是一系列堆叠而成的全连接层,它们能够学习到特征之间的更高阶交互。正式地,全连接层的定义如下:

L代表隐层的数量,W_l,b_l和\sigma_l分别代表第l层的权重矩阵,偏置向量和激活函数。通过指定非线性的激活函数,比如sigmoid、tanh、ReLU,模型可以以一种非线性的方式学习到高阶的特征交互。

4. 预测层

最后一个隐层的输出z_l通过以下方式转换成最终的预测分数:

h代表预测层的权重参数。

总结一下,NFM预测模型的计算公式可以被概括为:

相比于FM模型而言,NFM模型仅仅是多了参数\{W_l,b_,\},即DNN部分的参数,这部分参数是用来学习特征之间的更高阶交互。

NFM和FM的关系

FM可以认为是一个浅层的线性模型,它可以被看做是NFM模型的一个特例,即不包含隐层。为了表明这种关系,我们令L为0,直接将Bi-Interaction层的输出映射成预测分数,我们将这个模型简化为NFM-0,那么其方程如下:

NFM-0
如果我们将h设为一个常向量(1,...,1),那么我们就可以还原FM模型的方程。

NFM和Wide&Deep和DeepCross的关系

NFM与现存的几种深度学习解决方法都有着相同的多层神经网络结构。关键的不同点在于BI-Interaction池化组件,这个在NFM中是唯一的。如果我们将Bi-Interaction池化层换成一个concatenation层,并应用一个塔型MLP的隐层,那么我们就可以还原Wide&Deep模型。concatenation操作的一个明显缺陷是它并没有考虑不同特征之间的交互。因此,这些深度学习方法只能依靠接下来的全连接层来学习有意义的特征交互,然而不幸的是,这在实践中通常很难进行训练。

代码实践

模型部分代码(主要包含了B-Interaction模型和NFM模型):

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

class BiInteractionPooling(nn.Module):
    """Bi-Interaction Layer used in Neural FM,compress the
      pairwise element-wise product of features into one single vector.
      Input shape
        - A 3D tensor with shape:``(batch_size,field_size,embedding_size)``.
      Output shape
        - 3D tensor with shape: ``(batch_size,1,embedding_size)``.
    """
    def __init__(self):
        super(BiInteractionPooling, self).__init__()

    def forward(self, inputs):
        concated_embeds_value = inputs
        square_of_sum = torch.pow(
            torch.sum(concated_embeds_value, dim=1, keepdim=True), 2)
        sum_of_square = torch.sum(
            concated_embeds_value * concated_embeds_value, dim=1, keepdim=True)
        cross_term = 0.5 * (square_of_sum - sum_of_square)
        return cross_term

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

        # NFM的线性部分,对应 ∑WiXi
        self.linear_model = nn.Linear(self.num_dense_feature + self.num_sparse_feature, 1)

        # NFM的Embedding层
        self.embedding_layers = nn.ModuleList([
            nn.Embedding(num_embeddings=feat_dim, embedding_dim=config['embed_dim'])
                for feat_dim in sparse_features_cols
        ])

        # B-Interaction 层
        self.bi_pooling = BiInteractionPooling()
        self.bi_dropout = config['bi_dropout']
        if self.bi_dropout > 0:
            self.dropout = nn.Dropout(self.bi_dropout)

        # NFM的DNN部分
        self.hidden_layers = [self.num_dense_feature + config['embed_dim']] + 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.linear_model(x)

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

        # 送入B-Interaction层
        fm_input = sparse_embeds.view(-1, self.num_sparse_feature, self._config['embed_dim'])
        # print(fm_input)
        # print(fm_input.shape)

        bi_out = self.bi_pooling(fm_input)
        if self.bi_dropout:
            bi_out = self.dropout(bi_out)

        bi_out = bi_out.view(-1, self._config['embed_dim'])
        # 将结果聚合起来
        dnn_input = torch.cat((dense_input, bi_out), dim=-1)

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

        # Final
        logit = linear_logit + dnn_logit
        y_pred = torch.sigmoid(logit)

        return y_pred

依旧使用criteo数据集的小样本来做demo,测试部分代码如下:

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

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

def toOneHot(x, MaxList):
    res = []
    for i in range(len(x)):
        t = torch.zeros(MaxList[i])
        t[int(x[i])] = 1
        res.append(t)
    return torch.cat(res, -1)

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

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

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

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

    y_pred_probs = nfm(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/tree/main/NFM

参考

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

推荐阅读更多精彩内容