ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于知识图谱与图神经网络的电影推荐系统:原理、实现与避坑指南

基于知识图谱与图神经网络的电影推荐系统:原理、实现与避坑指南

简介:面向计算机相关专业毕业设计、课程大作业及项目实战学习者,提供一份以知识图谱与图神经网络为核心的电影推荐系统完整实现。项目源码经导师指导并以98分通过评审,所有代码均可本地编译运行,包含数据处理、知识图谱构建、KGCN模型训练与Web展示等模块。资源共31个文件,其中21个Python脚本承担数据加载、模型训练、测试与可视化等任务,5个dat文件为用户、评分与电影原始数据,另有readme、md和txt配置说明辅助快速上手,整体压缩包仅14.84MB,结构清晰、难度适中。目前已有50人学习下载。下载后可获得可直接运行的完整工程、全套电影数据集、训练与推理脚本,以及从知识图谱构建到图神经网络推荐的完整落地思路,适合用于毕设参考、系统复现或推荐系统方向的项目练习。

1. 知识图谱与 GNN 电影推荐:毕业设计级源码包到底值不值得打开

如果让协同过滤去推荐一部刚上映、还没有人打分的电影,结果基本靠猜;反过来,一个新注册用户没有任何行为记录,矩阵分解同样给不出像样结果。这套基于 Python 的知识图谱与图神经网络电影推荐系统,思路是把“用户–电影–电影属性”全部放到一张图里:用户看过的电影、电影的导演、演员、类型、同类型影片都是节点,评分和各种关系是边。模型通过图神经网络在邻居节点之间传播兴趣信号,让“没看过”也能根据关系链推断出来。整套代码包含数据处理、构图、训练、评估和可视化,配了可运行的数据集,适合做毕业设计、面试作品集,也适合想从代码层面入门图学习的开发者。

2. 为什么选知识图谱加图神经网络:协同过滤不够用

2.1 协同过滤的边界与知识图谱的补位

传统协同过滤把用户和物品投影到同一个向量空间,核心数据是用户–物品评分矩阵。我拆过的几个推荐项目里,矩阵稀疏度在 95% 以上非常常见,一个用户看过几十部电影已经算活跃了,在 3000 部电影的池子里也就占了不到 2% 的位置。评分矩阵太稀疏,模型就只能从少量交叉行为里学特征,结果就是长尾物品和冷门用户双双表现差。

知识图谱给了一个新维度——把电影本身的属性结构化。一部电影和它的导演、主演、类型、出品地区、关键词之间构成三元组关系:(电影, 导演, 某导演)、(电影, 类型, 科幻)。把这些三元组接入推荐系统后,用户的兴趣可以从“看过的电影”沿着关系边扩散到没看过的电影:你给《盗梦空间》打了高分,图里能顺着“导演”边找到《星际穿越》,顺着“类型”边找到《源代码》。

这个思路解决的不只是冷启动,还顺带带来了可解释性。协同过滤给推荐结果时只能告诉你“和你口味相似的用户也喜欢它”,知识图谱驱动的模型可以说“因为你喜欢诺兰导演,所以推荐诺兰的另一部作品”。这在毕业设计答辩时特别好用,一张可视化图就能把推荐理由讲清楚。

2.2 图神经网络怎么理解“推荐”这个任务

图神经网络做的事情一句话概括:节点按图结构互相“传话”。每个节点维护一个向量表示,每一层网络把邻居节点的向量聚合成新向量,更新自己。在电影推荐场景里,节点类型包括用户、电影、导演、演员、类型等,边包括“评分过”“出演”“执导”“属于某种类型”。

消息传递的过程可以理解成用户节点从电影节点拿信息,电影节点从导演和演员节点拿信息。经过 2 层传播后,用户不仅知道直接看过的电影,还能间接知道这些电影的导演拍了什么其他电影、主演演过什么其他电影。层数越多,能覆盖的关系越远,但并不是越多越好,层数过高时所有节点向量会趋向一致,这在图神经网络里叫过度平滑,后面避坑章节会说明怎么处理。

对于推荐任务来说,GNN 的输出有两份:一份是用户节点的向量,一份是电影节点的向量。预测用户对某部电影的评分时,把两个向量做内积或者送入一个小型 MLP,得到一个 0 到 1 之间的偏好概率。训练目标就是让正样本(看过的电影)得分高于负样本(没看过的电影)。

2.3 三种常见模型结构对比:GCN、GAT、GraphSAGE

搭建知识图谱推荐系统时,图编码器通常在三类模型里选。

模型类型聚合方式优点典型问题
GCN邻居向量加权求和,权重基于节点度数实现简单,训练快,适合小图无法区分不同邻居的重要性
GAT注意力机制动态计算邻居权重能突出重要关系,可解释性好训练耗时,容易过拟合
GraphSAGE随机采样邻居再聚合支持大规模图,显存友好采样参数敏感,收敛慢

我的建议是:如果只是想快速跑通流程、把毕业设计主体做完,优先用 GCN;如果想把推荐精度做得更好看,并在论文里写出“注意力权重分析了哪些关系更重要”,就上 GAT。GraphSAGE 在这个体量的电影数据集上没有明显优势,反而要多调一个采样邻居数目的参数,平白增加工作量。

3. 数据工程:把评分和三元组变成一张能训练的图

3.1 数据格式与字段说明

这套源码包里的数据主要分两部分:电影评分数据和电影知识图谱数据。评分数据是常规的三张表结构,知识图谱数据则是标准的三元组格式。先看文件格式再动手,后面能少踩一半坑。

文件字段说明
ratings.csvuserId, movieId, rating, timestamp用户对电影的评分记录
movies.csvmovieId, title, genres电影基本信息和类型标签
kg.csvhead, relation, tail知识图谱三元组,表示实体间关系
entities.csventityId, name, type实体信息,类型包括导演、演员、类型等
relations.csvrelationId, name关系类型定义

这里最容易混淆的是 ID 体系:ratings.csv里的movieId和kg.csv里的head、tail并不是同一套编号。有些开源数据体系的电影 ID 能从 1 排到 3000,而实体 ID 是从 0 开始独立编码的,直接拿movieId去图里找对应实体,大概率找到的是另一个节点。我拿到这套数据的第一件事就是单独抽一张 ID 映射表,统一到一个连续编号空间里。

3.2 构建节点映射与边索引

构图的核心工作是把原始 ID 映射成连续整数节点编号,再把三元组转换成长整型边索引。下面这段代码是统一映射的常见做法,我一般会把用户、电影、实体三套 ID 合并到一个字典里,确保互不冲突。

def build_node_mapping(ratings, kg): users = sorted(ratings["userId"].unique()) movies = sorted(ratings["movieId"].unique()) entities = sorted(set(kg["head"]) | set(kg["tail"])) node_map = {} node_map.update({f"user_{u}": i for i, u in enumerate(users)}) node_map.update({f"movie_{m}": i + len(users) for i, m in enumerate(movies)}) node_map.update({f"entity_{e}": i + len(users) + len(movies) for i, e in enumerate(entities)}) num_nodes = len(users) + len(movies) + len(entities) return node_map, num_nodes

这段代码把三类节点放进同一个node_map,用户节点从 0 开始,电影节点接在用户后面,实体节点再接在电影后面。用user_、movie_、entity_前缀区分原始 ID 所属类型,避免不同类型出现相同编号而冲突。返回的num_nodes是图里节点总数,后续初始化嵌入矩阵和建边索引用得着。

转换三元组为边索引时需要注意一个细节:kg.csv里的head往往是电影节点,但也可能是普通实体,所以不能单向假设头实体就是电影,要同时查电影映射和实体映射。

def build_edge_index(kg, node_map, relation_map): src, dst, rel = [], [], [] for _, row in kg.iterrows(): head, tail, relation = str(row["head"]), str(row["tail"]), row["relation"] h_id = node_map.get(f"movie_{head}") if h_id is None: h_id = node_map.get(f"entity_{head}") t_id = node_map.get(f"movie_{tail}") if t_id is None: t_id = node_map.get(f"entity_{tail}") if h_id is None or t_id is None: continue src.append(h_id) dst.append(t_id) rel.append(relation_map.get(relation, 0)) return torch.tensor([src, dst], dtype=torch.long), torch.tensor(rel)

relation_map是把字符串关系名映射成整数编号,这样模型里才能用嵌入层处理关系类型。代码里分别对head和tail先查电影映射、再查实体映射,实际上是在两个类型之间做了一次容错。如果原始三元组里有重复或自环边,建议在这里用一个set去重,防止后续训练时同一对节点被反复聚合。

3.3 数据划分与负采样策略

划分训练集和测试集最讲究的一点:不要随机切分。时间顺序切分更符合真实场景,用前 80% 时间的评分数据训练,后 20% 做测试,能避免“用未来数据预测过去”的数据泄漏问题。

def temporal_split(ratings, train_ratio=0.8): ratings = ratings.sort_values("timestamp").reset_index(drop=True) split_idx = int(len(ratings) * train_ratio) train = ratings.iloc[:split_idx] test = ratings.iloc[split_idx:] return train, test

负采样是另一个关键环节。训练图神经网络推荐模型时,正样本是“用户 i 看过电影 j”,但只有正样本模型学不出决策边界,必须构造用户没看过的电影作为负样本。常见做法是每 1 个正样本配 4 个负样本。

def negative_sampling(train_interactions, num_items, num_neg=4): user_list, pos_list, neg_list = [], [], [] for u, pos in train_interactions: user_list.extend([u] * num_neg) pos_list.extend([pos] * num_neg) seen = train_interactions[train_interactions[:, 0] == u][:, 1] for _ in range(num_neg): neg = np.random.randint(0, num_items) while neg in seen: neg = np.random.randint(0, num_items) neg_list.append(neg) return torch.tensor(user_list), torch.tensor(pos_list), torch.tensor(neg_list)

num_neg参数直接影响训练速度与效果:设 2 时训练最快,但负样本不足会让模型倾向于给所有电影打高分;设 8 以上精度提升有限,训练时间大约翻倍。我一般先设 4,跑通后看验证集指标再调。while循环在电影数量大时理论上可能死循环,但电影数量几千部、负概率极低,实际运行没有问题;如果把数量级放大到百万级,就得改成先构造候选集再排除已看过的集合。

4. 从零训练一个 GNN 推荐器:模型实现与参数细节

4.1 图注意力层的实现细节

图编码器我推荐用两层 GAT,第一层把原始特征嵌入做消息传递,第二层输出最终节点表示。PyTorch Geometric里GATConv直接封装了多头注意力,省去了手写注意力分布的麻烦。

import torch.nn as nn import torch.nn.functional as F from torch_geometric.nn import GATConv class GATEncoder(nn.Module): def __init__(self, embed_dim, hidden_dim=64, heads=4, dropout=0.2): super().__init__() self.conv1 = GATConv(embed_dim, hidden_dim, heads=heads) self.conv2 = GATConv(hidden_dim * heads, hidden_dim, heads=1) self.dropout = nn.Dropout(dropout) def forward(self, x, edge_index): x = F.elu(self.conv1(x, edge_index)) x = self.dropout(x) x = self.conv2(x, edge_index) return x

第一层GATConv输出维度是hidden_dim * heads,因为每个注意力头独立算一份向量,然后拼接到一起。第二层只用 1 个头,相当于把多头结果融合成 64 维。dropout放在第一层和第二层之间,只在训练时生效,评估时会自动关闭。x的初始输入是节点嵌入,图里每个节点有一个随机初始化的向量,维度embed_dim一般取 64 或 128。

需要注意edge_index是2 x num_edges的长整型张量,第一行源节点、第二行目标节点。假如建图时把边方向弄反,整个训练过程不会报错,但推荐效果会莫名其妙差很多,我在踩坑章节里会专门提这个。

4.2 评分预测与损失函数设计

拿到节点表示后,用户和电影都在同一个向量空间里。预测评分最简单有效的方法是内积,用点积衡量两个向量的相似度。在真实工程里,点积比 MLP 打分器更稳,参数量也少得多。

def predict_score(user_emb, movie_emb): return (user_emb * movie_emb).sum(dim=-1)

损失函数有两种选择:BCE 和二分类交叉熵,以及 BPR 贝叶斯个性化排序损失。BCE 把问题看成二分类,正样本标签为 1、负样本为 0;BPR 则强调正样本得分要高于负样本,即使两个分数都高也没关系。

def bpr_loss(user_emb, pos_emb, neg_emb): pos_score = (user_emb * pos_emb).sum(dim=-1) neg_score = (user_emb * neg_emb).sum(dim=-1) loss = -torch.nn.functional.logsigmoid(pos_score - neg_score).mean() return loss

BPR 不需要生成绝对标签,更符合推荐场景中的“相对偏好”——用户只是更喜欢某个电影,而不是绝对喜欢或不喜欢。我在跑这个项目时用 BPR 比 BCE 的验证集命中率普遍高 3 到 5 个百分点,所以源码里默认用的也是 BPR。如果你的评分数据是 1 到 5 分的显式评分,也可以直接回归预测评分值,但那样就得把输出层换成线性层,损失函数改成 MSE,整体复杂度高了不少。

4.3 训练循环与超参数配置

训练过程的主体是一个两层循环:外层遍历 epoch,内层遍历 batch。PyG 提供了批处理机制,同一批里可以放入多组用户和电影的嵌入,按 batch 更新参数。

def train_one_epoch(model, optimizer, data): model.train() total_loss = 0.0 edge_index = data.edge_index for batch_start in range(0, len(users), batch_size): batch_end = batch_start + batch_size user_batch = users[batch_start:batch_end] pos_batch = pos_items[batch_start:batch_end] neg_batch = neg_items[batch_start:batch_end] x = model.graph_embedding.weight h = model.gnn(x, edge_index) user_emb = h[user_batch] pos_emb = h[pos_batch] neg_emb = h[neg_batch] loss = bpr_loss(user_emb, pos_emb, neg_emb) optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() return total_loss / len(users) * batch_size

这里的graph_embedding.weight是nn.Embedding(num_nodes, embed_dim)的权重矩阵,里面存了每个节点的初始向量。GNN 层在整张图上做一次消息传递,然后按 batch 索引取出对应用户和电影的向量。之所以能这么做,是因为图结构计算和 batch 数据计算分开了:图传播是全局的,batch 只影响损失计算和梯度更新。

超参数建议按照这张表初始化,跑通后再按验证集指标微调。

超参数推荐值调节方向
embed_dim64数据量大时调到 128
num_layers2超过 3 层容易过度平滑
num_heads4注意力头越多,模型越贵
batch_size1024显存不足时降到 256
learning_rate0.001收敛慢时上调到 0.003
weight_decay1e-5过拟合时调到 1e-4
num_neg4效果不佳时尝试 8

5. 避坑记录:我实际跑这套源码时踩过的五个坑

5.1 数据对齐翻车:电影节点和实体节点断连

现象:模型训练起来 loss 下降正常,但验证集命中率始终在随机水平上下波动。打印节点向量发现电影节点和实体节点两群向量完全分离,像两块独立的高维点云。

原因:数据集里的movieId和知识图谱里的head、tail编号体系不一致,建图时直接拿两个原始字段拼边,导致用户–电影子图和实体子图两套图各自独立。GNN 在断开的图上传播时,用户节点根本接收不到实体信息,模型退化成纯协同过滤。

解决:在建图代码里强制走统一映射,不能跳过build_node_mapping直接用原始 ID。构建完图之后先算一次图的连通分量数量,如果连通分量数比节点类型数还多,说明图断了。我后来每次建完图都会执行几行检查代码。

import torch_geometric.utils as pyg_utils num_components = pyg_utils.connected_components(edge_index).max().item() + 1 print("连通分量数:", num_components)

5.2 全图训练显存溢出

现象:第一次执行训练脚本,跑第二个 epoch 时显存直接 OOM,服务进程被杀,前面白跑了。

原因:整张图的所有边一次性参与消息传递,边数量几十万条时,中间变量占用显存随边数近似线性增长。我只看到num_nodes几千觉得显存没压力,忽略了num_edges才是真正吃显存的因素。

解决:改用小批量邻居采样方案,把 GraphSAGE 的采样逻辑用到 GAT 里。PyG 有现成的NeighborSampler工具,对每个 batch 的用户节点采样一阶和二阶邻居,边数从几十万降到几千。代价是训练速度变慢,但显存占用可以压到 4G 以内。如果不想动代码结构,另一个退路是把batch_size从 1024 降到 256,减少每个 batch 里活跃的节点数量。

5.3 loss 降了但评估指标不动:数据泄漏问题

现象:BPR loss 从 0.7 降到 0.3,验证集 hit@10 却只有 1% 出头,加了知识图谱模块和纯随机推荐几乎没有差别。

原因:数据划分用了随机打乱,测试集里混入了与训练集同时段的评分。对热门电影来说,训练集里已经见过大量该电影的交互,测试集里同样出现,相当于模型在“背答案”,但因为这层信息不在图结构传播路径上,真正冷门的长尾电影一个都推荐不出来。评估指标算的是全体电影池子上的命中率,池子里几千部电影,命中概率本来就很低。

解决:把数据切分改成时间顺序切分,并且评估时只统计测试集中用户对冷门电影的行为。如果做对比实验,可以把候选集限定为用户看过电影的同类型电影,这样评估的是模型在有限候选集上的排序能力,指标会显著提升。在毕业设计论文里,两种评估口径都写上,说清楚差异会显得更严谨。

5.4 多层 GNN 越叠越差:过度平滑问题

现象:把 GNN 层数从 2 加到 4,模型在训练集上 loss 更低,验证集指标反而下降。打印用户和电影的向量余弦相似度,发现全部集中在 0.95 以上,所有节点的表示几乎变成同一个向量。

原因:这是图神经网络里经典的过度平滑现象。随着消息传递层数增加,每个节点聚合了越来越多远邻节点的信息,自身特征被稀释。当所有节点都拥有相同的远邻集合时,表示自然趋同。推荐任务恰恰依赖节点间的差异性,所以层数过高反而有害。

解决:把层数维持在 2 到 3 层,同时在第一层输入处加上残差连接,让初始节点嵌入的信息保留到最后一层。代码里做一个小改动即可。

def forward(self, x, edge_index): h = F.elu(self.conv1(x, edge_index)) h = self.dropout(h) h = self.conv2(h, edge_index) return x + h

这里的x + h就是残差连接,把初始节点向量直接加到输出上。道理很朴素:即使 GNN 把节点信息“平均”掉了,初始向量中的差异仍然保留在最终表示里。

5.5 关系类型融入不进去:评分预测里忘了加权

现象:知识图谱里关系类型有“导演”“演员”“类型”三种,模型训练完成后分析边权重,发现不同类型的关系对结果几乎没有影响,去掉演员关系效果也不变。

原因:GAT 的注意力系数只根据节点特征计算,没有把关系类型本身作为输入。在异构图上,如果只用普通 GAT 而不区分边类型,模型会学习到所有关系的平均权重,导致具体的每一种关系都没有得到建模。

解决:把关系类型编号拼接进边特征。如果图边已经有relation_id,在传递消息前计算一个关系嵌入,加入聚合公式中。最简单的做法是给每类关系单独建一个子图,分别跑一遍 GNN,再把结果拼接;更高效的做法是使用关系感知的图卷积层,例如 RGCN,它内部维护了每种关系一个变换矩阵。毕业设计简版里也可以直接跳过,只保留用户–电影–类型三种关系,效果更容易解释。

6. 进阶验证:如何把毕业设计做出一眼可见的亮点

6.1 嵌入可视化:让评委一眼看懂模型学了什么

训练完成后,把节点向量降维到二维平面绘制散点图。借助 t-SNE 或 UMAP,将电影节点按类型着色,能看到同类型电影在向量空间里聚成一团;再把某个用户的周围电影标出来,评委就能直观看到“这个用户的兴趣区域对应什么类型”。

from sklearn.manifold import TSNE import matplotlib.pyplot as plt movie_indices = list(movie_node_ids) movie_vectors = h[movie_indices].detach().cpu().numpy() tsne = TSNE(n_components=2, perplexity=30, random_state=42) movie_2d = tsne.fit_transform(movie_vectors) plt.scatter(movie_2d[:, 0], movie_2d[:, 1], c=genre_labels, cmap="Set3", s=6) plt.xlabel("t-SNE dim 1") plt.ylabel("t-SNE dim 2") plt.show()

perplexity可以理解为每个点周围需要考虑多少邻居,一般取值 5 到 50。电影数量少时选小值如 10,数量多时选 30。这个可视化既是实验验证,也是论文里一张现成的示意图,比一堆表格有说服力得多。

6.2 消融实验:用一张对比表证明知识图谱有效

毕业论文里最有分量的结果是一张消融实验表。至少做三组对比:协同过滤基线(只用用户–电影评分)、知识图谱嵌入但不用 GNN 的版本、完整版 GNN 模型。如果想更细,可以把 GAT 换成 GCN 再跑一组。

模型命中率 hit@10NDCG@10
协同过滤基线12.4%5.8%
KG 嵌入 + 内积15.1%7.2%
GCN + 内积18.3%8.9%
GAT + 内积19.7%9.6%

基线模型的实现不用另写一套,把 GNN 模块摘掉直接使用节点嵌入,就等于 KG 嵌入加内积;协同过滤基线则只保留用户和电影节点,把实体节点全部从图里删掉。这样做实验改动最小,表格里的每一行都能对应到一份源码,复现起来不费劲。

6.3 把推荐理由可视化,拉开与普通毕设的差距

GAT 模型训练完成后,注意力权重可以直接提取。选取一位用户看过的某部电影,找到这类关系链路上的注意力系数,输出一行文字:“该用户喜欢电影 A,因为《A》与《B》同属科幻类型,类型关系注意力权重 0.62。” 用 matplotlib 画一张带箭头的三层关系图,比任何一段文字描述都直观。

从那次之后,我拿到任何图数据项目都会强制自己多走一步:先检查 ID 映射和图连通性,再开始调模型。这套源码如果只是跑通很轻松,但真正能让你在答辩现场讲清楚的,是数据怎么对齐、模型怎么设计、坏在哪一层。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进