
简介面向计算机相关专业毕业设计、课程大作业及项目实战学习者提供一份以知识图谱与图神经网络为核心的电影推荐系统完整实现。项目源码经导师指导并以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({fuser_{u}: i for i, u in enumerate(users)}) node_map.update({fmovie_{m}: i len(users) for i, m in enumerate(movies)}) node_map.update({fentity_{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(fmovie_{head}) if h_id is None: h_id node_map.get(fentity_{head}) t_id node_map.get(fmovie_{tail}) if t_id is None: t_id node_map.get(fentity_{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], dtypetorch.long), torch.tensor(rel)relation_map是把字符串关系名映射成整数编号这样模型里才能用嵌入层处理关系类型。代码里分别对head和tail先查电影映射、再查实体映射实际上是在两个类型之间做了一次容错。如果原始三元组里有重复或自环边建议在这里用一个set去重防止后续训练时同一对节点被反复聚合。3.3 数据划分与负采样策略划分训练集和测试集最讲究的一点不要随机切分。时间顺序切分更符合真实场景用前 80% 时间的评分数据训练后 20% 做测试能避免“用未来数据预测过去”的数据泄漏问题。def temporal_split(ratings, train_ratio0.8): ratings ratings.sort_values(timestamp).reset_index(dropTrue) 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_neg4): 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_dim64, heads4, dropout0.2): super().__init__() self.conv1 GATConv(embed_dim, hidden_dim, headsheads) self.conv2 GATConv(hidden_dim * heads, hidden_dim, heads1) 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、负样本为 0BPR 则强调正样本得分要高于负样本即使两个分数都高也没关系。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 lossBPR 不需要生成绝对标签更符合推荐场景中的“相对偏好”——用户只是更喜欢某个电影而不是绝对喜欢或不喜欢。我在跑这个项目时用 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数据量大时调到 128num_layers2超过 3 层容易过度平滑num_heads4注意力头越多模型越贵batch_size1024显存不足时降到 256learning_rate0.001收敛慢时上调到 0.003weight_decay1e-5过拟合时调到 1e-4num_neg4效果不佳时尝试 85. 避坑记录我实际跑这套源码时踩过的五个坑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验证集 hit10 却只有 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_components2, perplexity30, random_state42) movie_2d tsne.fit_transform(movie_vectors) plt.scatter(movie_2d[:, 0], movie_2d[:, 1], cgenre_labels, cmapSet3, s6) 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 再跑一组。模型命中率 hit10NDCG10协同过滤基线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 映射和图连通性再开始调模型。这套源码如果只是跑通很轻松但真正能让你在答辩现场讲清楚的是数据怎么对齐、模型怎么设计、坏在哪一层。希望帮到你。本文还有配套的精品资源点击获取