毕设选题季一到,电影推荐系统几乎成了Django方向的热门题目。这个题目的好处在于:协同过滤算法原理讲得清楚,公开数据集随手就能拿到(MovieLens就是最常用的),而且做出来的效果非常直观——用户登录、浏览推荐列表、给电影打分、再刷新推荐列表发现推荐越来越准,整个闭环非常适合现场演示和答辩问答。我接过不少毕设项目,这个“基于Django的协同过滤个性化电影推荐系统”是里面复现率最高、也最容易讲出深度的一个。这篇就把完整的设计思路、算法原理、工程实现和踩坑记录一次说透,给要选这个题或正在做的同学一份可以直接照着落地的参考。
做这类系统,最容易被一带而过的就是算法模块。很多人花了大把时间折腾页面,最后推荐结果却是写死的假数据,答辩时一问“协同过滤怎么实现的”就露馅。所以我这篇会重点讲算法落地的细节,包括相似度计算怎么选型、评分矩阵怎么处理、冷启动怎么兜底,然后再把Django的工程结构、数据建模、路由视图串起来。适合刚准备做毕设的同学当作完整参考,也已经写了一半、想补算法深度的同学直接当“查缺补漏”的清单用。
1. 项目整体设计与思路拆解
1.1 技术选型背后的逻辑
先聊选型。为什么不选Spring Boot,不选Flask,非要Django?我的判断很明确:毕设项目要的是“全”——有后台管理、有用户认证、有ORM、有模板引擎、有Admin后台,Django全家桶全包了,不用额外拼装。这样你写代码的时间能压缩一大半,剩下的精力全砸在推荐算法和文档上,性价比极高。
协同过滤则是推荐系统里讲解成本最低、效果又不寒碜的算法。基于用户的协同过滤(UserCF)和基于物品的协同过滤(ItemCF)原理都很好表达,配上相似度矩阵和评分预测公式,答辩时你可以直接在白板上画矩阵、写公式,评委一眼就明白你做的是什么。相比之下,如果你选深度学习做推荐,模型训练环境、数据集规模、解释成本都太高,很容易把自己绕进去。
另外,电影推荐这个场景本身就是天然的演示素材。用户理解电影、评分、推荐这些概念零门槛,比“商品推荐”“新闻推荐”更容易代入。用MovieLens公开数据集做算法验证,再用一小部分自建数据做页面演示,两条腿走路,数据和效果都有了。
1.2 功能模块怎么拆最合理
我把这套系统拆成了六个模块,每一块都要能对应到需求文档里,答辩时也方便按模块讲。
用户模块:注册、登录、个人信息维护。Django自带的auth用户体系直接继承扩展,不要自己造轮子。
电影管理模块:电影信息的增删改查,包括片名、导演、演员、类型、上映年份、海报地址。这块可以直接挂在Django Admin后台里,省掉手写管理页面的工作量。
评分模块:用户对看过的电影打分(1到5分),评分数据是整个协同过滤算法的心脏。页面上做成简单的星标或下拉框,提交后通过表单或AJAX写库。
推荐模块:算法核心。根据用户的历史评分数据计算相似用户或相似电影,输出推荐列表。推荐结果要排除用户已经看过的电影,并且每个推荐项附一个“推荐理由”(比如“因为你看过《盗梦空间》”),观感会好很多。
排行榜模块:按评分人数、平均分展示热门电影,这个既是冷启动阶段的兜底推荐,也能让系统首屏有内容可看,不至于空空荡荡。
后台管理模块:直接用Django Admin,管电影、管用户、管评分数据,也方便老师验收时自己动手加数据看效果。
这个拆法最核心的指导思想是:推荐系统是主干,Admin和榜单是枝叶。主干保证算法闭环,枝叶保证项目完整度和演示体验。很多同学把精力耗在做花哨的展示页面上,到最后算法没实现,这是本末倒置。
1.3 数据模型设计是地基
数据模型直接决定算法能不能顺畅实现。我推荐建三张核心表,外加一张用户表,一共四个模型。
用户表直接继承AbstractUser,扩展一个昵称字段就行。
电影表(Movie)至少要有这些字段:title(片名)、genres(类型,多对多关系或者逗号分隔字符串都行)、release_year(年份)、director、actors、poster_url(海报地址)、description(简介)。其中类型字段建议单独建一张Genre表,用多对多关联,后面做“同类型电影推荐”时效率高得多。
评分表(Rating)是整个系统的关键:user(外键到用户)、movie(外键到电影)、score(1到5分)、created_at(评分时间)。注意一定要给user和movie建联合唯一约束,防止同一个人对同一部电影重复评分,否则算法数据会被污染。
用Django ORM写出来大概是这个样子:
from django.db import models from django.contrib.auth.models import AbstractUser class User(AbstractUser): nickname = models.CharField('昵称', max_length=32, blank=True) class Genre(models.Model): name = models.CharField('类型名', max_length=32, unique=True) class Movie(models.Model): title = models.CharField('片名', max_length=128) genres = models.ManyToManyField(Genre, verbose_name='类型') release_year = models.IntegerField('上映年份', null=True, blank=True) director = models.CharField('导演', max_length=64, blank=True) actors = models.CharField('主演', max_length=255, blank=True) poster_url = models.URLField('海报地址', blank=True) description = models.TextField('简介', blank=True) class Rating(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name='用户') movie = models.ForeignKey(Movie, on_delete=models.CASCADE, verbose_name='电影') score = models.IntegerField('评分', choices=[(i, str(i)) for i in range(1, 6)]) created_at = models.DateTimeField('评分时间', auto_now_add=True) class Meta: unique_together = ('user', 'movie')这里有个容易被忽略的细节:on_delete一定要设成CASCADE,删用户或删电影时评分记录级联删掉,不然跑算法时会遇到“评分数据指向不存在的电影”这种诡异错误。我就是在这个坑里浪费过一晚上。
2. 协同过滤核心算法:原理与实现
2.1 基于用户的协同过滤(UserCF)是怎么工作的
UserCF的核心思想是:跟你兴趣相似的人喜欢的东西,你大概率也喜欢。实现分三步。
第一步,构建“用户-电影”评分矩阵。行是用户,列是电影,格子是评分(没评过的留空或记为0)。
第二步,计算目标用户和其他用户之间的相似度。相似度高的那一批人,就是你的“邻居”。
第三步,把邻居们评分高、但目标用户没看过的电影,按加权评分排序,选前N个推荐出去。
我用一个生活化的例子解释:假设你和隔壁寝室的哥们A共同喜欢《教父》《无间道》《黑客帝国》,他觉得《星际穿越》特别棒,但你没看过。系统就会推断你们口味接近,于是把《星际穿越》放进你的推荐列表。这就是UserCF的完整链路。
UserCF的优势是推荐结果有“社交感”,容易解释“因为和你相似的人喜欢它”。劣势是用户量大的时候相似度矩阵庞大,而且新用户没有任何行为数据,算不出邻居。所以UserCF更适合用户规模中等、行为数据密集的场景,毕设项目正好符合。
2.2 基于物品的协同过滤(ItemCF)又是怎么回事
ItemCF的核心思想是:喜欢这个物品的人,也常常喜欢另一个物品,那这两个物品就是相似的。注意,这里说的“相似”不是内容上像(比如同导演、同类型),而是行为上被同一群人喜欢。
这句话是理解协同过滤的关键。很多同学一开始会拿电影的类型、导演去算相似度,那是“基于内容的推荐”,跟协同过滤不是一回事。ItemCF用的是用户行为数据:两部电影同时被同一个人评分,就说明它们之间存在关联。被越多同一个人同时喜欢,相似度就越高。
实现同样三步:构建“用户-电影”评分矩阵,然后转置成“电影-用户”矩阵,计算电影之间的相似度;最后根据用户的历史评分记录,找出他喜欢的电影对应的TopN相似电影,加权汇总后排推荐列表。
我个人的经验是:ItemCF在电影推荐场景里通常比UserCF效果好。原因很简单,电影数量比用户数量少几个量级,几百部电影算相似度矩阵的代价远低于几万用户算相似度;而且用户的兴趣会漂移,而物品的相似关系相对稳定,算完一次可以缓存复用。
2.3 相似度计算的几种常见公式
相似度公式是答辩时最容易被追问的点。我建议至少能写出下面三种,并说出各自适用场景。
余弦相似度(Cosine Similarity):最常用。把用户对电影的评分看作向量,公式是cos(A, B) = (A·B) / (|A| * |B|)。它只关心向量方向,不关心向量长度,适合评分数据比较密集的场景。
皮尔逊相关系数(Pearson Correlation):在余弦相似度的基础上,把用户各自的平均分减掉,再算余弦。这样做的好处是消除了“有些人习惯给4分,有些人习惯给3分”带来的偏差。公式是先对评分向量做中心化,再算余弦相似度。数据中心化是这步的关键操作。
杰卡德相似度(Jaccard):只看“共同评分的电影数量”占“两人评过的电影总数”的比例,完全不看评分高低。适合“是否评过分”“是否收藏过”这类0/1数据。用在评分数据上会丢失评分强度信息,但做冷启动阶段的粗筛非常好用。
我做这个项目实际用的是皮尔逊系数,因为它对用户评分习惯的差异有天然的矫正作用。示例代码里会把三种都封装好,方便对比演示。
2.4 冷启动问题怎么处理
冷启动是推荐系统里的经典问题,也是答辩时评委百问不腻的点。三个冷启动,每个都要有方案。
用户冷启动:新注册用户没有任何评分,协同过滤算不了。处理方案是混合推荐——注册后先推荐排行榜靠前的热门电影,等他打够5部电影的分数后,再切回协同过滤推荐。
物品冷启动:新上架的电影没人评过分,无法进入协同过滤的推荐池。处理方案是用内容特征兜底:按导演、演员、类型找最相近的已有电影,把它关联推荐出去,同时在详情页展示“相似电影”,让用户有机会给它打分。
系统冷启动:你的项目刚起步,演示时自己造的数据还不够跑算法。解决方法是写一个seed_data.py的初始化脚本,造20个用户、50部电影、500条左右的评分数据,覆盖足够的用户重叠度,让相似度矩阵不至于稀疏到全是零。我习惯用这批数据跑通算法链路,再在演示时让老师现场注册新用户、打分,看推荐结果的变化。
冷启动这块是加分项。很多毕设不去处理这个问题,你在答辩里主动讲出来,评委能立刻感受到你对推荐系统实际业务的理解深度。
3. Django工程实现全流程
3.1 环境准备与项目骨架
基础环境我用的是Python 3.10 + Django 4.2 LTS,数据库开发阶段用SQLite,部署演示时如果数据量真的上万条,再切MySQL,Django ORM切换成本很低,前期不需要纠结。
建项目命令:
# 创建虚拟环境 python -m venv venv # 激活(Windows/Linux命令略有差异) venv\Scripts\activate # Windows source venv/bin/activate # Linux/macOS # 安装依赖 pip install django pandas numpy scipy # 创建Django项目和应用 django-admin startproject movie_recommend cd movie_recommend python manage.py startapp recommender顺手在settings.py里把recommender加进INSTALLED_APPS,然后执行python manage.py migrate初始化数据库。这里需要多提一句:pandas和numpy是必不可少的,推荐算法的评分矩阵构建、相似度计算全靠它们,别想着只用原生Python手写循环,数据量稍微大点性能就难看了。
整体目录结构参考:
movie_recommend/ ├── manage.py ├── movie_recommend/ │ ├── settings.py │ ├── urls.py ├── recommender/ │ ├── models.py # User/Movie/Genre/Rating │ ├── views.py # 页面渲染与推荐结果输出 │ ├── recommend_service.py # 协同过滤核心算法 │ ├── seed_data.py # 演示数据初始化 │ ├── urls.py │ └── admin.py └── templates/ └── recommender/ ├── index.html ├── movie_list.html ├── movie_detail.html └── recommend.html注意recommend_service.py是我单独拆出来的算法模块,不写在views.py里,也不写在models.py里。这样做的目的是把算法逻辑和Django的请求处理解耦,以后想换算法、想单元测试、想讲代码结构,都会清爽很多。
3.2 核心算法模块的代码实现
算法模块我分成三部分:数据处理、相似度计算、推荐生成。这里给一份可以直接改改就用的核心代码。
先看数据处理和相似度计算:
import pandas as pd import numpy as np from .models import Rating, Movie def build_user_movie_matrix(): """从数据库中读取评分数据,构建用户-电影评分矩阵""" ratings = Rating.objects.all().values('user_id', 'movie_id', 'score') df = pd.DataFrame(list(ratings)) matrix = df.pivot_table(index='user_id', columns='movie_id', values='score') matrix = matrix.fillna(0) # 缺失评分置0,后续计算相似度时用权重掩码处理 return matrix def pearson_similarity(matrix): """基于皮尔逊相关系数计算用户之间的相似度矩阵""" # 先做数据中心化,减去每个用户的平均分 user_mean = matrix.mean(axis=1) matrix_centered = matrix.sub(user_mean, axis=0) # 只考虑共同评分的电影,这里用内积近似 # 注意:实际生产要处理“只对都评过的电影计算均值”,这里简化为项目够用 similarity = np.corrcoef(matrix_centered.values) sim_df = pd.DataFrame(similarity, index=matrix.index, columns=matrix.index) return sim_df这里有个被很多教程忽略的细节:直接用fillna(0)会让“没评分”和“打了0分”混淆。为了项目简单我确实这样处理了,但在写文档时我专门加了一段说明——真实场景应该用掩码矩阵,只统计双方都评过分的电影。答辩时能主动说出这个细节,立刻显示你不是照着教程抄的。
再看推荐生成:
def recommend_for_user(user_id, top_n=10): matrix = build_user_movie_matrix() sim_df = pearson_similarity(matrix) # 当前用户还没看过的电影 watched_movie_ids = set( Rating.objects.filter(user_id=user_id).values_list('movie_id', flat=True) ) all_movie_ids = set(Movie.objects.values_list('id', flat=True)) candidate_movie_ids = all_movie_ids - watched_movie_ids # 获取当前用户最相似的5个邻居 if user_id not in sim_df.index: return fallback_hot_movies(top_n) # 冷启动:返回热门电影 neighbors = sim_df.loc[user_id].sort_values(ascending=False).iloc[1:6] scores = {} for movie_id in candidate_movie_ids: total_score = 0.0 total_sim = 0.0 for neigh_id, sim in neighbors.items(): score = matrix.at[neigh_id, movie_id] if movie_id in matrix.columns else 0 if score > 0: total_score += sim * score total_sim += sim if total_sim > 0: scores[movie_id] = total_score / total_sim ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True)[:top_n] return [Movie.objects.get(id=mid) for mid, _ in ranked]这段逻辑的核心是加权求和:邻居用户的相似度作为权重,乘上他对某部电影的评分,再除以权重总和,得到目标用户对该电影的预测分。预测分从高到低排,取前N个就是推荐的电影。
这里必须补一句:上面是UserCF的推荐实现。ItemCF要做的事情类似,只是先算电影相似度矩阵。我做的是在项目里同时实现UserCF和ItemCF,然后通过一个开关切换,这样文档的对比实验部分就有素材写了,答辩也能讲“我两个都做了,实测ItemCF在电影数据上更稳定”。
3.3 视图层如何组织推荐逻辑
视图层要做的事情是:接收请求、调用算法、传数据给模板、渲染页面。我建议推荐结果不要实时全量计算,而是在用户“点击推荐页”时先判断缓存,没有缓存才现算。
from django.shortcuts import render, get_object_or_404 from django.contrib.auth.decorators import login_required from .recommend_service import recommend_for_user from .models import Movie, Rating @login_required def recommend_view(request): user = request.user recommended_movies = recommend_for_user(user.id, top_n=12) # 同时取用户评分过的电影,用于页面展示历史行为 rated_movies = Rating.objects.filter(user=user).select_related('movie') return render(request, 'recommender/recommend.html', { 'recommended_movies': recommended_movies, 'rated_movies': rated_movies, })模板里就简单了,循环推荐列表渲染卡片、海报、评分、推荐理由即可。这里我强烈建议用select_related做预取。否则模板里每显示一部电影都会额外查一次Movie表,12部电影就是12条SQL,这就是经典的“N+1查询”问题。评委如果懂技术,很容易问到这个点。
再补充一个细节:模板里展示“推荐理由”时,我在算法返回里附带了一个字段,说明是“根据与您相似的用户喜好生成”还是“根据您看过的《XX》生成”。这个小小的设计在演示时比较有说服力,页面看起来像真的有推荐逻辑在背后工作。
路由配置没什么特别,指向视图就行:
from django.urls import path from . import views urlpatterns = [ path('recommend/', views.recommend_view, name='recommend'), path('movie/<int:movie_id>/', views.movie_detail, name='movie_detail'), path('rate/', views.rate_movie, name='rate_movie'), ]评分提交我建议用AJAX,页面不刷新就能更新推荐列表。因为评委现场演示时,就是“这里打个分、回到推荐页、看变化”,交互顺滑会留下很好的第一印象。
4. 常见问题与排查技巧实录
4.1 评分矩阵太稀疏,相似度全是零
这是我做这个项目遇到的第一个问题。自己造的50部电影、20个用户、500条评分,理论上不算稀,但用户之间重叠评分的电影很少,np.corrcoef算出来全是NaN或0,推荐结果直接为空。
排查思路是先看重叠度:随机挑两个用户,数一数他们共同评过的电影有几部。如果普遍是个位数,就得加数据。我用了一个简单策略:在seed_data.py里预先设定几组“口味相近”的用户,让他们对同一批电影评分,这样矩阵里天然就存在高相似度用户对。这不算作弊,只是保证演示数据有算法可算。
另外,把相似度矩阵里NaN统一填充为0,避免后续运算报错:
sim_df = sim_df.fillna(0)4.2 推荐结果不随评分变化
页面推荐列表不刷新,绝大多数情况是缓存问题。我调试时先打开浏览器开发者工具,看推荐接口的响应内容。如果响应里已经有新电影,说明算法没问题,是模板渲染或静态缓存的问题;如果响应里还是老数据,说明视图走的还是旧缓存,那就检查推荐函数里有没有写全局变量缓存。
另外一个隐蔽的坑:Rating表里新增了评分,但算法模块里build_user_movie_matrix读取时用了objects.all(),而Django的查询集默认是实时查询,一般不会缓存旧数据。但是如果你在模块级定义了一个全局的df变量,那就会一直拿到旧数据。我后来改成每次调用都重新读库,演示数据量小根本不影响性能,同时杜绝了这类问题。
4.3 远程调试的经验
毕设的场景往往不止在你自己电脑上跑。老师要验收、同学要演示,经常需要在一台服务器上跑起来,或者你把项目发给对方,在对方的机器上出问题要远程看。
我接手源项目时常用的调试方式分三种:一是让对方把settings.py里的DEBUG打开再跑runserver,出错页面会直接显示堆栈信息,比自己瞎猜效率高很多;二是依赖日志,在算法模块和视图关键位置加print或logging,然后看终端输出定位“是请求没到视图,还是算法算错了”;三是用PyCharm的远程解释器,把代码同步到对方机器,直接在本地打断点单步调试。我个人最常用的是第二种,日志够用就好,简单不容易出错。
远程调试有个小坑:Django的runserver默认开在本机8000端口,对方通过IP访问时需要在settings.py里把ALLOWED_HOSTS配上服务器的IP或域名,否则返回403。还有,runserver默认是单进程,调试时多开窗口容易端口冲突,先lsof -i:8000查占用。
4.4 文档编写和答辩要点
标题里明确写着“全套源码+文档”,那文档到底写哪些、怎么写,我多说两句。一套完整的毕设文档至少要有:开题报告、需求分析、总体设计、详细设计、数据库设计、算法设计说明、测试报告、总结展望。
其中最容易被老师挑刺的是“算法设计说明”和“测试报告”。算法部分一定要配公式和流程图。公式不用多,核心的相似度计算和预测评分公式画清楚就行;流程图不需要用复杂的工具,Draw.io或ProcessOn画一张就行,步骤是“用户登录 → 获取评分 → 构建评分矩阵 → 计算相似度 → 生成推荐列表 → 展示推荐结果”。
测试报告里我建议写三部分内容:功能测试(注册、登录、评分、推荐、管理后台)、性能测试(推荐页面响应时间)、算法效果测试(拿MovieLens的1万条评分数据,按80%训练、20%测试,算一下推荐命中率或RMSE)。第三点是很多毕设没有的,你能写出来就是加分项。
答辩时还有一个实战技巧:提前准备好一页“遇到的问题与解决”。我当时讲了“评分矩阵稀疏导致相似度全零”“N+1查询导致页面慢”“冷启动用户无推荐可看”这三个问题及解决方案。评委听到实际排查过程,会觉得这个项目确实是独立做的,而不是几分钟下载的源码套壳。
我个人在实际操作中的体会是:这类型项目最大的价值不在于“推荐结果有多准”,而在于你把这套数据的流转链路讲清楚了——从用户打分开始,到矩阵构建、相似度计算、预测评分,最后回到页面展示,每一步你都亲手调过、修过、优化过,答辩自然站得住。最后再分享一个小技巧:演示前把数据库清空重跑一遍seed_data.py,确保评分数据干净;现场注册一个新账号,当着他的面打5-6部高分电影,刷新推荐页,让他看到推荐列表确实根据刚才的行为发生了变化。这一套流程走下来,比你说任何技术术语都更有说服力。