推荐模型NeuralCF:原理介绍与TensorFlow2.0实现

1. 简介

NCF是协同过滤在神经网络上的实现——神经网络协同过滤。由新加坡国立大学与2017年提出。

我们知道,在协同过滤的基础上发展来的矩阵分解取得了巨大的成就,但是矩阵分解得到低维隐向量求内积是线性的,而神经网络模型能带来非线性的效果,非线性可以更好地捕捉用户和物品空间的交互特征。因此可以极大地提高协同过滤的效果。

另外,NCF处理的是隐式反馈数据,而不是显式反馈,这具有更大的意义,在实际生产环境中隐式反馈数据更容易得到。

本篇论文展示了NCF的架构原理,以及实验过程和效果。

2. 网络架构和原理

image

2.1 输入

由于这篇文章的主要目的是协同过滤,因此输入为user和item的id,把他们进行onehot编码,然后使用单层神经网络进行降维即Embedding化。作为通用框架,其输入应该不限制与id类信息,可以是上下文环境,可以是基于内容的特征,基于邻居的特征等辅助信息。

为啥图中使用两组user和item的向量?一组走向GMF一组走向MLP?——后续分析

2.2 MLP部分

可以发现MLP部分为多层感知机的堆叠,每一层的输出就作为下一层的输入,文中描述最后一层Layer X表示模型的容量能力,所以越大容量就越大。

这部分可以捕获用户-物品的交互非线性关系,增强模型的表达能力。

每层的非线性通过ReLu(符合生物学特征;能带来稀疏性;符合稀疏数据,比tanh效果好一点)来激活,可以防止sigmoid带来的梯度消失问题

2.3 GMF部分

NCF其实是MF的一个通用化框架,去掉MLP部分,如果添加一层element-product(上图左侧),就是用户-物品隐向量的内积。同时NeuMF Layer仅仅使用线性激活函数,则最终的结果 就是MF的一个输出。如果激活函数是一般的函数,那么MF可以被称为GMF,Generalized Matrix Factorization广义矩阵分解。

2.4目标函数

如果是矩阵分解模型,常处理显式反馈数据,这样可以将目标函数定义为平方误差损失(MSE),然后进行回归预测:

\[L_{sqr}=\sum_{(u,i)\in y\cup y^-}w_{ui}(y_{ui}-\hat{y}_{ui})^2 \]

隐式反馈数据,MSE不好用,因此隐式反馈数据的标记不是分值而是用户是否观测过物品,即1 or 0.其中,1不代表喜欢,0也不代表不喜欢,仅仅是否有交互行为。

因此,预测分数就可以表示为用户和物品是否相关,表征相关的数学定义为概率,因此要限制网络输出为[0,1],则使用概率函数如,sigmoid函数。目的是求得最后一层输出的概率最大,即使用似然估计的方式来进行推导:

\[p(y,y^-|P,Q,\Theta_f)=\prod_{(u,i)\in{y}}\hat{y}_{ui}\prod_{(u,j)\in{y^-}}(1-\hat{y}_{uj}) \]

连乘无法光滑求导,且容易导致数值下溢,因此两边取对数,得到对数损失取负数可以最小化 损失函数,

\[L=-\sum_{(u,i)\in{y}}log\hat{y}_{ui}-\sum_{(u,j)\in{y^-}}log(1-\hat{y}_{uj})=-\sum_{(u,i)\in{y}\cup{y}^-}y_{ui}log \hat{y}_{ui}+(1-y_{ui})log(1-\hat{y}_{ui}) \]

2.5 GMF和MLP的结合

GMF,它应用了一个线性内核来模拟潜在的特征交互;MLP,使用非线性内核从数据中学习交互函数。接下来的问题是:我们如何能够在NCF框架下融合GMF和MLP,使他们能够相互强化,以更好地对复杂的用户-项目交互建模?一个直接的解决方法是让GMF和MLP共享相同的嵌入层(Embedding Layer),然后再结合它们分别对相互作用的函数输出。这种方式和著名的神经网络张量(NTN,Neural Tensor Network)有点相似。然而,共享GMF和MLP的嵌入层可能会限制融合模型的性能。例如,它意味着,GMF和MLP必须使用的大小相同的嵌入;对于数据集,两个模型的最佳嵌入尺寸差异很大,使得这种解决方案可能无法获得最佳的组合。为了使得融合模型具有更大的灵活性,我们允许GMF和MLP学习独立的嵌入,并结合两种模型通过连接他们最后的隐层输出。

黑体字部分解释了输入部分使用两组Embedding的作用。

3. 代码实现

使用TensorFlow2.0和Keras API 构造各个模块层,通过继承Layer和Model的方式来实现。

1. 输入数据

为了简化模型输入过程中的参数,使用一个namedtuple定义稀疏向量的关系,如下

from collections import namedtuple

# 使用具名元组定义特征标记:由名字 和 域组成,类似字典但是不可更改,轻量便捷
SparseFeat = namedtuple('SparseFeat', ['name', 'vocabulary_size', 'embedding_dim'])

2. 定义Embedding层

上篇Deep Crossing使用ReLu激活函数自定义单层神经网络作为Embedding不同的是,使用TF自带的Embedding模块。

好处是:自定义的Embedding需要自己对类别变量进行onehot后才能输入,而自带Embedding只需要定义好输入输入的格式,就能自动实现降维效果,简单方便。

class SingleEmb(keras.layers.Layer):
    def __init__(self, emb_type, sparse_feature_column):
        super().__init__()
        # 取出sparse columns
        self.sparse_feature_column = sparse_feature_column
        self.embedding_layer = keras.layers.Embedding(sparse_feature_column.vocabulary_size, 
                                                      sparse_feature_column.embedding_dim, 
                                                      name=emb_type + "_" + sparse_feature_column.name)    
    
    def call(self, inputs):
        return self.embedding_layer(inputs)

3. 定义NCF整个网络

class NearalCF(keras.models.Model):
    def __init__(self, sparse_feature_dict, MLP_layers_units):
        super().__init__()
        self.sparse_feature_dict = sparse_feature_dict
        self.MLP_layers_units = MLP_layers_units
        self.GML_emb_user = SingleEmb('GML', sparse_feature_dict['user_id'])
        self.GML_emb_item = SingleEmb('GML', sparse_feature_dict['item_id'])
        self.MLP_emb_user = SingleEmb('MLP', sparse_feature_dict['user_id'])
        self.MLP_emb_item = SingleEmb('MLP', sparse_feature_dict['item_id'])
        self.MLP_layers = []
        for units in MLP_layers_units:
            self.MLP_layers.append(keras.layers.Dense(units, activation='relu')) # input_shape=自己猜
        self.NeuMF_layer = keras.layers.Dense(1, activation='sigmoid')
        
    def call(self, X):
        #输入X为n行两列的数据,第一列为user,第二列为item
        GML_user = keras.layers.Flatten()(self.GML_emb_user(X[:,0]))
        GML_item = keras.layers.Flatten()( self.GML_emb_item(X[:,1]))
        GML_out = tf.multiply(GML_user, GML_item)
        MLP_user = keras.layers.Flatten()(self.MLP_emb_user(X[:,0]))
        MLP_item = keras.layers.Flatten()(self.MLP_emb_item(X[:,1]))
        MLP_out = tf.concat([MLP_user, MLP_item],axis=1)
        for layer in self.MLP_layers:
            MLP_out = layer(MLP_out)
        # emb的类型为int64,而dnn之后的类型为float32,否则报错
        GML_out = tf.cast(GML_out, tf.float32)
        MLP_out = tf.cast(MLP_out, tf.float32)
        concat_out = tf.concat([GML_out, MLP_out], axis=1)
        return self.NeuMF_layer(concat_out)

3. 模型验证

  • 数据处理

    按照论文正负样本标记为1一个观测样本,4个未观测样本,所以需要训练测试集的处理

# 数据处理
# train是字典形式,不然不太容易判断是否包含u,i对
def get_data_instances(train, num_negatives, num_items):
    user_input, item_input, labels = [],[],[]
    for (u, i) in train.keys():
        # positive instance
        user_input.append(u)
        item_input.append(i)
        labels.append(1)
        # negative instances
        for t in range(num_negatives):
            j = np.random.randint(num_items)
            while train.__contains__((u, j)): # python3没有has_key方法
                j = np.random.randint(num_items)
            user_input.append(u)
            item_input.append(j)
            labels.append(0)
    return user_input, item_input, labels

# 这个字典,当数据量较大时,可以使用scipy.sparse 的dok_matrix:sparse.dok_matrix
def get_data_dict(data, lst=['userId', 'movieId']):
    d = dict()
    for idx, row in data[lst].iterrows():
        d[(row[0], row[1])] = 1
    return d

得到数据(可使用movielen数据)

train, test = train_test_split(data, test_size=0.1,random_state=13)
train_dict, test_dict = get_data_dict(train), get_data_dict(test)
train_set, test_set = get_data_instances(train_dict, 4, train['movieId'].max()), get_data_instances(test_dict, 4, test['movieId'].max())
  • 模型验证
# 这里没特意设置验证集,因此直接使用array来喂给模型
BATCH = 128
X = np.array([train_set[0], train_set[1]]).T # 根据模型的输入为两列,因此转置
# 模型验证
feature_columns_dict = {'user_id': SparseFeat('user_id', data.userId.nunique(), 8), 
                        'item_id': SparseFeat('item_id', data.movieId.nunique(), 8)}
# 模型
model = NearalCF(feature_columns_dict, [16, 8, 4])
model.compile(loss=keras.losses.binary_crossentropy,
              optimizer=keras.optimizers.Adam(0.001),
              metrics=['acc'])
model.fit(X,
         np.array(train_set[2]), 
         batch_size=BATCH,
         epochs=5, verbose=2, validation_split=0.1)

out:

Train on 408384 samples, validate on 45376 samples
Epoch 5/5
408384/408384 - 10s - loss: 1708.5975 - acc: 0.8515 - val_loss: 277.9610 - val_acc: 0.8635
X_test = np.array([test_set[0], test_set[1]]).T
loss, acc = model.evaluate(X_test, np.array(test_set[2]),batch_size=BATCH, verbose=0)
print(loss, acc) # 276.6405882021682 0.86309004

4. 说明

  • tf.data.Dataset的数据处理方式已经在前面文章提到了,这里换种思路和方式,在划分数据集的时候不划分验证集,而是使用array的形式输入后,在fit阶段划分。如果是Dataset的格式则不能进行fit阶段划分,详情见官网fit的函数说明。

  • 文章中所计算的评估指标是HR@10和NDCG@10,并对BPR,ALS等经典的传统方法进行了比较发现最终的NCF的效果是最好的;

  • 文章中高斯分布初始化参数,推荐使用的是pre-training的GMF和MLP模型,预训练过程优化方法为Adam方法,在合并为NCF过程后,由于未保存参数之外的动量信息,所以使用SGD方法优化;

  • 在合并为NCF时,还有一个可调节超参数是GML_out和MLP_out的系数\(\alpha\),pre-training时为0.5,本篇博客直接使用了0.5且没有使用预训练方式,仅仅展示了使用tf构造NCF模型的过程。

  • MLP的Layer X越大模型的容量越大,越容易导致过拟合,至于使用多少 视实验情况而定。文章中使用了[8, 16, 32, 64]来测试。

  • 与DeepCrossing和AutoRec的深层一样,越深效果越好。

4. 小结

本篇文章介绍了神经协同过滤的网络架构和代码实践,并对文章实验中的细节部分加以说明。

NCF模型混合了MLP和GML二者的特性,具有更强的特征组合以及非线性表达的能力。

要注意的是模型结构不是越复杂越好,要防止过拟合,这部分并没有使用Dropout和参数初始化的正则化,因为模型相对简单。

NCF模型的局限性在于协同过滤思想中只用用户-物品的id信息,尽管可以添加辅助信息,这些需要后续的研究人员进行扩展,同时文章中说损失是基于pointwise的损失 可能也可以尝试pairwise的损失。

posted @ 2021-03-27 11:37  道之有道  阅读(2085)  评论(0编辑  收藏  举报