
简介一份基于Python实现的知识图谱与图神经网络电影推荐系统完整源码适用于高校毕业设计、课程设计及推荐系统入门进阶学习者。项目融合知识图谱与图神经网络技术代码结构清晰、注释详细可作为学术研究与工程实践的高质量参考。压缩包内含37个文件以21个Python源码文件为核心覆盖数据处理、模型训练、系统评估与Web应用等模块5个dat数据文件为电影与用户数据集5个zbak文件为重要脚本备份另有2个txt说明文件、2个readme及md文档提供部署说明与使用指引并附带1个zip备份压缩包整体大小14.88MB。目前已有49人学习下载。学习者可获取全套可运行工程包括知识图谱构建、图神经网络建模、模型训练与评估、前端可视化等模块并依据部署指南快速搭建环境进行参数调整或功能扩展深入理解推荐系统从数据到服务的完整链路。1. 基于Python的电影推荐系统知识图谱加GNN这才是毕设该有的深度做电影推荐很多人第一反应是拿UserCF、ItemCF或者DeepFM跑个评分预测但这类方案有个硬伤它把电影当成孤立的ID完全看不到电影之间“类型相近、导演关联、演员同框”这类语义关系。这个项目不一样它先把电影数据构造成知识图谱再用图神经网络在图上做推荐——简单说就是把“猜你喜欢”变成“理解你喜欢的东西和其他电影怎么关联”。对毕业设计来说这个选题既踩中了知识图谱构建的热点又用到了图神经网络技术含量和答辩说服力都在线。项目自带完整源码和部署指南适合正在做Python方向毕业设计、或者想往知识图谱与GNN方向转的同学。它不是给你一个调包就跑的demo而是把数据预处理、Neo4j图谱写入、图特征构造、LightGCN训练、推荐结果输出全链路都打通了。我拆完这套代码后发现最花时间的其实不是模型而是知识图谱的构建和特征对齐。下面按我实际复现的顺序把这套系统的完整链路拆给你看。2. 知识图谱构建从数据清洗到Neo4j落地2.1 为什么非要用知识图谱传统推荐系统的用户-物品矩阵是二维的但电影这个场景里藏着大量结构化关系导演执导电影、演员参演电影、电影属于类型、用户看过电影。这些关系天然就是图结构知识图谱的价值在于把散落在不同表里的实体和关系统一建模变成“实体-关系-实体”的三元组之后无论是做路径特征还是做图上传播都有据可依。这个项目的知识图谱构建分四步走数据清洗、本体设计、三元组抽取、写入Neo4j。数据源用的是MovieLens标准数据集加扩充的电影元数据但原始数据里演员名、导演名、类型字段经常有缺失和重复清洗这一步不做扎实后面图谱就是脏的。常见做法是先统一实体ID再对导演名做别名归并比如“Tim Robbins”和“Timothy Robbins”要合并成同一个演员节点。本体设计是整个图谱的骨架也就是定义“有哪些实体类型、哪些关系类型”。这个项目用的本体结构如下实体类型关键属性关系类型关系示例Moviemovie_id, title, release_yearACTED_IN(Actor)-[ACTED_IN]-(Movie)Actoractor_id, nameDIRECTED(Director)-[DIRECTED]-(Movie)Directordirector_id, nameBELONGS_TO(Movie)-[BELONGS_TO]-(Genre)Genregenre_id, nameRATED(User)-[RATED]-(Movie)Useruser_id, age, occupation2.2 从原始数据到三元组抽取脚本怎么写数据清洗和三元组抽取是这个项目里最“脏”的环节。原始数据是CSV你没法直接导入Neo4j得先转成三元组或者等价的导入文件。我一般会用Pandas先把数据读进来然后逐表抽三元组写成一个统一的构建脚本。import pandas as pd from tqdm import tqdm movies pd.read_csv(data/movies.csv) links pd.read_csv(data/links.csv) ratings pd.read_csv(data/ratings.csv) # 类型字段是管道分隔比如 Action|Adventure|Sci-Fi movies[genres] movies[genres].str.split(|) triples [] for _, row in tqdm(movies.iterrows(), totallen(movies)): movie_id fmovie_{row[movieId]} triples.append((Movie, movie_id, title, row[title])) for genre in row[genres]: genre_id fgenre_{genre.strip()} triples.append((Genre, genre_id, name, genre.strip())) triples.append((Movie, movie_id, BELONGS_TO, genre_id)) # ratings表转成 User-RATED-Movie 三元组 for _, row in tqdm(ratings.head(20000).iterrows(), total20000): user_id fuser_{row[userId]} movie_id fmovie_{row[movieId]} triples.append((User, user_id, RATED, movie_id)) triples.append((Rating, frating_{row[userId]}_{row[movieId]}, score, str(row[rating])))这段代码的逻辑是先把每部电影变成一个Movie实体再把它所属的类型变成Genre实体用BELONGS_TO关系连起来用户评分则变成User节点到Movie节点的RATED关系。注意Rating分数我没有直接放在关系属性里而是拆成了独立的Rating实体节点这样后续在GNN里做特征聚合时评分信息的存取更灵活。关系三元组的格式统一成(实体类型, 实体ID, 关系名, 目标ID)这样无论写入Neo4j还是转成DGL的图数据都只需要做一个映射不用改逻辑。2.3 Neo4j批量写入逐条CREATE在大数据量下是灾难如果你用Neo4j的官方驱动一条条执行CREATE语句10万条三元组能跑半小时以上而且很容易触发事务超时。这个项目用的方式是先事务批量提交同时把实体和关系分开写。先用MERGE把实体节点全部建好再批量建关系这样可以避免关系创建时频繁查找节点。from neo4j import GraphDatabase driver GraphDatabase.driver(bolt://localhost:7687, auth(neo4j, password)) def batch_merge_nodes(tx, nodes): for node_type, node_id, attr_name, attr_val in nodes: tx.run( fMERGE (n:{node_type} {{id: $id}}) fSET n.{attr_name} $val, idnode_id, valattr_val ) def batch_merge_edges(tx, edges): for head_type, head_id, rel_type, tail_id in edges: tx.run( fMATCH (a:{head_type} {{id: $head_id}}), f(b {{id: $tail_id}}) fMERGE (a)-[r:{rel_type}]-(b), head_idhead_id, tail_idtail_id ) with driver.session() as session: # 每5000条一个事务避免大事务超时 for i in range(0, len(entities), 5000): session.execute_write(batch_merge_nodes, entities[i:i5000]) for i in range(0, len(edges), 5000): session.execute_write(batch_merge_edges, edges[i:i5000])这里有两个坑提前说一是MERGE语句里的标签和关系类型不能参数化必须用f-string拼进去所以实体类型和关系类型名要严格校验不能有特殊字符二是分片写入时如果中途失败重复执行会产生重复实体所以节点ID必须全局唯一MERGE就是靠id属性去重跟节点的主键语义一致。写完以后我习惯在Neo4j浏览器里跑一条MATCH (n) RETURN count(n)确认节点数量和数据源行数对得上。3. 图特征与工程骨架把图数据变成GNN能吃的东西3.1 特征设计光有Embedding不行还得有边的权重图谱建好之后不能直接丢给GNN因为Neo4j里的图数据不会自动变成张量。这个项目里特征工程的核心是给每条边算一个权重再给每个节点算一组初始特征。初始特征用One-Hot编码实体类型和关键属性边权重则用评分归一化后加上共现次数这样模型才能区分“用户看过但不喜欢”和“用户打了高分”的关系强度。我拆代码时发现作者这里的设计很聪明用户对电影的评分不是直接作为特征喂进去而是转成边权重再参与聚合。理由是评分本身是强信号但直接拼到特征向量里会让模型过拟合某个评分值而作为边权重做邻域聚合模型学到的是“这个用户和这部电影之间的关联强度”泛化性更好。边权重计算公式import math def edge_weight(rating_score, common_neighbors): # rating_score 归一化到 0-1common_neighbors 是该用户与电影共同关联的实体数 score_norm (rating_score - 1) / 4.0 neighbor_factor math.log1p(common_neighbors) return score_norm * 0.8 neighbor_factor * 0.23.2 训练数据怎么切按用户切而不是按边切推荐系统和普通分类任务最大的区别在于数据划分。很多新手把用户评分数据随机打乱后按比例拆分结果同一个用户出现在训练集和测试集里模型相当于“见过”测试用户的交互了评估出来的指标虚高。这个项目是严格按用户ID切分的训练集和测试集的用户完全不重叠。from sklearn.model_selection import train_test_split unique_users ratings[userId].unique() train_users, test_users train_test_split( unique_users, test_size0.2, random_state42 ) train_data ratings[ratings[userId].isin(train_users)] test_data ratings[ratings[userId].isin(test_users)]这么切之后测试集里的用户对模型来说是“冷启动”状态模型必须靠知识图谱里的路径特征来推断他们的偏好而不是直接去查他们已有的评分记录。这也正是知识图谱推荐的优势所在——即使是新用户只要他有过一次交互图谱就能通过导演、演员、类型等路径把相关的电影带进来。如果换成纯协同过滤冷启动用户直接就废了。3.3 从Neo4j导出子图DGL格式转换模型训练用的图数据跟Neo4j里的图不是同一个东西。Neo4j是存储层DGL是计算层从Neo4j查询子图再转成DGL格式是这个项目里承上启下的关键一步。常见做法是先用Cypher查出用户和电影节点的邻居路径再导出成边列表最后用DGL的dgl.graph()构建计算图。import dgl import torch def build_dgl_graph(edge_list): src_nodes [e[0] for e in edge_list] dst_nodes [e[1] for e in edge_list] graph dgl.graph((torch.tensor(src_nodes), torch.tensor(dst_nodes))) return graphDGL图构建的时候要注意节点ID必须是从0开始的连续整数否则DGL内部会做隐式重映射导致你后面查特征向量时对不上号。我一般是先用一个字典把Neo4j的字符串ID映射成整数ID再建图。4. 模型与训练LightGCN里的关键实现细节4.1 为什么用LightGCN而不是GCN或GAT知识图谱推荐里常用的图神经网络有GCN、GAT、GraphSAGE、LightGCN几种但做电影推荐这个场景LightGCN是性价比最高的选择。GCN在每一层都做了非线性变换和特征映射在图规模比较大的时候容易过平滑节点Embedding互相趋同GAT引入了注意力权重效果是好但训练速度和显存开销都要翻倍。LightGCN去掉非线性激活和特征变换只保留邻域聚合操作在协同过滤场景下效果反而更好。这个项目的核心模型就是LightGCNEmbedding维度设为64图卷积层数设成2层。2层聚合的意思是第0层是节点自身的Embedding第1层聚合一跳邻居的信息第2层聚合两跳邻居的信息。对电影推荐来说两跳恰好能覆盖“用户-电影-演员-其他电影”这个路径一跳信息不够三跳容易把不相关的电影也聚合进来。4.2 负采样与BPR损失函数训练图神经网络做推荐不能用普通的交叉熵因为正样本好找但负样本得靠采样。这个项目用的是BPRBayesian Personalized Ranking损失核心思想是给用户推荐时在知识图谱里和他关联过的电影得分要比没关联过的电影得分高并且这个差距越大越好。所以每一轮训练都要随机采样一批用户没看过的电影作负样本期望的Loss就是让“正样本分-负样本分”的差尽量大。class LightGCN(nn.Module): def __init__(self, num_users, num_items, embed_dim64, n_layers2): super().__init__() self.user_embed nn.Embedding(num_users, embed_dim) self.item_embed nn.Embedding(num_items, embed_dim) self.n_layers n_layers self._init_weights() def _init_weights(self): # 用正态分布初始化Embedding标准差0.1避免初始值过大导致梯度爆炸 nn.init.normal_(self.user_embed.weight, std0.1) nn.init.normal_(self.item_embed.weight, std0.1) def forward(self, graph, user_ids, item_ids): # graph 是DGL图user_ids是当前batch的用户ID all_embeds [] cur_embeds torch.cat([self.user_embed.weight, self.item_embed.weight], dim0) all_embeds.append(cur_embeds) for _ in range(self.n_layers): cur_embeds graph.ndata[h] if h in graph.ndata else cur_embeds # 邻域聚合平均池化无非线性变换 neighbor_embeds dgl.ops.copy_u_sum(graph, cur_embeds) cur_embeds neighbor_embeds / graph.in_degrees().unsqueeze(1).clamp(min1) all_embeds.append(cur_embeds) final_embeds torch.stack(all_embeds, dim0).mean(dim0) user_final final_embeds[user_ids] item_final final_embeds[item_ids] return user_final, item_final这段代码里有个容易忽略的地方LightGCN每一层得到的Embedding最后都不是只取最后一层而是把所有层的Embedding做平均池化。原因是最初的ID Embedding包含了用户和物品的固有语义完全丢掉会损失信息把每一层的表示平均起来等于同时保留了一阶、二阶的语义最后的效果更稳定。我复现的时候试过只取最后一层结果Recall掉了5个百分点所以这个均值池化不是可用可不用的优化而是LightGCN的核心机制。训练时用的优化器是Adam学习率设0.001weight_decay设1e-4这两个参数是LightGCN结果好坏的胜负手。Learning rate太大Loss会剧烈震荡最后Embedding范数会发散weight_decay设小了模型很快过拟合到训练集的评分分布上测试集的指标会很难看。我一般会跑20个Epoch然后看验证集上的Recall10来判断是否提前停止训练。4.3 训练脚本Loss和评估指标的打印逻辑训练过程不是闷头跑就行要能实时看到Loss的趋势和评估指标的变化。这个项目的训练循环写了完整的日志输出每个Epoch打印训练Loss、测试集的Recall10和NDCG10。for epoch in range(epochs): model.train() total_loss 0.0 for batch in train_loader: # 正样本边和负采样边 pos_u, pos_i batch neg_i torch.randint(num_items, (len(pos_u),)) user_emb, pos_item_emb model(graph, pos_u, pos_i) _, neg_item_emb model(graph, pos_u, neg_i) pos_score (user_emb * pos_item_emb).sum(dim1) neg_score (user_emb * neg_item_emb).sum(dim1) loss -torch.log(torch.sigmoid(pos_score - neg_score)).mean() total_loss loss.item() optimizer.zero_grad() loss.backward() optimizer.step() if epoch % 5 0: recall, ndcg evaluate(model, test_loader, top_k10) print(fEpoch {epoch}: Loss{total_loss:.4f}, fRecall10{recall:.4f}, NDCG10{ndcg:.4f})注意这里正样本边是根据用户历史评分采出来的不能直接用全量数据因为太大跑不动。我一般会保证每个Epoch里每个活跃用户至少采到20条正样本这样梯度更新才够充分。负样本的采样范围是整个物品池不用管用户是否看过——如果用户恰好看过这个电影那这个负样本本质上是个假阴性但因为用户没评过分模型会认为它是负样本。实际操作中这问题不大因为物品池大误伤概率很低。5. 实践避坑我跑这套代码踩过的五个坑5.1 Neo4j导入中的MERGE与CREATE混淆现象第一次导数据时用了CREATE跑完发现图谱节点数量是原始数据的1.7倍。原因CREATE每次执行都会新建节点脚本分片重跑时已经存在的节点又被建了一遍。MERGE是有则匹配、无则创建天然去重但前提是得有唯一的id属性做匹配键。解决实体节点一律用MERGE并且用带id属性的MERGE语句如MERGE (n:Movie {id: $id})。关系也尽量用MERGE防止重复。排查时在Neo4j里用MATCH (n:Movie) RETURN count(n)对照原始数据量一眼就能看出有没有重复。5.2 NaN进入Embedding后模型直接“废掉”现象训练到第3个EpochLoss突然变成nan之后模型输出全是nan怎么调学习率都没用。原因图里有些节点的初始特征存在空值比如演员名缺失导致One-Hot特征全是0聚合时in_degrees为0的孤立节点被零除产生NaNNaN在反向传播里会沿着计算图传染到整个Embedding。解决数据预处理阶段把所有缺失值填0或填充一个默认值然后在聚合操作里加clamp(min1)保证分母不为0。我现在每次训练前都会固定跑一条检查脚本统计有没有节点的入度为0发现就剔除或加自环。5.3 电影数据里的“超级节点”导致过平滑现象模型训练后Recall10始终卡在0.12上不去打印用户Embedding发现一大半用户的向量都长一个样。原因《肖申克的救赎》这类电影关联了几百个用户、数十个演员和导演是图里的超级节点。LightGCN聚合时这些超级节点把巨量信息平均到每个邻居上导致所有用户的Embedding都被“稀释”成相似的向量。这是图神经网络的过平滑问题在知识图谱推荐里比普通推荐更严重。解决我先把出度大于500的节点单独统计给它们的边权重做衰减也就是热门电影打折话事权同时给LightGCN加上边权重归一化不让超级节点的一家之言盖掉小众电影的独特信息。5.4 Embedding维度到底选多少现象Embedding维度从16调到128模型效果忽高忽低很难判断哪个是真实效果差异。原因维度太小图结构信息装不下维度太大数据量不够时直接过拟合。这个项目用64维是因为电影数据本身的实体规模在几千左右64足够表达实体之间的差异显存占用也友好。解决如果你是拿MovieLens 1M数据跑64和128都行如果是自己爬的数据只有几百部电影32就够别硬上128。维度是超参数不是越大越好要用验证集的NDCG来定。5.5 用户冷启动推荐效果“打骨折”现象测试集里那些评分记录少于3条的用户推荐结果几乎没有命中。原因LightGCN的邻域聚合高度依赖用户已有的交互交互太少图上的邻居少聚合出来的Embedding几乎就是初始值推不出有效结果。解决这个项目最后在服务层加了一个回退策略——冷启动用户优先走知识图谱路径比如查Neo4j里和他看过的电影导演、演员最相似的其他电影而不是强行依赖GNN。这也是知识图谱推荐的额外价值模型推不动的场景图谱规则还能兜底。6. 完整链路复现从零跑到出推荐结果6.1 启动顺序与依赖检查拿到源码后不要上来就跑训练脚本先按部署指南把环境装好。我复现时发现最常见的问题是Neo4j版本不匹配旧版的py2neo和新版的Neo4j写事务时接口不一样。按部署指南的依赖清单安装即可Python环境用3.8到3.10都行PyTorch用2.0以上DGL用对应CUDA版本。装完之后把五个模块顺序跑通数据预处理、图谱构建、子图导出、模型训练、推荐服务。# 一键构建知识图谱 python scripts/build_kg.py --data data/movies.csv --output kg_output # 导出DGL子图 python scripts/export_graph.py --kg kg_output --output graph.bin # 训练LightGCN模型 python scripts/train.py --graph graph.bin --epochs 20 --embed-dim 64 # 启动推荐服务 python scripts/serve.py --model checkpoints/lightgcn_best.pth --port 8000每条命令都加了--output或--model参数实际部署时记得改成自己的路径。Order不能乱先得有图谱才能导出子图模型训练依赖子图文件推荐服务依赖训练好的权重。我一开始跳过了export_graph.py直接训练结果程序报“图文件不存在”回头补上这一步就好了。6.2 模型评估不只是看Loss训练结束后代码会自动在测试集上算Recall10、Precision10和NDCG10。这三个指标分开看才有意义Recall10看推荐列表覆盖真实喜欢的比例Precision10看推荐列表里有多少是用户真的喜欢的NDCG10看排在前面的推荐是不是比后面的更准。# 输出最终评估指标 python scripts/train.py --graph graph.bin --epochs 20 --eval-only我复现时的效果在MovieLens 100K数据上Recall10约0.18NDCG10约0.32这个水平在纯协同过滤基线之上。如果你换数据指标可能不一样但关键是模型训练不会崩。训练完之后推荐服务提供的是一个HTTP接口传用户ID返回推荐电影列表。6.3 验证图谱有没有建对一条Cypher查全链路我有个习惯每次建完图谱、训练完模型都要用Neo4j跑一条Cypher验证“某用户看过的电影”能不能通过导演路径关联到“从未看过的电影”。这是对整个链路最直接的健康检查。MATCH (u:User {id: user_1})-[r:RATED]-(m1:Movie) WITH u, m1 MATCH (m1)-[:DIRECTED]-(d:Director) MATCH (d)-[:DIRECTED]-(m2:Movie) WHERE NOT EXISTS ((u)-[:RATED]-(m2)) RETURN m2.title, d.name LIMIT 20;这条查询如果返回空说明知识图谱里没有导演关系或用户没有评分记录得回头检查数据导入那一步如果能查到未看过的电影说明图谱的结构是通的GNN模型训练才有意义。从那以后我每次README里的数据集跑通一遍都会先跑一遍这条Cypher再做模型训练已经成习惯了希望这个项目里的这个检查思路也能帮到你少走一次弯路。本文还有配套的精品资源点击获取