知识图谱+图神经网络电影推荐:从数据构建到GAT实战

发布时间:2026/10/25 6:42:31
知识图谱+图神经网络电影推荐:从数据构建到GAT实战 简介面向毕业设计、期末大作业与课程设计基于Python的电影推荐系统源码利用知识图谱与图神经网络KGCN提升推荐准确度代码注释详尽特别适合刚开始接触图算法应用的学生开发者。项目完整覆盖数据预处理、知识图谱构建、KGCN模型训练与Web前端展示从MovieLens数据集处理到推荐结果呈现形成闭环可支撑毕业设计答辩和课程报告。压缩包共37个文件、约14.84MB包含21个Python源码如create_kg.py负责图谱构建、KGCN layer.py与model.py实现图卷积网络、train.py完成训练、web app.py提供交互界面testkg.py、data_process.py协助验证数据与图谱构建环节方便二次开发调试。5个dat文件提供实验数据zbak备份防止代码丢失README与txt文档提供使用说明。已有65人学习浏览项目经过严格调试可部署使用附赠内容包可作扩展素材适合作为课设、期末大作业和毕设参考。1. 知识图谱与图神经网络做电影推荐为什么值得做成毕设源码电影推荐系统毕业设计里“知识图谱图神经网络”是被问得最多的方向之一。它的本质是把用户、电影、导演、演员、类型变成一张有语义的图再用图神经网络把图里的每个节点学成向量。和纯协同过滤相比它能多做几步“推理”比如你看过《盗梦空间》模型可以沿着“莱昂纳多→《荒野猎人》”这条路径把你没看过的电影排上来冷启动也因此受益新电影没人评分也能通过图谱里的导演和演员获得可用的Embedding。这篇笔记按“能跑通、能答辩、能讲清楚”的标准把这个方案从数据到部署拆开讲适合正在写Python毕设、或想往推荐算法方向走的同学。2. 电影知识图谱构建从MovieLens评分表到三元组的完整流水线2.1 数据准备先把评分表和演员表拼成一张实体表做推荐系统Python毕设第一步不是写模型而是把数据整理成“能当图数据喂给GNN”的样子。我最常用的起点是MovieLens 100K它自带1000个用户对1700部电影的10万条评分字段是经典的user_id、movie_id、rating、timestamp。但MovieLens的u.item只给出电影标题、上映日期和类型没有导演、演员、简介纯粹靠它建不了有意义的电影知识图谱。常见做法是再补一份The Movies Dataset或TMDB导出的movies_metadata.csv和credits.csv按电影标题最好再加年份把两张表join起来。这样一部电影就有了导演和前三主演类型可以直接从metadata的genres字段解析出来。先把评分表和电影基本信息读进来import pandas as pd ratings pd.read_csv( ml-100k/u.data, sep\t, names[user_id, movie_id, rating, timestamp] ) movies pd.read_csv( ml-100k/u.item, sep|, encodinglatin1, headerNone ) movies.columns [movie_id, title, release_date, video_date, imdb_url] \ [fgenre_{i} for i in range(19)] print(ratings.head()) print(movies[[movie_id, title]].head())这段代码把MovieLens的评分和电影基本信息读成DataFrame。注意u.item的编码是latin1不是utf-8Windows下直接用pandas读会报UnicodeDecodeError。genre_0到genre_18这19列是二值类型字段值为0或1后面可以做类型三元组的来源。评分表里的timestamp在切分训练集和测试集时很关键先保留不要丢。如果你选择The Movies Dataset这套数据还需要把演员和导演从JSON字符串里拆出来。credits.csv中的cast和crew列看起来是字符串实际是JSON数组必须用ast.literal_eval解析不能直接用str.contains查。我一般按job过滤导演取前三位演员import ast credits pd.read_csv(credits.csv) def get_director(crew_str): try: crews ast.literal_eval(crew_str) for person in crews: if person.get(job) Director: return person.get(name) except (ValueError, SyntaxError): return None return None def get_actors(cast_str, top_k3): try: cast ast.literal_eval(cast_str) return [person.get(name) for person in cast[:top_k]] except (ValueError, SyntaxError): return []这里有个小坑有的电影crew里没有Director有的cast空数组函数一定要返回None或空列表否则后面组三元组时会出现NaN混入。如果直接拿NaN当实体IDGNN的edge_index里就会多出无效节点训练时会出现奇怪的loss抖动。我习惯在生成三元组前做一次dropna。2.2 三元组生成与实体ID映射让图数据能被GNN直接读取知识图谱在推荐系统里的最小形态是三元组(头实体, 关系, 尾实体)。对电影推荐来说头实体通常是电影尾实体可以是导演、演员、类型、关键词、出品公司关系就是directed_by、starring、has_genre。这里有一个很多新手会犯的错把用户行为也当普通三元组塞进去导致用户节点和电影节点连成一张大图这其实没错反而能提高协同信息。关键是区分开“行为边”和“知识边”你可以在三元组的关系字段里保留“rate”这个关系后面模型则可以把这些边原样建进图里。下面是一个通用的三元组生成函数。我假设你已经把导演、演员整理成了以movie_id为外键的DataFrame格式是movie_id、person/type字段def build_kg_triples(movies_df, directors_df, actors_df, genres_df): triples [] for movie_id in movies_df[movie_id].unique(): m fmovie:{movie_id} for g in genres_df.loc[genres_df[movie_id] movie_id, genre]: triples.append((m, has_genre, fgenre:{g})) for d in directors_df.loc[directors_df[movie_id] movie_id, director]: triples.append((m, directed_by, fperson:{d})) for a in actors_df.loc[actors_df[movie_id] movie_id, actor]: triples.append((m, starring, fperson:{a})) return triples这个函数把所有实体用前缀包起来比如“movie:1”“person:克里斯托弗·诺兰”这样不同领域同名实体不会撞车。比如一个导演叫“迈克尔”一个演员也叫“迈克尔”不带前缀就会在后面的全局实体ID映射里被误判成同一个节点这是构建知识图谱时很常见的实体歧义问题。下一步是给实体和关系分配数字ID。GNN不能直接吃字符串必须把所有实体和关系映射成连续整数编号。这个映射可以用dict一边遍历一边分配triples build_kg_triples(movies, directors, actors, genres) entity2id {} relation2id {} for h, r, t in triples: if h not in entity2id: entity2id[h] len(entity2id) if t not in entity2id: entity2id[t] len(entity2id) if r not in relation2id: relation2id[r] len(relation2id) with open(kg_triples.tsv, w) as f: for h, r, t in triples: f.write(f{entity2id[h]}\t{relation2id[r]}\t{entity2id[t]}\n) torch.save(entity2id, entity2id.pt)代码用len(entity2id)生成自增ID。把映射表用torch.save存下来后面训练和推断才能把电影ID翻译回实体ID。三元组文件每行是“头实体ID、关系ID、尾实体ID”这是后面建PyTorch Geometric的edge_index最直接输入。如果你手头已经有三元组文件直接用pandas读就行不一定要重跑build_kg_triples。2.3 图谱存储选型Neo4j展示还是NetworkX直接跑模型建好三元组以后有一个分叉路口要不要把图谱丢进Neo4j。如果毕设只是要求完成推荐系统Neo4j不是必需的直接用PyTorch Geometric读三元组文件就够了。Neo4j的价值在答辩展示你可以用Cypher查询把“用户看过《盗梦空间》→《盗梦空间》由诺兰执导→诺兰也执导《星际穿越》→推荐《星际穿越》”这样的路径画出来。这是可解释推荐的利器但代价是环境配置多了一层。社区版对1700部电影这种规模没压力。如果只是为了模型能跑最轻的方案是直接构建edge_index。我一般会先做一次节点数检查num_entities len(entity2id) print(实体数:, num_entities) print(三元组数:, len(triples))如果实体数和三元组数在个位数通常说明数据join出了问题比如电影的movie_id在演员表里全找不到。这一步很便宜但能省掉后面训练时才发现“边指向了不存在的节点”的时间。上面这套流程的好处是全程离线不依赖外部API哪怕答辩现场没有外网也能跑通。如果你还是想用Neo4j做可视化常见做法是把三元组CSV用LOAD CSV导入LOAD CSV WITH HEADERS FROM file:///kg_triples.tsv AS line FIELDTERMINATOR \t MERGE (h:Entity {id: toInteger(line[0])}) MERGE (t:Entity {id: toInteger(line[2])}) MERGE (h)-[:REL {rid: toInteger(line[1])}]-(t)这里用MERGE而不是CREATE是为了避免重复实体。导入后用MATCH p()-[*1..2]-() RETURN p就能看到电影和人物之间的关系网络。注意CSV文件要放在Neo4j的import目录下路径不能随便填。3. 图神经网络选型与实现为什么毕设首选两层GAT3.1 矩阵分解和GNN的本质区别从直连推荐到多跳语义传统协同过滤模型SVD也好矩阵分解也好学的是用户向量和物品向量在同一个隐空间里的内积相似度。它只能用到用户-物品二部图里的边用户1给电影A打了5分这条边只能让用户1和电影A的embedding互相靠近完全不知道电影A和诺兰、诺兰和《星际穿越》之间的关系。图神经网络不一样它通过消息传递把邻居信息一层层聚合到节点上。用户经过第一层和电影交互电影又聚合了导演、演员、类型那么第二层再聚合用户时用户的向量里就被迫包含了“导演”的知识即使该用户对导演的另一部电影没有任何评分。这个多跳聚合就是GNN做推荐的核心价值。你也许听过GCN、GAT、LightGCN、R-GCN这些名词毕设选题时不必贪多我觉得两层GAT是最稳的选择。GAT给每条边一个注意力权重而不是像GCN那样按邻居数量平均因此能自动学会“导演关系比类型关系更重要”如果数据确实支持这个假设。GATConv在PyG里是一行调用训练不慢答辩时又足以讲清楚“我如何理解注意力机制”。3.2 异构图边融合把用户-电影和电影-演员塞进同一张图要在一个普通GAT里处理异构边最简单是把所有关系折叠成无向边。虽然会丢失关系类型但对于电影推荐毕设来说信息量已经足够用户-电影边保证协同信息电影-实体边保证知识语义。如果你的导师要求必须区分关系类型再去考虑R-GCN或Heterogeneous Graph Transformer。我建议先按无向图跑通再在论文里说明这项简化。假设你已经有四个边数组user_ids、movie_ids用户评分电影的边movie_ids、actor_ids电影主演movie_ids、director_ids电影导演movie_ids、genre_ids电影类型把所有边拼成一个统一的edge_indeximport torch edge_index torch.tensor([ user_ids movie_ids movie_ids movie_ids, movie_ids actor_ids director_ids genre_ids ], dtypetorch.long) # 补上反向边让信息能双向传播 edge_index torch.cat([edge_index, edge_index.flip(0)], dim1)第一个列表是每条边的源节点第二个列表是目标节点。拼接之后要把每个关系方向反过来再拼一次因为无向图相当于每条边要准备两个方向。很多人忘了flip这一步导致信息只能从电影传到导演不能从导演传回电影训练效果会打折扣。这不是玄学是GCN/GAT消息传递方向的规定。接下来把节点总数、用户节点索引、电影节点索引整理好num_nodes len(entity2id) user_nodes [entity2id[fuser:{uid}] for uid in user_ids] movie_nodes [entity2id[fmovie:{mid}] for mid in movie_ids]注意用户三元组里的前缀也必须一致。如果你在build_kg_triples里用了“movie:”前缀那用户也要是“user:”。我在这里吃过亏前缀不统一导致同一部电影在实体映射里出现两个ID图变成了两个连通分量。3.3 PyTorch Geometric实现GAT模型核心代码与参数含义下面是整个推荐系统的核心模型只有GAT两层加一个Embedding层。embedding_dim我习惯取64实体数大约几千64维足够承载语义再高维度在小数据集上反而容易过拟合。import torch.nn as nn import torch.nn.functional as F from torch_geometric.nn import GATConv class GATRecommender(nn.Module): def __init__(self, num_nodes, embed_dim64, hidden_dim64, heads4): super().__init__() self.node_embedding nn.Embedding(num_nodes, embed_dim) self.gat1 GATConv(embed_dim, hidden_dim, headsheads, concatTrue) self.gat2 GATConv(hidden_dim * heads, embed_dim, heads1, concatFalse) self.dropout nn.Dropout(0.2) def forward(self, edge_index): x self.node_embedding.weight x self.gat1(x, edge_index).relu() x self.dropout(x) x self.gat2(x, edge_index) return xGATConv第一个参数是输入维度第二个是输出维度。第一层用4个头每个头输出hidden_dim64concat之后变成64×4256所以第二层的输入维度必须是hidden_dim * heads。第二层head数为1这样输出正好是embed_dim方便后面拿用户向量和电影向量做点积。embed_dim要和node_embedding的输出维度保持一致否则维度对不上。训练时用BPR损失也就是让正样本分数尽量大于负样本分数。负样本对每个用户随机采样一部没看过的电影def bpr_loss(pos_scores, neg_scores): return -F.logsigmoid(pos_scores - neg_scores).mean() model GATRecommender(num_nodes) optimizer torch.optim.Adam(model.parameters(), lr0.005) for epoch in range(50): model.train() emb model(edge_index) pos_scores (emb[user_nodes] * emb[pos_movie_nodes]).sum(dim1) neg_scores (emb[user_nodes] * emb[neg_movie_nodes]).sum(dim1) loss bpr_loss(pos_scores, neg_scores) optimizer.zero_grad() loss.backward() optimizer.step() if epoch % 5 0: print(fepoch {epoch}, loss {loss.item():.4f})训练时有一个细节user_nodes和pos_movie_nodes用的是同一个训练交互batch的IDneg_movie_nodes是采样出来的负样本ID形状和user_nodes一致。BPR损失只要求正样本比负样本大一点就行不要求分数绝对值高。lr0.005配合Adam在小数据集上收敛很快如果loss不降先检查负样本比例再考虑调学习率。这段代码已经能跑但还要解释一个边界所有节点共享同一个node_embedding包括演员、导演、类型。节点类型不同但ID不同权重互不影响。坏处是类型语义混合在同一空间但作为基础版本足够。4. 推荐流程集成召回、排序与冷启动处理4.1 用用户和电影Embedding做全量召回训练完成后整个图的所有节点都有一个embedding向量。推荐的第一步是召回把所有用户和所有电影做内积取每行TopK。model.eval() emb model(edge_index) user_emb emb[torch.tensor(user_nodes)] movie_emb emb[torch.tensor(movie_nodes)] score_matrix user_emb movie_emb.T topk torch.topk(score_matrix, k20, dim1).indices这里要注意推理前必须调用model.eval()关闭dropout否则每个用户每次结果都不一样。score_matrix是[用户数×电影数]1700部电影内存不会爆如果是20M数据就要换faiss做近似最近邻毕设不用。topk返回的是实体全图中的节点ID不是movies_metadata里的movie_id。所以还需要一次映射把topk里的ID翻译回电影标题。这也是为什么前面要把entity2id保存下来。在源码里可以写一个reverse mapid2entity {v: k for k, v in entity2id.items()} movie_id_from_entity lambda eid: int(id2entity[eid].split(:)[-1])4.2 排序打分与过滤已看过的电影全量召回靠内积排序也就用同一批分数不需要单独训练排序模型。有些同学喜欢在召回后接一个MLP做精排但在这个规模的数据上收益不大反而容易过拟合。我一般只是把内积打分经过sigmoid压到0到1之间方便前端展示import torch.nn.functional as F scores (user_emb movie_emb.T).squeeze() prob F.sigmoid(scores)sigmoid只改分数尺度不改变排序顺序所以TopK还是那一批。真正需要关注的是召回结果里会包含用户已经看过的电影。在评估和Demo中这些电影必须过滤掉否则Precision会虚高。过滤逻辑很简单seen_mask torch.zeros(num_movies, dtypetorch.bool) for mid in user_history[uid]: seen_mask[entity2id[fmovie:{mid}]] True topk_filtered topk[~seen_mask][:20]这段代码先在seen_mask里把用户历史交互对应的电影节点置为True然后用布尔索引过滤TopK。注意这里用entity2id转换不能直接用原始movie_id因为电影节点在全图中的位置不是连续的原始ID。4.3 冷启动电影如何通过知识图谱“曲线救国”冷启动是纯协同过滤的死穴也是知识图谱加GNN最加分的地方。假设新电影刚上线没有人评分它在交互图中没有邻居但知识图谱里有导演、演员、类型。因为GNN的消息传递沿着边走新电影初始节点没有和任何用户相连但可以把它和已有实体连起来。具体做法是给新电影分配一个新的实体ID把初始向量设置为其属性邻居的平均值def add_cold_movie(new_id, director_emb_idx, actor_emb_idx, genre_emb_idx): new_vec torch.cat([ emb[director_emb_idx], emb[actor_emb_idx], emb[genre_emb_idx] ]).mean(dim0) model.node_embedding.weight.data[new_id] new_vec随后把“新电影→导演/演员/类型”的边加入edge_index再跑一次前向传播。虽然新电影没有评分但经过GAT两层聚合后它可以从导演、演员那里吸收语义向量从而和用户产生相似度。这个功能很能在答辩时演示输入一部冷门老片照样能给出推荐。注意新增节点必须和已有节点ID不重复Embedding表需要在训练前预分配足够大的空间。我一般会在训练前把Embedding表初始化成num_nodes 10000给预留空间。如果你在训练后调整权重要小心Embedding表维度已经固定无法随意扩展。5. 避坑与排查数据泄漏、内存爆炸和负采样翻车记录5.1 数据划分泄漏评分乱序切分导致评估分数虚高现象模型在验证集上的Precision10高达0.85比训练集还好。原因随机切分评分时同一个用户的高分行为出现在训练集和测试集模型见过“未来”的评分。解决按timestamp排序后做时间切分。ratings ratings.sort_values(timestamp) split_idx int(len(ratings) * 0.8) train_ratings ratings.iloc[:split_idx] test_ratings ratings.iloc[split_idx:]测试时用户的历史交互只取训练期间的评分。如果你在测试集里包含用户的全部历史推荐指标会虚高。这个是答辩时最容易被问倒的地方我第一版就是在这里翻车后来老老实实按时间戳切。5.2 图内存爆炸与邻居采样现象把图做成稠密邻接矩阵程序直接OOM。原因N个节点就需要N×N矩阵实体数刚过万就受不了。解决用PyG的edge_index稀疏表示并在训练时用NeighborLoader做邻居采样。from torch_geometric.loader import NeighborLoader train_loader NeighborLoader( data, num_neighbors[10, 5], batch_size1024, shuffleTrue, input_nodesuser_nodes )num_neighbors[10,5]表示第一层每个节点采样10个邻居第二层采样5个邻居。如果图度数很高这个数不能太大否则batch内子图还是会爆炸。一个用户历史可能有几百部电影采样10条足够因为GAT本来就靠注意力汇聚特征。如果你没有做邻居采样整张大图直接forward一次内存占用随epoch线性增长训练到一半卡死是常态。5.3 负采样比例不对导致训练波动现象loss在0.6附近震荡不下降。原因负样本和正样本比例太低一对一的时候模型没有学到相对关系。解决每个正样本配5个负样本。import random num_negatives 5 neg_items [] for u in batch_users: candidates list(all_movie_ids - user_seen[u]) neg_items.append(random.sample(candidates, num_negatives))另外负样本不要全取冷门电影因为太容易区分偶尔采样一些热门但用户没看过的电影作为难负样本能加快收敛。但难负样本过多也会导致训练不稳定我一般把汤普森采样后的难负样本比例控制在50%左右。5.4 PyG安装与版本不匹配现象import torch_geometric之后一调用GATConv就报undefined symbol或者extension not found。原因torch-scatter、torch-sparse这些扩展没有安装和当前PyTorch/CUDA匹配的版本。解决先查看torch版本和cuda版本然后从data.pyg.org的whl仓库安装对应wheel。pip install torch-scatter -f https://data.pyg.org/whl/torch-2.0.0cu118.html pip install torch-sparse -f https://data.pyg.org/whl/torch-2.0.0cu118.html pip install torch-geometric这个操作最怕的是镜像源不匹配建议不要用带自定义镜像的pip。装完以后跑一个小图前向传播确认扩展真的能用再开始正式训练。5.5 中文乱码与Streamlit缓存踩坑现象前端页面上导演的名字变成“???”或者每次点击用户都重新算推荐。原因CSV用utf-8写入但Windows下默认以GBK打开Streamlit每次交互都会重跑脚本。解决写文件时指定encodingutf-8-sigStreamlit用st.cache_data缓存推荐结果。triples_df.to_csv(kg_triples.tsv, sep\t, indexFalse, encodingutf-8-sig)st.cache_data def load_model_and_embeddings(): model load_model() emb model(edge_index) return emb如果你用了Neo4j做路径搜索查询结果也可以加缓存避免来回访问数据库。缓存的是返回的DataFrame或listStreamlit会自动区分参数。这里有个坑如果模型参数更新了Streamlit不会自动失效需要重启或者用st.cache_data.clear()清缓存。6. 离线评估与交互Demo让毕设从“能跑”变成“能答辩”6.1 离线指标PrecisionK、RecallK和NDCG推荐系统的完整评估必须有离线指标。我用测试集里每个用户真正看过的电影作为ground truth然后算TopK命中情况import math def precision_at_k(pred, true, k): return len(set(pred[:k]) set(true)) / k def recall_at_k(pred, true, k): return len(set(pred[:k]) set(true)) / len(set(true)) if true else 0 def ndcg_at_k(pred, true, k): dcg sum(1.0 / math.log2(i 2) for i, p in enumerate(pred[:k]) if p in set(true)) idcg sum(1.0 / math.log2(i 2) for i in range(min(len(true), k))) return dcg / idcg if idcg 0 else 0NDCG对排序位置敏感命中越靠前分数越高。如果指标只在K10时有点提升可以补一组K5、K20的曲线展示稳定性。6.2 用Streamlit做一个带解释路径的推荐Demo交互页面我用Streamlit没有额外前端负担。关键是把模型加载和Embedding缓存起来然后选择一个用户展示Top10和推荐理由import streamlit as st st.cache_data def load_emb(): return emb, id2entity user_id st.selectbox(选择用户, list(user_idx.keys())) recs recommend(user_id, top_k10) st.write(recs[[title, score]])对于每条推荐结果可以加一个“解释路径”按钮用Cypher查知识图谱中的最短路径MATCH (u:User {id:1})-[r:RATED]-(m:Movie)-[rel]-(e:Entity)-[rel2]-(rec:Movie) RETURN rec.title, e.name LIMIT 10这对答辩非常加分能让观众直观看到“你看过AA的导演也拍过B所以推荐B”。6.3 三个答辩看点消融实验、参数敏感性、可视化第一个看点是消融实验分别跑“只有用户-电影边”和“完整知识图谱边”两个模型对比Precision证明知识图谱确实有用。第二个看点是参数敏感性把embed_dim调成16/32/128记录指标变化至少说明你不是瞎调参。第三个看点是可解释路径把3条典型推荐路径截图放进论文。我自己的教训是不要等全部代码写完才做评估。先在数据切分时留下测试集每几十个epoch就评估一次否则最后发现指标不对根本不知道是数据泄漏还是模型有问题。这套方案最花时间的永远不是GAT而是知识图谱的实体对齐和三元组清洗。别名电影、多导演、多人合作的数据脏活才是真正决定推荐效果的地方。希望这篇能帮你把“毕业设计基于Python的知识图谱与图神经网络的电影推荐系统”从标题变成能跑、能讲、能答辩的完整作品。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询