ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

知识图谱与图神经网络驱动的电影推荐系统:从图谱构建到GCN实战

知识图谱与图神经网络驱动的电影推荐系统:从图谱构建到GCN实战 简介基于Python的知识图谱与图神经网络电影推荐系统源码专为毕业设计、期末大作业及课程设计打造适合具备基础Python知识并希望动手实践图神经网络项目的学习者。系统利用知识图谱构建电影实体与关系网络再通过图神经网络提取用户和电影的深层特征从而大幅提升电影推荐的准确度。代码注释详尽、模块划分清晰涵盖主程序入口、知识图谱加载、图卷积模型、训练评估、数据转换等环节并配有单元测试与Windows平台运行脚本方便阅读和二次开发。资源共37个文件包含21个Python脚本、5个dat数据文件、5个zbak备份文件、2个txt及2个readme/md说明文档压缩包仅14.84MB轻量易部署。系统功能完善、界面美观经过严格调试后可直接使用个人手打项目已获导师认可。目前已有65人学习下载配套说明文档与备份文件是快速掌握知识图谱与图神经网络落地应用的实用参考。1. 毕设用知识图谱图神经网络做电影推荐为什么它比纯协同过滤更值得做很多做毕业设计的同学一开始都会选电影推荐系统因为 MovieLens 数据集现成、协同过滤代码满网都是。但答辩时一问“你的系统相比 SVD 改进在哪”大多数人就卡住了。这个基于 Python 的标题给了一条差异化路线把电影、导演、演员、类型、用户行为一起建模成知识图谱再用图神经网络GNN在图上做表示学习最后输出推荐结果。它既有图谱构建的工程含量又有模型设计的算法含量正好覆盖毕设里“工作量”和“创新点”两个得分点。适合有 Python 基础、愿意碰 Neo4j 和 PyTorch 的读者。下面按实际方案拆解。2. 电影知识图谱怎么建从原始数据到 Neo4j 实体关系入库2.1 实体与关系设计先想清楚图谱里有哪些节点和边很多同学上来就写代码结果图谱建得一团乱。知识图谱的根基是本体设计也就是先定义清楚“有哪些实体、哪些关系、属性约束是什么”而不是拿到数据就往 Neo4j 里灌。做电影推荐我一般把实体分成两类一类是电影的属性实体一类是用户行为实体。属性实体包括电影、人物、类型三类电影节点存 title、release_year、rating 这些基础属性人物节点用一个 Person 标签统一表示用 role 字段区分演员和导演类型节点单独抽出来因为同一部电影属于多个类型抽成节点比存成逗号分隔的字符串更适合后面的图查询和 GNN 聚合。用户行为实体就是 User它通过 RATED 边连接到电影边上带 score 和时间戳这是后面图神经网络训练的核心交互信号。有人图省事把导演、演员全塞进 Movie 节点的属性里结果 GNN 聚合时完全学不到“演员关系”这种语义这是模型效果上不去的最常见原因。知识图谱的价值就在于把关系变成显式的边这一步省掉后面调参再猛也救不回来。先定义本体再建图的思路在工业场景下的知识图谱设计里同样是第一步企业里建图谱前会有数据治理团队先产出本体文档毕设不需要那么重的流程但至少要手工画一张实体关系草图。我自己踩过的坑是跳过设计环节直接灌数据最后图谱里同一个意思的关系出现三种写法比如 acted_in、出演、ACTOR_OFGNN 建模时光是清洗关系类型就花了两天。实体/关系标签/类型主要属性电影Moviemovie_id, title, release_year人物Personperson_id, name, role类型Genregenre_id, name用户Useruser_id出演关系ACTED_IN无导演关系DIRECTED无属于关系BELONGS_TO无评分关系RATEDscore, timestamp2.2 用 Pandas 把原始数据拆成节点表和边表数据从哪来常见做法是拿 MovieLens 100k 或 1M 数据集做行为数据再拿 TMDB 或电影网站爬虫数据补电影属性。爬虫不是必选项能稳定拿到数据就行建议优先用官方 API 或现成数据集。关键步骤是先把原始表拆成“节点文件”和“边文件”两种格式因为 Neo4j 批量导入工具 LOAD CSV 对字段命名有约定写错了后缀整批导入就报错。import pandas as pd movies pd.read_csv(movies.csv) # movieId, title, genres ratings pd.read_csv(ratings.csv) # userId, movieId, rating, timestamp # movie 节点表从标题里拆出上映年份 movie_nodes movies[[movieId, title]].rename( columns{movieId: movie_id:ID, title: title} ) movie_nodes[release_year] movie_nodes[title].str.extract(r\((\d{4})\)) movie_nodes.to_csv(movie_nodes.csv, indexFalse) # user 节点表userId 去重即可 user_nodes ratings[[userId]].drop_duplicates().rename( columns{userId: user_id:ID} ) user_nodes.to_csv(user_nodes.csv, indexFalse) # RATED 边表两端 ID 用 :START_ID 和 :END_ID 标记 rated_edges ratings.rename( columns{userId: user_id:START_ID, movieId: movie_id:END_ID, rating: score} ) rated_edges[[user_id:START_ID, movie_id:END_ID, score, timestamp]].to_csv( rated_edges.csv, indexFalse )这里的关键在字段名后缀:ID 表示主键:START_ID 和 :END_ID 表示边的两端这是 LOAD CSV 能识别的最小约定。很多同学不写后缀导入时一直报 Couldnt load the external resource其实是字段名的问题。另外MovieLens 的 genres 字段是管道符分隔的如果直接存成字符串在 Neo4j 里做类型聚合很别扭正确做法是在导出阶段就把它拆开单独生成一个 BELONGS_TO 的边文件每行是 movie_id 加一个 genre_id这样图谱的语义才干净。2.3 批量写入 Neo4jpy2neo 事务分批、LOAD CSV 与索引毕设数据量一般在十万级以下py2neo 直接写完全够用而且可以在同一个 Python 工程里和后面的 GNN 流程串起来。写入顺序有讲究先写节点再写边边两端的节点必须已存在否则会创建出孤立节点或者直接标红报错。from py2neo import Graph, Node, Relationship, Subgraph graph Graph(http://localhost:7474, auth(neo4j, your_password)) def write_nodes(df, label, key_field): 分批写节点每 500 条提交一次事务避免事务过大拖垮内存 batch [] for row in df.itertuples(): props {k: v for k, v in row._asdict().items() if k not in (Index, key_field)} batch.append(Node(label, **{key_field: getattr(row, key_field)}, **props)) if len(batch) 500: graph.create(Subgraph(batch)) batch [] if batch: graph.create(Subgraph(batch)) write_nodes(user_nodes, User, user_id) write_nodes(movie_nodes, Movie, movie_id)参数说明batch 控制在 500 条是因为 Neo4j 单事务太大容易把堆内存打满太小又频繁提交导致导入变慢。毕设机器 8G 内存时 500 是稳妥值到百万级边数再换 neo4j-admin import 离线导入。写边之前务必建索引否则每次 match 坐标都是全库扫描十万条边能跑半小时。CREATE INDEX FOR (m:Movie) ON (m.movie_id); CREATE INDEX FOR (u:User) ON (u.user_id);索引建好后边的写入我通常不用 py2neo 的逐条 match而是把边表导出 CSV再用 LOAD CSV 配合 USING PERIODIC COMMIT 导入两边都不卡。Cypher 脚本大致是这样的USING PERIODIC COMMIT 500 LOAD CSV WITH HEADERS FROM file:///rated_edges.csv AS row MATCH (u:User {user_id: toInteger(row.user_id)}) MATCH (m:Movie {movie_id: toInteger(row.movie_id)}) CREATE (u)-[:RATED {score: toFloat(row.score)}]-(m)脚本里 toInteger 和 toFloat 是必修课。CSV 导入后所有字段默认是字符串不显式转换score 排序、GNN 取特征都会出问题。这里的 USING PERIODIC COMMIT 500 和 py2neo 的 batch500 是一个目的每处理 500 行提交一次事务防止长事务把 NeO4j 内存撑爆。提示Neo4j Desktop 和 Community Server 对 CSV 路径的处理有差异。Desktop 把 csv 放在 import 目录下路径写 file:/// 开头Server 环境要确认 dbms.directories.import 配置指向哪个目录路径写错了会直接报找不到文件。3. 图神经网络模型怎么选GCN、GAT 还是 LightGCN三个必调参数3.1 为什么推荐要用 GNN把协同过滤放进图里看传统协同过滤的核心假设是“相似用户喜欢相似物品”在图上体现为 user-item 二部图的消息传播。矩阵分解做的事情本质上是 user-item 邻接矩阵的低秩近似它只建模了一阶交互——用户和电影的直接连接。GNN 不一样每一层卷积让节点的表示向邻居聚合两层之后一个用户就能感知到“和我看过同一批电影的人还喜欢什么”这是高阶协同信号。知识图谱在其中的作用是让 movie-actor-director-genre 这些属性关系也变成图里的边聚合时不仅看到行为相似性还能把“同一导演”“同一类型”这些语义相似性带进表示这正是这个方案相比纯协同过滤的核心卖点。选型的答案取决于图谱里有什么。如果只有 user-item 行为边LightGCN 是首选它去掉了 GCN 里对推荐无益的非线性变换训练更快如果图谱里知识图谱是主体、属性边很丰富就用 GCN 或 GAT让不同类型的边贡献各自的语义。毕设标题既然同时点了知识图谱和图神经网络我建议用带属性的 GCN 做底子既覆盖了 GNN 的技术点实现难度也适中答辩时讲“属性图上的消息传递”比讲“纯行为图”更有故事性。GAT 比 GCN 多了注意力机制理论上能给不同邻居分配不同权重但电影推荐场景的图很稀疏GAT 的收益有限训练却慢不少。我的建议是先用 GCN 跑通全流程作为论文基线学有余力再把第一层换成 GAT 做对比实验这正好是毕业设计里“消融实验”的素材比空谈模型创新实在得多。3.2 最小可跑的 GCN 训练代码基于 DGL 的实现图神经网络的工程实现我通常选 DGL它对异构图和多关系边的支持比 PyG 更顺手文档也跟得上。下面这段代码是把用户行为图读进来、用两层 GCN 学嵌入的最小实现import dgl import torch import torch.nn as nn import torch.nn.functional as F class GCNEncoder(nn.Module): def __init__(self, in_feats, hidden_dim, out_dim, dropout0.3): super().__init__() self.conv1 dgl.nn.GraphConv(in_feats, hidden_dim) self.conv2 dgl.nn.GraphConv(hidden_dim, out_dim) self.dropout nn.Dropout(dropout) def forward(self, g, features): h F.relu(self.conv1(g, features)) h self.dropout(h) h self.conv2(g, h) return h # user_id 和 movie_id 拼进同一套节点编号再建图 # 注意 movie_id 要加 offset避免和 user_id 撞号 user_id torch.tensor(rated_edges[user_id].values) movie_id torch.tensor(rated_edges[movie_id].values offset) g dgl.graph((user_id, movie_id)) g dgl.add_self_loop(g) # 初始特征随机初始化够用想更强可以拼电影的类型向量 in_feats 64 features torch.randn(g.num_nodes(), in_feats) model GCNEncoder(in_feats, hidden_dim128, out_dim64) emb model(g, features) # 所有节点的嵌入逻辑说明dgl.graph 接收边起点数组和边终点数组用户和电影落在同一套 id 空间里所以这里用 offset 把电影 id 整体平移避免和用户 id 重叠。add_self_loop 是 GCN 的标配不加的话第一层卷积会忽略节点自身特征表达力明显下降。初始特征用随机值在“邻居聚合”机制下仍然能学出节点在图上的位置信息这个可以理解为空特征版本想加强效果可以把电影的类型 one-hot 向量或预训练词向量拼进来毕设里属于加分项而不是必选项。数据量到百万边时全图前向会越来越慢这时应该换成邻居采样的小批量训练用 dgl.dataloading.NeighborSampler 每次只聚合两层邻居。这是从毕设走向真实项目必须知道的扩展点答辩时主动提一句老师印象分会不一样。3.3 三个必调参数层数、嵌入维度、dropout层数是最容易翻车的。网上很多教程照抄 GCN 论文写三层四层但推荐场景里两层是分水岭。三层以上用户的表示会聚合到太大范围的邻居所有用户输出趋同推荐多样性直接崩掉这个现象在 GNN 里叫过平滑。我的经验是图谱边数在十万级时两层 GCN 的 Recall20 普遍比三层高 3 到 5 个百分点。判断方法很简单把模型输出的嵌入做一次 PCA 可视化如果不同用户的点挤成一团就是层数多了。嵌入维度从 64 起步128 是上限。毕设数据量小256 维不仅训练慢验证集 loss 还会在某个 epoch 后反弹。过拟合的判断标准是训练 loss 贴到 0 而验证 loss 开始上升这时候优先降维度而不是加正则。dropout 建议设在 0.2 到 0.4 之间用户行为图通常很稀疏dropout 太小时模型很容易把噪声当信号记住。参数建议值取值范围调参信号层数21~3嵌入 PCA 可视化挤成一团就减层嵌入维度6432~128验证 loss 反弹就降维dropout0.30.2~0.4训练/验证 loss 差距过大就加大学习率1e-35e-4~1e-2loss 震荡就降学习率学习率单独说一下。Adam 配 1e-3 是大多数 GNN 工程默认的起手式如果 loss 曲线像锯齿一样上下跳先降到 5e-4不要去动网络结构。调参时每次只改一个变量把结果记录成表格这个习惯在写毕设的“实验对比”章节时非常值钱比临时抱佛脚补数据有用得多。4. 推荐链路整合从 GNN 嵌入到 Top-N 推荐与离线评估4.1 召回 vs 排序GNN 的输出到底拿来做哪一步推荐系统里召回负责从百万物品里快速筛出几百个候选排序负责给候选精确打分。毕设通常把两步合并用 GNN 学出的用户嵌入和电影嵌入直接对全部电影算内积相似度取 Top-N 返回。这样做的优势是流程短、答辩时好讲劣势是当电影数量超过五万时全量内积有毫秒级延迟但毕设规模完全可接受。另一种更完整的做法是把 GNN 嵌入当成特征喂给一层 LR 或 MLP 做排序这更接近工业界的推荐架构但需要额外构造排序样本工作量翻倍。我建议毕设选第一种直接内积排序把“全量内积”和“Top-N 截断”讲清楚已经能体现对推荐系统的理解深度。整个项目的源码目录我建议按 data、kg、model、server 四个目录组织data 放原始数据和处理脚本kg 放图谱构建和查询model 放 GNN 模型和训练server 放 Web 接口。评审老师打开目录能一眼看到四条线这个结构本身就说明项目是完整的工程而不是单文件脚本堆出来的。4.2 BPR 损失训练与候选集打分模型训练用的损失函数是贝叶斯个性化排序BPR它的核心思路是用户交互过的电影得分应当高于随机抽出的负样本。正样本直接取训练集里的 RATED 边负样本从用户没看过的电影里抽样抽样策略直接决定训练质量具体坑在第五章展开。import random import torch def bpr_loss(user_emb, pos_emb, neg_emb): BPR 损失正样本得分与负样本得分的差值经 sigmoid 后取负对数 pos_score (user_emb * pos_emb).sum(dim-1) neg_score (user_emb * neg_emb).sum(dim-1) return -torch.log(torch.sigmoid(pos_score - neg_score)).mean() optimizer torch.optim.Adam(model.parameters(), lr1e-3) for epoch in range(50): random.shuffle(train_triples) # train_triples: (user_id, pos_id, neg_id) for u, pos, neg in train_triples: emb model(g, features) # 每步全图前向小图可接受 loss bpr_loss(emb[u], emb[pos], emb[neg]) optimizer.zero_grad() loss.backward() optimizer.step()逻辑说明BPR 损失不直接预测评分而是学习用户对不同电影的偏好排序。训练时每个三元组包含用户、正样本电影、负样本电影模型要让正样本得分比负样本高。这里每步都对全图做一次前向图小的时候没关系图大到百万边就要用 4.2 提到的 NeighborSampler 改成小批量训练。打分阶段就更直接了用训练好的模型对全图算一次嵌入用户向量和电影向量做内积排序取前 N 个。4.3 离线评估RecallK 与 NDCG 怎么算才严谨答辩时最怕被问“你怎么证明你的系统比基线好”。口头解释没用必须跑离线评估拿数据说话。评估协议要按时间切分把每个用户的评分记录按时间排序前 80% 做训练后 20% 做测试这样可以避免随机切分带来的数据泄漏。import math def recall_at_k(scores, test_items, k20): 单个用户的 RecallK测试集命中了多少被模型捞回的电影 topk scores.argsort(descendingTrue)[:k].tolist() hits len(set(topk) test_items) return hits / len(test_items) if test_items else 0 def ndcg_at_k(scores, test_items, k20): 单个用户的 NDCGK位置越靠前权重越大 topk scores.argsort(descendingTrue)[:k].tolist() dcg 0.0 for i, item in enumerate(topk): if item in test_items: dcg 1.0 / math.log2(i 2) idcg sum(1.0 / math.log2(i 2) for i in range(min(k, len(test_items)))) return dcg / idcg if idcg 0 else 0逻辑说明RecallK 看的是“测试集里用户真正看的电影被捞回来多少”NDCG 额外惩罚排在后部的命中项。注意 DCG 的折扣因子要写 log2(i2)网上很多简版代码用 i1 代替数量级差不大但答辩老师较真时会被挑刺。同一个评估脚本跑矩阵分解或 ItemCF 作为基线把 Recall20 随 K 的变化画成折线图一张图比十页文字说明都管用。画图用 Matplotlib 就够了这也是 Python 数据分析与可视化基本功里最实在的一块。5. 毕设避坑实录知识图谱与 GNN 推荐系统最常见的 5 个翻车点这一章写的都是做这类项目时真实踩过的坑按出现频率从高到低排。每条按现象、原因、解决三段写方便对号入座。如果你时间只够看一章看这章。5.1 坑一py2neo 写入慢到怀疑人生Neo4j 内存还爆了现象用 py2neo 导入 MovieLens 100k 的评分数据跑了十分钟还没写完Neo4j 内存占用一路飙升最后直接 OutOfMemory。原因graph.create 在循环里一条一条调用每次调用就是独立事务产生上万个事务开销同时 Movie 和 User 节点没建索引边导入时每次坐标匹配都是全库扫描。解决节点写入用 Subgraph 打包每 300 到 500 条提交一次边导入改用 LOAD CSV 加 USING PERIODIC COMMIT导入前先执行 CREATE INDEX。这三步改完十万条边的导入从十分钟级别降到几十秒。5.2 坑二冷启动用户嵌入全零推荐列表变成随机数现象把测试集里一个训练时没见过的用户输入模型输出的嵌入全是 0推荐结果和乱猜没有区别。原因图神经网络是转导学习只能给训练时见过的节点学出表示。新节点没有邻居消息传递聚合不到任何信息随机初始化的特征也学不到梯度。解决两个方案。一是把用户属性年龄、性别、职业也建成节点放进图谱新用户至少能从属性边聚合到信息二是做内容兜底——新用户没有行为边时直接推荐知识图谱里与热门类型关联的电影。毕设里我推荐第二种实现成本低答辩时也好解释“冷启动本来就该走规则兜底”。5.3 坑三随机切分训练测试导致指标虚高被评委当场拆穿现象随机按 80/20 切分后Recall20 跑出 0.6 的“好成绩”答辩时老师追问“你确定没有数据泄漏”现场答不上来。原因随机切分时用户未来的行为记录可能混进了训练集。模型在训练阶段已经“见过”测试交互相当于先看了答案再考试指标自然虚高。解决改成时间切分。按 timestamp 排序后每个用户取前 80% 做训练后 20% 做测试。同时要过滤掉只在测试集里出现的电影否则这些电影没有嵌入可查评估时要么报 key error要么静默漏算指标反而被低估。5.4 坑四负采样太随意模型退化成“热门电影排序器”现象训练 loss 降得飞快但 Recall20 纹丝不动推荐结果清一色是热门大片的 id。原因均匀随机负采样时大部分负样本是用户没听过的冷门电影模型轻松区分之后不学细腻偏好转而把所有热门电影排到前面这是最常见的模型退化。解决改成按流行度加权的负采样以电影被评分次数的 0.75 次方作为抽样权重这样负样本里既有难分的热门电影也有冷门电影。PyTorch 里用 torch.utils.data.WeightedRandomSampler 一行实现。这个改动通常能把 Recall20 拉高 2 到 4 个点性价比极高。5.5 坑五DGL 和 PyTorch 版本不匹配import 直接崩现象pip install dgl 装完import dgl 报找不到 _C 扩展或者代码在 GPU 机器上跑模型前向时报 CUDA error。原因DGL 的预编译包和 PyTorch、CUDA 版本严格绑定默认 pip 源装的通常是 CPU 版和代码里 .to(cuda) 的调用不匹配。解决先确认 PyTorch 版本再按对应版本安装 DGL。CPU 机器就全程不调 cudaDGL 的 CPU 版和 GPU 版 API 完全一致只是速度差异。答辩演示前一定要在演示机上完整跑一遍推理脚本版本问题在会场现场装包是最狼狈的。6. 最后的加分项用 Flask 把推荐结果和图谱理由一起展示模型训练完推荐列表还停在 Jupyter 里答辩时给评委看黑底白字的命令行输出效果会打折扣。花半天做一个简单的 Flask 页面输入用户 id返回推荐电影并附一句推荐理由——这个理由就是知识图谱最值钱的部分“因为你喜欢《盗梦空间》而《星际穿越》与它同导演、同类型”。from flask import Flask, request, jsonify import torch app Flask(__name__) app.route(/recommend, methods[POST]) def recommend(): body request.get_json() user_id int(body[user_id]) if user_id not in known_users: return jsonify({items: hot_movie_fallback()}) # 冷启动兜底规则 emb model(g, features) # 训练好的模型推理时只前向一次 user_emb emb[user_id] scores torch.matmul(user_emb, emb[movie_ids_with_offset].T) topk scores.argsort(descendingTrue)[:20].tolist() return jsonify({ user_id: user_id, items: [{movie_id: mid, reason: explain(mid, user_id)} for mid in topk] }) def explain(movie_id, user_id): 用 Cypher 查共同导演或共同类型拼成一句话推荐理由 query MATCH (u:User {user_id: $uid})-[:RATED]-(m:Movie) WHERE (m)-[:DIRECTED]-(:Person)-[:DIRECTED]-(:Movie {movie_id: $mid}) OR (m)-[:BELONGS_TO]-(:Genre)-[:BELONGS_TO]-(:Movie {movie_id: $mid}) RETURN m.title AS liked_title LIMIT 1 result graph.run(query, uiduser_id, midmovie_id).data() if result: return f因为你喜欢《{result[0][liked_title]}》它与推荐电影有共同导演或同属一个类型 return 因为和你看过的电影属于同一类型这个页面的核心不在 UI而在 explain() 里的 Cypher 查询——它是把“知识图谱”这个概念可视化给评委看的唯一方式。推荐理由哪怕只有一行字也比冷冰冰的评分列表有说服力。接口写好之后用 Postman 或 requests 发一个 POST 请求就能验证不需要把前端做得多花哨。另一个值得做的验证是参数敏感性实验固定其他变量只改层数或嵌入维度把 RecallK 的变化画成折线。这是毕设论文“实验对比”章节最扎实的素材也能反过来验证我在第三章说的“两层 GCN 是分水岭”。我做这个项目时最深的教训是先花两天把图谱本体设计清楚再写任何代码。当初我贪快导演和演员全塞在 Movie 节点属性里结果图查询倒是顺手GNN 却完全学不到语义关系最后推倒重建比一开始好好设计多花了一倍时间。图谱结构决定了模型能学到什么这个先后顺序不能省。希望帮到你。本文还有配套的精品资源点击获取
返回列表