简介这是一套面向计算机相关专业学生与项目实战学习者的Python电影推荐系统毕业设计资料以知识图谱为核心技术路线适合用作毕业设计、课程设计或期末大作业。资源包共31个文件以21个py源码为主体涵盖知识图谱构建、KGCN模型、训练与评估等模块另含dat数据文件、txt与readme说明及md文档压缩包约14.84MB结构清晰便于按模块查阅。项目经导师指导并获评审99分认可代码完整可运行对新手较为友好。读者可从中获得完整的推荐系统实现方案包括知识图谱加载与处理、模型层与工具层代码、训练测试脚本及配套说明文档既能理解图谱与推荐算法结合的整体流程也便于在此基础上修改扩展或撰写论文。目前已有80人学习下载可作为同类选题的参考范例。1. 电影推荐做腻了协同过滤换知识图谱这条路走走看做电影推荐系统的毕业设计十个人里有八个上来就是协同过滤要么 UserCF 要么 ItemCF再不然矩阵分解走一遍最后答辩时被问一句“冷启动怎么办”就卡住了。这不是能力问题是选型问题。协同过滤的本质是拿用户行为共现来推行为稀疏或者新用户进来整个链路就瘫了。而知识图谱这条路是把电影、导演、演员、类型、年份这些实体和它们之间的关系显式建模成一张图推荐时沿着图上的路径去推理——就算某个用户只看过三部电影只要这三部电影在图上有足够丰富的关联照样能推出他可能喜欢什么。这篇要聊的就是用 Python 搭一套基于知识图谱的电影推荐系统从数据建模、图谱构建、推荐算法到可解释性输出完整走一遍。适合正在做毕业设计、需要一套能跑通且有技术深度的方案的同学也适合已经做过协同过滤、想看看知识图谱到底怎么落地的一线开发者。我不会只讲概念每一步都会落到代码和参数上该踩的坑也会提前说清楚。2. 知识图谱推荐到底比协同过滤强在哪选型逻辑与数据建模2.1 协同过滤的天花板在哪知识图谱补的是什么协同过滤的核心假设是“相似的人喜欢相似的东西”这个假设在行为数据充足时成立但电影推荐场景有个天然问题长尾电影的行为数据极度稀疏。一部小众文艺片可能只有几十个用户看过UserCF 根本找不到足够的邻居来算相似度。ItemCF 稍好一些但新上映的电影没有行为数据连相似度矩阵都进不去。知识图谱的补法不一样。它不依赖用户行为共现而是依赖实体间的语义关系。比如“某导演拍了某电影”“某演员出演了某电影”“某电影属于某类型”这些关系是客观存在的不需要用户行为来“喂养”。当用户看过该导演的一部电影图谱就能沿着“导演→其他电影”这条边推出候选集哪怕那些电影没人看过。这就是知识图谱在冷启动和长尾场景下的核心优势。另一个容易被忽略的点是可解释性。协同过滤给出推荐结果后你很难跟用户解释“为什么推这部”。知识图谱可以给出路径“因为你喜欢《电影A》而《电影A》的导演也拍了《电影B》。”这条路径本身就是解释答辩时也是加分项。2.2 电影知识图谱的实体与关系设计动手之前先把 schema 定清楚不然后面改起来很痛苦。电影领域常见的实体类型和关系类型如下实体类型示例属性说明电影名称、上映年份、评分、时长核心实体导演姓名、国籍与电影是一对多演员姓名、性别与电影是多对多类型名称动作、喜剧、悬疑等用户用户ID推荐对象制片公司名称可选视数据源而定关系类型包括导演-执导-电影、演员-出演-电影、电影-属于-类型、用户-观看-电影、用户-评分-电影。这套 schema 不算复杂但足够支撑路径推理。注意实体粒度要提前想好。比如“类型”是单值还是多值一部电影同时是“动作”和“科幻”很常见所以电影和类型之间应该是多对多关系建模时用独立的边来表示不要塞进电影节点的属性里。2.3 用 Neo4j 还是 NetworkX图存储选型对比这是第一个实际选型问题。两种常见做法Neo4j原生图数据库Cypher 查询语言对路径推理非常友好适合数据量大、查询复杂的场景。缺点是部署成本高毕业设计环境里不一定能跑起来。NetworkXPython 库内存中建图零部署成本适合中小规模数据几万节点以内。缺点是图大了内存吃紧没有持久化查询能力。我的建议是毕业设计场景优先用 NetworkX 做原型验证数据量控制在 5 万节点以内完全够用。如果导师要求“有数据库”再用 Neo4j 做一层存储但推荐算法本身还是在 Python 里跑。下面这段代码用 NetworkX 建一个最小可用的电影知识图谱import networkx as nx # 创建有向图边表示关系方向 G nx.DiGraph() # 添加电影节点 movies [ (M001, {type: movie, name: 电影A, year: 2019, rating: 8.2}), (M002, {type: movie, name: 电影B, year: 2021, rating: 7.5}), (M003, {type: movie, name: 电影C, year: 2020, rating: 8.8}), ] G.add_nodes_from(movies) # 添加导演节点 directors [ (D001, {type: director, name: 导演X}), (D002, {type: director, name: 导演Y}), ] G.add_nodes_from(directors) # 添加类型节点 genres [ (G001, {type: genre, name: 悬疑}), (G002, {type: genre, name: 科幻}), ] G.add_nodes_from(genres) # 建立关系边 G.add_edge(D001, M001, relationdirected) G.add_edge(D001, M002, relationdirected) G.add_edge(D002, M003, relationdirected) G.add_edge(M001, G001, relationbelongs_to) G.add_edge(M002, G002, relationbelongs_to) G.add_edge(M003, G001, relationbelongs_to) # 添加用户节点和观看关系 G.add_node(U001, typeuser) G.add_edge(U001, M001, relationwatched, rating5.0) G.add_edge(U001, M003, relationwatched, rating4.5) print(f节点数: {G.number_of_nodes()}, 边数: {G.number_of_edges()})这段代码的逻辑很直白节点用元组列表批量添加边用add_edge逐条建立每条边带relation属性来区分关系类型。参数上注意两点一是nx.DiGraph()是有向图因为“导演执导电影”和“电影执导导演”显然不是一回事二是节点 ID 用字符串而非自增整数方便后续从数据库导入时做映射。2.4 从原始数据到图谱字段映射与清洗规则实际数据来源通常是 MovieLens 或者豆瓣爬取数据毕业设计用公开数据集即可。原始数据一般是 CSV包含 movie.csv、ratings.csv、users.csv 等。转成图谱需要做字段映射movie.csv 的movieId→ 图谱中的电影节点 IDmovie.csv 的genres字段通常是Action|Comedy这种管道分隔→ 拆成多个类型节点分别建边ratings.csv 的userId、movieId、rating→ 用户节点、观看边、评分属性清洗时最容易翻车的地方是类型字段的拆分。有些数据集用|分隔有些用逗号还有些用空格。写个兼容函数处理def parse_genres(genre_str): 兼容多种分隔符的类型字段解析 if not genre_str or genre_str (no genres listed): return [] # 先统一替换所有可能的分隔符为 | for sep in [,, /, ]: genre_str genre_str.replace(sep, |) # 去重并去除空字符串 return list(set(g.strip() for g in genre_str.split(|) if g.strip()))这个函数看着简单但不写的话后面建图时会发现类型节点数量对不上排查半天才发现是分隔符不统一。血泪经验数据清洗阶段多花十分钟建图阶段少调两小时。3. 推荐算法怎么跑从路径推理到向量召回3.1 基于元路径的推荐手写一个可解释的推理函数元路径是知识图谱推荐里最直观的方法。所谓元路径就是图上一条固定模式的路径比如“用户→电影→导演→电影”表示“用户看过的电影其导演还拍了哪些其他电影”。沿着这条路径走就能得到候选推荐集。def recommend_by_metapath(G, user_id, max_results10): 基于元路径 用户-电影-导演-电影 的推荐 返回候选电影列表及其推荐路径 candidates {} # movie_id - 推荐路径列表 # 第一步找到用户看过的所有电影 watched [ (u, v) for u, v, d in G.edges(dataTrue) if u user_id and d.get(relation) watched ] for _, movie_id, _ in watched: # 第二步找到这些电影的导演 directors [ (u, v) for u, v, d in G.edges(dataTrue) if v movie_id and d.get(relation) directed ] for director_id, _, _ in directors: # 第三步找到该导演执导的其他电影 other_movies [ (u, v) for u, v, d in G.edges(dataTrue) if u director_id and d.get(relation) directed ] for _, other_movie_id, _ in other_movies: if other_movie_id movie_id: continue # 跳过已看过的 # 记录推荐路径 path f{user_id} - {movie_id} - {director_id} - {other_movie_id} if other_movie_id not in candidates: candidates[other_movie_id] [] candidates[other_movie_id].append(path) # 按被推荐次数排序取 Top-N sorted_candidates sorted( candidates.items(), keylambda x: len(x[1]), reverseTrue ) return sorted_candidates[:max_results]逻辑说明函数分三步走先找用户看过的电影再找这些电影的导演最后找导演的其他电影。每一步都用边属性relation做过滤确保走的是正确的路径。参数max_results控制返回数量默认 10 条。返回结果里带了推荐路径这就是可解释性的来源。这个函数的时间复杂度是 O(n²) 级别嵌套遍历边在几万条边以内跑得动再大就需要建索引或者换 Neo4j 了。3.2 图嵌入方案用 Node2Vec 把节点变成向量元路径方法直观但覆盖有限只能走预设的路径模式。图嵌入的思路是把每个节点映射成一个低维向量然后在这个向量空间里算相似度。Node2Vec 是常用方案核心思想是通过随机游走生成节点序列再用 Word2Vec 训练得到嵌入。from node2vec import Node2Vec import numpy as np # 基于已有的图 G 训练 Node2Vec 模型 node2vec Node2Vec( G, dimensions64, # 嵌入维度64 或 128 常见 walk_length30, # 每次随机游走的步数 num_walks200, # 每个节点发起的游走次数 p1.0, # 回退参数控制游走回到上一个节点的概率 q1.0, # 前进参数控制游走向外探索的概率 weight_keyNone, # 边权重字段没有就用 None workers4 # 并行线程数 ) # 训练 Word2Vec 模型 model node2vec.fit(window10, min_count1, batch_words4) # 获取某个电影节点的向量 movie_vector model.wv[M001] print(f电影 M001 的嵌入向量维度: {movie_vector.shape}) # 计算两部电影的余弦相似度 from numpy.linalg import norm def cosine_sim(v1, v2): return np.dot(v1, v2) / (norm(v1) * norm(v2)) sim cosine_sim(model.wv[M001], model.wv[M002]) print(fM001 与 M002 的相似度: {sim:.4f})参数说明dimensions控制嵌入向量维度太小表达力不够太大容易过拟合64 到 128 是常见区间。walk_length和num_walks共同决定游走序列的总量数据量大时可以适当减小。p和q是 Node2Vec 的核心参数p越小越倾向于回到上一个节点类似 BFS捕捉局部结构q越小越倾向于探索远处节点类似 DFS捕捉全局结构。默认都是 1.0表示均匀游走。拿到嵌入向量后推荐逻辑就变成算用户看过的电影向量均值然后找余弦相似度最高的未看电影。这种方法比元路径覆盖更广但可解释性弱一些——你没法说清楚“为什么这两部电影相似”只能说“在嵌入空间里它们离得近”。3.3 召回与排序的两阶段设计实际系统里不会只用一个算法出结果。常见做法是两阶段召回阶段用多种策略各出一批候选元路径召回、嵌入召回、热门召回合并去重后得到几百个候选排序阶段用一个轻量模型甚至就是加权打分对候选做精排。def hybrid_recall(G, model, user_id, top_k50): 混合召回元路径 嵌入相似度 热门兜底 recall_pool {} # 策略一元路径召回 metapath_results recommend_by_metapath(G, user_id, max_resultstop_k) for movie_id, paths in metapath_results: recall_pool.setdefault(movie_id, {})[metapath] len(paths) # 策略二嵌入相似度召回 watched_movies [ v for u, v, d in G.edges(dataTrue) if u user_id and d.get(relation) watched ] if watched_movies: # 用户向量 看过的电影向量的均值 user_vec np.mean( [model.wv[m] for m in watched_movies if m in model.wv], axis0 ) # 对所有电影算相似度 all_movies [ n for n, d in G.nodes(dataTrue) if d.get(type) movie ] sims [] for m in all_movies: if m in model.wv and m not in watched_movies: sim cosine_sim(user_vec, model.wv[m]) sims.append((m, sim)) sims.sort(keylambda x: x[1], reverseTrue) for movie_id, sim in sims[:top_k]: recall_pool.setdefault(movie_id, {})[embedding] sim # 策略三热门兜底按评分排序 if len(recall_pool) top_k: hot_movies sorted( [(n, d.get(rating, 0)) for n, d in G.nodes(dataTrue) if d.get(type) movie and n not in recall_pool], keylambda x: x[1], reverseTrue ) for movie_id, rating in hot_movies[:top_k - len(recall_pool)]: recall_pool.setdefault(movie_id, {})[hot] rating return recall_pool这段代码把三种召回策略的结果合并到一个字典里每个候选电影记录了它被哪些策略召回以及对应的分数。后续排序时可以按策略加权比如元路径召回权重 0.5、嵌入召回权重 0.3、热门兜底权重 0.2。权重怎么定没有标准答案需要根据实际数据做 A/B 测试调。4. 避坑指南知识图谱推荐系统最容易翻车的五个地方4.1 图谱建好了但推荐结果全是热门电影现象不管给哪个用户推荐出来的都是那几部评分最高的电影个性化几乎为零。原因路径推理时没有做惩罚热门电影因为连接多被走到的概率天然就高。比如一部大片可能和十几个导演、几十个演员有关联从任何路径出发都容易走到它。解决在排序阶段引入热度惩罚项。常见做法是用1 / log(1 被推荐次数)作为惩罚因子或者直接对节点的度做归一化。另一个思路是在召回阶段限制每个导演最多贡献 N 部候选电影避免单个热门节点主导结果。4.2 Node2Vec 训练完发现所有向量都差不多现象算任意两个节点的余弦相似度都在 0.9 以上区分度极低。原因通常是walk_length太短或者num_walks太少随机游走没有充分探索图结构。另一个常见原因是图中存在大量孤立节点或度极低的节点这些节点的游走序列几乎一样。解决先把walk_length调到 30 以上、num_walks调到 200 以上试试。如果还不行检查图里有没有度为零的节点有的话先清理掉。还有一个容易忽略的点dimensions设得太小比如 16嵌入空间容量不够也会导致所有向量挤在一起。调到 64 或 128 再试。4.3 元路径推理跑一次要等好几分钟现象用户量稍微大一点推荐接口响应时间从毫秒级变成秒级甚至分钟级。原因前面写的recommend_by_metapath函数每次都在全图遍历边时间复杂度是 O(E²) 级别。用户看过的电影越多嵌套循环越深。解决三个方向。一是建邻接表缓存把每个节点的出边和入边预先存好避免每次遍历全图。二是限制用户观看历史的使用数量比如只取最近 20 部电影做推理。三是如果数据量确实大换 Neo4j 用 Cypher 查询图数据库对路径查询有专门优化。4.4 类型字段拆分后节点数量暴涨现象建完图发现类型节点有几百个但实际电影类型只有二十几种。原因原始数据里类型字段的写法不统一比如“Sci-Fi”和“Science Fiction”被当成两个不同的类型“Action”和“action”也因为大小写被拆成两个节点。解决在parse_genres函数里加一层标准化映射。先统一转小写再用一个预定义的映射表把别名归并到标准名称。映射表不用一次写全跑一遍数据看看有哪些类型名称手动整理一次即可。4.5 推荐结果无法复现每次跑出来不一样现象同样的输入两次运行推荐的电影不同答辩演示时很尴尬。原因Node2Vec 的随机游走和 Word2Vec 的训练都涉及随机种子不固定的话每次结果都不同。解决在训练前固定随机种子。Node2Vec 的fit方法可以传seed参数同时也要给 Python 的random和numpy设种子import random import numpy as np random.seed(42) np.random.seed(42) # Node2Vec 训练时也传入 seed model node2vec.fit(window10, min_count1, batch_words4, seed42)这样每次跑出来的嵌入向量就是确定的推荐结果也可复现。答辩前记得跑一遍确认。5. 让推荐结果能“说话”可解释性输出与效果验证可解释性是知识图谱推荐相比协同过滤最大的差异化优势但很多毕业设计只做到了“能推荐”没做到“能解释”。其实加一层解释输出并不难关键是把推荐路径翻译成人能看懂的话。def explain_recommendation(G, user_id, movie_id): 生成推荐解释文本 explanations [] # 查找用户看过的电影 watched [ v for u, v, d in G.edges(dataTrue) if u user_id and d.get(relation) watched ] for wm in watched: # 检查是否有共同导演 wm_directors { u for u, v, d in G.edges(dataTrue) if v wm and d.get(relation) directed } target_directors { u for u, v, d in G.edges(dataTrue) if v movie_id and d.get(relation) directed } common_directors wm_directors target_directors if common_directors: for d_id in common_directors: d_name G.nodes[d_id].get(name, d_id) wm_name G.nodes[wm].get(name, wm) explanations.append( f因为你喜欢《{wm_name}》而《{wm_name}》的导演{d_name} f也执导了这部电影 ) # 检查是否有共同类型 wm_genres { v for u, v, d in G.edges(dataTrue) if u wm and d.get(relation) belongs_to } target_genres { v for u, v, d in G.edges(dataTrue) if u movie_id and d.get(relation) belongs_to } common_genres wm_genres target_genres if common_genres: genre_names [G.nodes[g].get(name, g) for g in common_genres] explanations.append( f与你喜欢的《{G.nodes[wm].get(name, wm)}》同属 f{、.join(genre_names)}类型 ) return explanations[:3] # 最多返回 3 条解释这段代码从两个维度找解释共同导演和共同类型。实际使用时可以再加共同演员、同一年代等维度。返回的解释文本直接展示给用户答辩时也是很好的演示素材。效果验证方面毕业设计场景下常用的指标是 PrecisionK、RecallK 和 NDCGK。做法是把用户观看记录按时间切分前 80% 作为训练集后 20% 作为测试集看推荐结果里有多少命中了测试集中的电影。不用追求指标多高关键是有一套完整的评估流程能说清楚“我的方法比随机推荐好多少”。def precision_at_k(recommended, actual, k): 计算 PrecisionK top_k set(recommended[:k]) return len(top_k set(actual)) / k def recall_at_k(recommended, actual, k): 计算 RecallK top_k set(recommended[:k]) return len(top_k set(actual)) / len(actual) if actual else 0.0这两个函数足够简单但跑评估时要注意测试集里的电影必须是训练时没出现过的否则就是数据泄漏指标会虚高。我一般会在切分后检查一遍确认训练集和测试集的电影 ID 没有交集。最后说一个我自己的习惯每次改完推荐逻辑先拿三五个典型用户跑一遍人工看看推荐结果合不合理再去跑批量指标。指标好看但推荐结果离谱的情况在实际系统里并不少见。希望帮到你。本文还有配套的精品资源点击获取