图神经网络实战(24)——基于LightGCN构建推荐系统

发布于:2025-02-25 ⋅ 阅读:(9) ⋅ 点赞:(0)

0. 前言

推荐系统已成为现代网络平台不可或缺的一部分,其目标是根据用户的兴趣和历史互动情况向用户提供个性化推荐。推荐系统具有广泛应用,例如,在电子商务网站上向用户推荐商品,在流媒体服务上向用户推荐内容,以及在社交媒体平台上向用户推荐可能认识的用户。推荐系统是图神经网络 (Graph Neural Networks, GNN) 的主要应用之一,可以有效地将用户、物品及其交互之间的复杂关系纳入一个统一的模型中。此外,图结构还能够将用户和项目元数据等附带信息纳入推荐过程中。
在本节中,我们将介绍 LightGCN 架构,它是专为推荐系统设计的,并使用 Book-Crossing 数据集训练模型,Book-Crossing 数据集包含用户、图书和超过一百万个评分。利用该数据集,我们将构建一个基于协同过滤的图书推荐系统,并将其应用于为特定用户生成推荐。通过这一过程,我们将演示如何使用 LightGCN 架构构建实用推荐系统。

1. Book-Crossing 数据集介绍

在本节中,我们将对数据集 Book-Crossing 进行探索分析,并查看其主要特征。
Book-Crossing 数据集是由 BookCrossing 社区中 278,858 名用户提供的图书评分集。这些评分既有显式评分(评分在 1 到 ``之间),也有隐式评分(用户与图书有所交互),总计 1,149,780 个评分,涉及 271,379 本图书。该数据集是由 Cai-Nicolas Ziegler20048 月和 9 月为期四周内收集的。在本节中,我们将使用 Book-Crossing 数据集构建图书推荐系统。
首先,下载数据集,并进行解压缩。解压后将得到 3 个文件:

  • BX-Users.csv:包含 BookCrossing 用户的个人数据。用户 ID 已匿名化,并以整数表示。其中还包含一些用户信息,如所在地和年龄等,如果缺少这些信息,相应字段将为 NULL
  • BX-Books.csv:包含数据集中的书籍数据,通过其 ISBN 进行标识,无效的 ISBN 已从数据集中删除。除了基于内容的信息(如书名、作者、出版年份和出版商)外,该文件还包括链接到三种不同尺寸图书封面图片的 URL
  • BX-Book-Ratings.csv:包含数据集中图书的评分信息。评分可以是显式的,以 1-10 分表示,数值越高表示越受欢迎;也可以是隐性的,以 0 分表示

下图是使用 Gephi 制作的该数据集的一个子样本:

数据集可视化

节点的大小与图中的连接数(度)成正比。可以看到,《The Da Vinci Code》等热门图书由于连接数较高,因此起到枢纽作用。接下来,让我们继续探索数据集,以获得更多相关信息。

(1) 导入 pandas 并加载文件,为了解决兼容性问题,使用 ; 分隔符和 latin-1 编码。对于 BX-Books.csv,还需要设置 error_bad_lines 参数:

import pandas as pd

ratings = pd.read_csv('BX-CSV/BX-Book-Ratings.csv', sep=';', encoding='latin-1')
users = pd.read_csv('BX-CSV/BX-Users.csv', sep=';', encoding='latin-1')
books = pd.read_csv('BX-CSV/BX-Books.csv', sep=';', encoding='latin-1', error_bad_lines=False)

打印 ratings 数据帧,查看列数和行数:

print(ratings)

ratings数据帧

users 数据帧重复以上过程:

print(users)

users数据帧

由于 books 数据帧的列数过多,无法像其他两个数据帧一样打印,因此同时打印列名:

print(books)
print(list(books.columns))

books数据帧

ratings 数据帧使用 User-IDISBN 信息将 usersbooks 数据帧连接起来,并包含评分(可将其视为边权重)。Users 数据帧包括每个用户的统计信息,如所在地和年龄等。books 数据帧包括与书籍内容相关的信息,如书名、作者、出版年份、出版商和链接到三种不同尺寸封面图片的 URL

(2) 将评分分布可视化,可以使用 matplotlibseaborn 进行绘制:

import matplotlib.pyplot as plt 
import seaborn as sns 

sns.countplot(x=ratings['Book-Rating']) 
plt.show()

数据分布

(3) 判断评分数据是否与 booksusers 数据帧中的数据一致,可以将 ratings 数据帧中的唯一 User-IDISBN 条目数与 booksusers 数据帧中的行数进行比较:

print(len(ratings['User-ID'].unique())) 
# 105283
print(len(ratings['ISBN'].unique())) 
# 340556

users 相比,ratings 中的唯一用户数量较少( 105,283278,858),但与 books 相比,唯一 ISBN 数量较多( 340,556271,379)。这意味着数据集中缺少很多值,因此在连接表时需要多加注意。

(4) 绘制只被评价过一次、两次等的图书数量。首先,使用 groupby()size() 函数计算每个 ISBNratings 数据帧中出现的次数:

isbn_counts = ratings.groupby('ISBN').size()

得到一个新的数据帧—— isbn_counts,其中包含ratings数据帧中每个唯一 ISBN 的计数。

(5) 使用 value_counts() 函数计算每个计数值的出现次数。这个新的数据帧将包含 isbn_counts 中每个计数值的出现次数:

count_occurrences = isbn_counts.value_counts()

(6) 使用 pandasplot() 方法绘制分布图。在本节中,我们只绘制前 15 个值:

count_occurrences[:15].plot(kind='bar')
plt.xlabel("Number of occurrences of an ISBN number")
plt.ylabel("Count")
plt.show()

分布图

可以看到,很多图书只被评价过一两次。很少能看到有大量评分的书籍,这为模型的构建带来了困难,因为模型的构建依赖于这些联系。

(7) 重复同样的过程,得到每个用户 (User-ID) 在 ratings 数据帧中出现的次数分布:

userid_counts = ratings.groupby('User-ID').size() 
count_occurrences = userid_counts.value_counts() 
count_occurrences[:15].plot(kind='bar')
plt.xlabel("Number of occurrences of a User-ID")
plt.ylabel("Count")
plt.show()

数据分布

可以看到,大多数用户只对一两本书进行评分,但也有少数用户对很多书进行评分。
该数据集存在不同问题,例如出版年份或出版商名称的错误,以及其他缺失或错误的值。但在本节中我们不会直接使用图书和用户数据帧中的元数据。我们将依靠 User-IDISBN 值之间的联系,这就是我们无需在本节进行数据清理的原因。
在下一节中,我们将介绍如何处理数据集,以便将其处理为适合 LightGCN 输入的形式。

2. Book-Crossing 数据集预处理

协同过滤 (collaborative filtering) 是一种用于向用户提供个性化推荐的技术。它的核心理念在于具有相似偏好或行为的用户更有可能具有相似的兴趣。协作过滤算法利用这些信息来识别模式,并根据相似用户的偏好向用户提供推荐。
这与基于内容的过滤不同,后者是一种依赖于被推荐物品特征的推荐方法。它通过识别物品的特征并将其与用户过去喜欢的其他物品的特征进行匹配来生成推荐。基于内容的过滤 (content-based filtering) 方法通常基于以下理念:如果用户喜欢具有某些特征的物品,那么他们也会喜欢具有类似特征的物品。
在本节中,我们将重点讨论协同过滤,目标是根据其他用户的偏好来决定向用户推荐哪本书,此问题可以表示为下图所示的二部图形式。

二部图

已知用户 1 喜欢物品 AC,用户 3 喜欢物品 AD,我们可能应该向用户 2 推荐物品 A,因为用户 2 也喜欢 C
这就是我们要从 Book-Crossing 数据集中构建的图类型。更准确地说,我们还希望包含负样本,其中负样本指的是未被特定用户进行评分的物品,由特定用户评分的物品也被称为正样本。我们将在实现损失函数时解释为什么要使用负采样技术。

(1) 导入所需库:

import numpy as np
from sklearn.model_selection import train_test_split

import torch
import torch.nn.functional as F
from torch import nn, optim, Tensor

from torch_geometric.utils import structured_negative_sampling
from torch_geometric.nn.conv.gcn_conv import gcn_norm
from torch_geometric.nn import LGConv

(2) 重新加载数据集:

df = pd.read_csv('BX-CSV/BX-Book-Ratings.csv', sep=';', encoding='latin-1') 
users = pd.read_csv('BX-CSV/BX-Users.csv', sep=';', encoding='latin-1') 
books = pd.read_csv('BX-CSV/BX-Books.csv', sep=';', encoding='latin-1', error_bad_lines=False) 

(3) 只保留在 books 数据帧中能找到 ISBN 信息和在 users 数据帧中能找到 User-ID 信息的行:

df = df.loc[df['ISBN'].isin(books['ISBN'].unique()) & df['User-ID'].isin(users['User-ID'].unique())]

(4) 只保留高评分 (>= 8/10),这样创建的连接就与用户喜欢的图书相对应。然后,过滤掉更多样本,并保留有限数量的行 (100,000),以加快训练速度:

df = df[df['Book-Rating'] >= 8].iloc[:100000]

(5) 创建从用户和物品标识符到整数索引的映射:

user_mapping = {userid: i for i, userid in enumerate(df['User-ID'].unique())}
item_mapping = {isbn: i for i, isbn in enumerate(df['ISBN'].unique())}

(6) 计算数据集中的用户数、物品数和实体总数:

num_users = len(user_mapping)
num_items = len(item_mapping)
num_total = num_users + num_items

(7) 根据数据集中的用户评分创建用户和物品索引张量。通过堆叠这两个张量,创建 edge_index 张量:

user_ids = torch.LongTensor([user_mapping[i] for i in df['User-ID']])
item_ids = torch.LongTensor([item_mapping[i] for i in df['ISBN']])
edge_index = torch.stack((user_ids, item_ids))

(8) 使用 scikit-learntrain_test_split() 函数将 edge_index 拆分为训练集、验证集和测试集:

train_index, test_index = train_test_split(range(len(df)), test_size=0.2, random_state=0)
val_index, test_index = train_test_split(test_index, test_size=0.5, random_state=0)

train_edge_index = edge_index[:, train_index]
val_edge_index = edge_index[:, val_index]
test_edge_index = edge_index[:, test_index]

(9) 使用 np.random.choice() 函数生成一批随机索引 index,从 0edge_index.shape[1]-1 的范围内生成 BATCH_SIZE 个随机索引。这些索引将用于从 edge_index 张量中选择行:

def sample_mini_batch(edge_index):
    # Generate BATCH_SIZE random indices
    index = np.random.choice(range(edge_index.shape[1]), size=BATCH_SIZE)

使用 PyTorch Geometricstructured_negative_sampling() 函数生成负样本,负样本是指相应用户未与之交互的项目,使用 torch.stack() 函数在开头添加一个维度:

    edge_index = structured_negative_sampling(edge_index)
    edge_index = torch.stack(edge_index, dim=0)

使用 index 数组和 edge_index 张量为批数据选择用户、正样本和负样本索引:

    user_index = edge_index[0, index]
    pos_item_index = edge_index[1, index]
    neg_item_index = edge_index[2, index]
    
    return user_index, pos_item_index, neg_item_index

user_index 张量包含批数据的用户索引,pos_item_index 张量包含批数据的正样本索引,neg_item_index 张量包含批数据的负样本索引。
对数据集进行预处理后,接下来,我们继续介绍并实现 LightGCN 架构。

3. 构建 LightGCN 架构

3.1 LightGCN 架构

LightGCN 架构旨在通过平滑图的特征来学习节点的表示。它迭代地执行图卷积,将相邻节点的特征汇总为目标节点的新表示,LightGCN 整体架构如下所示。

LightGCN

LightGCN 采用的是简单的加权和聚合器,而非像图卷积网络 (Graph Convolutional Network, GCN) 图注意力网络 (Graph Attention Networks,GAT) 等其他模型那样使用特征转换或非线性激活。轻量级图卷积操作计算第 k + 1 k+1 k+1 个用户 e u ( k + 1 ) e_u^{(k+1)} eu(k+1) 和物品嵌入 e i ( k + 1 ) e_i^{(k+1)} ei(k+1) 如下:
e u ( k + 1 ) = ∑ i ∈ N u 1 ∣ N u ∣ ∣ N i ∣ e i ( k ) e i ( k + 1 ) = ∑ u ∈ N i 1 ∣ N i ∣ ∣ N u ∣ e u ( k ) e_u^{(k+1)}=\sum _{i\in \mathcal N_u}\frac {1}{\sqrt {|\mathcal N_u|}\sqrt {|\mathcal N_i|}}e_i^{(k)}\\ e_i^{(k+1)}=\sum _{u\in \mathcal N_i}\frac {1}{\sqrt {|\mathcal N_i|}\sqrt {|\mathcal N_u|}}e_u^{(k)} eu(k+1)=iNuNu Ni 1ei(k)ei(k+1)=uNiNi Nu 1eu(k)
对称归一化项确保嵌入的规模不会随着图卷积操作而增加。与其他模型不同的是,LightGCN 只聚合连接的邻居,而不包括自连接。
事实上,LightGCN 通过层组合操作能够达到同样的效果,这种机制利用各层的用户和物品嵌入进行加权求和,通过以下公式产生最终的嵌入信息 e u e_u eu e i e_i ei
e u = ∑ k = 0 K α k e u ( k ) e i = ∑ k = 0 K α k e i ( k ) e_u=\sum_{k=0}^K\alpha_k e_u^{(k)}\\ e_i=\sum_{k=0}^K\alpha_k e_i^{(k)} eu=k=0Kαkeu(k)ei=k=0Kαkei(k)
其中,第 k k k 层的贡献由变量 α ≥ 0 α ≥ 0 α0 加权,LightGCN 的作者建议将其设置为 1 ( K + 1 ) \frac 1 {(K + 1)} (K+1)1
LightGCN 模型架构图中所示的预测与评分或排名得分相对应,它是利用用户和物品最终表示的内积得到的:
y ^ u i = e u T e i \hat y_{ui}=e_u^Te_i y^ui=euTei

2. 实现 LightGCN

接下来,使用 PyTorch Geometric 实现 LightGCN 架构。

(1) 创建 LightGCN 类,它包含四个参数:num_usersnum_itemsnum_layerdim_hnum_usersnum_items 参数分别指定数据集中用户和物品的数量,num_layer 表示将使用的 LightGCN 层数,dim_h 参数指定嵌入向量(用户和物品)的大小:

class LightGCN(nn.Module):
    def __init__(self, num_users, num_items, num_layers=4, dim_h=64):
        super().__init__()

(2) 存储用户和物品的数量,并创建用户和物品嵌入层。emb_users ( e u ( 0 ) e_u^{(0)} eu(0)) 的形状是 (num_users, dim_h)emb_items ( e i ( 0 ) e_i^{(0)} ei(0)) 的形状是 (num_itmes, dim_h)

        self.num_users = num_users
        self.num_items = num_items
        self.num_layers = num_layers
        self.emb_users = nn.Embedding(num_embeddings=self.num_users, embedding_dim=dim_h)
        self.emb_items = nn.Embedding(num_embeddings=self.num_items, embedding_dim=dim_h)

(3) 使用 PyTorch GeometricLGConv() 创建包含 num_layer ( K K K) 个 LightGCN 层的列表,用于执行轻量级图卷积操作:

        self.convs = nn.ModuleList(LGConv() for _ in range(num_layers))

(4) 使用标准差为 0.01 的正态分布来初始化用户层和物品嵌入层,有助于防止模型在训练时陷入较差的局部最优状态:

        nn.init.normal_(self.emb_users.weight, std=0.01)
        nn.init.normal_(self.emb_items.weight, std=0.01)

(5) forward() 方法接受边索引张量,并返回最终的用户和物品嵌入向量 e u ( K ) e_u^{(K)} eu(K) e i ( K ) e_i^{(K)} ei(K)。首先,将用户和物品嵌入层连接起来,并将结果存储在 emb 张量中,然后创建列表 embs,以 emb 作为第一个元素:

    def forward(self, edge_index):
        emb = torch.cat([self.emb_users.weight, self.emb_items.weight])
        embs = [emb]

(6) 然后,循环应用 LightGCN 层,并将每个层的输出存储在 embs 列表中:

    def forward(self, edge_index):
        emb = torch.cat([self.emb_users.weight, self.emb_items.weight])
        embs = [emb]

(7) 通过计算 embs 列表中各张量在第二个维度上的平均值,得出最终的嵌入向量,从而进行层组合:

        emb_final = 1/(self.num_layers+1) * torch.mean(torch.stack(embs, dim=1), dim=1)

(8)emb_final 分割成用户和物品嵌入向量 ( e u e_u eu e i e_i ei),并将它们与 e u ( 0 ) e_u^{(0)} eu(0) e i ( 0 ) e_i^{(0)} ei(0) 一起返回:

        emb_users_final, emb_items_final = torch.split(emb_final, [self.num_users, self.num_items])

        return emb_users_final, self.emb_users.weight, emb_items_final, self.emb_items.weight

(9) 最后,通过调用带有适当参数的 LightGCN() 类来创建模型:

model = LightGCN(num_users, num_items)

3.3 损失函数

在训练模型之前,需要一个损失函数。LightGCN 架构采用了贝叶斯个性化排序 (Bayesian Personalized Ranking, BPR) 损失函数,它可以优化模型的能力,使给定用户的正样本排序高于负样本。其实现方法如下:
L B P R = − ∑ u = 1 M ∑ i ∈ N u ∑ j ∉ N u l n σ ( y ^ u i − y ^ u j ) + λ ∣ ∣ E ( 0 ) ∣ ∣ 2 L_{BPR}=-\sum_{u=1}^M\sum_{i\in \mathcal N_u}\sum_{j\notin \mathcal N_u}ln\sigma(\hat y_{ui}-\hat y_{uj})+\lambda ||E^{(0)}||^2 LBPR=u=1MiNuj/Nul(y^uiy^uj)+λ∣∣E(0)2
其中, E ( 0 ) E^{(0)} E(0) 是第 0 层嵌入矩阵(初始用户和物品嵌入的连接), λ λ λ 衡量正则化强度, y ^ u i \hat y_{ui} y^ui 对应于正样本的预测评分, y ^ u j \hat y_{uj} y^uj 代表负样本的预测评分,接下来,使用 PyTorch 实现 bpr_loss 函数。

(1) 根据存储在 LightGCN 模型中的嵌入计算正则化损失:

def bpr_loss(emb_users_final, emb_users, emb_pos_items_final, emb_pos_items, emb_neg_items_final, emb_neg_items):
    reg_loss = LAMBDA * (emb_users.norm().pow(2) +
                        emb_pos_items.norm().pow(2) +
                        emb_neg_items.norm().pow(2))

(2) 以用户嵌入和物品嵌入之间的点积来计算正样本和负样本物品的评分:

    pos_ratings = torch.mul(emb_users_final, emb_pos_items_final).sum(dim=-1)
    neg_ratings = torch.mul(emb_users_final, emb_neg_items_final).sum(dim=-1)

(3) 与上一公式中的对数 sigmoid 不同,计算 BPR 损失时,将 softplus 函数的平均值应用于正负分数之差,这是因为它能带来更好的实验结果:

    bpr_loss = torch.mean(torch.nn.functional.softplus(pos_ratings - neg_ratings))
    # bpr_loss = torch.mean(torch.nn.functional.logsigmoid(pos_ratings - neg_ratings))

(4) 返回 BPR 损失与正则化损失:

    return -bpr_loss + reg_loss

除了 BPR 损失,同时使用以下两个指标来评估模型的性能:

  • Recall@k:在所有可能的相关物品中,排名前 k k k 的相关推荐物品所占的比例。但该指标并不考虑相关物品在前 k k k 项中的顺序:
def get_user_items(edge_index):
    user_items = dict()
    for i in range(edge_index.shape[1]):
        user = edge_index[0][i].item()
        item = edge_index[1][i].item()
        if user not in user_items:
            user_items[user] = []
        user_items[user].append(item)
    return user_items

def compute_recall_at_k(items_ground_truth, items_predicted):
    num_correct_pred = np.sum(items_predicted, axis=1)
    num_total_pred = np.array([len(items_ground_truth[i]) for i in range(len(items_ground_truth))])

    recall = np.mean(num_correct_pred / num_total_pred)

    return recall
  • 归一化折扣累计增益 (Normalized Discounted Cumulative Gain, NDGC):衡量系统对推荐的排名有效性,同时考虑到物品的相关性,相关性通常用分数或二元相关性(相关或不相关)来表示:
def compute_ndcg_at_k(items_ground_truth, items_predicted):
    test_matrix = np.zeros((len(items_predicted), K))

    for i, items in enumerate(items_ground_truth):
        length = min(len(items), K)
        test_matrix[i, :length] = 1
    
    max_r = test_matrix
    idcg = np.sum(max_r * 1. / np.log2(np.arange(2, K + 2)), axis=1)
    dcg = items_predicted * (1. / np.log2(np.arange(2, K + 2)))
    dcg = np.sum(dcg, axis=1)
    idcg[idcg == 0.] = 1.
    ndcg = dcg / idcg
    ndcg[np.isnan(ndcg)] = 0.
    
return np.mean(ndcg)

定义 get_metricstest 函数用于度量模型性能

def get_metrics(model, edge_index, exclude_edge_indices):

    ratings = torch.matmul(model.emb_users.weight, model.emb_items.weight.T)

    for exclude_edge_index in exclude_edge_indices:
        user_pos_items = get_user_items(exclude_edge_index)
        exclude_users = []
        exclude_items = []
        for user, items in user_pos_items.items():
            exclude_users.extend([user] * len(items))
            exclude_items.extend(items)
        ratings[exclude_users, exclude_items] = -1024

    # get the top k recommended items for each user
    _, top_K_items = torch.topk(ratings, k=K)

    # get all unique users in evaluated split
    users = edge_index[0].unique()

    test_user_pos_items = get_user_items(edge_index)

    # convert test user pos items dictionary into a list
    test_user_pos_items_list = [test_user_pos_items[user.item()] for user in users]

    # determine the correctness of topk predictions
    items_predicted = []
    for user in users:
        ground_truth_items = test_user_pos_items[user.item()]
        label = list(map(lambda x: x in ground_truth_items, top_K_items[user]))
        items_predicted.append(label)

    recall = compute_recall_at_k(test_user_pos_items_list, items_predicted)
    ndcg = compute_ndcg_at_k(test_user_pos_items_list, items_predicted)

return recall, ndcg

def test(model, edge_index, exclude_edge_indices):
    emb_users_final, emb_users, emb_items_final, emb_items = model.forward(edge_index)
    user_indices, pos_item_indices, neg_item_indices = structured_negative_sampling(edge_index, contains_neg_self_loops=False)

    emb_users_final, emb_users = emb_users_final[user_indices], emb_users[user_indices]

    emb_pos_items_final, emb_pos_items = emb_items_final[pos_item_indices], emb_items[pos_item_indices]
    emb_neg_items_final, emb_neg_items = emb_items_final[neg_item_indices], emb_items[neg_item_indices]

    loss = bpr_loss(emb_users_final, emb_users, emb_pos_items_final, emb_pos_items, emb_neg_items_final, emb_neg_items).item()

    recall, ndcg = get_metrics(model, edge_index, exclude_edge_indices)

    return loss, recall, ndcg

3.4 模型训练与测试

接下来,创建训练循环用于训练 LightGCN 模型。

(1) 定义以下常数,可以作为超参数进行调整,以提高模型的性能:

K = 20
LAMBDA = 1e-6
BATCH_SIZE = 1024

(2) 将模型和数据转移到指定设备上:

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = model.to(device)
edge_index = edge_index.to(device)
train_edge_index = train_edge_index.to(device)
val_edge_index = val_edge_index.to(device)

(3) 创建学习率为 0.001Adam 优化器:

optimizer = optim.Adam(model.parameters(), lr=0.001)

(4) 开始训练循环。首先,计算 num_batch,即一个 epochBATCH_SIZE 批数据的数量。然后,创建两个循环,第一个指定训练 31epoch,另一循环指定一个 epoch 中的批数据数量:

n_batch = int(len(train_index)/BATCH_SIZE)

for epoch in range(31):
    model.train()

    for _ in range(n_batch):

(5) 在训练数据上训练模型,并返回初始和最终的用户和物品嵌入:

        optimizer.zero_grad()

        emb_users_final, emb_users, emb_items_final, emb_items = model.forward(train_edge_index)

(6) 使用 sample_mini_batch() 函数对训练数据进行小批量采样,并返回采样用户、正样本和负样本嵌入的索引:

        user_indices, pos_item_indices, neg_item_indices = sample_mini_batch(train_edge_index)

(7) 检索采样用户、正样本和负样本嵌入:

        emb_users_final, emb_users = emb_users_final[user_indices], emb_users[user_indices]
        emb_pos_items_final, emb_pos_items = emb_items_final[pos_item_indices], emb_items[pos_item_indices]
        emb_neg_items_final, emb_neg_items = emb_items_final[neg_item_indices], emb_items[neg_item_indices]

(8) 使用 bpr_loss() 函数计算损失:

        train_loss = bpr_loss(emb_users_final, emb_users, emb_pos_items_final, emb_pos_items, emb_neg_items_final, emb_neg_items)

(9) 使用优化器执行反向传播并更新模型参数:

        train_loss.backward()
        optimizer.step()

(10) 使用 test() 函数在验证集上对模型性能进行评估,并打印评估指标:

    if epoch % 5 == 0:
        model.eval()
        val_loss, recall, ndcg = test(model, val_edge_index, [train_edge_index])
        print(f"Epoch {epoch} | Train loss: {train_loss.item():.5f} | Val loss: {val_loss:.5f} | Val recall@{K}: {recall:.5f} | Val ndcg@{K}: {ndcg:.5f}")

输出结果如下所示:

训练过程)

(11) 对模型在测试集上进行性能评估:

test_loss, test_recall, test_ndcg = test(model, test_edge_index.to(device), [train_edge_index, val_edge_index])

print(f"Test loss: {test_loss:.5f} | Test recall@{K}: {test_recall:.5f} | Test ndcg@{K}: {test_ndcg:.5f}")

# Test loss: 1.90047 | Test recall@20: 0.01834 | Test ndcg@20: 0.00902

得到的 recall@20 值为 0.01834ndcg@20 值为 0.00902,与 LightGCN 在其他数据集上得到的结果相近。

3.5 生成推荐

模型训练完成后,就可以为给定用户提供推荐。推荐函数包括两个部分:

  1. 首先,要检索用户喜欢的图书列表,这有助于我们理解推荐的上下文
  2. 其次,要生成一个推荐列表。这些推荐不能是用户已经评价过的图书(不能是正样本)

接下来,我们逐步实现此函数。

(10) 创建 recommend 函数,其包含两个参数:user_id (用户的标识符)和 num_recs (要生成的推荐数量):

def recommend(user_id, num_recs):

(2) 通过在 user_mapping 字典中查找用户的标识符来创建用户变量,该字典将用户 ID 映射为整数索引:

    user = user_mapping[user_id]

(3) 检索 LightGCN 模型为指定用户学习的 dim_h 维向量:
emb_user = model.emb_users.weight[user]

(4) 可以用它来计算相应的评分。如前所述,使用存储在 LightGCNemb_items 属性中的所有物品的嵌入和 emb_user 变量的点积:

    ratings = model.emb_items.weight @ emb_user

(5)topk() 函数应用于评分张量,它会返回前 100 个值(模型计算的分数)及其相应的索引:

    values, indices = torch.topk(ratings, k=100)

(6) 获取该用户最喜欢的图书列表。通过过滤索引列表来创建一个新的索引列表,仅包括给定用户的 user_items 字典中存在的索引。换句话说,我们只保留这个用户评价过的图书,然后,将此列表切片以保留前 num_recs 个项目:

    ids = [index.cpu().item() for index in indices if index in user_pos_items[user]][:num_recs]

(7) 将这些图书 ID 转换为 ISBN

    item_isbns = [list(item_mapping.keys())[list(item_mapping.values()).index(book)] for book in ids]

(8) 使用这些 ISBN 检索有关图书的更多信息,获取书名和作者以便打印:

    titles = [bookid_title[id] for id in item_isbns]
    authors = [bookid_author[id] for id in item_isbns]

(9) 打印以上信息:

    print(f'Favorite books from user n°{user_id}:')
    for i in range(len(item_isbns)):
        print(f'- {titles[i]}, by {authors[i]}')

(10) 重复上述过程,但使用用户未评分图书的 ID (不在 user_pos_items[user] 中):

    ids = [index.cpu().item() for index in indices if index not in user_pos_items[user]][:num_recs]
    item_isbns = [list(item_mapping.keys())[list(item_mapping.values()).index(book)] for book in ids]
    titles = [bookid_title[id] for id in item_isbns]
    authors = [bookid_author[id] for id in item_isbns]

    print(f'\nRecommended books for user n°{user_id}')
    for i in range(num_recs):
        print(f'- {titles[i]}, by {authors[i]}')

    headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3'}
    fig, axs = plt.subplots(1, num_recs, figsize=(20,6))
    fig.patch.set_alpha(0)
    for i, title in enumerate(titles):
        url = books.loc[books['Book-Title'] == title]['Image-URL-L'][:1].values[0]
        img = Image.open(requests.get(url, stream=True, headers=headers).raw)
        rating = df.loc[df['ISBN'] == books.loc[books['Book-Title'] == title]['ISBN'][:1].values[0]]['Book-Rating'].mean()
        axs[i].axis("off")
        axs[i].imshow(img)
        axs[i].set_title(f'{rating:.1f}/10', y=-0.1, fontsize=18)
    plt.show()

(11) 在数据库中为一个用户(用户 ID2774427 )获取 5 条推荐信息:

bookid_title = pd.Series(books['Book-Title'].values, index=books.ISBN).to_dict()
bookid_author = pd.Series(books['Book-Author'].values, index=books.ISBN).to_dict()
user_pos_items = get_user_items(edge_index)

from PIL import Image
import requests

recommend(277427, 5)

输出结果如下所示:

输出结果

推荐结果

现在我们可以为原始 df 数据框中的任何用户生成推荐。我们可以测试其他用户 ID,并观察这些用户 ID 对推荐结果的改变。

小结

本节详细介绍了如何使用 LightGCN 完成图书推荐任务。使用 “Book-Crossing” 数据集,对其进行了预处理以形成二部图,并使用 BPR 损失实现了 LightGCN 模型。对模型进行了训练,并使用 recall@20ndcg@20 指标对其进行了评估。最后,通过为给定用户生成推荐来证明该模型的有效性。

系列链接

图神经网络实战(1)——图神经网络(Graph Neural Networks, GNN)基础
图神经网络实战(2)——图论基础
图神经网络实战(3)——基于DeepWalk创建节点表示
图神经网络实战(4)——基于Node2Vec改进嵌入质量
图神经网络实战(5)——常用图数据集
图神经网络实战(6)——使用PyTorch构建图神经网络
图神经网络实战(7)——图卷积网络(Graph Convolutional Network, GCN)详解与实现
图神经网络实战(8)——图注意力网络(Graph Attention Networks, GAT)
图神经网络实战(9)——GraphSAGE详解与实现
图神经网络实战(10)——归纳学习
图神经网络实战(11)——Weisfeiler-Leman测试
图神经网络实战(12)——图同构网络(Graph Isomorphism Network, GIN)
图神经网络实战(13)——经典链接预测算法
图神经网络实战(14)——基于节点嵌入预测链接
图神经网络实战(15)——SEAL链接预测算法
图神经网络实战(16)——经典图生成算法
图神经网络实战(17)——深度图生成模型
图神经网络实战(18)——消息传播神经网络
图神经网络实战(19)——异构图神经网络
图神经网络实战(20)——时空图神经网络
图神经网络实战(21)——图神经网络的可解释性
图神经网络实战(22)——基于Captum解释图神经网络
图神经网络实战(23)——使用异构图神经网络执行异常检测