基于Django和MySQL的协同过滤电影推荐系统设计与实现
简介这是一份面向毕业设计场景的协同过滤电影推荐系统完整源码包。后端基于Python 3.7与Django 2.2.1开发前端使用Bootstrap 3数据层可切换MySQL或SQLite并完整实现了基于用户的协同过滤与基于物品的协同过滤两种推荐算法。资源包共包含83个文件压缩后大小约8.41MB其中以Python源码、HTML页面模板、CSV数据集和编译缓存文件为主另含PDF格式的毕业论文、SQLite数据库、豆瓣爬虫脚本、依赖清单与使用说明。项目内容覆盖从Movielens数据导入、豆瓣影评爬取、用户评分数据填充到推荐结果展示的完整流程目录按功能模块划分readme与技术文档齐全便于二次开发。目前该资源已有10612人学习下载适合计算机相关专业学生作为毕业设计参考也可作为学习协同过滤算法与Django Web开发的实践项目代码结构清晰注释较完整。1. 协同过滤电影推荐系统不是“猜你喜欢”而是“和你像的人在看什么”先给一个反直觉的结论一个基于协同过滤的电影推荐系统核心逻辑不在“这部电影本身好不好看”而在“和你兴趣相似的那群人最近在点什么”。这套基于 Python 3.7 Django 2.2.1 MySQL 的毕业设计级项目把用户注册登录、电影信息管理、在线评分、基于物品的协同过滤推荐这几件事串成了一条完整链路——既能当毕设答辩的实物也能当练手项目去理解推荐系统从数据到接口再到页面的完整落法。我第一次跑通时随便在评分表里造了三行测试数据刷新首页推荐位顺序立刻就变了。那一刻才觉得“协同过滤”不是黑匣子而是能摸到输入输出的业务代码。适合刚学完 Django 基础、想动手接触推荐算法落地的人也适合手里已有数据、想复现一套可运行推荐服务的开发者。2. 系统架构与数据建模Django 2.2 MySQL 的表设计如何支撑协同过滤2.1 项目真实目录结构先看 Django 项目怎么拆毕业设计类 Django 项目最常见的拆法不是复杂的分层微服务而是“一个 project 多个 app”。这套电影推荐系统的目录是这样拆的movie_recommend/ ├── manage.py ├── requirements.txt ├── db.sqlite3 # 开发期 SQLite生产切换 MySQL ├── config/ # Django projectsettings/urls/wsgi │ ├── settings.py │ ├── urls.py │ └── wsgi.py ├── apps/ │ ├── users/ # 用户注册、登录、个人中心 │ ├── movies/ # 电影信息、评分录入 │ └── recommend/ # 协同过滤算法与推荐接口 └── templates/ # 模板config 目录是全局配置urls.py 在这里统一做路由分发apps 下每个子目录都是独立的 Django app之间通过“注册到 INSTALLED_APPS 导入模型”协作。推荐相关代码全在 recommend 里包括相似度计算、Top-N 推荐和冷启动策略。这样将来要把算法换掉只需要动一个包不会牵扯到用户注册和电影管理页面。这种结构对二次开发友好想改推荐算法只动 recommend 下的 services.py想加一个“电影详情页”直接在 movies 里加视图和模板。很多毕设项目把所有代码堆在一个 app 里看起来文件少但改一个功能经常连坐别处审阅代码时也容易丢分。保持每个 app 单一职责是我在拆这类项目时最推荐的一条原则。2.2 数据表设计用户、电影、评分这三角关系怎么定协同过滤只需要三个核心表就够User用户、Movie电影、Rating评分。设计上注意三点外键用 models.ForeignKey 指向目标表评分表的联合唯一约束设置为 user movie保证同一用户对同一电影只能评分一次最后Rating 表建了自定义的 Meta 索引因为推荐时的 SQL 全部按 user_id 分组取评分。表结构用 Django ORM 写出来比手写 SQL 更好维护。下面直接给 models 代码# apps/movies/models.py from django.db import models from django.conf import settings class Movie(models.Model): title models.CharField(片名, max_length255) genres models.CharField(类型, max_length255, blankTrue) release_year models.IntegerField(年份, nullTrue, blankTrue) rating_avg models.FloatField(平均分, default0.0) # 冗余字段列表页直接展示避免实时聚合 rating_count models.IntegerField(评分人数, default0) def __str__(self): return self.title class Rating(models.Model): user models.ForeignKey(settings.AUTH_USER_MODEL, on_deletemodels.CASCADE, related_nameratings) movie models.ForeignKey(Movie, on_deletemodels.CASCADE, related_nameratings) score models.FloatField(评分, default5.0) # 1.0 ~ 5.0允许 0.5 步进 create_time models.DateTimeField(auto_now_addTrue) class Meta: unique_together (user, movie) # 同一用户对同一电影只能评一次 indexes [ models.Index(fields[user, score]), # 协同过滤取评分的查询瓶颈在这 ] def __str__(self): return f{self.user_id}:{self.movie_id}:{self.score}代码里的 rating_avg 和 rating_count 是冗余字段电影列表页和详情页要频繁显示“豆瓣式”平均分如果每次都做 AVG(score) 聚合列表页 20 条电影就是 20 条子查询。写入评分时顺手更新这两个字段检索时直接取这是毕业设计里性价比很高的一种取舍。settings.AUTH_USER_MODEL 指向的是 users app 里自定义的用户模型继承 AbstractUser而不是默认的 User 表。加了手机号或头像字段时后面扩展用户体系不用改表结构。老师问这个问题时能解释清楚为什么用 settings.AUTH_USER_MODEL 而不是直接 import User基本就是回答加分项。顺带一提unique_together 在 Django 2.2 里已经可以用 UniqueConstraint 代替但 unique_together 在迁移和 Admin 后台展示上更直观毕设项目用它没问题。联合索引用 models.Index 而不是在字段上单独加 db_index是因为推荐接口的核心查询条件是 user score 同时出现单独给 user 或 score 建索引都不会命中最优路径。2.3 为什么推荐数据要单独落一张“相似度表”纯内存计算的 Item-CF 在小数据集上没问题但用户评分一旦过万每次请求都现场算相似度矩阵Django 的单线程 worker 会被打满。常见做法是离线把电影相似度算好写入一张 top_k_similar 表用户来请求推荐时直接按相似度排序查 TOP-N再过滤已评分的电影。# apps/recommend/models.py class SimilarMovie(models.Model): movie models.ForeignKey(Movie, on_deletemodels.CASCADE, related_namesimilar_base) similar_movie models.ForeignKey(Movie, on_deletemodels.CASCADE, related_namesimilar_target) similarity models.FloatField(相似度, default0.0) class Meta: ordering [movie, -similarity] indexes [ models.Index(fields[movie, -similarity]), ]这里把“谁和谁像、像到什么程度”固化成一张表。推荐接口的逻辑退化成一次带过滤条件的普通查询性能上比实时算矩阵高出几个量级。相似度表既可以由脚本在每天凌晨离线计算也可以在管理员点击“更新推荐数据”时手动触发实现上都走的同一条函数。相似度表什么时候更新两个时机一是管理后台手动点“更新相似度数据”二是每天凌晨跑定时脚本。对毕设场景手动触发就够要是未来接入真实上线再用 Django-Celery-Beat 做定时任务即可。3. 核心算法拆解Item-Based 协同过滤的相似度计算与 Top-N 推荐3.1 算法选型为什么毕设项目优先选 Item-CF协同过滤分 User-CF 和 Item-CF 两大类。在电影推荐这个场景下Item-CF 有两个天然优势第一用户的行为评分会随时间变化但对一部电影的“内容特征”是相对稳定的物品之间相似度可以离线算好并缓存第二解释性好——推荐位旁边可以直接写“因为你看过《星际穿越》所以推荐《盗梦空间》”答辩和演示都很直观第三Item-CF 的推荐结果倾向“相似物品”而 User-CF 倾向“惊喜物品”对冷启动要求更高的新用户Item-CF 更容易兜底。这套系统的算法实现放在 recommend/services.py 里不是放在 views.py因为算法函数需要被“离线脚本、管理员命令、推荐视图”三者复用从视图里拆出来是这里最值得抄的代码组织习惯。函数整体是一个 pipeline读评分 → 构造用户-物品矩阵 → 计算余弦相似度 → 取 Top-K → 生成推荐。3.2 读评分数据直接从 Rating 表喂给算法这一步的输入是 Rating 表里所有评分记录我们要把它转成“用户-电影”的二维矩阵行是用户列是电影值是评分。矩阵用 Python 的 dict 嵌套 dict 来实现比 numpy 的二维矩阵更省内存也更容易做稀疏处理。# apps/recommend/services.py from collections import defaultdict def load_rating_matrix(): 把 Rating 表拼成 {user_id: {movie_id: score}} 的稀疏矩阵。 from apps.movies.models import Rating ratings Rating.objects.values(user_id, movie_id, score) matrix defaultdict(dict) for row in ratings: matrix[row[user_id]][row[movie_id]] row[score] return matrix这段代码没有直接查 ORM 对象而是用 values() 只取三个字段的字典列表避免 Django 为每行评分都实例化一个 Rating 对象——几千条评分时能省下明显的内存和序列化时间。defaultdict(dict) 的用法是惯用写法外层字典的 value 是内层 dict不需要在循环里手动判断 key 是否存在。参数说明values(user_id, movie_id, score) 是 Django ORM 里“只读指定字段”的查询方式返回的是 QuerySet 迭代出的字典习惯上在大批量只读场景优先用它。matrix[user_id][movie_id] score 构成了完整的稀疏矩阵缺失值就是 0不需要补全。3.3 余弦相似度计算电影之间的“口味距离”Item-CF 的相似度计算有很多种这里用的余弦相似度最直观把每部电影被所有用户的评分看作一个向量两条向量的夹角余弦值就是电影的相似度。值域是 [-1, 1]在电影评分这种“评分越接近越像”的场景里通常只取大于 0 的作为有效相似度。import math def calc_movie_similarity(matrix): 输入 user-movie 矩阵输出 movie-movie 相似度字典。 # 第一步统计每部电影被评过分的用户及对应评分 movie_users defaultdict(dict) for user_id, movies in matrix.items(): for movie_id, score in movies.items(): movie_users[movie_id][user_id] score # 第二步两两电影计算余弦相似度 movie_ids list(movie_users.keys()) similarity defaultdict(dict) for i in range(len(movie_ids)): m1 movie_ids[i] for j in range(i 1, len(movie_ids)): m2 movie_ids[j] # 找出两部电影共同评分的用户 common_users set(movie_users[m1]) set(movie_users[m2]) if len(common_users) 2: continue # 向量点积 / 模长乘积 dot sum(movie_users[m1][u] * movie_users[m2][u] for u in common_users) norm1 math.sqrt(sum(v * v for v in movie_users[m1].values())) norm2 math.sqrt(sum(v * v for v in movie_users[m2].values())) if norm1 0 or norm2 0: continue sim dot / (norm1 * norm2) similarity[m1][m2] sim similarity[m2][m1] sim return similarity注意一个性能细节外层循环只做了 i1 到末尾的下三角遍历避免 m1 和 m2 重复计算两次同时用 set 取共同评分用户把原本 O(n²) 的交叉比较降到了实际有记录的用户集合里。len(common_users) 2 这个条件是防止“只有一个人同时看过两部片”时算出的相似度假性偏高。再解释一下余弦公式为什么要除以模长直接算评分均值差会受用户打分尺度影响有人偏好给 4 分有人偏好给 2 分除以各自向量模长相当于做了归一化保留的是“评分方向”而不是“绝对分值”。这也是为什么同一部电影在不同用户手里评分绝对值差很大时余弦相似度依然能给出稳定的相关性。3.4 生成推荐列表相似度排序 过滤已看 加权评分拿到 movie-to-movie 相似度后给某个用户推荐就变成三步找到该用户评过分的电影集合把这些电影的相似电影拉出来按“相似度 × 用户对原电影的评分”加权求和后排序再过滤掉用户已经看过的电影。def recommend_for_user(user_id, matrix, similarity, top_n20): 基于 Item-CF 给单个用户生成 Top-N 推荐。 if user_id not in matrix: return [] # 新用户冷启动交给策略处理 user_rated matrix[user_id] scores defaultdict(float) for movie_id, rating in user_rated.items(): for similar_movie_id, sim in similarity.get(movie_id, {}).items(): if similar_movie_id in user_rated: continue # 已经看过的电影不重复推荐 scores[similar_movie_id] sim * rating # 相似度加权评分 ranked sorted(scores.items(), keylambda x: x[1], reverseTrue) return [movie_id for movie_id, _ in ranked[:top_n]]加权公式 scores[similar_movie_id] sim * rating 是 Item-CF 最经典的预测评分公式用户给电影 A 打了 5 分A 和 B 的相似度是 0.8那 B 的预测得分就是 4.0。预测分接近用户自己的评分习惯排序才有意义。如果只按相似度排序而完全忽略用户原始评分推荐结果会整体偏向“大众热门”失去个性化。top_n 参数默认 20首页展示一屏刚好。实际项目里建议把这个参数做成可配置项放在 settings.py 里比如 RECOMMEND_TOP_N 20这样不用改代码就能调推荐位长度。3.5 冷启动新用户和新电影各自兜底协同过滤的天敌是冷启动。新用户没有任何评分matrix 里根本没有这条记录算法直接 return []新电影没有任何用户评分它永远不会出现在相似度表里。这套系统的做法很务实用户评分数少于 3 条时走“热门兜底”——按 rating_count 倒序取热度榜电影评分人数少于 5 时相似度计算的循环直接跳过它。冷启动的数据不是算法问题是业务策略问题答辩时能说出“我们在数据层先过滤了不可靠样本再进算法”这句话显得比单纯背公式扎实得多。4. 推荐流程落地一次首页请求从 URL 到渲染的完整链路4.1 URL 路由与视图层推荐接口是怎么暴露出来的算法写好了得接上 Web 层才能被点出来。这套系统的 URLs 分成两层全局 urls.py 用 include 把不同 app 的路由拆开recommend 应用内定义自己的路由。# config/urls.py from django.contrib import admin from django.urls import path, include urlpatterns [ path(admin/, admin.site.urls), path(account/, include(apps.users.urls)), path(movies/, include(apps.movies.urls)), path(, include(apps.recommend.urls)), # 首页直接走推荐 ]# apps/recommend/urls.py from django.urls import path from apps.recommend import views urlpatterns [ path(, views.home, namehome), path(refresh-recommend, views.refresh_recommend_data, namerefresh_recommend), ]路由拆分的好处是将来加一个 /api/v1/recommend 的 JSON 接口只需要在 recommend/urls.py 里加一行不影响 account 和 movies 的路由。refresh-recommend 这个地址做的是“手动更新相似度表”的后台操作方便在管理页面上点击触发不需要写命令行跑脚本。4.2 视图处理逻辑内存相似度优先数据库相似度兜底这里视图函数做了个双保险如果相似度表里已经有数据就直接查库排序性能最好如果表是空的比如刚部署完还没离线计算则现场算一遍并写入表保证系统开箱即用。# apps/recommend/views.py from django.shortcuts import render from apps.movies.models import Movie from apps.recommend.models import SimilarMovie from apps.recommend.services import ( load_rating_matrix, calc_movie_similarity, recommend_for_user, build_similarity_table ) def home(request): user request.user if request.user.is_authenticated else None if user is None: rec_list Movie.objects.order_by(-rating_count)[:20] # 未登录走热度榜 else: similar_exists SimilarMovie.objects.exists() if similar_exists: matrix load_rating_matrix() sim similarity_from_db() # 已存在走表查询 else: matrix load_rating_matrix() sim calc_movie_similarity(matrix) build_similarity_table(sim) # 不存在现算并落表 rec_list recommend_for_user(user.id, matrix, sim, top_n20) return render(request, index.html, {recommend_list: rec_list})注意这段代码的“兜底顺序”先查表有没有数据没有才现场算。这样第一次部署、管理后台还没有触发离线任务时系统依然能给出推荐。等到评分数据涨到几十万条现场计算撑不住了再改成纯离线脚本计算、页面只查表。登录用户和未登录用户走了完全不同的分支未登录用户没有用户画像直接给热门榜这也是推荐系统的通用做法——首屏做转化引导而不是给一个看了就走的空页面。4.3 模板渲染推荐位怎么展示才不像一堵墙模板层面没有用复杂的前端框架Django 模板 简单的 CSS 卡片就够出效果。首页把推荐列表传进去模板循环渲染电影卡片。!-- templates/index.html -- div classrecommend-grid {% for movie in recommend_list %} div classmovie-card h3{{ movie.title }}/h3 p classmeta{{ movie.genres }} | {{ movie.release_year }}/p span classscore平均分 {{ movie.rating_avg|floatformat:1 }}/span a href{% url movie_detail movie.id %}查看详情/a /div {% empty %} p暂无推荐去评分几部电影试试/p {% endfor %} /divfloatformat:1 把浮点数转成一位小数字符串避免直接渲染 8.666666 这种长小数。empty 分支处理推荐列表为空的情况提升体感。毕业生改这个模板时通常会把卡片样式换成 Bootstrap 或加一张海报图但核心结构不用动。4.4 管理后台让管理员能更新相似度数据Django Admin 在这个项目里承担了“数据运营台”的职责。把 Movie、Rating、SimilarMovie 三个模型都注册进去管理员就能在后台上维护电影信息、查看评分明细、一键清空相似度表再重建。SimilarMovie 表的查询条件和索引是后台性能的关键如果没有联合索引点击“查看全部相似电影”时MySQL 会做全表扫。# apps/recommend/admin.py from django.contrib import admin from apps.movies.models import Movie, Rating from apps.recommend.models import SimilarMovie admin.register(Movie) class MovieAdmin(admin.ModelAdmin): list_display [id, title, genres, rating_avg, rating_count] search_fields [title] admin.register(Rating) class RatingAdmin(admin.ModelAdmin): list_display [user, movie, score, create_time] list_filter [score] admin.register(SimilarMovie) class SimilarMovieAdmin(admin.ModelAdmin): list_display [movie, similar_movie, similarity]后台只做数据管理和查看推荐算法的运行入口放在 recommend 视图里不和 Admin 耦合这样一般老师要求“提供管理后台”的需求也满足了又没有把业务线程绑死在后台进程里。5. 运行与避坑Python 3.7 Django 2.2.1 MySQL 的务实清单5.1 从零到跑通虚拟环境、依赖、迁移三步走Windows 或 Linux 上都可用先建虚拟环境再装依赖再迁移数据库。Django 2.2.1 和 Python 3.7 的搭配是那几年很稳的组合不建议贸然升到 Django 3.2因为 urls.py 的写法差异和 mysqlclient 的适配容易让新人陷入查坑循环。python3.7 -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate pip install -r requirements.txtrequirements.txt 里至少要有这些Django2.2.1 mysqlclient建议大家注意pip 安装 mysqlclient 在 Windows 上经常因为缺少 VC 编译环境而失败。初级办法是直接下载对应版本的 whl 文件安装高级办法是部署到 Linux 服务器上用系统包管理装 mysqlclient 再 pip install。后端依赖就两个非常克制这也是毕设的好处——不堆没用的包。然后是数据库迁移步骤mysql -uroot -p -e CREATE DATABASE movies DEFAULT CHARACTER SET utf8mb4; python manage.py makemigrations users movies recommend python manage.py migrate python manage.py createsuperuser python manage.py runserver 0.0.0.0:8000makemigrations 之前先建库库名要和 settings.py 里的 DATABASES 配置一致。这一步常翻车的点是用户忘了把 settings.py 里的数据库连接换成 MySQLDjango 默认用的还是 SQLite导致 MySQL 里建了库却没被项目使用。5.2 settings 里必须改的三处配置这里直接给最小改动方案# config/settings.py 中需要确认或修改的片段 import pymysql # 仅在用 PyMySQL 适配 MySQL 时需要 pymysql.install_as_MySQLdb() DATABASES { default: { ENGINE: django.db.backends.mysql, NAME: movies, USER: root, PASSWORD: 你的数据库密码, HOST: 127.0.0.1, PORT: 3306, } } USE_TZ False # 如果评分时间不需要跨时区保持本地时间如果你的 MySQL 驱动选的是 mysqlclient那这几行就不需要 install_as_MySQLdb直接 DATABASES 配置好即可。推荐用 mysqlclient踩坑更少。USE_TZFalse 是为了让 create_time 字段直接显示本地时间避免画面里出现比当前时间慢 8 小时的“穿越”数据。5.3 避坑专题协同过滤项目最常见的 5 个翻车现场现象一首页推荐永远为空。用户登录了、也评分了首页推荐列表还是空的。先检查 Rating 表里有没有数据再看相似度表 SimilarMovie 里有没有记录。常见原因是管理员误点了“清空相似度表”后没有点“重建”页面查数据库查不到任何相似电影recommend_for_user 返回空列表。解决在管理后台触发重建任务或者直接调用 recommend/refresh-recommend 接口。现象二导入电影数据 Insert 了半小时还没结束。电影信息和评分的导入脚本如果逐条 save()几千条数据会产生几千次 SQL round-trip非常慢。解决用 bulk_create() 批量写入。只需要把 3.2 节的 load_rating_matrix 换成批量版本速度能从半小时缩到几秒。现象三评分超过一万条后推荐接口响应几十秒。这是没走离线相似度表每次请求都现算相似度矩阵。解决确认首页视图走的是 4.2 讲的“先查 SimilarMovie 表”分支而不是每次重新 calc_movie_similarity。相似度计算改成定时任务凌晨跑一次白天只查表。现象四电影年代和类型字段在模板里显示为 None。release_year 和 genres 在导入时部分数据缺失模板渲染时直接打印 None 不友好。解决模板里用 default 过滤器比如 {{ movie.release_year|default:未知 }}同时在导入时给缺失字段一个空串兜底。现象五Windows 下 mysqlclient 装不上。现象pip install mysqlclient 报 Microsoft Visual C 14.0 required。原因mysqlclient 在 Windows 下需要编译且依赖 MySQL 客户端库。解决下载对应 Python 3.7 的 mysqlclient whl 文件本地安装或者改用 PyMySQL 并在 settings.py 里 install_as_MySQLdb()。6. 二次开发思路把 Item-CF 换成混合推荐的三条路6.1 改成 User-CF把矩阵反过来Item-CF 算的是“电影之间的相似度”User-CF 改成算“用户之间的相似度”。改动量其实很小def recommend_by_user_cf(user_id, matrix, similarity_user, top_n20): similar_users sorted(similarity_user[user_id].items(), keylambda x: x[1], reverseTrue)[:10] scores defaultdict(float) for other_user, sim in similar_users: for movie_id, score in matrix[other_user].items(): if movie_id in matrix[user_id]: continue scores[movie_id] sim * score return [mid for mid, _ in sorted(scores.items(), keylambda x: x[1], reverseTrue)[:top_n]]把 3.3 节 calc_movie_similarity 的入参换成 user 维度即可复用。做这一层改造时重点讲清 Item-CF 和 User-CF 的适用边界——用户量小于物品量时用 User-CF反之用 Item-CF。6.2 加入“热度惩罚”让推荐不那么大众化Item-CF 天然偏向热门电影因为热门电影被评分次数更多、更容易和别的电影算高相似度。一个经典的改法是在排序后对结果做一次 re-rank热门程度越高降权越多。def rerank_by_popularity(rec_list, top_n20): from apps.movies.models import Movie scored [] for mid in rec_list: movie Movie.objects.get(pkmid) penalty 1.2 if movie.rating_count 100 else 0.8 scored.append((mid, penalty)) scored.sort(keylambda x: x[1], reverseTrue) return [mid for mid, _ in scored[:top_n]]6.3 加一个“随机探索位”避免推荐结果永远是同一批把 Top-N 的前 4 位留给强相关结果最后 2 位从相似度稍低的电影里随机取样。这样用户每次刷新多了一点新鲜感对毕业设计的演示也更友好。import random def hybrid_recommend(rec_list, top_n20, explore_ratio0.2): fixed_n int(top_n * (1 - explore_ratio)) # 前 80% 走强相关 explore_n top_n - fixed_n # 后 20% 随机探索 explore_pool list(set(rec_list) - set(rec_list[:fixed_n])) return rec_list[:fixed_n] random.sample(explore_pool, min(explore_n, len(explore_pool)))这里的 explore_pool 是从原推荐列表里去掉头部后的部分避免完全随机导致推荐位质量崩得太难看。这个技巧是我当年做这个改造时踩过坑的——import random 很容易就直接从全量电影库抽了 20% 的推荐结果首页出现了一堆冷门烂片。从那以后我每次做推荐算法的扩展都强制先跑一遍“推荐结果覆盖率”的脚本看看有没有异常占比再把改动合并进主分支。希望帮到你。本文还有配套的精品资源点击获取