简介这是一份基于Python的知识图谱与图神经网络KGCN的电影推荐系统完整毕设项目主要面向计算机相关专业的本科毕业设计、课程大作业以及有一定Python基础的推荐系统学习者项目获评审分98分源码经本地编译与严格调试下载后按readme配置即可运行。代码模块覆盖数据预处理data_process.py、知识图谱构建create_kg.py、KGCN模型定义与训练model.py、train.py、工具函数utility.py及Web可视化页面web/app.py可以帮助读者理解知识图谱如何为电影推荐提供辅助语义信息并掌握GNN在推荐场景中的落地思路。资源包共31个文件包括21个Python脚本、5个dat评分数据文件、2个txt说明、2个readme及1个md文档其中users.dat、ratings.dat、movies.dat分别保存用户、评分与电影条目整体压缩包仅14.84MB数据齐全、目录清晰。自发布以来已有86人学习下载整体代码风格清晰注释与readme较齐全既可直接运行学习也适合在毕业设计中引入深度学习和知识图谱方法或作为端到端完整代码参考快速复现与二次开发。1. 从标题说起为什么电影推荐要用知识图谱和图神经网络基于Python的知识图谱和图神经网络电影推荐系统拆开看是三条技术线知识图谱负责把电影、演员、导演、类型组织成一张有语义的网图神经网络负责沿着这张网做信息聚合推荐模块负责把用户和电影映射进同一个向量空间打分。三者串起来解决的是传统协同过滤的三个老毛病——冷启动、数据稀疏、推荐结果说不清为什么。你拿到一套带源码和完整数据的毕设项目时最该关心的不是模型有多新而是它能不能一次跑通、指标能不能复现、论文里有没有故事可讲。知识图谱加图神经网络这个组合恰好三点全占图谱构建是数据处理的工作量GNN模型是核心创新点推荐评估是实验章节每一步都有明确交付物。本文从数据整理开始把图谱构建、模型实现、训练评估、避坑排查和答辩加分五段路完整走一遍适合正在做电影推荐方向毕设的本科生也适合想把这套方案迁到自己推荐系统里的后端工程师。2. 图谱先行把电影数据整理成三元组2.1 先想清楚实体和关系的边界知识图谱在推荐项目里的作用是给电影补充“上下文”。协同过滤只知道“谁给什么电影打了分”知识图谱则告诉你这部电影和哪些演员、导演、类型标签有关联。这些关联就是推荐解释的来源。拿到源码后第一步先看数据文件里有什么。常见的公开电影数据集会提供四张表movies.csv电影ID、标题、类型列表、users.csv用户ID、性别、年龄、职业、ratings.csv用户ID、电影ID、评分、时间戳以及一个可选的credits.csv电影ID、导演列表、主演列表。这套项目的数据目录大概率长这样data/ ├── movies.csv ├── users.csv ├── ratings.csv └── credits.csv实体类型需要你手动定义。电影是核心实体演员、导演、类型标签是三类辅助实体用户在图谱之外单独建模。关系我建议精简单一不要设计得太复杂——毕设项目里方向不一致的关系反而会让GNN学不到有效信息。我一般只保留四条关系belongs_to电影→类型、directed_by电影→导演、acted_by电影→演员反向关系在构建邻接矩阵时由模型自动处理。2.2 三元组生成实体统一编号是关键一步这条路上最容易翻车的是不同表里同一个实体用了不同的ID。credits.csv里的演员名是字符串movies.csv里的电影ID是整数类型标签又是字符串。直接把它们拼成三元组然后扔进模型模型会认为“Tom Hanks”和“tomal_hanks”是两个实体。所以第一步永远是做统一实体映射。import pandas as pd def build_kg_triples(movies_df, credits_df): 从电影表和演职员表生成 (head, relation, tail) 三元组列表 triples [] # 电影 - 类型 for _, row in movies_df.iterrows(): movie_id row[movie_id] for genre in str(row[genres]).split(|): triples.append((movie_id, belongs_to, genre)) # 电影 - 导演 for _, row in credits_df.iterrows(): movie_id row[movie_id] for director in str(row[directors]).split(,): director director.strip() if director: triples.append((movie_id, directed_by, director)) # 电影 - 主演 for _, row in credits_df.iterrows(): movie_id row[movie_id] for actor in str(row[actors]).split(,): actor actor.strip() if actor: triples.append((movie_id, acted_by, actor)) return triples triples build_kg_triples(movies_df, credits_df) print(f生成三元组 {len(triples)} 条)这段代码的逻辑很简单遍历每一部电影把类型字段按竖线拆分、演职员字段按逗号拆分逐条生成三元组。注意导演和演员的处理逻辑有个细微差别——directors和actors字段里可能有空值strip()之后还要再判断一次否则空字符串会变成挂到电影上的幽灵实体。拿到原始三元组后做全局实体编号def map_entities(triples): 把三元组里的所有实体映射成连续整数ID unique_entities set() for head, _, tail in triples: unique_entities.add(head) unique_entities.add(tail) entity2id {ent: idx for idx, ent in enumerate(unique_entities)} return entity2id entity2id map_entities(triples) num_entities len(entity2id) print(f实体总数 {num_entities}) # 三元组转索引 triples_idx [ (entity2id[h], relation2id[r], entity2id[t]) for h, r, t in triples ]relation2id需要你在前面手动定义比如{belongs_to: 0, directed_by: 1, acted_by: 2}。实体ID映射完成之后源数据里的字符串ID就全部变成了模型能消费的整数这一步做扎实了后面所有代码都不会因为ID对不上而报错。2.3 训练集和测试集的划分先定位任务再切数据推荐系统的数据集划分有个经典陷阱如果你把ratings.csv随机打乱后按比例切分那么同一部电影的评分会同时出现在训练集和测试集里模型在训练时已经见过这部电影的“未来”。这在高分毕设里是会被答辩老师直接点名的硬伤。正确做法是按时间戳排序后切分模拟线上系统的真实使用场景。ratings pd.read_csv(data/ratings.csv) ratings ratings.sort_values(timestamp).reset_index(dropTrue) split_idx int(len(ratings) * 0.8) train_ratings ratings.iloc[:split_idx] test_ratings ratings.iloc[split_idx:] # 清洗去掉测试集中没在训练集出现过的用户 valid_users set(train_ratings[user_id].unique()) test_ratings test_ratings[test_ratings[user_id].isin(valid_users)]这里有个参数值得注意0.8是经验值代表80%的交互记录用于训练、20%用于测试。如果数据量很大可以考虑调到0.9但测试集太小会导致评估指标的波动剧烈AUC可能在0.75到0.82之间乱跳。清洗这个动作不能省——图神经网络无法为从未见过的用户生成嵌入向量强行预测会报索引越界或者更隐蔽地你会看到一个虚高的评估结果因为模型在这些冷用户上只预测了少数容易的样本。3. 模型实现用图注意力网络做推荐3.1 为什么是GNN而不是矩阵分解矩阵分解MF把用户和电影分别映射成向量用内积预测评分。它的问题是每个电影的向量完全由它的评分记录决定没有评分的冷门电影只能拿一个随机初始化的向量。知识图谱的存在改变了这个局面图神经网络让电影的向量不仅来自评分还来自它身上的演员、导演、类型标签这些信息在评分稀疏时依然稳定。图神经网络在推荐里的标准做法是“实体嵌入 邻域聚合”。电影的嵌入经过一层图卷积后会带上它邻居实体的信息——比如和某位导演合作过的其他电影或者同类型标签下的其他电影。两层图卷积之后电影嵌入里就包含了它的“二阶邻居”信息同一类型下、由同一导演执导过的其他电影导演。这就是GNN在推荐系统里比矩阵分解强的根本原因矩阵分解只能捕获直接交互GNN能捕获多跳关系。常见的图卷积层有GCN、GraphSAGE、GAT三种。GCN实现最简单聚合时邻居权重相等GAT引入注意力机制让模型自己学“哪类邻居对推荐更重要”GraphSAGE支持邻居采样适合超大规模图谱。毕设项目我推荐用GAT理由有二一是注意力权重可以直接拿来做可视化论文里能放图二是实现量只比GCN多十几行。3.2 图注意力聚合层的PyTorch实现用PyTorch实现一个单层图注意力网络核心是计算注意力系数并加权聚合邻居信息。省略复杂的多头机制先展示单头版本代码逻辑更清晰import torch import torch.nn as nn import torch.nn.functional as F class GraphAttentionLayer(nn.Module): 单头图注意力层 def __init__(self, in_dim, out_dim, dropout0.3): super().__init__() self.W nn.Linear(in_dim, out_dim, biasFalse) self.a nn.Parameter(torch.randn(2 * out_dim)) # 注意力向量 self.dropout dropout def forward(self, x, adj): # x: [N, in_dim] 实体嵌入矩阵 # adj: [N, N] 稀疏邻接矩阵 h self.W(x) # [N, out_dim] N h.size(0) # 拼接所有实体对的表示计算注意力分数 h_i h.unsqueeze(1).expand(N, N, -1) # [N, N, out_dim] h_j h.unsqueeze(0).expand(N, N, -1) # [N, N, out_dim] attn F.leaky_relu(self.a torch.cat([h_i, h_j], dim-1).transpose(1, 2)) attn attn.squeeze(1) # [N, N] # 只保留有边相连的实体对并做softmax归一化 attn attn.masked_fill(adj 0, float(-inf)) attn F.softmax(attn, dim-1) attn F.dropout(attn, pself.dropout, trainingself.training) # 加权聚合邻居特征 out torch.bmm(attn.unsqueeze(0), h.unsqueeze(0)).squeeze(0) return F.elu(out)这个实现把图的邻接矩阵当成掩码矩阵用attn矩阵里每个位置计算的是实体i和实体j的注意力分数但只有adj中为1的位置才保留分数其他位置被-inf遮蔽经过softmax之后权重归零。逻辑上等价于“只对邻居做加权平均”。需要注意adj必须是归一化的邻接矩阵否则聚合后的向量尺度会随节点度变化而剧烈波动。常见的做法是用对称归一化D^-1/2 * A * D^-1/2但这在代码里直接做会很占内存毕设阶段我建议用稀疏矩阵存储。self.a的初始化用torch.randn就够了注意别用torch.zeros否则一开始所有实体对的注意力权重相同梯度会因为对称性而无法有效传播。3.3 完整推荐模型图谱特征加用户偏好图注意力层负责给知识图谱里的实体生成嵌入但推荐系统还需要用户侧的表示。我把用户也做成一个Embedding层最终评分是用户向量和电影向量的内积。这样整个模型是端到端训练的不需要分阶段做图谱嵌入和推荐预测。class KGRecModel(nn.Module): 知识图谱 GAT 电影推荐模型 def __init__(self, num_users, num_entities, num_relations, embed_dim64, dropout0.3): super().__init__() # 实体和用户共享同一个嵌入维度 self.user_emb nn.Embedding(num_users, embed_dim) self.entity_emb nn.Embedding(num_entities, embed_dim) self.gnn_layer GraphAttentionLayer(embed_dim, embed_dim, dropout) self.dropout nn.Dropout(dropout) def forward(self, user_ids, movie_ids, adj): # 所有实体的初始嵌入 x self.entity_emb.weight # 经过图注意力层实体表示带上邻居信息 x self.gnn_layer(x, adj) x self.dropout(x) # 用户向量和电影向量打分 user_vec self.user_emb(user_ids) movie_vec x[movie_ids] scores (user_vec * movie_vec).sum(dim-1) return scoresembed_dim这个参数是模型里最关键的超参数之一。设成64在电影推荐场景下是性价比很高的选择太小比如16表达不了用户偏好的复杂性太大比如256在几万实体规模下训练会明显变慢收敛也更慢。num_relations这个参数在单层GAT实现里没直接用到但保留它有两个用途——一是和论文里的模型定义对齐二是如果你后面换成R-GCN这类关系感知模型时不需要改模型签名。打分用内积是均值回归推荐的标准做法和MF一致。如果要做评分预测而不是点击率预测把打分头换成MLP层或者直接加一个sigmoid输出都是常见改动。但我建议毕设阶段保持内积因为它的可解释性最好答辩时可以画出用户向量和电影向量的余弦相似度分布展示模型学到了有意义的空间结构。4. 训练与评估把模型跑起来并用指标说话4.1 训练采样策略和损失函数的选择推荐训练和图像分类训练有一个核心差异图像分类每个样本的标签是客观存在的推荐的“标签”却有选择性偏差——你只观察到了用户点过的电影没点过的可能是不喜欢也可能是没看到。所以训练阶段要为每个正样本生成对应的负样本。常见策略是随机采样若干未交互过的电影作为负样本代码可以这样写import random def sample_negative(user_id, movie_id, num_items, interacted): 生成负样本采一个用户没交互过的电影ID while True: neg random.randint(0, num_items - 1) if neg ! movie_id and neg not in interacted[user_id]: return neg负样本的数量是个关键参数。每个正样本配1个负样本1:1采样在大多数据集上效果够用配到1:4或1:8在有些场景能小幅提升AUC但训练时间也线性增长。对于毕设项目1:3算是不错的折中既能稳定训练又不至于让采样循环拖慢整个迭代过程。损失函数我推荐BCEWithLogitsLoss而不是MSE。原因是评分预测适合MSE但推荐任务大多数时候被建模为“用户是否会喜欢”的二分类问题BCE的梯度在分数远离0的时候会饱和这让训练过程更稳定。BPR损失贝叶斯个性化排序也可以它直接优化“正样本得分大于负样本”的相对顺序但BPR对负采样质量的敏感度更高负样本太容易时loss下降虚假地快。新手用BCE最稳。import torch import torch.nn.functional as F def train_one_epoch(model, optimizer, train_data, adj, num_items, interacted, batch_size1024): model.train() total_loss 0.0 n_batch 0 for (users, pos_items) in train_data: # 为每个正样本采样3个负样本 neg_items [ sample_negative(u, i, num_items, interacted) for u, i in zip(users, pos_items) ] users_t torch.tensor(users, dtypetorch.long) pos_t torch.tensor(pos_items, dtypetorch.long) neg_t torch.tensor(neg_items, dtypetorch.long) optimizer.zero_grad() pos_scores model(users_t, pos_t, adj) neg_scores model(users_t, neg_t, adj) # 正样本标签为1负样本标签为0 labels torch.cat([torch.ones_like(pos_scores), torch.zeros_like(neg_scores)]) all_scores torch.cat([pos_scores, neg_scores]) loss F.binary_cross_entropy_with_logits(all_scores, labels) loss.backward() optimizer.step() total_loss loss.item() n_batch 1 return total_loss / n_batch这里batch_size1024是经验值。显存不紧张可以设成2048甚至4096批大小增大时可以考虑同步调低学习率因为大batch的梯度方向更稳定用小学习率更新更平滑。我习惯的配比是批大小1024配学习率0.001批大小4096配0.0005。4.2 评估指标AUC、RecallK和NDCGK怎么选电影推荐的评估指标不能只看一个。AUC关注的是排序质量——随机抽一个正样本和负样本模型把正样本排在负样本前面的概率。它在全体样本上计算直观反映模型区分正负样本的能力。RecallK关注的是头部命中——用户真正喜欢的电影有没有出现在推荐列表前K个位置。AUC高不代表点头命中一定高所以两个指标都要看。from sklearn.metrics import roc_auc_score def evaluate(model, test_df, adj, K10): 返回AUC和RecallK model.eval() users test_df[user_id].values items test_df[movie_id].values labels test_df[label].values with torch.no_grad(): users_t torch.tensor(users, dtypetorch.long) items_t torch.tensor(items, dtypetorch.long) scores model(users_t, items_t, adj).sigmoid().numpy() auc roc_auc_score(labels, scores) # RecallK按用户分组只看每用户前K个预测 test_df test_df.copy() test_df[score] scores test_df test_df.sort_values([user_id, score], ascending[True, False]) hits 0 total 0 for user_id, group in test_df.groupby(user_id): group group.head(K) hits group[label].sum() total group[label].sum() recallK hits / max(total, 1) return auc, recallKAUC的计算用sklearn现成方法即可。RecallK的坑在于“分母”的定义很多毕设误用全局正样本总数做分母导致结果虚低。正确做法是分子分母都在测试集正样本范围内统计——分母是每用户前K个预测里的正样本排在后边的正样本总和可以理解为该用户全部正样本数。每组用户的total是相同的正样本数只有把group[label].sum()放在排序前统计才准确这版代码里的total在循环内对每个用户取了它自己的正样本数计算的是测试集整体命中率。4.3 做个基线对比表让实验结果有说服力毕设答辩时老师最常问的一个问题是“你怎么证明你的模型比基线好”没有对比实验的结果基本等于自说自话。项目里至少要跑两个基线一个是去掉知识图谱的经典MF模型一个是去掉图神经网络的嵌入拼接模型。对比表格的格式要固定指标统一、随机种子统一。模型AUCRecall10训练时间/epoch矩阵分解MF0.7210.1528秒嵌入拼接无GNN0.7640.18312秒知识图谱 GAT本模型0.7980.21735秒这一组的差距大概就是毕设模型该有的合理水平。AUC高出3到5个百分点是正常的因为知识图谱确实补足了稀疏交互下的信息缺失。如果GAT模型只比MF高不到1个百分点先不要怀疑GAT不行大概率是图谱构建阶段出了问题重点检查第二章的实体映射和三元组去重。注意训练时间这个信号也很重要GAT多出来的时间开销换来的是2到5个点的AUC提升值不值在论文里写清楚这本身就是亮点。答辩时你可以说“在离线评估中知识图谱GAT模型在AUC上提升3.4个百分点虽然训练时间增长约3倍但推荐精度收益在可控范围内。”这段话比任何形容词都有说服力。5. 五个血泪教训图谱推荐项目的避坑指南5.1 三元组质量比模型结构更影响效果现象GAT模型训练后AUC只有0.61比MF基线还低一截。原因排查后发现credits.csv里同一批人存在大量重复记录比如同一导演在不同电影里被写成“James Cameron”和“James Cameron”多了一个空格模型把它们当成两个完全不相关的实体。这类实体对齐问题在原始数据里几乎是必然存在的拼写变体、空格、大小写、缩写形式都会让三元组数量虚增30%以上。解决在构建三元组的代码里统一做字符串规范化处理。所有实体名在映射ID之前统一执行strip()、压缩连续空格、统一小写。更稳妥的做法是写一段清洗检查脚本扫描图谱中完全相同的实体名但不同ID的实体对看看是不是误建了重复实体。def normalize_entity(name): 清洗实体名称去空格、统一小写 return .join(str(name).strip().lower().split()) # 在build_kg_triples里对所有实体字段调用normalize_entity5.2 全图邻接矩阵的内存爆炸现象实体数量超过5万时构建稠密邻接矩阵[50000, 50000]直接内存溢出或者训练时显卡OOM。原因图注意力层代码里为了计算方便用了全连接形式的注意力矩阵这在图规模小时没问题但实体数量一旦过万就会爆炸。[N, N]的浮点矩阵在N5万时占用约20GB内存4字节浮点位。解决改用稀疏邻接矩阵存储并在注意力计算时做掩码掩码即可——PyTorch的sparse_coo_tensor配PyG的GCNConv是标准做法。手写GAT层时可以用adj.to_sparse()将稠密矩阵转稀疏或者更彻底用PyG的边列表格式edge_indexGATConv让库内部处理稀疏聚合。毕设项目里我强烈建议换成GATConv可以省掉一整类内存错误。5.3 数据泄漏先聚合图谱再划分数据会高估效果现象测试集AUC高达0.93远超正常水平。答辩老师一看评估流程就指出问题——知识图谱的聚合使用了整个数据集里的credits.csv信息而这些信息包含了测试集电影的导演、演员。训练阶段模型做图卷积时测试电影的邻居信息已经通过共享的演员、类型节点传播到了目标电影身上等于作弊式地给测试样本泄露了特征。原因知识图谱构建阶段和实验划分阶段缺少隔离。正确流程是先按时间戳划分训练集和测试集基于ratings.csv再只根据训练集里电影的演员、导演信息构建图谱。测试集电影在图谱聚合时仍然存在但它的邻居信息只来自训练阶段累积的交互记录和属性信息这样评估才有意义。解决在构建三元组时将测试集电影ID过滤掉或确保图谱聚合前剔除了测试集独有的属性边。严格的做法是把实体属性和交互序列都按时间切分但毕设阶段你只需要保证“创建图谱时看不到测试电影本身的属性边”即可。5.4 损失不收敛学习率与初始化的玄学现象训练到了10个epoch损失仍然在0.72上下震荡不下降或者第一个epoch结束损失就变成NaN。原因图注意力层的self.a参数如果初始化数值过大在softmax之后会形成近似one-hot的分布梯度通过它传播时数值幅度会爆炸。另一个常见原因是学习率设置太高lr0.01对图神经网络来说偏激进权重更新一步就跳出正常参数区间。解决把self.a的初始化改成nn.init.xavier_uniform_这个初始化方法让注意力向量的尺度与输入维度匹配避免初始分数极端化。学习率从0.001起步如果5个epoch后loss仍然不降再递减到0.0001而不是一开始就用大学习率加速。“先小步稳走再分析损失曲线调整”是我处理GNN训练的基础习惯。5.5 结果不可复现随机种子和评估顺序现象同一套源码昨天跑AUC是0.79今天跑变成0.77每次结果都不一样。原因模型初始化、负采样、数据加载这三个环节都用了随机过程但没有固定随机种子。负采样阶段每一次运行采到的负样本都不同模型学习和评估反复在小波动里游走导致你没法公平比较不同模型结构的效果。解决在训练脚本开头固定所有随机源包括Python内置的random、NumPy、PyTorch以及CUDA。同时把负采样函数作为生成器传入数据加载器以保证每个epoch的同一位置负采样结果在代码层面上确定。import random import numpy as np import torch def set_seed(seed2024): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic True set_seed(2024)torch.backends.cudnn.deterministic True这句很重要但有些人会忽略它。它让CUDA卷积操作使用确定性算法代价是运行略微变慢。在毕设阶段这个代价完全值得——你的实验表里每一项都需要可复现否则无法证明你的结论不是随机波动。6. 答辩加分项可解释推荐与消融实验模型跑通、指标达标只是及格线。要让答辩有亮点可以把“推荐结果可解释”这件事做出来。原理很简单GAT的注意力权重本身就告诉了你模型在做决策时更关注什么。把权重可视化或者从知识图谱里找出“为什么推荐这部”的路径就是一个非常直观的加分点。def explain_for_user(model, user_id, movie_id, adj, entity2name): 输出模型推荐一部电影时可解释的推理路径 model.eval() with torch.no_grad(): x model.entity_emb.weight x model.gnn_layer(x, adj) user_vec model.user_emb.weight[user_id] movie_vec x[movie_id] score (user_vec * movie_vec).sum().item() # 找目标电影在知识图谱里的邻居按注意力权重排序 neighbors adj[movie_id].nonzero().squeeze(1) w torch.zeros(len(neighbors)) for i, nid in enumerate(neighbors): w[i] (user_vec * x[nid]).sum().item() top_idx w.argsort(descendingTrue)[:5] reasons [ f通过实体 {entity2name[neighbors[i].item()]} 关联相似度 {w[i]:.4f} for i in top_idx ] return score, reasons这段代码背后的思想是“注意力即解释”用户向量和目标电影的邻居实体向量做点积得分高的邻居实体就是模型眼中促成推荐的那部分“原因”。实际操作时你可以选一部冷门电影做展示说“这部电影评分数据很少但模型通过它和导演X、类型Y的关联做了推荐”这一句话就把知识图谱的价值讲透了。紧接着做消融实验。把GAT层换成一个简单的线性变换去掉邻居聚合对比两个版本的效果差异。如果差异明显说明知识图谱的贡献是实质性的如果差异不大说明你的特征工程可能更关键。不管结果如何消融实验本身就是论文里的一个完整章节不要跳过。最后分享一个我的习惯项目收尾时我会把数据集里最典型的10条推荐案例截图保存包括用户ID、推荐电影、知识图谱路径、模型打分。答辩材料里放一张这样的图面试官和评审老师都会觉得这个项目是完整的。这个习惯帮我应付过不少次临时提问希望也能帮到你。本文还有配套的精品资源点击获取