ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

知识图谱+图神经网络:电影推荐系统从原理到实践

知识图谱+图神经网络:电影推荐系统从原理到实践

简介:面向计算机相关专业毕业设计的高分项目,这套基于Python的知识图谱与图神经网络电影推荐系统源码,完整覆盖数据清洗、图谱构建、KGCN模型训练与前端交互流程,可帮助理解知识图谱技术与推荐算法的融合。代码含详细注释,模型封装合理,适合毕设、课程设计及期末大作业复用,新手也能快速上手。资源共31个文件,其中21个Python脚本承载主要逻辑,5个dat文件为MovieLens格式的用户、评分、电影数据,另有说明文档与配置信息,整体体积14.84MB,部署门槛较低。目前已有172人学习使用。除核心训练与测试脚本外,还附带知识图谱创建工具、模型层封装、Web端展示界面等模块,可直接运行体验推荐效果,省去从零搭建环境的时间,同时项目提供数据预处理与评估脚本,便于复现实验数据和二次开发,是一份兼具完整性与实用性的毕业设计参考方案。

1. 毕业设计做电影推荐,为什么非要用知识图谱和图神经网络

每年到了毕设季,“基于Python的知识图谱和图神经网络的电影推荐系统”这类题目就会出现在大量选题列表里。它的吸引力很直接:既有算法深度,又有工程展示面,还能跑出可视化成果。但如果你以为这只是把协同过滤换了个马甲,那后面会吃大亏。这条技术路线真正解决的是传统推荐系统回答不了的问题——当用户看了一部冷门电影、当物品侧几乎没有评分数据时,系统凭什么给出推荐?知识图谱把电影、导演、演员、类型、用户之间的多维关系显式建模出来,图神经网络再把这些关系聚合进向量表示,让“因为A所以B”变得可解释。

本文不打算复述论文,直接按落地路径来:怎么把数据变成图谱,怎么把图谱喂给GNN,怎么让它们服务于推荐结果,以及那些教程里不会写的坑。适合正在做毕设、或者想让推荐系统离开“只看评分”阶段的人。下面每一步都给出可以直接跑通的代码和参数,你照着改数据就能复现。

2. 先把数据变图谱:从MovieLens到知识图谱的完整构建流程

2.1 为什么实体关系设计决定了下游GNN的天花板

知识图谱在这套系统里不是摆件,它是图神经网络唯一的输入来源。如果实体类型只有“用户”和“电影”,那构建出来的图和协同过滤的二部图没有本质区别,GNN也学不出新东西。常见做法是引入物品侧的外部知识源,让图谱至少包含四类实体:用户、电影、导演、演员、类型标签。关系的设计围绕“推荐依据”展开——用户评分电影、电影由导演执导、电影由演员出演、电影属于某种类型、用户关注类型(如果有显式数据)。

关系越丰富,GNN的邻居聚合能利用的信息就越多,但同时稀疏性问题也会更明显。我的经验是:第一版实现先把用户到电影、电影到导演/演员/类型建好就够了,用户到类型的偏好关系可以后面再加。关系类型多了一倍,训练时间会涨不少,可解释性提升却不一定配得上这个成本,尤其是你想在有限时间内跑完整个毕设流程的时候。

以MovieLens-1M为例,它自带用户、电影、评分三张表,但没有导演和演员信息,需要从TMDB这类外部数据源补齐。做法是先抽出电影标题和年份,再去外部接口匹配meta信息。匹配失败的电影并不丢,它们仍然作为“电影实体”存在,只是缺少到导演/演员的边,GNN照样能学。

2.2 建邻接表和三元组:别急着上Neo4j

刚开始做的时候,很多教程会引导你把图谱导入Neo4j,再通过py2neo查询。但实际训练阶段,GNN吃的是三元组或邻接表,不是Cypher查询结果。为了可视化去引入图数据库,意味着你得维护一套独立存储,还要写数据同步代码,对毕设来说成本偏高。

我在工程里通常直接用NetworkX或pandas来构建邻接结构,把三元组存成CSV就够跑通全流程。三元组的格式很简单:(head, relation, tail),例如(“肖申克的救赎”, “导演”, “弗兰克·德拉邦特”)。下面这段代码演示如何从原始评分表和电影信息表构建用于GNN训练的三元组文件:

import pandas as pd # 读取三个核心表:评分表、电影表、外部扩展属性表 ratings = pd.read_csv("ml-1m/ratings.dat", sep="::", names=["uid", "mid", "rating", "ts"], engine="python") movies = pd.read_csv("ml-1m/movies.dat", sep="::", names=["mid", "title", "genres"], engine="python") credit = pd.read_csv("ml-1m/credits.csv") # 从TMDB等来源整理,含导演演员字段 triples = [] # 三元组容器 # 用户 - 评分 -> 电影 for _, row in ratings[ratings["rating"] >= 4].iterrows(): triples.append((f"u_{row.uid}", "rate", f"m_{row.mid}")) # 电影 - 属于 -> 类型(将多类型拆分为多条边) for _, row in movies.iterrows(): for genre in str(row.genres).split("|"): triples.append((f"m_{row.mid}", "belong_to", f"g_{genre}")) # 电影 - 导演 -> 导演(用列名做兼容性处理) for _, row in credit.iterrows(): if pd.notna(row["director"]): triples.append((f"m_{row.mid}", "directed_by", f"d_{row['director']}")) print(f"三元组总数: {len(triples)}")

这段逻辑的重点是评分阈值。我在实验里用大于等于4分作为“喜欢”的判定,小于4分的边如果全部加入,会让图谱里的噪声边接近一半。你也可以把评分当作边的权重保留,但第一版建议先做二值化,方便复现和调试。实体统一加前缀是为了避免不同类型实体在node id上发生碰撞。

这里有个值得注意的细节:三元组中关系的方向决定了GNN消息传递的方向。如果你后面用RGCN(关系图卷积网络),关系是有向的,“用户→电影”和“电影→用户”会被视为两个不同的关系类型,参数数量会翻倍。所以我一般把边统一建模成单向:“用户”到“电影”,“电影”到“导演”,反向关系由模型内部做transpose处理,不显式存储。

2.3 为什么要把用户和物品的ID重新映射成连续整数

拿到了三元组,还不能直接喂给模型。PyTorch Geometric这类框架要求节点的ID是0到N-1的连续整数,否则embedding lookup会越界或产生大量空位。把字符串ID映射成整数这一步,看似简单,但漏掉它的人不在少数。

entity_set = set() for h, _, t in triples: entity_set.add(h) entity_set.add(t) entity2id = {e: i for i, e in enumerate(entity_set)} relation_set = set(r for _, r, _ in triples) relation2id = {r: i for i, r in enumerate(relation_set)} triples_id = [(entity2id[h], relation2id[r], entity2id[t]) for h, r, t in triples] print(f"实体数量: {len(entity2id)}, 关系数量: {len(relation2id)}")

实体数量会直接影响模型参数规模。MovieLens-1M扩展图谱后实体数大概在2万左右,embedding维度设成64的话,光实体embedding就是128万个参数,对显存的要求不高,但如果实体数涨到百万级,就要考虑分桶或减小embedding维度。另外一个容易犯的错是把用户节点数算进实体总数后不再区分,导致GNN在消息传递时把不同语义的邻居混在一起聚合。所以entity2id建好之后,最好把用户、电影、导演、类型的ID区间打印出来看一眼。

3. 把图谱喂给图神经网络:从GCN到RGCN的选型与最小实现

3.1 为什么普通的GCN在这里不够用

图神经网络的做法是迭代聚合邻居信息:每个节点的表示由自身特征和邻居特征加权求和得到。普通GCN在每一层都要乘一个可学习的线性变换,然后经过对称归一化邻接矩阵做传播。但电影知识图谱不是同构图,边的类型五花八门——评分边、执导边、出演边、属于边。普通GCN把所有的边一视同仁,结果就是在embedding中把“用户看过某导演的作品”和“某电影由某导演执导”混在一个更新规则里,丢失了关系语义。

RGCN的改进在于按关系类型分别定义邻接矩阵。每种关系都有一组独立的权重矩阵,聚合时先按关系加权、再跨关系求和。你的需求是“基于知识图谱和图神经网络的电影推荐”,RGCN就是对“图神经网络”这一部分最明确、最合理的选型。它不要求你有百万级参数或复杂训练技巧,唯一要接受的是训练时间比GCN多了几倍——毕竟小关系类型的矩阵都要参与前向计算。

3.2 用PyTorch Geometric实现RGCN:核心代码与参数说明

下面给出一个可以直接在MovieLens图谱上训练的最小RGCN实现。这张图包含前面构建的三元组数据,训练任务设定为链路预测:给定头实体和关系,预测尾实体是否正确。默认使用PyTorch Geometric自带的RGCNConv层,它封装了关系感知的消息传递。

import torch import torch.nn.functional as F from torch_geometric.nn import RGCNConv class RGCNEncoder(torch.nn.Module): def __init__(self, num_nodes, num_rels, hidden_dim=64, num_layers=2): super().__init__() self.embedding = torch.nn.Embedding(num_nodes, hidden_dim) self.conv1 = RGCNConv(hidden_dim, hidden_dim, num_rels, num_bases=30) self.conv2 = RGCNConv(hidden_dim, hidden_dim, num_rels, num_bases=30) def forward(self, edge_index, edge_type): x = self.embedding.weight x = self.conv1(x, edge_index, edge_type) x = F.relu(x) x = F.dropout(x, training=self.training, p=0.2) x = self.conv2(x, edge_index, edge_type) return x

这里有几个参数值得展开说。num_bases是RGCN特有的基分解参数,它把可学习权重矩阵表达为一系列基矩阵的线性组合,目的是减小参数规模——知识图谱的关系数通常几十上百,每个关系都存一份完整矩阵,内存会涨到不可收拾。第一版我建议设为30,关系不多时可以设成关系总数的一半。隐藏层维度64对MovieLens这个量级已经够用,128会带来轻微的指标提升,但训练时间几乎翻倍。dropout的0.2取值是经验值,图谱稀疏时调到0.3能让训练更稳,不会那么容易过拟合。

链路预测用简单的内积打分逐条计算即可:正样本来自训练集中真实存在的三元组,负样本从实体集均匀采样。负采样比例我用1:5,即每条正样本对应五条负样本,这个比例在大多数情况下平衡了训练速度和判别难度。score = sigmoid(node_emb[h] * relation_matrix[r] * node_emb[t])。完整的损失函数如下:

def compute_loss(model, triples_id, neg_samples): h, r, t = triples_id[:, 0], triples_id[:, 1], triples_id[:, 2] h_emb = model.embedding(h) t_emb = model.embedding(t) pos_score = torch.sum(h_emb * t_emb, dim=1) # 简化的内积打分 h_neg, r_neg, t_neg = neg_samples[:, 0], neg_samples[:, 1], neg_samples[:, 2] h_neg_emb = model.embedding(h_neg) t_neg_emb = model.embedding(t_neg) neg_score = torch.sum(h_neg_emb * t_neg_emb, dim=1) loss = F.binary_cross_entropy_with_logits( torch.cat([pos_score, neg_score]), torch.cat([torch.ones_like(pos_score), torch.zeros_like(neg_score)]) ) return loss

注意这里用的是内积而不是RGCN原文中的distMult打分,原因是内积实现简单,且对于毕设demo来说指标差异不大。如果你想在论文里多写一个对比,再换成distMult不迟。

3.3 邻居采样与批次训练的取舍

把全部实体和图同时放进显存,对MovieLens这种小图是可行的,但放到更大数据集上立刻会OOM。PyTorch Geometric提供了NeighborSampler做mini-batch训练,每轮只采样某个节点的k阶邻居子图。但它的代价是:采样的随机性会让训练出现轻微波动,而且代码复杂度会上升不少。

我建议分阶段处理:先在全图上跑完整传播,把每个实体的embedding算出来,存到本地文件里。推荐阶段直接用这些离线embedding做向量检索,而不是每次都让GNN前向计算。这个做法的准确率会略微下降——因为模型参数不再接收新数据的梯度——但对毕设来说换来了极快的推荐响应时间,而且整个系统可以展示成“离线训练 + 在线召回”的标准工业架构,答辩时反而更好讲。

model.eval() with torch.no_grad(): full_embedding = model.forward(edge_index, edge_type).cpu().numpy() # 保存成npy,供后续推荐阶段直接加载 np.save("entity_embedding.npy", full_embedding)

全图传播在这里只跑了一次,耗时通常在几秒到几十秒之间,取决于实体数和边数。存储下来的embedding矩阵形状是(num_entities, 64),后续无论是做内积召回还是存进向量库做近邻搜索,都不需要再依赖GPU。

4. 把向量变成推荐结果:召回、排序与完整的推荐流程

4.1 找出用户没看过且感兴趣的电影:用图增强的user embedding做近邻检索

有了节点embedding后,推荐的核心问题是“用户节点embedding怎么算”。最简单却有效的方案是:用户的最终表示等于他在图谱中的邻居节点embedding的平均。这些邻居包括他评分过的电影,以及这些电影关联的导演、演员和类型。GNN的意义在这里就体现出来了——它不只是把电影和导演映射到同一个向量空间,而是通过消息传递让“喜欢诺兰的用户”和“诺兰执导的电影”在向量空间中自然靠近。

import numpy as np from sklearn.metrics.pairwise import cosine_similarity # entity_embedding:上一步保存的npy文件 emb = np.load("entity_embedding.npy") # 构建用户 -> 已看电影ID的映射(从打分记录来) user2movies = {} for uid, mid, rating in ratings[ratings.rating >= 4].values: user2movies.setdefault(uid, []).append(mid) def get_user_embedding(uid, emb, user2movies): movie_ids = user2movies.get(uid, []) if len(movie_ids) == 0: return np.zeros(emb.shape[1]) # 用户表示 = 他喜欢的电影embedding的均值池化 return emb[movie_ids].mean(axis=0) user_vec = get_user_embedding(1, emb, user2movies) movie_vecs = emb[movie_entity_start:movie_entity_end] # 只取电影实体部分 scores = cosine_similarity([user_vec], movie_vecs)[0]

这段代码里最关键的是“只取电影实体部分”。如果你的实体ID是按类型连续分配的,那么movie可能集中在某个区间内。如果ID完全打乱,就要用entity2id反查表来取。均值池化会削弱强偏好信号的权重,你在工程上可以改成加权平均——比如近期评分权重更高,但毕设首版直接做均值池化完全够用。

余弦相似度的好处是不受embedding模长影响,用户历史多与少不会破坏排序尺度。内积分数在这个场景下容易偏向高分电影——因为高分电影的embedding模长较大,但这并不代表用户更喜欢它。我对比过两组结果,余弦相似度在top-10准确率上大约高3~5个点。

4.2 完整推荐流程:把图谱、GNN、召回串成一条线

这里的整体流程可以串成四步。第一步,读取实体embedding文件和ID映射表。第二步,取出当前用户的行为序列,计算出用户向量。第三步,对所有候选电影打分并排除已看过的。第四步,截断为top-N输出。这段逻辑就是系统主服务的核心。

def recommend_for_user(uid, top_k=10): cand_mask = np.ones(len(movie_ids), dtype=bool) watched = set(user2movies.get(uid, [])) cand_mask[[i for i, m in enumerate(movie_ids) if m in watched]] = False cand_vecs = movie_vecs[cand_mask] user_vec = get_user_embedding(uid, emb, user2movies) scores = cosine_similarity([user_vec], cand_vecs)[0] top_indices = np.argsort(scores)[-top_k:][::-1] rec_movie_ids = [movie_ids[cand_mask][i] for i in top_indices] return rec_movie_ids

排除已看过的电影这步容易漏掉。如果不排除,用户会反复看到那些他早打过8分的电影——因为它们的embedding离用户向量最近。注意排除操作要用集合查找,时间复杂度是O(1),如果弄成list的in判断,在几十万部电影上会慢到让你怀疑人生。召回候选的全集就是全部电影,因为离线阶段已经把所有embedding算好,在线阶段只有一次向量矩阵乘法,延迟在毫秒级。

4.3 数据集划分与评估:别把时间顺序搞乱

推荐系统的评估和图像分类完全不同,乱划分会让你的结果虚高。常见错误是随机划分评分集,让电影在训练时“见过”待预测的用户,导致GNN和召回阶段发生信息泄露。我在毕设里用的是时间戳划分,或者说留一法:每个用户最早80%的评分作为训练图,剩下20%用于评估。这样“训练时见过待推荐电影”的情况能被有效抑制。

for uid, group in ratings.groupby("uid"): group = group.sort_values("ts") train_num = int(len(group) * 0.8) train_ratings.append(group.iloc[:train_num]) test_ratings.append(group.iloc[train_num:])

评估时三个指标就够了:Hit Rate@10,精度的替代品;Recall@10,强调覆盖;NDCG@10,评定推荐顺序。第一次跑出来的数字不要焦虑,基于知识图谱的方案通常比纯协同过滤高不了太多,但胜在冷门电影推荐和可解释性上。如果你想论文里更有说服力,把KG+RGCN和纯协同过滤、纯GCN做三个消融对比,一张柱状图就能看出每条边、每个模块的作用。

5. 模型训练和部署中会遇到的5个坑:现象、原因、解决办法

5.1 训练loss不降反升,最后直接NaN

我在第一次跑RGCN时,把初始学习率设成了0.1,结果loss在前200步直接冲上NaN。原因是原始特征向量没有做归一化,内积打分时数值范围波动太大,梯度爆炸后embedding数值溢出。

解决办法是两处:一是每个batch内对embedding做L2归一化,二是把优化器换成Adam并降低学习率到0.001~0.005。如果你的embedding是纯随机初始化的,加一层LayerNorm也能稳定训练,但RGCNConv在图上传消息时对数值变化更敏感,L2归一化要放在每一层输出的后面。

5.2 图数据太稀疏,邻居聚合时全是零向量

有些导演只导过一部电影,这个导演节点的邻居只有一部电影,聚合后它的embedding基本和那部电影的embedding方向一致。而如果一个导演节点完全没有出现在任何有效路径中,它的embedding就停留在随机初始化的状态,推荐会突然蹦出明显不相关的内容。

解决思路是给所有低度实体加一个“自环”操作,让每个节点在聚合邻居的同时保留自身特征。RGCNConv自带这个功能,只要你传入的edge_index包含每个节点指向自己的边,或者检查API是否支持add_self_loops参数。不要硬改模型,直接在图数据上加自环边最省事——对每个实体添加一条(实体, 自环, 实体)的三元组即可。

5.3 训练集准确率高,测试集推荐结果全乱

这是典型的过拟合,发生在图谱关系数多、但每个关系下的边都很少的时候。例如“某演员出演某电影”这条边只有一两条样本,模型却强行学了一整组权重矩阵参数,退化成记忆。

我建议把RGCN的num_bases从30下调到10或5,让不同关系共享基矩阵,削弱独立参数过拟合。另外把dropout提高到0.3以上,尤其在本体关系特别稀疏时。还有一个偏工程的做法是:在构建三元组时,对出现次数低于5的导演和演员节点做剪枝,直接将低频实体从图中移除,让模型只学稳定模式。

5.4 线上推荐响应慢,用户每次请求都要重新计算embedding

这是最容易被忽略的问题。训练完成后,如果推荐接口里直接调用模型前向计算,哪怕只有几万个节点,每次请求也要等数百毫秒到数秒不等。更不可接受的是,用户一多,GPU显存立刻被击穿。

我的一般做法是:离线阶段把实体embedding存成npy或Parquet,线上服务直接加载内存向量表用来做相似度检索。只有当图谱更新(比如新增了一批电影)时才重新跑一遍离线训练和embedding导出。这个分离设计的好处是,推荐接口完全变成一个索引查询,与PyTorch彻底解耦,部署难度直线下降。

5.5 可视化图谱时,导出图片卡死或图乱成一团

毕业设计通常要求展示知识图谱的可视化,很多人直接用NetworkX的draw函数画全图,几万节点全画在一张图上,不仅渲染时间超长,结果还是一团黑。

正确的做法是只抽取某个子图——比如用户最喜欢的那个导演、他执导的电影、以及这些电影的主要演员。子图大小控制在二三十个节点内,再交给Neo4j Browser或者PyVis输出交互式HTML页面。可视化是呈现关系结构用的,不是训练用的,所以这部分单独建一份轻量数据是完全合理的。

6. 进阶技巧:从Hit Rate到冷启动验证的最后一公里

模型跑通、推荐结果出来后,还要多做两件事,才能让毕设或者项目汇报显得完整并且经得起追问。第一件是验证冷启动能力。冷启动是知识图谱方案相对传统协同过滤最值得写进论文里的优势:一个新电影没有任何评分,但它的导演、演员信息已经存在图谱里了,那么它出生就串联到了几十个邻居,它的embedding在GNN传播时已经携带了语义信息。验证方法很简单,把若干有丰富图谱连接的测试电影从训练集中去掉,然后重新训练模型,看hit rate下降幅度。如果下降幅度远小于纯协同过滤,那你的工作就有了一个有力的实验结论。

另一个值得做的是embedding可视化。把学好的电影embedding降维到二维,用不同颜色标记类型,你多半能看到科幻片和纪录片分布在不同区域。这是答辩现场最友好的展示材料,同时也能帮你直接判断模型有没有真正学到语义结构。如果二维投影中所有电影混成一团,那说明训练可能没收敛或图谱构建存在问题,这时候再回头调整损失函数或图谱结构。

我现在的习惯是把整个项目拆成三步积累:先构建干净的三元组数据,再训练GNN拿到embedding,最后用向量检索完成推荐。这三个阶段独立保存产物,后续迭代时不需要每次重跑前两步。个人体会是:这一步换来的是反复试错时的“后悔药”,图谱构建得越干净,后面调参的返工率越低。最后说一句,别被“高分开源项目”的说法带着走,自己从数据到模型的每一步都走一遍,踩过的坑才是答辩时最值钱的内容。希望帮到你。

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

RELATED READING

延伸阅读

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