简介:基于协同过滤的电影推荐系统毕业设计资源,适合计算机专业学生用于课程设计、毕业设计或推荐系统入门学习。系统完整实现了基于用户与基于物品两种协同过滤算法,后端使用Django框架搭配Python环境,支持MySQL与SQLite数据库切换,前端采用Bootstrap,并使用Movielens公开数据集完成训练与演示。压缩包共83个文件,大小约8.41MB,文件类型以Python源码、HTML模板、CSV评分数据为主,另含PDF论文、SQLite数据库、豆瓣爬虫脚本、依赖清单及详细readme文档,目录结构清晰,便于直接运行和二次开发。目前已有10610人学习下载,适合毕业设计参考及推荐系统实战练习。附带的爬虫和评分填充脚本可采集豆瓣电影数据、生成个性化输入,readme对运行环境、依赖安装、配置修改有详细说明,能帮助从零搭建可在线预览的电影推荐平台,并支持扩展算法比较或界面优化。
1. 为什么把“协同过滤电影推荐”落在 Django 2.2.1 上
A同学把毕设选题交上去时,某导师只问了一句:你打算怎么把它做出来?这句话问住了不少人。网上搜到的电影推荐系统,要么只讲算法不落代码,要么做了个网页但推荐逻辑是摆设。我建议他把“基于协同过滤的电影推荐系统”放在 python3.7 + django2.2.1 + mysql 这套固定组合上:算法用协同过滤,避开深度学习这种自己都讲不透的黑匣子;数据库用 MySQL 存用户、电影、评分三类数据;Django 负责把注册、打分、展示推荐结果的完整链路串起来。这套方案适合想在毕设或课设里真正跑通全流程的人——能运行、能演示、能讲清楚每一步,而不是交一堆没人看的代码。
2. 总体架构设计:Django 项目布局与四个核心模块的职责划分
2.1 MTV 分层与请求流转逻辑
Django 的 MTV(Model-Template-View)模式是这个项目的骨架。浏览器发起请求后,根路由 urls.py 先做分发,把地址交给对应应用的路由;路由再调用视图函数,视图里读写数据库、计算推荐算法,最后把结果塞给模板渲染成页面。链路不长,但模块边界一旦模糊,后期改任何功能都会连坐。
我搭这类项目时有个习惯:算法逻辑绝对不写在 views.py 里,单独放一个 algorithms.py。原因有两个,一是算法会被多个视图复用,比如推荐页用、电影详情页的“相似推荐”也用;二是答辩或给导师讲代码时,核心算法单独一个文件,远比“打开视图找一段缩进很深的循环”好讲得多。
2.2 四个核心模块的职责划分
按 Django 的 app 概念,把项目拆成 users、movies、ratings、recommend 四个应用,每个应用只管自己那一块。
| 应用 | 核心功能 | 涉及的模型/文件 |
|---|---|---|
| users | 注册、登录、退出 | User 表、session |
| movies | 电影列表、详情、搜索 | Movie 表、模板渲染 |
| ratings | 提交评分、我的评分记录 | Rating 表、表单处理 |
| recommend | 相似度计算、推荐结果组装 | algorithms.py、推荐视图 |
划分原则是“谁的数据谁负责”。ratings 只负责评分的新增和查询,不做推荐;recommend 只读评分数据,不写数据。这样职责单一,出了问题直接定位到某个 app,不用在一个文件里上下翻。
2.3 项目目录结构与配置要点
movie_recommend/ ├── manage.py ├── movie_recommend/ │ ├── settings.py │ ├── urls.py │ └── wsgi.py ├── users/ │ ├── models.py │ ├── views.py │ └── urls.py ├── movies/ │ ├── models.py │ ├── views.py │ └── urls.py ├── ratings/ │ ├── models.py │ ├── views.py │ └── urls.py └── recommend/ ├── algorithms.py ├── views.py └── urls.pysettings.py 里有两个配置最容易忽略。一是 INSTALLED_APPS 必须把四个 app 全部注册,漏掉一个就报No module named之类的错;二是数据库连接要写成 MySQL,不能保留默认的 sqlite:
DATABASES = { 'default': { 'ENGINE': 'django.db.backends.mysql', 'NAME': 'movie_db', 'USER': 'root', 'PASSWORD': 'your_password', 'HOST': '127.0.0.1', 'PORT': '3306', } }这段配置决定了 Django 的 orm 把模型映射到哪台数据库。NAME 对应 MySQL 里提前建好的库,如果没建库就直接 runserver,Django 只会报一个连接失败,不会帮你自动建库。通常的做法是先手动执行CREATE DATABASE movie_db DEFAULT CHARSET utf8mb4;再跑迁移,否则后续中文数据全是乱码。
3. MySQL 数据库设计:五张核心表与评分数据的建模方式
3.1 表结构设计与字段说明
这个项目不需要复杂的表,核心就三类信息:用户、电影、评分。按照“事实用行存”的原则,把每次打分作为一条记录放到评分表里,不要在一个用户字段里塞一长串电影 id,那是给内存用的,不是给关系型数据库用的。
| 表名 | 字段 | 类型 | 说明 |
|---|---|---|---|
| users | id | INT 自增主键 | 用户编号 |
| users | username | VARCHAR(50) 唯一 | 登录名 |
| users | password | CHAR(64) | 存哈希值,不存明文 |
| movies | id | INT 自增主键 | 电影编号 |
| movies | title | VARCHAR(200) | 电影标题 |
| movies | genres | VARCHAR(100) | 类型,多类型用竖线分隔 |
| movies | release_year | SMALLINT | 上映年份 |
| ratings | id | INT 自增主键 | 评分记录编号 |
| ratings | user_id | INT 外键 | 关联 users.id |
| ratings | movie_id | INT 外键 | 关联 movies.id |
| ratings | score | TINYINT | 评分值,范围 1–5 |
| ratings | created_at | DATETIME | 评分时间 |
3.2 建表语句与约束设计
CREATE TABLE users ( id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE, password CHAR(64) NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE movies ( id INT AUTO_INCREMENT PRIMARY KEY, title VARCHAR(200) NOT NULL, genres VARCHAR(100), release_year SMALLINT, poster_url VARCHAR(500) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE ratings ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, movie_id INT NOT NULL, score TINYINT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_movie (user_id, movie_id), CONSTRAINT fk_rating_user FOREIGN KEY (user_id) REFERENCES users(id), CONSTRAINT fk_rating_movie FOREIGN KEY (movie_id) REFERENCES movies(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里有个细节值得在答辩时主动讲:UNIQUE KEY uk_user_movie是联合唯一约束,确保同一个用户对同一部电影只能有一条评分记录。用户反复点打分时,Django 侧可以先查再更新,也可以靠这个约束兜底,否则评分表会出现一条电影被同一个人刷 5 次的脏数据,最终污染相似度计算。
score 用 TINYINT 而不是 INT,是因为评分范围只有 1 到 5,TINYINT 占 1 字节,配合索引查询更快。外键约束保证了一点:删用户时评分记录不会变成孤儿数据。但要注意删用户的操作在 Web 端往往不开放,因为评分数据是推荐系统的原料,删了就没有可算的东西了。
3.3 公开数据集导入与数据校验
本地做测试时,通常的做法是导入公开电影数据集,比如 MovieLens 的 ml-latest-small 版本,里面大概有近万部电影和十万条左右评分,对毕设来说规模刚刚好。导入前先看原始 CSV 的表头,再写一次性脚本:
import csv import pymysql connection = pymysql.connect( host='127.0.0.1', user='root', password='your_password', database='movie_db', charset='utf8mb4' ) cursor = connection.cursor() with open('movies.csv', encoding='utf-8') as f: reader = csv.reader(f) next(reader) # 跳过表头 for row in reader: sql = "INSERT INTO movies (id, title, genres) VALUES (%s, %s, %s)" cursor.execute(sql, (int(row[0]), row[1], row[2])) connection.commit() cursor.close() connection.close()为什么要保留原始数据集里的 id?因为 ratings.csv 里关联的 movie_id 和 movies.csv 是同一套编号。如果自己重新自增生成 id,评分数据的关联就全断了。导入完跑一条校验 SQL:SELECT COUNT(*) FROM ratings;,和原始数据行数做对比,差了就说明有重复键被跳过或者外键失败。
4. 协同过滤算法落地:余弦相似度与两种推荐策略的实现
4.1 从评分矩阵构建开始
协同过滤算法的输入是“用户-物品”评分矩阵。行是用户,列是电影,值是我们打的分数。矩阵在 Python 里的最直观做法是用 pandas 的透视表:
import pandas as pd import pymysql def load_rating_matrix(): connection = pymysql.connect( host='127.0.0.1', user='root', password='your_password', database='movie_db', charset='utf8mb4' ) sql = "SELECT user_id, movie_id, score FROM ratings" df = pd.read_sql(sql, connection) matrix = df.pivot_table( index='user_id', columns='movie_id', values='score' ) matrix = matrix.fillna(0) connection.close() return matrixpivot_table会把缺失的评分变成 NaN,fillna(0)让矩阵变成稠密矩阵,方便算余弦相似度。这一步在设计上有个权衡:原始评分表是稀疏的,一个人一辈子看不了几千部电影,直接算矩阵会占内存。毕设级数据量这么干没问题,但要在答辩时说清楚“这是实验环境做法,生产环境会改用稀疏矩阵存储”。
4.2 基于用户的协同过滤(UserCF)
UserCF 的思路是“和你口味相似的人喜欢什么,你也大概率喜欢”。实现步骤分成三块:算用户间相似度、找最近邻、加权汇总候选电影得分。
import numpy as np from sklearn.metrics.pairwise import cosine_similarity def user_based_recommend(user_id, top_n=10, k=20): matrix = load_rating_matrix() # step1: 计算用户间余弦相似度 user_sim = cosine_similarity(matrix) user_sim_df = pd.DataFrame( user_sim, index=matrix.index, columns=matrix.index ) # step2: 取目标用户最相似的 k 个用户(排除自己) sim_users = user_sim_df.loc[user_id].sort_values( ascending=False ).iloc[1:k+1] # step3: 候选电影 = 邻居评过、目标用户没评过的,加权累计 user_rated = set(matrix.loc[user_id][matrix.loc[user_id] > 0].index) candidates = {} for other_user, sim in sim_users.items(): other_ratings = matrix.loc[other_user][matrix.loc[other_user] > 0] for movie_id, score in other_ratings.items(): if movie_id not in user_rated: candidates[movie_id] = candidates.get(movie_id, 0) + sim * score ranked = sorted(candidates.items(), key=lambda x: x[1], reverse=True) return ranked[:top_n]注释里已经把三步标清楚了。k 是邻居数量,取 20 的意思是不管全局有多少用户,只参考最像的 20 个人。这个值不是越大越好,太大了容易混入相似度低的人,反而拉低推荐质量。候选电影的加权分直接用“相似度 × 邻居评分”累加,没有做归一化,因为我们要的是排序结果,不是精确的预测分值,不归一化不影响 top_n。
4.3 基于物品的协同过滤(ItemCF)
ItemCF 换了个角度:如果用户给《教父》打了高分,那么和《教父》最相似的《疤面煞星》也值得推荐。实现上把矩阵转置一下,让每行变成电影,每列变成用户:
def item_based_recommend(user_id, top_n=10): matrix = load_rating_matrix().T # 转置成 电影-用户 矩阵 item_sim = cosine_similarity(matrix) item_sim_df = pd.DataFrame( item_sim, index=matrix.index, columns=matrix.index ) user_rated = matrix[user_id][matrix[user_id] > 0] scores = {} for movie_id, score in user_rated.items(): # 与当前电影最相似的 10 部 sim_items = item_sim_df.loc[movie_id].sort_values( ascending=False ).iloc[1:11] for other_movie, sim in sim_items.items(): if other_movie not in user_rated: scores[other_movie] = scores.get(other_movie, 0) + sim * score ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True) return ranked[:top_n]这段代码里的 “每部电影只看最相似的 10 部” 是一个实用主义剪枝。如果不剪枝,每次推荐要把矩阵里所有电影都遍历一遍,算得慢不说,低相似度的噪声还会混进来。ItemCF 在电影数量远小于用户数量时,性能通常比 UserCF 好,因为物品之间的相似度可以提前离线算好,用户访问推荐页时直接查结果。
4.4 混合策略与参数调整
两套算法单跑都有明显短板。UserCF 在用户量少时相似度不可靠,ItemCF 对冷门电影的覆盖不够。通常的做法是做一个简单的混合开关:默认走 ItemCF,当目标用户的评分数少于 5 条时走 UserCF,再少就直接给热门榜兜底。
def recommend_for_user(user_id, top_n=10): matrix = load_rating_matrix() user_rated_count = (matrix.loc[user_id] > 0).sum() if user_rated_count >= 5: return item_based_recommend(user_id, top_n) elif user_rated_count >= 1: return user_based_recommend(user_id, top_n) else: return hot_movies(top_n) # 按平均分和评分人数排序这个分支逻辑在答辩时非常加分,因为老师最常问的就是“冷启动怎么办”。把冷启动策略写成代码,比嘴上说“我们用热门推荐兜底”有说服力得多。调参时,k、top_n、相似物品数量,建议在 settings.py 里定义成常量,方便后面做实验对比,不需要每次改代码里的魔法数字。
5. 常见问题排查:环境搭建、数据稀疏与冷启动的避坑经验
5.1 mysqlclient 编译失败:卡住毕设第一天的经典坑
现象:Windows 上执行pip install mysqlclient直接报错,末尾一行是error: Microsoft Visual C++ 14.0 is required。
原因:Python 3.7 时代 mysqlclient 经常没有现成的 wheel 包,需要本地编译,而编译依赖 VC++ 构建工具。很多同学在这一步就放弃了,误以为代码有问题,其实是环境问题。
解决:跑pip install pymysql,然后在 Django 项目的__init__.py里加两行:
import pymysql pymysql.install_as_MySQLdb()pymysql 是纯 Python 实现,不需要编译,装完就能当 MySQLdb 用。这个手段适合项目快速落地,生产环境建议还是用 mysqlclient,但毕设阶段没必要在编译环境上耗时间。
5.2 中文乱码:建表时一个字符集决定成败
现象:电影标题里的中文在页面上显示成问号,或者直接变成一组乱码。检查 MySQL 命令行,发现数据本身存进去就是乱码。
原因:建库时没指定字符集,MySQL 用了默认的 latin1,存不了中文。等到发现再改,表里的数据已经坏了,删了重建又要重导一遍,属于典型的“前期偷懒后期翻车”。
解决:建库时执行CREATE DATABASE movie_db DEFAULT CHARSET utf8mb4;,同时把 Django 连接字符串里的 charset 也带上。命令行下还不行,就加一句SET NAMES utf8mb4;再操作。这套组合下来基本不会再看到乱码。
5.3 全量相似度计算内存溢出
现象:数据集导入后,跑item_based_recommend直接内存飙升,电脑风扇狂转,甚至进程被杀掉。
原因:余弦相似度对 n 部电影要生成 n×n 的相似度矩阵。电影数上万后,这个矩阵光存储就接近 GB 级,普通笔记本扛不住。
解决:不预先算全量矩阵,改成惰性计算,只算用户评分过的电影与其他电影的相似度,同时在算相似度时加上“只保留前 50 个近邻”的剪枝逻辑。这一步能把时间复杂度从 O(n²) 降到 O(评分过的电影数×候选电影数),数据量大的情况下效果立竿见影。
5.4 评分矩阵 0 填充带来的“伪相似”
现象:两个用户明明没有任何共同评分,但算法算出来的相似度不为 0,甚至偏高。推荐结果出现得莫名其妙。
原因:fillna(0)把“没看过”和“打了 0 分”混为一谈了。余弦相似度把 0 当成负向评分,于是没看过的电影全部变成 0,反而拉近了向量方向。
解决:评分体系里禁止出现 0 分,然后计算相似度时只统计双方都有评分的维度;或者对评分做中心化处理——每个用户减去自己的平均分,让 0 代表“用户的中性偏好”。我在项目里用的是“只统计共同评分的电影”的方式,因为实现最简单,讲起来也直白。
5.5 冷启动:新用户打开推荐页只有一个空列表
现象:新注册的用户没有任何评分,recommend_for_user走到最后返回空列表,页面一片空白,观感上像个 bug。
原因:评分矩阵里新用户的行全是 0,和任何老用户的相似度都低,取不到可靠邻居。
解决:按 4.4 的分支处理,评分数为 0 时走热门榜。热门榜不要只看平均分,否则一部电影只有一个人打了 5 分就会排到第一。我一般用加权分:平均分乘以评分人数占比,再加一个常数平滑,避免冷门电影刷榜。这个细节解释给导师听,对方立刻知道你懂推荐系统的常见陷阱。
6. 验收与答辩:演示路径设计与高频追问的应答准备
6.1 演示脚本:五分钟走完一条验收路径
答辩演示不要临时点来点去找功能,按固定脚本走,既省时间又不容易出事故。我推荐的五分钟路径是:
- 注册一个新账号,进入推荐页,截图当前的热门榜兜底推荐;
- 随便挑三部电影打 5 分,再刷一次推荐页,看结果是否变化;
- 打开 MySQL 的 ratings 表,展示刚才的三条评分记录存在,证明数据真实落库;
- 切换到“基于用户”和“基于物品”两个标签页,对比推荐列表的差异,顺带说一句各自原理。
这段脚本覆盖了 Web 基本功能、数据库、算法三个维度,每一项都是答辩时可能被追问的点,主动演示完,老师再提问就有了上下文。
6.2 高频追问与应答要点
| 老师提问 | 应答要点 |
|---|---|
| 协同过滤原理是什么? | 用一句话讲明白:把用户的历史评分当成向量,用余弦距离找相似用户或相似物品,再按加权分排序取 top N |
| 为什么不用深度学习? | 数据量不够,深度学习容易过拟合;且毕设强调可解释,协同过滤每一条推荐都能讲出依据 |
| 相似度为什么选余弦? | 余弦只关注向量方向,不受用户打分习惯影响,比如有人全场打 3 分、有人全场打 5 分,余弦能消除这种尺度偏差 |
| 怎么证明推荐有效? | 离线做留一法评估:每次拿掉用户的一条真实评分,看推荐列表前 10 里是否包含它,统计命中率 |
留一法可以在答辩前写一个轻量脚本跑出数值,不需要做完整评测集,取 100 个用户各留 1 条评分即可。就算精度只有百分之十几,也远比口说“效果不错”强,因为你有数字。
有一次验收前夜,我发现演示机上的 MySQL 还是 latin1,页面标题全是问号,整个推荐列表看起来像乱码。那次确实狼狈,改表重导数据搞到凌晨。从那以后,我每次拿到一个 Django 项目,部署完环境第一件事就是强制走一遍“注册、打分、推荐、查库”四步,不跑通不罢休。这条习惯帮我避掉了至少三次现场事故,希望帮到你。
本文还有配套的精品资源,点击获取