尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

协同过滤+知识图谱:构建电影问答推荐系统毕设全解析

发布时间:2026/9/10 3:44:49

资讯中心
01
ARTICLE

协同过滤+知识图谱:构建电影问答推荐系统毕设全解析

协同过滤+知识图谱:构建电影问答推荐系统毕设全解析
简介一套完整的Python知识图谱电影推荐问答系统毕业设计项目覆盖协同过滤推荐、知识图谱构建、电影问答交互、用户管理等功能模块适合计算机相关专业学生用于课程设计、毕业设计或项目复现练习。包内含源码、数据库脚本、说明文档及项目运行所需的图片与前端资源共871个文件主要涉及Python源码、JS脚本、CSS样式、HTML页面、SQL数据库、文档与各类媒体资源压缩包总体约307.53MB。目前已有912人学习下载。通过这套资料读者可获得前后台完整的推荐与问答实现思路包括基于协同过滤的推荐算法、知识图谱三元组构建与检索、电影信息展示与问答交互、登录注册和用户管理等功能配套说明文档还包含系统总体结构、数据库设计和测试内容便于理解并二次开发拓展。整体适合具备一定Python基础、希望快速搭建毕业设计原型的学习者使用。1. 从协同过滤到知识图谱一份毕设级电影问答系统的拆解思路如果你打开过 MovieLens 的 ua.base、u1.base 这类数据集会发现它们只是一堆“用户ID、电影ID、评分、时间戳”的纯数字组合。仅凭这些字段能算出推荐列表却答不出“诺兰执导的烧脑科幻片有哪些”这类自然语言问题。这套基于 Python 的毕业设计把两件事接在了一起底层用协同过滤处理评分数据做个性化召回上层用知识图谱组织电影、导演、类型、演员之间的关系再通过问答接口把自然语言查询翻译成图谱查询。B/S 架构、MySQL 存储、Flask 或 Django 风格的后端、Layui 前端整体不算重但胜在链路完整。适合正在做推荐类毕设、想快速理解推荐与图谱如何落地的开发者你能看到评分矩阵怎么为推荐服务也能看到实体关系如何被检索而不是只拿到一个跑不动的空壳。2. 数据底座ua.base 评分文件与 MySQL 的导入策略2.1 先读懂 MovieLens 的评分数据格式项目里出现的 ua.base、ub.base、u1.base 到 u5.base 是 MovieLens 100K 数据集的经典划分文件。每个文件都是 TSV 格式四列分别为 user id、item id、rating、timestamp例如196 242 3 881250949 186 302 3 891717742 22 377 1 878887116字段顺序固定没有表头。这类文件的处理要点是“按分隔符切割、忽略空行、把评分列转成数值类型”否则后续协同过滤计算相似度时会出现字符串与整数相乘的诡异错误。我一般会先写一个简单的加载函数做数据探查import pandas as pd def load_ratings(path: str) - pd.DataFrame: df pd.read_csv( path, sepr\s, headerNone, names[user_id, movie_id, rating, timestamp], enginepython ) df[rating] df[rating].astype(float) print(fLoaded {len(df)} ratings, {df[user_id].nunique()} users, {df[movie_id].nunique()} movies) return df ratings load_ratings(ua.base)这段代码用正则r\s切分连续空格比sep\t更宽容能同时处理空格和制表符混杂的行。headerNone是因为文件没有表头names显式指定列名避免后续df.user_id访问时找不到列。enginepython是为了兼容正则分隔符大数据量下性能略慢但 100K 级数据完全够用。2.2 MySQL 建表与批量导入毕设要求数据库设计合理原项目自带的说明文档里推荐 MySQL 建三张核心表用户表、电影表、评分表。建表语句建议把 rating 表的主键设为(user_id, movie_id)联合主键同时给 movie_id 建立索引因为协同过滤阶段最频繁的查询是按电影聚合评分CREATE DATABASE IF NOT EXISTS movie_rec DEFAULT CHARSET utf8mb4; USE movie_rec; CREATE TABLE users ( user_id INT PRIMARY KEY, user_name VARCHAR(64) NOT NULL, password_hash VARCHAR(128) NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB; CREATE TABLE movies ( movie_id INT PRIMARY KEY, title VARCHAR(255) NOT NULL, genres VARCHAR(255) NOT NULL, release_year INT ) ENGINEInnoDB; CREATE TABLE ratings ( user_id INT NOT NULL, movie_id INT NOT NULL, rating FLOAT NOT NULL, ts INT NOT NULL, PRIMARY KEY (user_id, movie_id), FOREIGN KEY (user_id) REFERENCES users(user_id), FOREIGN KEY (movie_id) REFERENCES movies(movie_id), INDEX idx_movie (movie_id) ) ENGINEInnoDB;注意utf8mb4字符集电影标题里可能有特殊 Unicode 符号只建utf8会报 1071 错误。导入评分数据不用一条条 insert用 MySQL 的LOAD DATA LOCAL INFILE最快mysql -u root -p movie_rec -e LOAD DATA LOCAL INFILE /path/ua.base INTO TABLE ratings FIELDS TERMINATED BY (user_id, movie_id, rating, ts);这里字段终止符是空格但 MovieLens 文件里是制表符所以更稳妥的写法是用FIELDS TERMINATED BY \t。如果文件实际混合了空格与制表符可以先在 Python 里统一转换成制表符再导入避免LOAD DATA中途因字段错位而失败。导入完成后执行SELECT COUNT(*) FROM ratings应该能看到 943 用户、1682 电影、约 8万行评分的规模这是 100K 数据集的典型体量。3. 协同过滤与知识图谱双通道推荐不只看评分3.1 基于物品的协同过滤先算相似度矩阵推荐模块的核心是协同过滤算法。考虑到毕设演示场景数据量不大我会选基于物品的协同过滤ItemCF它的思路是“用户看过 A而 A 与 B 相似度高就推荐 B”。相似度计算常用余弦相似度先把评分矩阵转成电影-用户二维表再对电影两两计算余弦距离from sklearn.metrics.pairwise import cosine_similarity import numpy as np def build_item_similarity(ratings: pd.DataFrame): # 构建 user-item 矩阵缺失值填0 user_item ratings.pivot_table( indexuser_id, columnsmovie_id, valuesrating ).fillna(0) # 转置得到 item-user 矩阵 item_user user_item.T # 稀疏矩阵更省内存但这个小数据集用稠密矩阵也行 sim_matrix cosine_similarity(item_user, item_user) movie_ids list(item_user.index) sim_df pd.DataFrame(sim_matrix, indexmovie_ids, columnsmovie_ids) return sim_df sim build_item_similarity(load_ratings(ua.base))关键点在于.fillna(0)这会让缺失评分的维度变成 0余弦相似度只统计两个电影都被评过分的情况。cosine_similarity传入行是电影、列是用户的矩阵得到的就是电影与电影之间的相似度矩阵。排序后取 top-K 即可生成推荐列表。这里有个容易踩的坑如果直接用原始评分矩阵未评分的 0 占比太大相似度会被稀释所以实践里通常先做均值中心化再计算相似度效果会更好。3.2 构建知识图谱实体、关系、属性三要素知识图谱部分需要从电影数据中抽出实体和关系。常见实体包括电影、导演、演员、类型、用户关系则有“执导”“主演”“属于类型”“用户观看过”。实现方式不一定要上 Neo4j毕设场景可以用 Python 的networkx构建图再序列化保存import networkx as nx G nx.MultiDiGraph() def add_movie_entities(G, movies_df): for _, row in movies_df.iterrows(): movie_id row[movie_id] G.add_node(fmovie:{movie_id}, typemovie, titlerow[title]) # 类型可能是 Action|Comedy拆成多个实体 for genre in row[genres].split(|): genre_node fgenre:{genre} G.add_node(genre_node, typegenre, namegenre) G.add_edge(fmovie:{movie_id}, genre_node, relationbelongs_to) def add_user_rating_edges(G, ratings_df): for _, row in ratings_df.iterrows(): user_node fuser:{row[user_id]} movie_node fmovie:{row[movie_id]} G.add_node(user_node, typeuser) G.add_edge(user_node, movie_node, relationrated, weightrow[rating]) add_movie_entities(G, movies_df) add_user_rating_edges(G, ratings_df) nx.write_gexf(G, movie_graph.gexf)这里用MultiDiGraph而不是普通DiGraph的原因是同一个用户可能对同一部电影有多次评分记录虽然聚合后不会有向图可以区分“用户→电影”的评分关系方向和“电影→类型”的归属关系方向。write_gexf输出 GEXF 格式后续可以用 Gephi 可视化也可以自己再解析成图数据库需要的 CSV 格式。3.3 双通道融合推荐结果如何被知识图谱增强协同过滤给出的是“和你口味相似的人喜欢的电影”这组 ID但它解释不了“为什么”。知识图谱可以做到这一点拿到推荐电影列表后在图谱中查询这些电影的共同属性抽取出高权重的导演、题材或演员标签作为推荐解释展示给用户。def explain_movie(G, movie_id, top_k5): neighbors [] for neighbor in G.successors(fmovie:{movie_id}): rel G.edges[fmovie:{movie_id}, neighbor, 0].get(relation, ) if rel belongs_to: neighbors.append(neighbor) return neighbors[:top_k]这个函数的逻辑是获取电影节点的出边只保留belongs_to关系返回类型节点名。配合协同过滤结果你就可以在推荐卡片上写“因为您看过《盗梦空间》我们推荐同属科幻类型的《星际穿越》”。这类解释在毕设答辩里非常加分评委看到的不只是一个黑盒推荐而是可解释的推荐链路。4. 问答接口实现将自然语言查询翻译成图谱查询4.1 问题模板匹配与实体抽取电影问答模块的核心不是训练一个 NLP 模型而是使用模板匹配加关键词表。常见问法有“某演员演过哪些电影”“某导演的作品推荐”“找某类型的喜剧片”。实现时先定义实体词典再从用户输入中抽取实体和意图import re DIRECTORS [诺兰, 斯皮尔伯格, 卡梅隆, 王家卫] ACTORS [莱昂纳多, 汤姆克鲁斯, 周星驰] GENRES [科幻, 喜剧, 动作, 爱情] def parse_question(question: str): intent None entity None if any(g in question for g in [导演, 执导, 拍过]): intent query_director_movies for d in DIRECTORS: if d in question: entity d break elif any(g in question for g in [演员, 主演, 演过]): intent query_actor_movies for a in ACTORS: if a in question: entity a break elif any(g in question for g in [类型, 题材, 喜剧片, 科幻片]): intent query_genre_movies for g in GENRES: if g in question: entity g break return intent, entity这段代码的思路是“意图靠句型词实体靠词典”。它的短板是词典覆盖有限但毕设答辩时演示的问答集是固定的所以模板匹配完全够用。re模块在这里没有被直接用到但如果你遇到“诺兰导演的科幻片”这种多层修饰问句就需要用正则从句子中切分出多个实体。建议后续扩展时同时维护同义词表比如“导演”与“执导过”映射到同一个意图像sync_terms {导演: [导演, 执导, 拍过]}。4.2 图谱查询与答案组装实体和意图识别出来后查询逻辑就是对networkx图的定向遍历def answer_question(G, intent: str, entity: str) - list: if intent query_director_movies: # 假设导演节点格式为 director:诺兰 node fdirector:{entity} movies [] for neighbor in G.successors(node): if G.nodes[neighbor].get(type) movie: movies.append(G.nodes[neighbor][title]) return movies elif intent query_genre_movies: node fgenre:{entity} movies [] for neighbor in G.neighbors(node): if G.nodes[neighbor].get(type) movie: movies.append(G.nodes[neighbor][title]) return movies return []注意这里导演节点和电影节点的关系方向如果构建图谱时定义的边是director:诺兰 - movie:记忆碎片那么查询时使用successors如果反过来是movie - director就用predecessors。项目源码里的关系方向可能在文件中有明确注释建议先跑一遍边方向检查list(G.edges(director:诺兰))确认输出再写查询否则答案会为空。4.3 用 Flask 暴露问答 API为了让前端 Layui 页面能调用问答模块需要把上面的逻辑包装成接口from flask import Flask, request, jsonify app Flask(__name__) app.route(/api/chat, methods[POST]) def chat(): data request.get_json() question data.get(question, ) intent, entity parse_question(question) if not intent: return jsonify({answer: 暂时没能理解您的问题请换个说法试试}) movies answer_question(G, intent, entity) if not movies: return jsonify({answer: f没有找到与{entity}相关的电影}) return jsonify({answer: 、.join(movies[:10])}) # 启动时加载图谱避免每个请求重复构建 if __name__ __main__: G nx.read_gexf(movie_graph.gexf) app.run(host0.0.0.0, port5000, debugFalse)调用方式为POST /api/chatbody 是{question: 诺兰导演过哪些电影}返回 JSON 中answer字段就是拼接后的答案列表。debugFalse很重要因为 GEXF 文件读取本身有 IO 耗时开着 debug 会双倍触发加载浪费启动时间。前端 Layui 页面用$.ajax发送这个请求即可注意后端返回 JSON 的编码要保持utf-8否则中文会乱码。5. 从数据集划分到系统测试u1.base 到 u5.base 的交叉验证技巧5.1 训练集测试集划分与评估指标项目携带的 ua.base、ub.base 以及 u1.base 到 u5.base 是标准的 5 折交叉验证划分。每一组中带.base后缀的是训练集对应同名的.test是测试集。评估推荐系统时用 RMSE 或 MAE一般会这样写评估循环from sklearn.metrics import mean_squared_error, mean_absolute_error def evaluate(train_path, test_path): train load_ratings(train_path) test load_ratings(test_path) models {} # 真实工程里这里是训练好的模型 for (uid, mid) in test[[user_id, movie_id]].values: # 预测逻辑省略实际会用协同过滤模型计算 pred pass rmse mean_squared_error(y_true, y_pred, squaredFalse) mae mean_absolute_error(y_true, y_pred) return rmse, mae for i in range(1, 6): rmse, mae evaluate(fu{i}.base, fu{i}.test) print(fFold {i}: RMSE{rmse:.4f}, MAE{mae:.4f})用 5 折交叉验证能显著说明推荐模型的稳定性如果某一个折叠的 RMSE 突然偏高大概率是训练集和测试集之间存在用户重合度过低的问题需要检查划分的随机种子是否固定。而 ua.base 和 ub.base 则是另一种划分方式适合做 A/B 对比实验。5.2 数据库并发与接口异常测试系统测试章节里除了功能用例还应该包括接口级测试。比如用curl直接验证问答接口的状态码和响应时间curl -X POST http://127.0.0.1:5000/api/chat \ -H Content-Type: application/json \ -d {question: 周星驰演过哪些电影} \ -w \nHTTP状态码: %{http_code}, 耗时: %{time_total}s\n如果返回 500 错误优先查看 Flask 控制台堆栈多半是图谱节点名未找到或数据类型不匹配。数据库层面建议多用户同时登录推荐时对 MySQL 连接池做配置否则默认连接上限 151 会卡死。项目说明文档里如果写了系统测试用例表大概率包括“登录注册”“电影列表分页”“问答响应”几个模块你可以在test/目录下用 pytest 把接口用例固化下来import unittest import json class TestChatAPI(unittest.TestCase): def setUp(self): self.app app.test_client() def test_director_question(self): rv self.app.post(/api/chat, json{question: 诺兰导演过哪些电影}) data json.loads(rv.data) self.assertEqual(rv.status_code, 200) self.assertIn(记忆碎片, data[answer])setUp中的test_client是 Flask 自带的测试客户端不需要真实启动服务就能模拟 POST 请求。这种方式在 CI 环境里跑起来非常快适合在答辩前做回归验证。5.3 前端联调中的常见状态问题Layui 前端默认的表单提交是同步的如果直接提交到 Flask 接口页面会整页刷新。需要改成异步提交示例代码如下layui.use([jquery, layer], function(){ var $ layui.jquery; $(#sendBtn).on(click, function(){ var question $(#questionInput).val(); $.ajax({ url: /api/chat, type: POST, data: JSON.stringify({ question: question }), contentType: application/json, success: function(res){ layer.msg(res.answer); } }); }); });重点在于contentType: application/json不加这一行Flask 的request.get_json()会拿到 None。很多毕设项目在这里“代码看起来对但一直报错”大多是前端没有指定 JSON 内容类型。另一个高频问题是跨域如果前端单独用live-server启动在 8000 端口后端在 5000 端口浏览器会拦截请求需要给 Flask 添加 CORS 头或者把前端静态文件放到 Flask 的static目录下统一访问。6. 问答系统的知识图谱可视化与性能优化技巧知识图谱构建完成后不要只在后端用nx.info(G)打印节点和边数量。建议做一个简单的图谱可视化页面使用 ECharts 的关系图组件加载categories和links数据让评委直接看到实体之间的网络关系。这里有个实用的数据导出函数def export_echarts_data(G, max_nodes100): nodes [] links [] for n, data in list(G.nodes(dataTrue))[:max_nodes]: nodes.append({id: n, name: data.get(title) or data.get(name), category: data.get(type, unknown)}) for u, v, k, data in list(G.edges(dataTrue, keysTrue))[:max_nodes*3]: links.append({source: u, target: v, relation: data.get(relation, )}) return {nodes: nodes, links: links}对 max_nodes 做截断是因为 ECharts 渲染超过 200 个节点时关系图的力导向布局会卡顿。如果图谱规模继续扩大建议把 networkx 数据导入 Neo4j用 Cypher 查询替代内存遍历比如实时问答时MATCH (m:Movie)-[:BELONGS_TO]-(g:Genre {name:科幻}) RETURN m.title LIMIT 10的响应速度远快于 Python 遍历。性能侧的另一个优化点是相似度矩阵的持久化。每次启动项目都重新执行cosine_similarity在 100K 数据量下大约要 2-3 秒如果数据量到百万级会卡到分钟级。可把相似度矩阵用np.save存成.npy文件启动时检测文件是否存在就跳过计算import os import numpy as np if os.path.exists(sim_matrix.npy): sim_array np.load(sim_matrix.npy) else: sim_array cosine_similarity(item_user, item_user) np.save(sim_matrix.npy, sim_array)这属于典型的空间换时间策略。如果你想让系统再聪明一点可以把用户当前点击的电影 ID 作为上下文动态调整相似度权重但那是另一个课题了。当前这套从 ua.base 数据导入、ItemCF 召回、知识图谱建图、问答模板匹配到可视化导出的完整链路已经足够支撑一次合格的毕设答辩和真实场景的二次开发。本文还有配套的精品资源点击获取
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。