
简介基于Python的知识图谱与图神经网络电影推荐系统完整项目适合计算机、人工智能方向学生作为毕业设计或课程设计参考。项目选用MovieLens 1M数据集在Python3.8与TensorFlow2.3.8环境下运行涵盖数据预处理、知识图谱构建、KGCN模型训练与评估等关键环节并提供了清晰的调用流程。资源共32个文件以21个Python脚本为主包含模型定义、训练、评估、工具函数等模块另有5个dat数据文件、3个txt说明、2个readme及1个md文档压缩包整体约12.69MB目录结构清晰便于按流程研读。按项目说明运行主程序生成模型权重再执行测试脚本即可预测推荐结果。目前已有490人学习下载对想快速复现知识图谱推荐系统、掌握图神经网络实践要点的学习者具有较高参考价值。1. 推荐系统的新解法知识图谱与图神经网络到底解决了电影推荐的什么痛点做电影推荐传统的协同过滤Collaborative Filtering已经能跑出不错的精度但有两个硬伤始终绕不过去冷启动和可解释性。新用户没有行为记录模型推什么都像在摇骰子老用户看到“推荐理由与您相似的用户也看了”这种话术完全不知道系统凭什么把某部电影塞进列表。基于Python的知识图谱与图神经网络电影推荐系统就是冲着这两个痛点来的——把电影、导演、演员、类型这些实体做成图结构再用图神经网络学习实体之间的关系表示让推荐结果既照顾到行为稀疏的用户又能给出一条“因为你喜欢诺兰的《盗梦空间》所以推《星际穿越》”的可解释路径。这套技术路线特别适合作为高分毕设项目因为它在模型之外天然带着知识图谱可视化和可解释性的展示场景工作量好量化答辩也有故事线。下面就把这套系统的构建路径从数据三元组到GNN模型推理完整拆开讲一遍。2. 知识图谱层电影数据到三元组的构建路径与实体对齐2.1 数据源选型评分数据与属性数据的配套思路知识图谱电影推荐系统的第一块地基是拿到能对齐的两份数据用户行为数据和电影实体属性数据。行为数据最常用的是MovieLens系列尤其是MovieLens 1M或10M它们包含userId、movieId、rating、timestamp四列正好用于构造“用户—电影”二部图。属性数据则用来构造知识图谱三元组head, relation, tail常见的方案有两条路第一条是直接使用学术界公开的KG数据集例如基于DBpedia或Wikidata抽取出来的电影知识图谱优点是省时间缺点是schema不是自己掌控的实体ID不一定和MovieLens的movieId对得上第二条是自己从IMDB或TMDB抓取导演、演员、类型、编剧、发行年份等属性按规则转成三元组。做毕设建议选第二条因为整个构建过程本身就是评分表里的一项重头戏——从原始网页或API响应到结构化三元组的转换能体现工程能力。我一般会这样设计三元组的schema核心实体是电影movie关系包括“导演导演directed_by”“演员出演acted_in”“电影属于类型has_genre”“电影发行年份release_year”。这里有一个容易被忽视的点知识图谱三元组的tail并非只能是实体ID也可以是字面量Literal比如release_year的tail是“1997”这个字符串。字面量三元组不会参与图卷积的邻居聚合但可以用于后续的属性过滤和可视化展示建议单独存一张表。关于工具链用pandas处理表格数据用networkx做图结构可视化验证用rdflib把三元组序列化成RDF格式。这些库都是Python生态里的标配安装没有负担。2.2 三元组抽取把表格行变成(head, relation, tail)属性数据下载下来通常是行式表格一列是电影ID其余列是导演、演员、类型等字段。三元组抽取就是从这些宽表里拆出多条subject, predicate, object记录。这一步看起来只是简单的循环拼接但坑在字段的清洗。比如IMDB的top credits字段经常包含“George Lucas (director)”这种带括号角色标注的文本或者“Created by”这类不属于导演/演员的信用标注如果不做正则清洗三元组里会出现一类叫“director”另一个叫“creator”的重复关系图结构被污染。import pandas as pd import re # 原始属性表示例movie_id, title, year, directors, actors, genres attr_df pd.read_csv(movie_attributes.csv) def clean_credits(raw_value: str): 清洗credit字符串去掉括号中的角色标注按逗号拆分人名 if not isinstance(raw_value, str): return [] # 去掉 George Lucas (director) 中的 (director) cleaned re.sub(r\([^)]*\), , raw_value) names [n.strip() for n in cleaned.split(,) if n.strip()] return names triples [] for _, row in attr_df.iterrows(): movie_id row[movie_id] # 导演tail是实体保留ID形式便于后续GNN构图 for director in clean_credits(row[directors]): triples.append((movie_id, directed_by, director)) # 演员 for actor in clean_credits(row[actors]): triples.append((movie_id, acted_by, actor)) # 类型直接作为实体 for genre in str(row[genres]).split(|): triples.append((movie_id, has_genre, genre)) # 年份作为字面量属性不参与聚合 triples.append((movie_id, release_year, str(row[year]))) kg_df pd.DataFrame(triples, columns[head, relation, tail]) kg_df kg_df.drop_duplicates()这段代码里clean_credits是清洗核心正则re.sub把括号里的角色描述直接删掉。有人会问直接按逗号拆分会不会把“John, Jr.”这类名字切断——确实会所以IMDB这种级别的数据源通常在人名列里会用引号或特定分隔实际操作中要在真实数据分布上验证拆分规则不要在样例上验证。另一个值得注意的点是drop_duplicates原始IMDB数据一集电视剧可能有多个条目一个演员在一部电影里同时有多个credit角色不去重会让后续GNN的邻居计数虚高影响采样概率。2.3 实体对齐与ID映射为什么会发现“两部阿甘正传”实体对齐Entity Alignment是这个项目第一个真正让人抓狂的环节。MovieLens里的movieId是一个纯数字内部IDIMDB的movieID是“tt0109830”这种格式两边没有直接映射。网上有人直接下载了别人打包好的映射文件这当然省事但毕设答辩时一旦被问“映射是怎么做出来的”很容易露怯。自己做的标准做法是用“标题标准化 发行年份”双条件匹配再到验证集上人工抽检。标题标准化是重头戏像“The Lord of the Rings: The Return of the King”和“Lord of the Rings: Return of the King”在IMDB和MovieLens里写法不同需要去掉停用词、去掉标点、全量转小写再做模糊匹配。from rapidfuzz import process def normalize_title(title: str) - str: 标题标准化小写、去标点、去停用词保留数字 title title.lower() title re.sub(r[^\w\s], , title) tokens [t for t in title.split() if t not in {the, a, an, of, and}] return .join(tokens) ml_df[norm_title] ml_df[title].apply(normalize_title) attr_df[norm_title] attr_df[title].apply(normalize_title) ml_df[match_key] ml_df[norm_title] _ ml_df[year].astype(str) # 用冒泡式的日期过滤注意year本身也可能有偏差 merged pd.merge( ml_df[[movieId, match_key]], attr_df[[movie_id, match_key, title]], onmatch_key, howinner, ) print(f对齐率: {len(merged) / len(ml_df):.2%})对齐率是这个环节的核心验收指标。如果低于85%优先排查年份字段——MovieLens和IMDB的发行年份有时相差一年比如中国电影在国外首映年份不同这就是“两部阿甘正传”的来源同一年上映的翻拍片标题标准化后撞车。解决办法是把年份匹配放宽成±1年但如果放宽后仍然重复匹配就按IMDB的popularity字段选最大的一条。这个环节的代码量不大但耗费的调试时间可能是全项目最长的预留一周比较合理。3. 图神经网络层把知识图谱变成可训练的节点向量3.1 图结构定义从三元组到异构图/同构图拿到三元组之后第一步是决定用什么图结构喂给GNN。这里有两种路线异构图的路线是保留“电影—导演”“电影—演员”“电影—类型”三种边类型为每种关系训练不同的变换矩阵表达能力更强但参数多、容易过拟合适合数据量大的工业场景同构图的路线是把所有节点当成统一类型只区分边的关系类型靠关系加权来保留信息。毕设级的MovieLens-1M数据集通常只有数千部电影、数万演员我建议走“同构图 关系加权”的简化路线。理由很现实异构图的参数规模在万级演员节点上会导致训练一轮的时间长到让人失去耐心而同构图能把更多容量留给节点向量的学习。import networkx as nx # 用networkx先构图验证三元组正确性再转成稠密矩阵交给GNN kg_graph nx.MultiDiGraph() for _, row in kg_df.iterrows(): kg_graph.add_edge(row[head], row[tail], relationrow[relation]) print(f节点数: {kg_graph.number_of_nodes()}, 边数: {kg_graph.number_of_edges()}) # 同构图转换把所有实体节点映射到连续索引 all_entities list(kg_graph.nodes()) entity2idx {e: i for i, e in enumerate(all_entities)} num_nodes len(all_entities)这里的代码只是图和映射还没有真正进入训练。但请注意节点数这个数字本身就很有信息量——如果节点数比预期多了几千多半是实体对齐时演员姓名拼写变体没有合并比如“Bryan Cranston”和“Bryan Cranston Jr.”是两个节点。在做GNN之前要跑一遍节点归并按统一的清洗规则再做一次。networkx在这里只用来做连通性和度分布校验不是用来跑GNN的真正训练要用DGL或PyTorch Geometric因为networkx的消息传递太慢一张中等规模的图一轮迭代跑到天亮都跑不完。3.2 邻居采样与聚合为什么用GraphSAGE式采样全图训练在大规模知识图谱上是不可行的因为一层GCN的消息传递需要聚合所有邻居而电影的邻居可能几百个。GraphSAGE式采样是标准解法对每个节点每层固定采样一定数量的邻居把聚合范围限制在一个可控的预算内。采样的核心参数有两个采样邻居数fanout和采样深度即层数。对于电影推荐典型配置是两层消息传递第一层采样10个邻居第二层再对这10个邻居各采样5个二阶邻居——也就是每个节点在每轮训练最多看到50个高阶邻居显存占用非常可控。import dgl # graph_data: 边列表 (src, dst)dgl需要统一为int索引 src kg_df[head].map(entity2idx).values dst kg_df[tail].map(entity2idx).values g dgl.graph((src, dst), num_nodesnum_nodes) g dgl.add_reverse_edges(g) # 知识图谱是多向图要补反向边 # 邻居统一采样第一层10个第二层5个 sampler dgl.dataloading.MultiLayerNeighborSampler([10, 5]) dataloader dgl.dataloading.NodeDataLoader( g, torch.arange(num_nodes), sampler, batch_size1024, shuffleTrue, num_workers0, )这段代码里add_reverse_edges是容易被漏掉的操作。知识图谱中的三元组“A导演了B”本身是有向的但GNN的消息传递默认沿边方向传播如果只建单向边信息只能从电影传到导演不能反方向传回对称信息就丢失了。补上反向边让信息在图里双向流动表达能力立刻提升一截。采样参数[10, 5]不是拍脑袋随便写的它对应了“一跳邻居看10个二跳邻居看5个”的聚合预算——数值越大信息越全但内存消耗随之线性增长。先跑通再逐步加大不要一开始就上[50, 50]很容易在显存上翻车。3.3 特征传播与GCN消息传递的实现与调参GCN的核心公式是 H(l1) σ(D⁻¹/² A D⁻¹/² H(l) W(l))。用DGL实现时可以不用手写稀疏矩阵运算而是直接调用dgl.nn.GraphConv层。但这里有一个比代码更重要的细节——输入特征H(0)怎么定。知识图谱节点没有天然的图像像素特征可用需要自己构造。常见做法有两种一种是随机初始化节点embedding让模型端到端学习这在小图上很有效另一种是预计算节点属性比如把每个节点的出度、入度、PageRank值拼成特征向量作为先验注入。毕设项目建议直接用随机embedding因为知识图谱实体ID的one-hot输入本身没有语义GNN的学习目标就是把这种无语义表示压成有语义的图嵌入。import torch.nn as nn from dgl.nn import GraphConv class KGRecGCN(nn.Module): def __init__(self, in_dim, hidden_dim, out_dim, dropout0.3): super().__init__() self.conv1 GraphConv(in_dim, hidden_dim) self.conv2 GraphConv(hidden_dim, out_dim) self.dropout nn.Dropout(dropout) self.activation nn.ReLU() def forward(self, g, features): h self.dropout(self.activation(self.conv1(g, features))) h self.conv2(g, h) return h # 初始化num_nodes x embedding_dim init_emb nn.Parameter(torch.randn(num_nodes, 64)) model KGRecGCN(in_dim64, hidden_dim64, out_dim64, dropout0.3)这里hidden_dim选了64层数默认2dropout设0.3这组参数在多个推荐类数据集上都能有相对稳定的表现。如果训练集不大hidden_dim过大会在小样本上过拟合hidden_dim过小则表达力不足。一个判断标准是看训练损失和验证损失的曲线训练损失收敛很快但验证损失不降就是容量过大需要减小hidden_dim或增大dropout。层数方面知识图谱推荐和纯社交网络不同2层GCN已经能让每个节点感知到“电影→导演→另一部电影”的二阶路径这正是协同过滤信息的核心模式——导演肖申克的救赎的导演也导了另一部高评分电影这条路径在2层内就能传递到目标节点。4. 推荐融合层从图节点向量到电影评分预测的完整管线4.1 用户建模把评分二分图与知识图谱对齐到同一向量空间知识图谱GCN做完之后我们拥有的是每个电影实体的向量表示接下来要回答的是用户向量怎么得到经典的KGCN模型思路是这样的把用户的历史交互电影作为种子在知识图谱上做消息传播聚合邻居信息回填到用户表示里。但更轻量的做法是用“用户—电影”评分矩阵把用户也看成一种节点和电影节点一起做基于图神经网络的矩阵补全。这里选择哪种取决于你要向答辩评委展示什么——如果强调知识图谱的可解释性走前者如果强调推荐精度的提升走后者。多数毕设选择前者因为它用上了之前构建的三元组知识图谱不是摆设。构建用户—电影交互图的代码逻辑和构建KG图一样把rating 3的评分作为正反馈边构建一个包含用户和电影两类节点的交互图然后把这个交互图的知识图谱拼接成一个更大的图。这个拼接过程是毕设的核心创新点展示普通GCN只编码KG内部信息拼接后的图让消息同时从演员、导演、类型和自己交互过的电影三条路径流入用户节点。拼接图规模要时刻关注MovieLens-1M有三万多个用户、三千多部电影加知识图谱的演员导演节点总体节点量接近四万个稠密图。邻接矩阵一旦超过内存阈值就要改用边列表存储DGL的graph格式天然支持这种稀疏表达。# 正反馈评分rating4 视为喜欢 positive_df ratings_df[ratings_df[rating] 4.0] user_src positive_df[userId].map(user2idx).values movie_dst positive_df[movieId].map(movie2idx).values interact_graph dgl.graph((user_src, movie_dst), num_nodesnum_user num_movie) # 拼接知识图谱与交互图 combined_g dgl.hetero_graph({ (user, rate, movie): (user_src, movie_dst), (movie, rate_by, user): (movie_dst, user_src), (movie, directed_by, director): (movie_src, director_dst), # ... 其他KG关系 })用户和电影实体向量可以从各自图卷积层获得但要放在同一个工程里跑需要统一的embedding lookup操作用户节点和电影节点在embedding矩阵里只是不同ID区间这样设计简化了训练逻辑。所有节点共享同一个输入特征维度GCN的卷积参数也被所有节点类型共享这种参数共享是模型轻量化的关键也是“简化异构图”的主要体现。4.2 损失函数与采样BPR Loss与负采样比例为什么重要推荐系统的训练不能直接用回归损失去拟合rating分值因为用户打分尺度主观性强、且大量条目是缺失的用MSE强制预测4.2分没有实际意义。业界和学术界更常用的排序损失是BPRBayesian Personalized Ranking它的思路是给定一个用户来自历史正反馈的电影应该比随机采样的电影得分高。每一对用户正电影负电影构成一个训练样本损失值表达的是正样本预测分数是否高于负样本。负样本的采集质量直接影响模型效果——随机采样负样本很可能踩中“用户其实看过但评分低于4被丢到负样本池”的边界情况这会让优化目标自相矛盾。def bpr_loss(pos_scores, neg_scores): # pos_scores: 用户对正样本电影的预测分数 # neg_scores: 用户对负样本电影的预测分数 loss -torch.log(torch.sigmoid(pos_scores - neg_scores) 1e-8) return loss.mean() # 负采样每个正样本配对3个负样本 num_negatives 3 batch_users, batch_pos positive_pairs batch_neg [] for user_id in batch_users: # 避开用户已经看过的电影 candidates all_movies[~np.isin(all_movies, user_seen[user_id])] batch_neg.append(np.random.choice(candidates, sizenum_negatives))负采样比例的设定有讲究比例太低了模型区分正负的能力弱比例太高负样本里难度太低的样本占大多数会让模型训练效率下降。实践中负采样比例为1:3到1:5表现比较均衡。这组多选题中还有一个隐藏掉的工程点是“硬负样本”—尽量从用户没看过的热门电影中采样因为热门电影和被推荐机会更多用户看过但没打分的概率低。同时不用全部避免配合hard negative mining往往比调大采样比例更有效。4.3 训练配置batch size、学习率与embedding维度怎么设跑通管线之后真正决定模型好坏的是训练配置。embedding维度方面64维是这类论文复现的标准起点低于32维信息压缩太狠、无法区分相似电影高于256维在万级节点数据上过拟合风险陡增。学习率用Adam优化器配1e-3起步每5个epoch衰减一次衰减因子0.5这样既能快速收敛又能在后期稳定到更优区域。Batch size这块DGL的minibatch训练通常设置256到1024具体取决于GPU显存训练阶段CPU和GPU都占着高负载是很正常的现象不要看到风扇狂转就以为卡死了。optimizer torch.optim.Adam(model.parameters(), lr1e-3) scheduler torch.optim.lr_scheduler.StepLR(optimizer, step_size5, gamma0.5) for epoch in range(30): for batch in train_dataloader: user_emb model(interact_graph, all_emb)[batch_users] movie_emb model(interact_graph, all_emb)[batch_movies] pos_scores (user_emb * pos_movie_emb).sum(dim1) neg_scores (user_emb * neg_movie_emb.reshape(-1, 3, 64)).mean(dim1) loss bpr_loss(pos_scores, neg_scores) optimizer.zero_grad() loss.backward() optimizer.step() scheduler.step()这个训练循环里model(interact_graph, all_emb)每轮迭代都对全图做一次GCN推理这是毕设能接受的规模但工程上更优的做法是用dgl.dataloading的minibatch对子图做卷积。全图滚动训练优点是方便调试、不用维护邻居采样子图的映射关系缺点是随着图变大每个epoch的时间线性增长。另外值得注意pos与neg的分数计算需要保持在同一个向量空间内最后一次全连接层的输出接一个sigmoid做成0-1分方便和后续的召回阈值和排序逻辑对接——推荐分数定为一个可解释的概率值而不是一个纯粹的相对大小。5. 毕设级避坑指南实体对齐、假负样本与过平滑的现象与解法5.1 现象一训练AUC到0.9但打开的推荐页完全不能看这是最打击人的翻车场景模型离线指标漂亮在线实测推荐出来的电影用户根本不感兴趣。原因几乎总是出在评估环节的泄漏或负样本构造逻辑上。如果负采样时没有排除用户已经评过分的电影模型其实是在“区分看过与没看过”而不是“区分喜欢与不喜欢”如果评估时拿训练时见过评分的样本去计算top-k命中率等于开卷考试。解决方式负采样强制排除user_seen列表评估集严格按时间戳切分训练集只保留userId出现过的历史行为不能随机打乱后切分。5.2 现象二实体对齐后KG三元组少了三成IMDB和MovieLens的自建映射常常只能对上七成放眼望去全是空的“有电影无属性”节点。这里有一个毕设的超常解法——牺牲图谱规模保住图谱质量。对不上的电影可以改用DBpedia的SPARQL端点按标题二次对齐但要注意网络请求量避免把免费端点打到限流。如果实在对齐不了就把这些电影标记为“图谱缺失节点”在GNN训练时用全部零向量代替输入特征靠邻接信息补齐。图神经网络在处理孤立节点时表现较差这类节点训练时要加大采样倍数确保它们能通过同类型节点的邻居关系获得表示能力。5.3 现象三模型第二轮就开始过拟合train loss降但valid loss涨训练冷启动时最容易踩这个坑。原因有三embedding维度设得过大、GNN层数过深、数据规模本身太小。在MovieLens-1M这种规模下建议直接放弃动辄256维的embedding切到64GNN层数不要超过3。层数一旦深于3节点感受野覆盖到全图几乎所有节点所有节点的表示趋同这就是“过平滑”现象。处理办法是加early stopping监控valid_loss连续5轮不降就停止训练并回滚到最佳checkpoint。dropout从0.3加到0.5也会立竿见影尤其对于Acted_by这类邻居数量极不均匀的节点。5.4 现象四稀疏节点的特征向量全部变成零向量做可视化时经常发现一类现象node embedding投影到二维后零向量的点全部聚在坐标轴原点附近形成一团稠密的黑点。这是因为度为零的节点没有任何KG关系的电影在归一化图卷积过程中聚合不到任何邻居信息初始化embedding又恰好被后续的norm层压到接近零。解决方式不只是加自环self-loop——自环只是让节点保留自身特征但没法注入邻居信息。更有效的是为每个零度节点强行拼接一个“属性节点”比如将release_year、popularity等数值特征拼进输入特征保证它们在特征空间里有初始的位置。5.5 现象五显存或内存直接爆掉图训练的内存峰值往往出现在邻居采样和embedding聚合阶段。遇到内存爆炸不要急着买更大的机器先查是不是采样器的fanout设置过大或者采样后的子图没有做去重。DGL有个坑同一个batch中多个源节点的邻居子图如果重叠比如两部同一部导演的电影存储重复节点的embedding会成倍膨胀。另一个容易被忽略的是代码里不小心做了稠密化转换例如调用了to_dense()把稀疏子图转成稠密邻接矩阵。凡是看到OOM优先跑一遍内存profiler找到到底是哪一行让峰值翻倍。6. 验证与进阶AUC之外可解释性才是这个毕设的真正加分项最后一个环节反而是操场跑道的直道冲刺阶段要拼的不是精度而是“说得出理由”。离线指标之外知识图谱推荐系统真正的优势是给出可解释路径用户A喜欢《肖申克的救赎》系统找到该片的导演弗兰克·德拉邦特发现他导演的《绿里奇迹》也是同类高分剧作由此推荐给用户推荐理由写上“你喜欢弗兰克·德拉邦特导演的作品”就很有说服力。实现这个路径回溯不需要改模型只需在拿到预测结果后对目标用户的历史正反馈电影集合中各取一跳邻居计算与新电影邻居路径的重合度按路径权重排序输出top-3作为推荐理由。这个功能彻底盘活了知识图谱也是答辩时最能展现系统完整度的地方。我自己的血泪经验是第一版系统只做了评分预测答辩时被评委问“你的知识图谱在推荐里究竟起了什么作用”当时的解释苍白无力——模型AUC比协同过滤高0.2个点不构成说服力。后来花了两天补上了路径回溯的可解释展示把“用户—电影—导演—新电影”的路径用graphviz画出来整个项目的完成度立刻不一样了。建议在开发排期里就把可视化解释作为一等功能预留而不是最后才发现要做。对这个项目方向而言模型上游的数据准备占了大约五成工作量GNN训练占三成可视化与集成占两成按这个时间比例去排期才不容易在答辩前夜手忙脚乱。希望这套从三元组到GNN再到可解释展示的完整工程路径能帮你把这个毕设题目做成真正的加分项。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。