乐趣区

关于机器学习:轻量级图卷积网络LightGCN介绍和构建推荐系统示例

举荐零碎是当今业界最具影响力的 ML 工作。从淘宝到抖音,科技公司都在一直尝试为他们的特定应用程序构建更好的举荐零碎。而这项工作并没有变得更容易,因为咱们每天都心愿看到更多可供选择的我的项目。所以咱们的模型不仅必须做出最优举荐,而且还必须高效地做出举荐。明天介绍的这个模型被称作:Light Graph Convolution Network 或 LightGCN¹。

让咱们将用户和我的项目设想成二分图中的节点,其中用户与曾经抉择的我的项目相连。所以寻找最佳举荐我的项目的问题就变成了链接预测问题。

示例数据集

作为一个理论示例,咱们所说的用户是搜寻音乐艺术家(“我的项目”)的音乐听众。原始数据集可在 ³ 取得。

该数据集蕴含 1824 位用户、6854 位艺术家和 20,664 个标签。一个一般艺术家与大概 3 个用户相关联,而一个普通用户与大概 11 个艺术家相关联,因为在这个特定数据集中,艺术家的数量大大超过了用户。这个数据集的一个特点是能够看到每个新连贯的创立工夫,这对咱们来说十分的重要,因为能够通过连接时间将数据分成训练集(最早工夫)和测试集(最新工夫)³。咱们的指标是想要创立一个举荐零碎模型来预测将来造成的新标签 / 连贯。

基于嵌入的模型

LightGCN 是一个基于嵌入的模型,这意味着它试图为用户和我的项目找到最佳嵌入(向量)。除此以外,它还在寻找最优评分函数 f,这个函数为新的用户 - 我的项目进行评分,分数高的则会被举荐。

对于嵌入向量,具备类似偏好的用户的嵌入会类似,而偏好不同的用户的嵌入会更加不同。

在持续钻研 lightGCN 之前,首先简略介绍一下的基于嵌入的模型,矩阵合成法在传统的举荐零碎中已被利用多年,并且成果始终都很好,所以它将作为咱们的基线模型:

上图是矩阵合成 (MF) 过程与原始图以及嵌入矩阵²的关系。

这里咱们将矩阵合成模型作为基线与 LightGCN 模型进行比拟。这里的评分函数 f 只是两个嵌入和模型通过最小化矩阵 (R – HW) 的 Frobenious 范数来训练的标量积,其中矩阵 R 是用户 - 我的项目邻接矩阵,而矩阵 H 蕴含用户嵌入,W 蕴含我的项目嵌入²。评分函数 f 在 lightGCN 的状况下是一样的,然而为了直观地了解模型,首先要思考 lightGCN 模型的性能优化指标是什么。

然而如何掂量性能呢?

一种风行的性能度量是从测试集中获取所有理论的新用户边并计算思考模型的前 K 个预测(意味着具备最高分数 f(用户,我的项目))。这个分数是为每个用户计算的,而后对所有用户的分数进行均匀以取得最终分数,称为 Recall @ K²。

然而 Recall@K 度量是不可微的,这意味着须要设计一个可微的损失函,这样 lightGCN 模型的训练能力利用梯度找到最优值。

设计这个损失函数的最次要的指标是:将来正边的计分函数后果是一个较大的数字,而将来负边的计分函数后果是一个较小的数字²。所以联合这两个问题的一个比拟好的办法是:心愿用户 u 的给定将来正边和用户 u 的给定将来负边之间的差值是一个较大的数字:

在应用 sigmoid 函数将两个分数的差别映射到区间 [0, 1]后,就可能将分数视为概率。因而对于一个给定用户 u,能够将给定的所有正边和负边对的分数组合起来输出损失函数中。而后在所有用户中均匀这些损失²以取得最终的损失,这称为贝叶斯个性化排名 (Bayesian Personalized Ranking BPR) 损失:

LightGCN

上面进入本文的正题,尽管矩阵合成办法仅捕捉图的一阶边连接结构(仅来自给定节点的间接街坊的信息),但咱们心愿模型可能捕捉更高阶的图构造。所以应用 LightGCN 来做这件事,它从用矩阵合成初始化的节点嵌入开始训练:

嵌入初始化后,LightGCN 应用 3 层来实现嵌入的训练,在每一层中,每个节点通过组合其街坊的嵌入来取得新的嵌入。这能够被认为是一种图卷积(参见上面与图像卷积的比拟):

图像卷积(左)能够看作是图卷积(右)的一个特例。图卷积是一种节点置换不变的操作。

上图所示,与卷积层相似重叠更多层意味着来自给定节点的信息可能取得离该节点更远的节点的信息,这样能够依据须要捕捉更高阶的图构造。然而在每次迭代 k 中,嵌入到底是如何组合成一个新的嵌入的呢?上面举两个例子:

上图为相邻我的项目嵌入对用户嵌入下一层的影响,反之亦然¹。初始嵌入的影响随着每次迭代而减小,因为它可能达到远离原点的更多节点。这就是所说的正在扩散嵌入,这种非凡的扩散形式还能够通过构建扩散矩阵进行矢量化并减速该过程:

从度矩阵和邻接矩阵²构建扩散矩阵。

因为扩散矩阵是从度矩阵 D 和邻接矩阵 A 计算出来的,所以扩散矩阵只须要计算一次并且不蕴含可学习的参数。模型中惟一可学习的参数是在浅输出节点嵌入,将其与扩散矩阵 K 次相乘以失去 (K + 1) 个嵌入,而后对其进行均匀以取得最终嵌入:

当初咱们理解了模型如何向前流传输出嵌入,这样就能够应用 PyTorch Geometric 对模型进行编码,而后应用下面提到的 BPR 损失来优化我的项目和用户的嵌入。PyG (PyTorch Geometric) 是一个基于 PyTorch 构建的库,可帮忙咱们编写和训练图形神经网络 (GNN)。

import torch

import torch.nn as nn
import torch.nn.functional as F
import torch_scatter
from torch_geometric.nn.conv import MessagePassing

class LightGCNStack(torch.nn.Module):
    def __init__(self, latent_dim, args):
        super(LightGCNStack, self).__init__()
        conv_model = LightGCN
        self.convs = nn.ModuleList()
        self.convs.append(conv_model(latent_dim))
        assert (args.num_layers >= 1), 'Number of layers is not >=1'
        for l in range(args.num_layers-1):
            self.convs.append(conv_model(latent_dim))

        self.latent_dim = latent_dim
        self.num_layers = args.num_layers
        self.dataset = None
        self.embeddings_users = None
        self.embeddings_artists = None

    def reset_parameters(self):
        self.embeddings.reset_parameters()

    def init_data(self, dataset):
        self.dataset = dataset
        self.embeddings_users = torch.nn.Embedding(num_embeddings=dataset.num_users, embedding_dim=self.latent_dim).to('cuda')
        self.embeddings_artists = torch.nn.Embedding(num_embeddings=dataset.num_artists, embedding_dim=self.latent_dim).to('cuda')

    def forward(self):
        x_users, x_artists, batch = self.embeddings_users.weight, self.embeddings_artists.weight, \
                                                self.dataset.batch

        final_embeddings_users = torch.zeros(size=x_users.size(), device='cuda')
        final_embeddings_artists = torch.zeros(size=x_artists.size(), device='cuda')
        final_embeddings_users = final_embeddings_users + x_users/(self.num_layers + 1)
        final_embeddings_artists = final_embeddings_artists + x_artists/(self.num_layers+1)
        for i in range(self.num_layers):
            x_users = self.convs[i]((x_artists, x_users), self.dataset.edge_index_a2u, size=(self.dataset.num_artists, self.dataset.num_users))
            x_artists = self.convs[i]((x_users, x_artists), self.dataset.edge_index_u2a, size=(self.dataset.num_users, self.dataset.num_artists))
            final_embeddings_users = final_embeddings_users + x_users/(self.num_layers+1)
            final_embeddings_artists = final_embeddings_artists + x_artists/(self.num_layers + 1)

        return final_embeddings_users, final_embeddings_artists

    def decode(self, z1, z2, pos_edge_index, neg_edge_index):  # only pos and neg edges
        edge_index = torch.cat([pos_edge_index, neg_edge_index], dim=-1)  # concatenate pos and neg edges
        logits = (z1[edge_index[0]] * z2[edge_index[1]]).sum(dim=-1)  # dot product
        return logits

    def decode_all(self, z_users, z_artists):
        prob_adj = z_users @ z_artists.t()  # get adj NxN
        #return (prob_adj > 0).nonzero(as_tuple=False).t()  # get predicted edge_list
        return prob_adj

    def BPRLoss(self, prob_adj, real_adj, edge_index):
        loss = 0
        pos_scores = prob_adj[edge_index.cpu().numpy()]
        for pos_score, node_index in zip(pos_scores, edge_index[0]):
            neg_scores = prob_adj[node_index, real_adj[node_index] == 0]
            loss = loss - torch.sum(torch.log(torch.sigmoid(pos_score.repeat(neg_scores.size()[0]) - neg_scores))) / \
                   neg_scores.size()[0]

        return loss / edge_index.size()[1]

    def topN(self, user_id, n):
        z_users, z_artists = self.forward()
        scores = torch.squeeze(z_users[user_id] @ z_artists.t())
        return torch.topk(scores, k=n)

class LightGCN(MessagePassing):
    def __init__(self, latent_dim, **kwargs):
        super(LightGCN, self).__init__(node_dim=0, **kwargs)
        self.latent_dim = latent_dim

    def forward(self, x, edge_index, size=None):
        return self.propagate(edge_index=edge_index, x=(x[0], x[1]), size=size)

    def message(self, x_j):
        return x_j

    def aggregate(self, inputs, index, dim_size=None):
        return torch_scatter.scatter(src=inputs, index=index, dim=0, dim_size=dim_size, reduce='mean')

应用 LightGCN 进行预测

PyTorch Geometric 还提供了训练的函数能够帮忙咱们简化训练过程。在实现训练后,嵌入示意当初曾经能够示意用户可能会喜爱类似的我的项目并具备类似的偏好。所以能够通过模型返回的最终嵌入来计算新的我的项目分数来预测每个用户对尚未看到的我的项目的偏好。对于每个用户举荐 K 个得分最高的我的项目(对用户来说是新的)。就像矩阵合成一样,评分函数 f 只是嵌入的标量积,通过矩阵乘法无效计算:

测试集还蕴含新用户,这些新用户未呈现在训练集中。所以在这种状况下,咱们只是举荐我的项目在训练集中存在的所有组合用户中都很受欢迎前 K 个我的项目。

from functools import partial

import get_pyg_data
from model import LightGCNStack
import torch

from src.data_preprocessing import TrainTestGenerator
from src.evaluator import Evaluator
from train_test import train, test
from torch_geometric.utils import train_test_split_edges
import time

import pandas as pd

class objectview(object):
    def __init__(self, *args, **kwargs):
        d = dict(*args, **kwargs)
        self.__dict__ = d

# Wrapper for evaluation
class LightGCN_recommender:
    def __init__(self, args):
        self.args = objectview(args)
        self.model = LightGCNStack(latent_dim=64, args=self.args).to('cuda')
        self.a_rev_dict = None
        self.u_rev_dict = None
        self.a_dict = None
        self.u_dict = None

    def fit(self, data: pd.DataFrame):
        # Default rankings when userID is not in training set
        self.default_recommendation = data["artistID"].value_counts().index.tolist()

        # LightGCN
        data, self.u_rev_dict, self.a_rev_dict, self.u_dict, self.a_dict = get_pyg_data.load_data(data)
        data = data.to("cuda")
        self.model.init_data(data)
        self.optimizer = torch.optim.Adam(params=self.model.parameters(), lr=0.001)

        best_val_perf = test_perf = 0

        for epoch in range(1, self.args.epochs+1):
            start = time.time()
            train_loss = train(self.model, data, self.optimizer)
            val_perf, tmp_test_perf = test(self.model, (data, data))
            if val_perf > best_val_perf:
                best_val_perf = val_perf
                test_perf = tmp_test_perf
            log = 'Epoch: {:03d}, Loss: {:.4f}, Val: {:.4f}, Test: {:.4f}, Elapsed time: {:.2f}'
            print(log.format(epoch, train_loss, best_val_perf, test_perf, time.time()-start))

    def recommend(self, user_id, n):
        try:
            recommendations = self.model.topN(self.u_dict[str(user_id)], n=n)
        except KeyError:

            recommendations = self.default_recommendation
        else:
            recommendations = recommendations.indices.cpu().tolist()
            recommendations = list(map(lambda x: self.a_rev_dict[x], recommendations))
        return recommendations

def evaluate(args):
    data_dir = "../data/"
    data_generator = TrainTestGenerator(data_dir)

    evaluator = Evaluator(partial(LightGCN_recommender, args), data_generator)
    evaluator.evaluate()

    evaluator.save_results('../results/lightgcn.csv', '../results/lightgcn_time.csv')
    print('Recall:')
    print(evaluator.get_recalls())
    print('MRR:')
    print(evaluator.get_mrr())

if __name__=='__main__':
    # best_val_perf = test_perf = 0
    # data = get_pyg_data.load_data()
    #data = train_test_split_edges(data)

    args = {'model_type': 'LightGCN', 'num_layers': 3, 'batch_size': 32, 'hidden_dim': 32,
         'dropout': 0, 'epochs': 1000, 'opt': 'adam', 'opt_scheduler': 'none', 'opt_restart': 0, 'weight_decay': 5e-3,
         'lr': 0.1, 'lambda_reg': 1e-4}

    evaluate(args)

后果比照

该模型在三年的三个测试集上运行:2008 年、2009 年和 2010 年。对于给定的测试集,训练数据由前几年建设的所有连贯组成,例如,在 2010 年的测试集上测试的模型,是在之前所有年份 (包含 2008 年和 2009 年) 的训练集上进行训练的。然而在 2008 年的测试集上测试的模型,只对 2007 年及更早的数据进行了训练。

模型产生预测后应用后面介绍的 Recall @ K 对其进行评估。上面的第一个表格显示了以矩阵合成作为基线的后果,而上面的第二个表格显示了应用 LightGCN 取得的后果:

通过矩阵合成失去 Recall@K 分数

LightGCN 的 Recall@K 分数

正如预期的那样召回率 @K 随着 K 的减少而减少,并且该模型仿佛在 2010 年的测试集上体现最好,可能是因为在这种状况下训练集的数据量是最大的。下面表都分明地表明 LightGCN 在 Recall @ K 方面优于矩阵合成基线模型。上面的图显示了三年内均匀的 Recall@K 值。

能够应用的另一个指标是均匀倒数排名 (mean reciprocal rank MRR)。该指标试图更好地阐明模型对预测连贯的确定水平。它通过思考所有理论正确的新连贯 Q 来做到这一点。对于每一个连贯,它都会查看有多少不正确的预测连贯(误报),以便取得该连贯的排名(可能的最小排名是 1,因为咱们还计算了正确的连贯自身)。这些排名的倒数进行均匀以取得 MRR:

对于 MRR,咱们能够再次分明地看到 LightGCN 模型比矩阵合成模型体现更好,如下表所示:

然而与用于初始化其嵌入的矩阵合成模型相比,LightGCN 模型的训练工夫要长得多。然而从名字中能够看出与其余图卷积神经网络相比,LightGCN 十分轻量级,这是因为 LightGCN 除了输出嵌入之外没有任何可学习的参数,这使得训练速度比用于举荐零碎的其余基于 GCN 的模型快得多。

对于预测的工夫,两个模型都须要几毫秒来生成预测,差距基本上能够忽略不计

援用

  1. Xiangnan He, Kuan Deng, Xiang Wang, Yan Li, Yongdong Zhang, and Meng Wang. Lightgcn: Simplifying and powering graph convolution network for recommendation. In Proceedings of the 43rd International ACM SIGIR conference on research and development in Information Retrieval, pages 639–648, 2020. arXiv:2002.02126
  2. Visualizations taken from lecture given by Jure Leskovec, available at http://web.stanford.edu/class…
  3. Iván Cantador, Peter Brusilovsky, and Tsvi Kuflik. 2nd workshop on information heterogeneity and fusion in recommender systems (hetrec 2011). In Proceedings of the 5th ACM conference on Recommender systems, RecSys 2011, New York, NY, USA, 2011. ACM.
  4. 本文代码 https://www.overfit.cn/post/4e217d5a562f40f9a5efc4a2b5300b09 (Authors: Ermin Omeragić, Tomaž Martičič, Jurij Nastran)

作者:jn2279

退出移动版