简介:面向毕业设计、期末大作业与课程设计,基于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="|", encoding="latin1", header=None ) movies.columns = ["movie_id", "title", "release_date", "video_date", "imdb_url"] + \ [f"genre_{i}" for i in range(19)] print(ratings.head()) print(movies[["movie_id", "title"]].head())这段代码把MovieLens的评分和电影基本信息读成DataFrame。注意u.item的编码是latin1,不是utf-8,Windows下直接用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_k=3): 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当实体ID,GNN的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 = f"movie:{movie_id}" for g in genres_df.loc[genres_df["movie_id"] == movie_id, "genre"]: triples.append((m, "has_genre", f"genre:{g}")) for d in directors_df.loc[directors_df["movie_id"] == movie_id, "director"]: triples.append((m, "directed_by", f"person:{d}")) for a in actors_df.loc[actors_df["movie_id"] == movie_id, "actor"]: triples.append((m, "starring", f"person:{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. 图神经网络选型与实现:为什么毕设首选两层GAT
3.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_index:
import torch edge_index = torch.tensor([ user_ids + movie_ids + movie_ids + movie_ids, movie_ids + actor_ids + director_ids + genre_ids ], dtype=torch.long) # 补上反向边,让信息能双向传播 edge_index = torch.cat([edge_index, edge_index.flip(0)], dim=1)第一个列表是每条边的源节点,第二个列表是目标节点。拼接之后要把每个关系方向反过来再拼一次,因为无向图相当于每条边要准备两个方向。很多人忘了flip这一步,导致信息只能从电影传到导演,不能从导演传回电影,训练效果会打折扣。这不是玄学,是GCN/GAT消息传递方向的规定。
接下来把节点总数、用户节点索引、电影节点索引整理好:
num_nodes = len(entity2id) user_nodes = [entity2id[f"user:{uid}"] for uid in user_ids] movie_nodes = [entity2id[f"movie:{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_dim=64, hidden_dim=64, heads=4): super().__init__() self.node_embedding = nn.Embedding(num_nodes, embed_dim) self.gat1 = GATConv(embed_dim, hidden_dim, heads=heads, concat=True) self.gat2 = GATConv(hidden_dim * heads, embed_dim, heads=1, concat=False) 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_dim=64,concat之后变成64×4=256,所以第二层的输入维度必须是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(), lr=0.005) for epoch in range(50): model.train() emb = model(edge_index) pos_scores = (emb[user_nodes] * emb[pos_movie_nodes]).sum(dim=1) neg_scores = (emb[user_nodes] * emb[neg_movie_nodes]).sum(dim=1) loss = bpr_loss(pos_scores, neg_scores) optimizer.zero_grad() loss.backward() optimizer.step() if epoch % 5 == 0: print(f"epoch {epoch}, loss {loss.item():.4f}")训练时有一个细节:user_nodes和pos_movie_nodes用的是同一个训练交互batch的ID,neg_movie_nodes是采样出来的负样本ID,形状和user_nodes一致。BPR损失只要求正样本比负样本大一点就行,不要求分数绝对值高。lr=0.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, k=20, dim=1).indices这里要注意,推理前必须调用model.eval()关闭dropout,否则每个用户每次结果都不一样。score_matrix是[用户数×电影数],1700部电影内存不会爆,如果是20M数据就要换faiss做近似最近邻,毕设不用。
topk返回的是实体全图中的节点ID,不是movies_metadata里的movie_id。所以还需要一次映射,把topk里的ID翻译回电影标题。这也是为什么前面要把entity2id保存下来。在源码里可以写一个reverse map:
id2entity = {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, dtype=torch.bool) for mid in user_history[uid]: seen_mask[entity2id[f"movie:{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(dim=0) model.node_embedding.weight.data[new_id] = new_vec随后把“新电影→导演/演员/类型”的边加入edge_index,再跑一次前向传播。虽然新电影没有评分,但经过GAT两层聚合后,它可以从导演、演员那里吸收语义向量,从而和用户产生相似度。这个功能很能在答辩时演示:输入一部冷门老片,照样能给出推荐。
注意新增节点必须和已有节点ID不重复,Embedding表需要在训练前预分配足够大的空间。我一般会在训练前把Embedding表初始化成num_nodes + 10000,给预留空间。如果你在训练后调整权重,要小心Embedding表维度已经固定,无法随意扩展。
5. 避坑与排查:数据泄漏、内存爆炸和负采样翻车记录
5.1 数据划分泄漏:评分乱序切分导致评估分数虚高
现象:模型在验证集上的Precision@10高达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_size=1024, shuffle=True, input_nodes=user_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.0+cu118.html pip install torch-sparse -f https://data.pyg.org/whl/torch-2.0.0+cu118.html pip install torch-geometric这个操作最怕的是镜像源不匹配,建议不要用带自定义镜像的pip。装完以后跑一个小图前向传播,确认扩展真的能用,再开始正式训练。
5.5 中文乱码与Streamlit缓存踩坑
现象:前端页面上导演的名字变成“???”,或者每次点击用户都重新算推荐。原因:CSV用utf-8写入,但Windows下默认以GBK打开;Streamlit每次交互都会重跑脚本。解决:写文件时指定encoding="utf-8-sig",Streamlit用@st.cache_data缓存推荐结果。
triples_df.to_csv("kg_triples.tsv", sep="\t", index=False, encoding="utf-8-sig")@st.cache_data def load_model_and_embeddings(): model = load_model() emb = model(edge_index) return emb如果你用了Neo4j做路径搜索,查询结果也可以加缓存,避免来回访问数据库。缓存的是返回的DataFrame或list,Streamlit会自动区分参数。这里有个坑:如果模型参数更新了,Streamlit不会自动失效,需要重启或者用st.cache_data.clear()清缓存。
6. 离线评估与交互Demo:让毕设从“能跑”变成“能答辩”
6.1 离线指标:Precision@K、Recall@K和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对排序位置敏感,命中越靠前分数越高。如果指标只在K=10时有点提升,可以补一组K=5、K=20的曲线,展示稳定性。
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_k=10) 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这对答辩非常加分,能让观众直观看到“你看过A,A的导演也拍过B,所以推荐B”。
6.3 三个答辩看点:消融实验、参数敏感性、可视化
第一个看点是消融实验:分别跑“只有用户-电影边”和“完整知识图谱边”两个模型,对比Precision,证明知识图谱确实有用。第二个看点是参数敏感性:把embed_dim调成16/32/128,记录指标变化,至少说明你不是瞎调参。第三个看点是可解释路径:把3条典型推荐路径截图放进论文。
我自己的教训是:不要等全部代码写完才做评估。先在数据切分时留下测试集,每几十个epoch就评估一次,否则最后发现指标不对,根本不知道是数据泄漏还是模型有问题。这套方案最花时间的永远不是GAT,而是知识图谱的实体对齐和三元组清洗。别名电影、多导演、多人合作的数据脏活,才是真正决定推荐效果的地方。
希望这篇能帮你把“毕业设计基于Python的知识图谱与图神经网络的电影推荐系统”从标题变成能跑、能讲、能答辩的完整作品。希望帮到你。
本文还有配套的精品资源,点击获取