简介面向计算机相关专业毕业设计及实战学习提供一套基于Python的知识图谱与图神经网络电影推荐系统源码曾获导师认可、评审98分所有文件均经本地调试可运行。压缩包共31个文件主体为21个Python脚本覆盖数据加载与预处理、知识图谱构建、KGCN模型实现、训练评估与测试等完整链路5个dat数据文件存放用户、电影、评分等原始数据另附txt、readme、md等说明文档整体大小14.84MB。内容兼顾推荐系统与传统协同过滤的冷启动、稀疏性问题通过图谱增强可解释性适合毕业设计参考或进阶练习。已有86人学习浏览项目目录清晰分模块组织便于从数据到模型的完整复现也可在此基础进行算法改进与功能扩展。1. 基于Python的知识图谱和图神经网络的电影推荐系统不只是毕设更是一条可复用的推荐技术路线做电影推荐多数人第一反应是协同过滤但拿到“基于Python的知识图谱和图神经网络的电影推荐系统”这个题目时你要做的不是再跑一个TensorFlow的DeepFM而是把电影、演员、导演、类型、用户行为揉成一张知识图谱再让图神经网络在这张图上做消息传递——模型的每一层都在回答“这部电影为什么和那部电影像”“这个用户为什么点进这部片子”。这套方案的直接价值是缓解冷启动和交互稀疏用户只看了三五部电影传统协同过滤几乎无计可施但知识图谱里演员、导演、题材这些边还在GNN照样能给出像样的推荐。它适合三类人正在选毕设题目、想往简历里放一个含金量高的GNN项目、以及刚接触知识图谱但不想只停留在Neo4j查询层面的工程师。下面按我自己的落地顺序从数据到模型到踩坑完整拆一遍。2. 选型与架构从协同过滤到GNN这套方案强在哪2.1 传统推荐的两个死穴冷启动和稀疏交互协同过滤的核心假设是“相似用户喜欢相似物品”它依赖用户-物品交互矩阵。但交互矩阵在真实场景里稀疏得可怕——一个万级用户、万级电影的系统交互密度常常不到1%。矩阵稀疏意味着用户向量和物品向量都不可靠相似度计算全是噪声。冷启动更是老问题新用户没有历史行为新电影没有评分记录协同过滤直接失效。内容推荐基于电影属性做特征交叉能缓解一部分冷启动但它只利用了电影自身的标签没有把电影之间的关系、用户与电影之间的多跳路径利用起来。比如“用户A喜欢《盗梦空间》而《盗梦空间》的导演诺兰也执导了《星际穿越》”这种二阶甚至三阶的关联内容推荐的特征工程很难显式构造出来。知识图谱的引入恰好补上这一环。把用户、电影、演员、导演、类型、评分建成一张异构图任意两个节点之间都存在可达路径。新电影没有评分但它和已有电影的导演、演员、类型共享关系模型就能借这些关系算出它的嵌入向量。这就是知识图谱推荐在稀疏场景下比协同过滤强的原因。提示知识图谱在这里不是用来做查询展示而是作为推荐模型的先验结构输入。它的价值在于提供“多跳关系路径”让模型能利用非直接交互的信号。2.2 整体流水线从原始数据到推荐结果常见做法是把整个系统拆成四层数据层、图谱构建层、模型层、服务层。数据层负责收集和清洗电影数据集产出结构化的电影属性表和用户评分表图谱构建层把表数据转成“头实体-关系-尾实体”的三元组再映射成图结构模型层用图神经网络在这些三元组上学习实体嵌入训练一个链接预测模型服务层把训练好的嵌入拿来计算用户和候选电影的相关性得分输出Top-N推荐列表。我用Python实现时数据层用Pandas处理CSV图谱构建层用NetworkX做原型验证模型层用PyTorch加DGL也可以只用PyTorch自己实现GCN层避免额外依赖。整体上不建议一上来就上Neo4j毕设项目的数据量级通常用内存图就能跑通Neo4j适合后面做可视化展示和查询演示。2.3 模型选型为什么是GNN而不是TransE知识图谱嵌入的经典做法是TransE、TransR这类翻译模型它们把实体和关系映射到向量空间优化“h r ≈ t”。但TransE是浅层模型它学到的嵌入是静态的没法在推理时利用目标节点的局部图结构。GNN则不同每一层卷积都在聚合邻居信息节点的最终表示是它多跳邻域的抽象。对于推荐场景用户节点和电影节点的嵌入需要反映“这个用户周围的世界”GNN的消息传递机制天然适合。在GNN家族里GCN实现最简单、训练稳定GAT引入注意力机制对邻居权重做自适应分配适合关系类型多的知识图谱R-GCN针对多关系图设计每种关系维护一个独立的变换矩阵参数多但表达力强。我的选择是原型阶段用GCN跑通最终版本换成GAT或R-GCN具体后面说。3. 把电影数据变成知识图谱实体抽取、关系构建与邻接矩阵3.1 数据源选型与字段设计推荐系统的经典公共数据集是MovieLens它提供用户ID、电影ID、评分、时间戳以及电影的标题和类型。但MovieLens缺少演员和导演信息所以通常要再找一份电影元数据IMDB的公开子集或豆瓣爬取的结构化数据做补充。我没有编造数据集的必要——你完全可以用MovieLens 100K/1M加上IMDB的public dataset字段对齐后合并。实体设计上至少要包含四类用户(User)、电影(Movie)、人物(Person分演员和导演)、类型(Genre)。关系设计是图谱构建的核心建议至少建这些关系名头实体尾实体含义watchedUserMovie用户看过电影有评分acted_inPersonMovie演员出演电影directed_byMoviePerson电影由该导演执导belongs_toMovieGenre电影属于类型这里“用户看过电影”本身是评分行为的简化。有些实现会把评分作为“用户-电影”边的权重有些会把评分分档次如4分以上算喜欢、以下算不喜欢我建议保留原始评分作为边权重但模型输入时归一化到0-1区间。注意不要把导演和演员混成一个Person类型草草了事。导演和演员在图结构中的语义完全不同分开实体后用不同的关系连接GNN才能学到不同的子图模式。3.2 三元组构建把DataFrame转成图边拿到合并后的数据表需要把它转成三元组列表。常见做法是给每个实体赋一个整数ID然后构造“头实体ID、关系类型、尾实体ID”的三元组。import pandas as pd from collections import defaultdict # 读取合并后的电影元数据和评分数据 movies pd.read_csv(movies_meta.csv) # 含 movie_id, title, director, actor_ids ratings pd.read_csv(ratings.csv) # 含 user_id, movie_id, rating # 实体ID分配电影、用户、人物、类型各自独立编号 entity2id {} relation2id {watched: 0, acted_in: 1, directed_by: 2, belongs_to: 3} def get_entity_id(name, entity_type): key f{entity_type}:{name} if key not in entity2id: entity2id[key] len(entity2id) return entity2id[key] triplets [] # 用户-电影评分边保留评分作为权重 for _, row in ratings.iterrows(): u get_entity_id(fuser_{row[user_id]}, user) m get_entity_id(fmovie_{row[movie_id]}, movie) triplets.append((u, relation2id[watched], m, float(row[rating]))) # 演员-电影边一部电影可能有多个演员需要拆行 for _, row in movies.iterrows(): m get_entity_id(fmovie_{row[movie_id]}, movie) for actor in row[actor_ids].split(,): a get_entity_id(factor_{actor}, person) triplets.append((m, relation2id[acted_in], a)) # 电影-导演边 d get_entity_id(fdirector_{row[director]}, person) triplets.append((m, relation2id[directed_by], d)) # 电影-类型边 for genre in row[genres].split(|): g get_entity_id(fgenre_{genre}, genre) triplets.append((m, relation2id[belongs_to], g))代码逻辑很简单为每个实体分配唯一ID按关系类型构造四类三元组。这里有个关键参数——实体ID空间的大小。全部实体数量决定了后面邻接矩阵的维度MovieLens 1M加IMDB元数据实体总数在2万到3万之间邻接矩阵规模可控。如果后期加入更多外源数据实体数可能冲到10万以上就要考虑稀疏存储。提示actor_ids字段是文本型逗号分隔列表Pandas读入后是字符串直接split即可。如果数据集里演员字段是空格分隔或JSON数组格式先清洗成统一分隔符再进管线。3.3 邻接矩阵与归一化这一步直接影响模型能否收敛GCN的核心操作是对邻接矩阵做归一化再乘特征矩阵。原始邻接矩阵直接使用会有两个问题一是度大的节点聚合到的信息远多于度小的节点模型偏向热门节点二是数值范围不稳定深层传播时特征值会迅速膨胀。标准做法是对称归一化A D^(-1/2) A D^(-1/2)。D是度矩阵。在Python里最直接的实现是用Scipy的稀疏矩阵避免把矩阵稠密化。import numpy as np import scipy.sparse as sp # 构建稀疏邻接矩阵只考虑无向结构关系类型后续用R-GCN区分 num_entities len(entity2id) # 需要把三元组拆成边列表忽略关系类型先构建结构邻接矩阵 edge_list [] for (h, r, t, *_) in triplets: edge_list.append((h, t)) edge_list.append((t, h)) # 无向化 rows [e[0] for e in edge_list] cols [e[1] for e in edge_list] adj sp.coo_matrix((np.ones(len(rows)), (rows, cols)), shape(num_entities, num_entities), dtypenp.float32) # 对称归一化D^(-1/2) * A * D^(-1/2) degree np.array(adj.sum(axis1)).flatten() degree_inv_sqrt sp.diags(np.power(degree, -0.5)) adj_norm degree_inv_sqrt adj degree_inv_sqrt这段代码里无向化是刻意的推荐场景中“用户-电影”和“电影-用户”的传播方向都有意义无向化之后信息可以在整张图上双向流动。但如果你的模型是R-GCN每种关系要单独构建邻接矩阵再做关系聚合不能简单无向化混在一起。注意稀疏矩阵转成稠密数组之前一定要检查实体总数。3万实体对应9亿元素的稠密矩阵接近4GB内存直接转会崩。全程保持稀疏格式只有送入GPU的批次数据才转为稠密张量。3.4 负采样与训练集切分知识图谱推荐的隐藏关键推荐模型的训练需要正样本和负样本。正样本是用户真实交互过的电影负样本要自己生成。最朴素的做法是随机抽用户没看过的电影但这样抽出来的负样本绝大多数是用户确实不感兴趣的模型容易偏向热门项。更好的做法是“知识感知负采样”优先选那些与正样本电影共享导演、演员或类型的电影作为负样本。这样模型被迫学习更精细的区分——两部相似的电影一部用户看了另一部没看差异往往在更深层的偏好上。我一般按1:5的比例做正负采样批次内正样本的邻居信息会参与梯度计算。训练集切分也有门道。把三元组按9:0.5:0.5切分成训练/验证/测试时要确保验证集和测试集里的“用户-电影”边对应的用户ID在训练集中出现过否则冷启动用户会让验证指标失真。4. 图神经网络模型落地GCN与GAT的选型、训练与调参4.1 模型结构两层消息传递就能出效果图神经网络做推荐的通用结构分三步初始化实体嵌入、堆叠消息传递层、用交互打分函数做预测。初始化嵌入可以随机初始化也可以预训练一个TransE模型来初始化后者在中小数据集上提升明显。消息传递层按照GCN的公式H^(l1) σ(A H^(l) W^(l))。A是归一化邻接矩阵H^(l)是第l层的嵌入矩阵W^(l)是可训练权重σ是激活函数。两层结构是经验之谈一层只看到直接邻居表达力不足三层以上容易出现过度平滑——所有节点的嵌入趋向一致推荐结果趋同。打分函数用内积即可score(user, movie) h_user^T h_movie。训练目标用BPR损失贝叶斯个性化排序让正样本对的得分比负样本对高出一个margin。import torch import torch.nn as nn import torch.nn.functional as F class GCNEncoder(nn.Module): def __init__(self, num_entities, embed_dim, hidden_dim, dropout0.3): super().__init__() self.embedding nn.Embedding(num_entities, embed_dim) self.conv1 nn.Linear(embed_dim, hidden_dim) self.conv2 nn.Linear(hidden_dim, embed_dim) # 输出维度对齐便于打分 self.dropout nn.Dropout(dropout) def forward(self, adj_norm): h self.embedding.weight h self.dropout(F.relu(self.conv1(adj_norm h))) h self.dropout(self.conv2(adj_norm h)) return h class BPRLoss(nn.Module): def __init__(self, margin0.5): super().__init__() self.margin margin def forward(self, pos_score, neg_score): # BPR Loss: -log(sigmoid(pos - neg)), margin 作为正则约束 return -torch.log(torch.sigmoid(pos_score - neg_score) 1e-8).mean()这段代码有几个关键点。self.embedding.weight的shape是(num_entities, embed_dim)adj_norm h在PyTorch里是稀疏矩阵乘稠密矩阵会自动调用稀疏内核不需要手动转稠密。conv1和conv2都是线性层激活函数用ReLU最后一层不激活因为打分函数需要保持数值范围。提示adj_norm是Scipy稀疏矩阵时要先用torch.sparse封装才能参与矩阵乘法。常见做法是adj_t torch.sparse.FloatTensor(indices, values, size).to(device)注意把邻接矩阵转成COO格式。4.2 GAT替换GCN注意力带来的收益与代价GCN对每个邻居一视同仁但知识图谱里邻居的重要性天差地别用户看了《盗梦空间》给他推荐《星际穿越》的推理路径主要靠导演这条边而不是类型边。GAT通过注意力系数给不同邻居分配不同权重正好解决这个问题。GAT的实现比GCN多一个注意力计算步骤对每个节点对(i, j)计算注意力系数α_ij softmax(LeakyReLU(a^T [Wh_i || Wh_j]))然后聚合时按α_ij加权。在PyTorch里可以用torch.nn.Parameter自定义也可以用PyG的GATConv一步到位。代价是训练时间变长。注意力计算让每层的时间复杂度从O(E)涨到O(E × head_num)但三元组量级在几十万时差距也就是每轮多十几秒。我的建议DGL或PyG的GAT层实现经过充分优化直接调用比手写稳。4.3 三个必调参数层数、隐藏维度、Dropout第一个参数是层数。知识图谱推荐里两层是公认的甜点区。一层只聚合直接邻居信息量不够三层在用户-电影-演员这种异质图上会把信息搅浑且训练集只有几十万三元组时三层参数量直接翻倍过拟合风险剧增。可以用验证集AUC做网格搜索但结果往往是2层最优、3层掉点。第二个参数是隐藏维度。MovieLens 1M规模下64维的嵌入已经足够表达实体语义128维收益很小但显存占用翻倍。判断标准看训练loss是否快速下降——如果64维跑50轮损失还在高位可以试128如果64维loss正常下降加维度只会让模型记住少量高频模式。第三个参数是Dropout。GNN的过拟合比传统模型更隐蔽因为邻居信息共享会导致训练集和验证集的嵌入高度相关。我一般设0.3到0.5边信息越稀疏Dropout越大。def train(model, optimizer, adj_norm, pos_pairs, neg_pairs, epochs50): model.train() for epoch in range(epochs): emb model(adj_norm) pos_score (emb[pos_pairs[:, 0]] * emb[pos_pairs[:, 1]]).sum(dim1) neg_score (emb[neg_pairs[:, 0]] * emb[neg_pairs[:, 1]]).sum(dim1) loss BPRLoss()(pos_score, neg_score) optimizer.zero_grad() loss.backward() optimizer.step() if epoch % 10 0: print(fepoch {epoch}, loss {loss.item():.4f})训练循环注意两点embedding是整个实体集合的不是只有batch内实体所以每次前向都要把整张图过一遍模型。这在小数据集上没问题实体数超过10万时整图前向会撑爆显存届时要改用GraphSAGE式的邻居采样训练后面避坑章细说。4.4 评估协议用AUC和RecallK衡量推荐质量评估推荐系统有一套自己的协议。把用户-电影边分成训练/验证/测试后测试时对每个用户随机采样100个未交互电影作为负候选加上该用户在测试集中的真实正样本一起用模型打分排序。计算AUC正样本得分高于负样本的概率和RecallK前K个推荐命中正样本的比例。K一般取10或20对应真实的推荐列表长度。def evaluate(model, adj_norm, test_edges, negative_edges, k10): model.eval() with torch.no_grad(): emb model(adj_norm) hits 0 total 0 for user, pos_movie in test_edges: neg_movies negative_edges[user] # 预先采样100个负样本 candidates [pos_movie] neg_movies scores (emb[user] * emb[candidates]).sum(dim1) rank (scores scores[0]).sum().item() # 正样本得分高于几个负样本 if rank k: hits 1 total 1 recall_k hits / total return recall_k这个评估协议的细节比模型本身更容易翻车。负样本必须和正样本来自同一时间段如果把训练集的旧电影作为测试期的负样本模型会凭着时间信号把新电影排前面指标虚高。更稳妥的做法是按时间戳切分用前80%时间段的交互做训练后20%做验证和测试。5. 毕设项目常见问题排查数据、显存、效果全翻车记录5.1 现象训练loss不降反升或震荡剧烈原因多半是邻接矩阵没有归一化或者归一化方式不对。原始邻接矩阵的度分布极不均匀——热门电影节点度数千冷门节点度数为0消息传递时局部数值爆炸梯度也跟着不稳定。另一个常见原因是嵌入初始化范围不对用正态分布初始化但标准差设得过大初始loss就落不到正常区间。解决确认使用了对称归一化A D^(-1/2) A D^(-1/2)用Scipy算完归一化矩阵后打印一行adj_norm.data.abs().max()如果最大值超过10说明归一化失效。嵌入初始化用nn.init.xavier_uniform_标准差自动适配维度不会出现初始loss爆掉的问题。5.2 现象显存OOM训练到一半程序被杀整张知识图谱的邻接矩阵和嵌入矩阵全部塞进GPU是新手最容易踩的坑。实体3万、嵌入64维嵌入矩阵才7MB但整图邻接矩阵如果转成稠密形式3万乘3万就是3.6GB直接OOM。解决全程用稀疏矩阵存储邻接关系前向传播时确保adj_norm是torch.sparse.FloatTensor而不是稠密张量。如果实体数超过10万整图前向依然吃力要改成邻居采样训练——每轮随机采样一部分节点的k阶邻居子图在子图上做消息传递PyG的NeighborSampler和DGL的dgl.dataloading.NeighborSampler都是成熟方案。提示在单卡或CPU机器上跑毕设项目推荐用DGL而非PyG。DGL对稀疏矩阵和采样的封装更省内存CPU上跑小数据集性能也够。5.3 现象推荐结果全是热门电影看起来“正确”但没有个性化模型学到的最短路径是“谁的热度高就推荐谁”因为热门电影在训练集里出现的频率天然高嵌入向量的模长远大于冷门电影内积打分自然占优。这在BPR损失下尤其严重负采样如果从全体电影中随机抽绝大多数负样本是冷门电影模型只要把热门电影排前面就能轻松降低loss根本没有学到用户偏好。解决负采样改为从“用户未看过但和其看过电影共享导演/演员”的电影中抽取。用知识图谱里的二跳邻居作为负样本池而不是全局随机。同时给物品嵌入加L2正则压制热门电影的嵌入模长。还可以在打分函数里对嵌入做归一化只用方向信息算相似度过滤模长影响。5.4 现象知识图谱构建后各类关系数量严重不平衡典型表现是watched边几百万条acted_in边只有几万条belongs_to更少。模型训练时少数派关系几乎没有梯度贡献预言效果是前三类关系在起作用轮到依赖导演关系的推荐场景就失灵。解决对关系做频率加权采样。训练时按关系类型分batch对低频率关系做过采样对watched边做欠采样。更简单的做法是给不同关系类型的损失分量乘以权重系数低频率关系权重调高5到10倍。这个操作不需要改模型结构只在采样器里加个权重参数。5.5 现象离线指标不错人工检查推荐列表却明显不合理常见起因是数据泄漏切分训练/测试时随机打乱同一个电影的属性和它的交互记录同时出现在训练集和测试集GNN通过属性边“记忆”了答案。第二个元凶是评估负采样太简单——负样本全是八竿子打不着的类型模型只学会了“不喜欢的别推荐”没有学会“喜欢的要推到位”。解决先按用户切分用户ID不出现在两个集合再按时间切分同一用户的交互按时间前80%训练、后20%测试。负采样时加入类型和导演约束让负样本和正样本相似度更高指标会显著下降——这是正常的说明之前的指标虚高。实际用这个更严的指标作为最终结果。6. 进阶与验证从跑通到高分毕设的最后一公里跑通基础模型后有四个方向能让项目从“可用”变成“亮眼”。第一个方向是R-GCN替换GCN每种关系一个权重矩阵实验对比显示R-GCN在知识图谱推荐上比GCN的AUC高2到4个百分点且能可视化每种关系对最终推荐的贡献大小写进论文里非常有说服力。第二个方向是消融实验分别去掉知识图谱、去掉GNN层数、去掉负采样策略记录各项指标的掉点幅度这是论文评审最爱看的内容。第三个方向是可视化。把训练好的实体嵌入用t-SNE降维到2D按类型和导演着色能得到一张“电影语义地图”——同一导演的电影聚成一簇同类型的电影分布在相邻区域。这个图放在毕设展示里外行也能看懂模型学到了什么。可视化用Matplotlib加scikit-learn的TSNE就能做几十行代码的事。第四个方向是推荐解释。在知识图谱上做路径推理用户u喜欢电影AA由导演B执导B执导了电影C所以推荐C。这条路径就是一条可解释的推荐理由。实现上用训练好的GAT提取注意力权重取权重最高的路径输出就是一套初级的推荐解释模块。我自己做这类项目的最大教训是不要在离线指标上追求极致先跑通全流程、把每个模块的结果存下来、做成对比表再回头调参。很多同学卡在模型调优上反而没时间做可视化和论文写作这是毕设最可惜的翻车方式。希望帮到你。本文还有配套的精品资源点击获取