图神经网络实战(24)——基于LightGCN构建推荐系统
- 0. 前言
- 1. Book-Crossing 数据集介绍
- 2. Book-Crossing 数据集预处理
- 3. 构建 LightGCN 架构
- 3.1 LightGCN 架构
- 2. 实现 LightGCN
- 3.3 损失函数
- 3.4 模型训练与测试
- 3.5 生成推荐
- 小结
- 系列链接
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 Ziegler
在 2004
年 8
月和 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)
对 users
数据帧重复以上过程:
print(users)
由于 books
数据帧的列数过多,无法像其他两个数据帧一样打印,因此同时打印列名:
print(books)
print(list(books.columns))
ratings
数据帧使用 User-ID
和 ISBN
信息将 users
和 books
数据帧连接起来,并包含评分(可将其视为边权重)。Users
数据帧包括每个用户的统计信息,如所在地和年龄等。books
数据帧包括与书籍内容相关的信息,如书名、作者、出版年份、出版商和链接到三种不同尺寸封面图片的 URL
。
(2) 将评分分布可视化,可以使用 matplotlib
和 seaborn
进行绘制:
import matplotlib.pyplot as plt
import seaborn as sns
sns.countplot(x=ratings['Book-Rating'])
plt.show()
(3) 判断评分数据是否与 books
和 users
数据帧中的数据一致,可以将 ratings
数据帧中的唯一 User-ID
和 ISBN
条目数与 books
和 users
数据帧中的行数进行比较:
print(len(ratings['User-ID'].unique()))
# 105283
print(len(ratings['ISBN'].unique()))
# 340556
与 users
相比,ratings
中的唯一用户数量较少( 105,283
对 278,858
),但与 books
相比,唯一 ISBN
数量较多( 340,556
对 271,379
)。这意味着数据集中缺少很多值,因此在连接表时需要多加注意。
(4) 绘制只被评价过一次、两次等的图书数量。首先,使用 groupby()
和 size()
函数计算每个 ISBN
在 ratings
数据帧中出现的次数:
isbn_counts = ratings.groupby('ISBN').size()
得到一个新的数据帧—— isbn_counts
,其中包含ratings数据帧中每个唯一 ISBN
的计数。
(5) 使用 value_counts()
函数计算每个计数值的出现次数。这个新的数据帧将包含 isbn_counts
中每个计数值的出现次数:
count_occurrences = isbn_counts.value_counts()
(6) 使用 pandas
的 plot()
方法绘制分布图。在本节中,我们只绘制前 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-ID
和 ISBN
值之间的联系,这就是我们无需在本节进行数据清理的原因。
在下一节中,我们将介绍如何处理数据集,以便将其处理为适合 LightGCN
输入的形式。
2. Book-Crossing 数据集预处理
协同过滤 (collaborative filtering
) 是一种用于向用户提供个性化推荐的技术。它的核心理念在于具有相似偏好或行为的用户更有可能具有相似的兴趣。协作过滤算法利用这些信息来识别模式,并根据相似用户的偏好向用户提供推荐。
这与基于内容的过滤不同,后者是一种依赖于被推荐物品特征的推荐方法。它通过识别物品的特征并将其与用户过去喜欢的其他物品的特征进行匹配来生成推荐。基于内容的过滤 (content-based filtering
) 方法通常基于以下理念:如果用户喜欢具有某些特征的物品,那么他们也会喜欢具有类似特征的物品。
在本节中,我们将重点讨论协同过滤,目标是根据其他用户的偏好来决定向用户推荐哪本书,此问题可以表示为下图所示的二部图形式。
已知用户 1
喜欢物品 A
和 C
,用户 3
喜欢物品 A
和 D
,我们可能应该向用户 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-learn
的 train_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
,从 0
到 edge_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 Geometric
的 structured_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
采用的是简单的加权和聚合器,而非像图卷积网络 (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)=i∈Nu∑∣Nu∣∣Ni∣1ei(k)ei(k+1)=u∈Ni∑∣Ni∣∣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=0∑Kαkeu(k)ei=k=0∑Kα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_users
、num_items
、num_layer
和 dim_h
。num_users
和 num_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 Geometric
的 LGConv()
创建包含 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=1∑Mi∈Nu∑j∈/Nu∑lnσ(y^ui−y^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_metrics
和 test
函数用于度量模型性能
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.001
的 Adam
优化器:
optimizer = optim.Adam(model.parameters(), lr=0.001)
(4) 开始训练循环。首先,计算 num_batch
,即一个 epoch
中 BATCH_SIZE
批数据的数量。然后,创建两个循环,第一个指定训练 31
个 epoch
,另一循环指定一个 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.01834
,ndcg@20
值为 0.00902
,与 LightGCN
在其他数据集上得到的结果相近。
3.5 生成推荐
模型训练完成后,就可以为给定用户提供推荐。推荐函数包括两个部分:
- 首先,要检索用户喜欢的图书列表,这有助于我们理解推荐的上下文
- 其次,要生成一个推荐列表。这些推荐不能是用户已经评价过的图书(不能是正样本)
接下来,我们逐步实现此函数。
(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) 可以用它来计算相应的评分。如前所述,使用存储在 LightGCN
的 emb_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) 在数据库中为一个用户(用户 ID
为 2774427
)获取 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@20
和 ndcg@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)——使用异构图神经网络执行异常检测