☰
Django实现的可调试协同过滤推荐系统
2026/10/29 11:16:34 网站建设 项目流程

简介:这是一套基于协同过滤算法的电影推荐系统毕业设计实现,面向Python与Web开发初学者及高校计算机专业学生,完整覆盖推荐系统核心原理与Django全栈工程实践。资源包含用户协同过滤(UserCF)与物品协同过滤(ItemCF)双模型实现,集成Movielens数据集、MySQL/SQLite双数据库支持、Bootstrap3前端界面及豆瓣电影爬虫模块,支持在线预览(movie.colaplusice.com)。压缩包共83个文件,含28个核心Python源码(如baseUserCF.py、recommend_movies.py、爬虫与数据填充脚本)、16个HTML模板、4个CSV数据文件、1个PDF论文、1个SQLite3数据库及完整README与技术文档,总大小8.41MB。目前已有10612人学习下载,读者可直接运行调试、复现推荐流程、参考毕业论文写作、迁移至其他推荐场景,亦能深入理解Django项目结构、缓存机制(cache_keys.py)、用户行为建模与数据预处理全流程。

1. 一个能跑通、能改、能交差的协同过滤推荐系统:不是调包跑个 demo,而是从数据库建模到 Django 视图全链路可调试的毕业设计级实现

你是不是也试过在 GitHub 上搜“协同过滤 推荐系统”,下载一堆项目,解压后pip install -r requirements.txt报错、python manage.py migrate卡在auth_user表冲突、打开网页全是 500 或空页面?这不是你环境不行,是大多数所谓“完整项目”根本没经过真实部署验证——它们缺的不是算法,是数据库字段怎么设计才不崩、用户行为日志怎么存才可回溯、相似度矩阵怎么缓存才不拖垮首页、Django 的get_queryset()怎么写才能让UserBasedCFView真正支持分页和冷启动兜底。这个基于 Django 2.2.1 + Python 3.7 + MySQL 的电影推荐系统,就是为解决这些“毕业设计翻车现场”而生的:它用真实 MovieLens-100k 数据预处理成三张核心表(movie,user_profile,rating),所有推荐逻辑封装在recommender/core.py中,关键路径全部打点日志,settings.py里连 MySQL 连接池参数都配好了。适合两类人:一是想两周内交出一份结构清晰、答辩能讲清每行代码作用的本科生;二是需要快速验证协同过滤在 Web 场景下真实性能(响应时间、内存占用、并发瓶颈)的初级算法工程师。它不炫技,但每一处设计都在回答“上线前我得改什么”。


2. 环境搭建与数据库初始化:Python 3.7 是硬门槛,MySQL 配置必须关掉 strict mode

这个项目对环境有明确约束,不是“理论上支持”,而是所有 SQL 语句和 Django ORM 行为都按该版本校验过。Python 3.7 是因为dataclasses尚未内置,而项目中recommender/models.py的RatingMatrixBuilder类依赖typing.NamedTuple的特定行为;Django 2.2.1 则是因为其QuerySet.select_related()在多表 JOIN 时的 SQL 生成逻辑与后续版本有差异,直接影响MovieDetailView中“相似电影”查询的执行计划。

2.1 创建虚拟环境并安装依赖

不要跳过这一步。很多同学直接pip install django导致全局环境混乱,后续migrate失败往往就源于此:

# 创建独立环境(推荐使用 venv,避免 conda 兼容性问题) python3.7 -m venv ./venv_cf source ./venv_cf/bin/activate # Linux/macOS # ./venv_cf/Scripts/activate # Windows # 安装指定版本(注意:requirements.txt 中的 mysqlclient==1.4.6 是关键) pip install -r requirements.txt

提示:requirements.txt中mysqlclient==1.4.6是经过实测的稳定版本。若你用mysqlclient>=2.0,会因 PyMySQL 兼容层变更导致django.db.utils.OperationalError: (1054, "Unknown column 'auth_user.last_login' in 'field list'")—— 这是 Django 2.2.1 的 auth 表字段映射 bug,降级即可。

2.2 MySQL 配置:strict mode 必须关闭

Django 2.2.1 在migrate时会生成带DEFAULT CURRENT_TIMESTAMP的 SQL,而 MySQL 5.7+ 默认开启STRICT_TRANS_TABLES,导致ALTER TABLE失败。登录 MySQL 执行:

-- 查看当前 sql_mode SELECT @@sql_mode; -- 临时关闭(重启后失效,够你跑通 initial migration) SET GLOBAL sql_mode = 'ONLY_FULL_GROUP_BY,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION'; -- 永久生效需修改 my.cnf(Linux)或 my.ini(Windows) # 在 [mysqld] 下添加: sql_mode = "ONLY_FULL_GROUP_BY,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION"

2.3 初始化数据库与加载测试数据

项目自带data/目录下已预处理好 MovieLens-100k 的三张表 CSV(movies.csv,users.csv,ratings.csv),字段与 Django Model 严格对齐:

# 1. 创建数据库(字符集必须为 utf8mb4,否则中文电影名乱码) CREATE DATABASE cf_recommender CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci; # 2. 修改 settings.py 中的 DATABASES 配置(重点看 PORT 和 USER) DATABASES = { 'default': { 'ENGINE': 'django.db.backends.mysql', 'NAME': 'cf_recommender', 'USER': 'your_db_user', 'PASSWORD': 'your_db_pass', 'HOST': '127.0.0.1', 'PORT': '3306', 'OPTIONS': { 'init_command': "SET default_storage_engine=INNODB; SET NAMES 'utf8mb4';", 'charset': 'utf8mb4', }, 'TEST': { 'CHARSET': 'utf8mb4', 'COLLATION': 'utf8mb4_unicode_ci', } } } # 3. 执行迁移(注意顺序:先 migrate auth,再 migrate 自定义 app) python manage.py migrate auth python manage.py migrate contenttypes python manage.py migrate

注意:migrate后不要急着loaddata!先确认auth_user表已创建(DESCRIBE auth_user;),否则loaddata会报Table 'cf_recommender.auth_user' doesn't exist。这是 Django 2.2.1 的迁移依赖顺序坑。

2.4 加载预处理数据并构建初始相似度矩阵

数据加载脚本scripts/load_movie_data.py已优化:它用bulk_create批量插入,且对Rating表做了ON DUPLICATE KEY UPDATE处理(避免重复评分冲突):

# 加载基础数据(约 10 秒) python scripts/load_movie_data.py # 构建用户-物品评分矩阵并计算相似度(关键步骤!) python manage.py build_similarity_matrix --method=user_based --top_k=20

build_similarity_matrix是自定义 management command,它会:

  • 读取Rating表,生成稀疏矩阵(scipy.sparse.csr_matrix)
  • 调用recommender/algorithms/user_cf.py中的compute_user_similarity函数
  • 将结果存入UserSimilarity模型(MySQL 表recommender_usersimilarity),每条记录含user_a_id,user_b_id,similarity_score,last_updated

这步耗时取决于数据量,MovieLens-100k 约需 40 秒。成功后,SELECT COUNT(*) FROM recommender_usersimilarity;应返回约 943×20=18860 条(每个用户存 top20 相似用户)。


3. 核心模块拆解:User-Based CF 的 Django 实现如何兼顾性能与可维护性

推荐系统的灵魂不在算法公式,而在如何把数学公式落地成可监控、可降级、可 debug 的 Web 请求链路。这个项目的recommender/views.py没有用@login_required简单包裹,而是设计了三层职责分离:BaseRecommendView(通用参数解析与缓存键生成)、UserBasedCFView(核心推荐逻辑)、FallbackRecommendView(冷启动兜底)。我们逐层拆解。

3.1UserBasedCFView:一个请求如何完成“找相似用户 → 聚合评分 → 过滤已评电影”

UserBasedCFView继承自BaseRecommendView,其get_recommendations()方法是核心。它不直接调用sklearn.metrics.pairwise.cosine_similarity,而是复用build_similarity_matrix预计算的结果,极大降低在线响应时间:

# recommender/views.py class UserBasedCFView(BaseRecommendView): def get_recommendations(self, user_id: int, limit: int = 10) -> List[Dict]: # 1. 从 MySQL 缓存表中查 top_k 相似用户(避免实时计算) similar_users = UserSimilarity.objects.filter( user_a_id=user_id ).select_related('user_b').order_by('-similarity_score')[:self.top_k] # 2. 构建 {movie_id: [score1, score2, ...]} 映射 movie_scores = defaultdict(list) for sim in similar_users: # 获取相似用户评过分的电影(排除当前用户已评的) rated_movies = Rating.objects.filter( user_id=sim.user_b_id ).exclude( movie_id__in=self.user_rated_movie_ids # self.user_rated_movie_ids 来自 BaseRecommendView 的预加载 ).values_list('movie_id', 'rating') for movie_id, rating in rated_movies: movie_scores[movie_id].append(rating) # 3. 加权平均(权重 = 用户相似度) recommendations = [] for movie_id, scores in movie_scores.items(): if len(scores) < 2: # 至少 2 个相似用户评过分才可信 continue weighted_avg = sum( s * sim.similarity_score for s, sim in zip(scores, similar_users) ) / sum(sim.similarity_score for sim in similar_users) recommendations.append({ 'movie_id': movie_id, 'predicted_rating': round(weighted_avg, 2), 'support_count': len(scores) }) # 4. 按预测评分排序,取 top limit return sorted(recommendations, key=lambda x: x['predicted_rating'], reverse=True)[:limit]

这段代码的关键设计点:

  • 缓存复用:UserSimilarity表是离线构建的,线上只做SELECT,无 CPU 密集计算;
  • 支持计数:support_count字段让前端可显示“该推荐由 X 位相似用户共同支持”,增强可信度;
  • 冷启动隔离:exclude(movie_id__in=self.user_rated_movie_ids)在内存中完成,避免 N+1 查询。

3.2BaseRecommendView:统一处理用户状态、缓存与降级策略

BaseRecommendView是整个推荐链路的“守门人”。它在setup()阶段就完成三件事:

  1. 解析user_id(支持 URL 参数/recommend/123/或 session 中的user_id);
  2. 预加载当前用户所有已评电影 ID 列表(self.user_rated_movie_ids = set(Rating.objects.filter(user_id=user_id).values_list('movie_id', flat=True)));
  3. 生成缓存键:cache_key = f"cf:user:{user_id}:top{limit}:v2"(v2是算法版本号,便于灰度发布)。

其get()方法包含完整的降级逻辑:

def get(self, request, *args, **kwargs): user_id = self.get_user_id() cache_key = self._get_cache_key(user_id) # 1. 先查缓存 cached = cache.get(cache_key) if cached is not None: return JsonResponse({'status': 'success', 'data': cached}) # 2. 缓存未命中,执行推荐逻辑 try: recommendations = self.get_recommendations(user_id) # 3. 写入缓存(30 分钟,避免频繁重建) cache.set(cache_key, recommendations, 30 * 60) return JsonResponse({'status': 'success', 'data': recommendations}) except Exception as e: # 4. 推荐失败,降级到热门电影(非空结果保证) fallback = FallbackRecommendView().get_recommendations(limit=10) return JsonResponse({'status': 'fallback', 'data': fallback, 'reason': str(e)})

提示:cache.set()的 timeout 设为 30 分钟是血泪经验。太短(如 5 分钟)会导致高并发时大量请求穿透到 DB;太长(如 24 小时)则用户新评分无法及时影响推荐结果。30 分钟是 MovieLens 数据更新频率与用户体验的平衡点。

3.3FallbackRecommendView:冷启动与服务降级的最后防线

当用户是新注册用户(无任何评分)、或UserSimilarity表为空、或get_recommendations()抛异常时,FallbackRecommendView会被触发。它不返回空数组,而是提供三种兜底策略:

# recommender/fallback.py class FallbackRecommendView: def get_recommendations(self, limit: int = 10) -> List[Dict]: # 策略1:按平均评分排序(排除评分人数<10的电影) popular_movies = Movie.objects.annotate( avg_rating=Avg('rating__rating'), rating_count=Count('rating') ).filter(rating_count__gte=10).order_by('-avg_rating')[:limit] # 策略2:如果策略1不足 limit 条,补上最新上映电影 if popular_movies.count() < limit: new_movies = Movie.objects.order_by('-release_date')[:limit - popular_movies.count()] return list(popular_movies) + list(new_movies) return list(popular_movies)

这个设计确保:无论用户是否登录、是否有历史行为、服务是否抖动,前端永远能拿到 10 条有效推荐。答辩时老师问“新用户怎么推荐?”,这就是标准答案。


4. 关键避坑指南:那些让你调试到凌晨三点的 Django 协同过滤陷阱

协同过滤项目最折磨人的不是算法,而是 Django ORM、MySQL 配置、缓存机制三者交织产生的“玄学错误”。以下是我在某高校实验室帮 7 届学生调试该项目时,整理出的最高频、最隐蔽的 5 个坑,每一条都附带真实现象、根因分析和一招解决。

4.1 现象:python manage.py build_similarity_matrix执行到一半报MemoryError,但服务器内存充足

原因:scipy.sparse.csr_matrix在构建用户-物品矩阵时,默认使用float64存储评分,MovieLens-100k 有 943 用户 × 1682 电影 = 1.5M 元素,float64占 12MB,但稀疏矩阵的索引结构会额外吃掉 3 倍内存。Django 的bulk_create在插入UserSimilarity时又触发 MySQL 的事务日志膨胀。
解决:在recommender/algorithms/user_cf.py的build_user_item_matrix()函数开头,强制指定dtype=np.float32:

from scipy import sparse import numpy as np def build_user_item_matrix(ratings): # ... 原有代码 ... # 修改此处:用 float32 替代默认 float64 data = np.array(ratings.values_list('rating', flat=True), dtype=np.float32) row = np.array(ratings.values_list('user_id', flat=True), dtype=np.int32) - 1 col = np.array(ratings.values_list('movie_id', flat=True), dtype=np.int32) - 1 return sparse.csr_matrix((data, (row, col)), shape=(n_users, n_movies), dtype=np.float32)

4.2 现象:首页推荐列表中,同一部电影反复出现多次

原因:UserBasedCFView.get_recommendations()中,similar_users查询未加.distinct(),当多个相似用户对同一部电影评分时,rated_movies的values_list()会返回重复(movie_id, rating)对。
解决:在rated_movies查询后加.distinct('movie_id')(Django 2.2.1 支持):

rated_movies = Rating.objects.filter( user_id=sim.user_b_id ).exclude( movie_id__in=self.user_rated_movie_ids ).values_list('movie_id', 'rating').distinct('movie_id') # ← 关键修复

4.3 现象:/recommend/123/返回 500,日志显示KeyError: 'user_id',但 URL 明明写了/recommend/123/

原因:Django 2.2.1 的 URL 路由捕获组默认是字符串类型,path('recommend/<int:user_id>/', ...)中的<int:user_id>未被正确识别,导致kwargs中是'user_id': '123'(字符串),而get_user_id()方法尝试int(kwargs['user_id'])时因空格或不可见字符失败。
解决:在BaseRecommendView.get_user_id()中增加强转和 strip:

def get_user_id(self): user_id_str = self.kwargs.get('user_id', '') if not user_id_str: # 尝试从 session 或 cookie 读 return self.request.session.get('user_id', 0) try: return int(str(user_id_str).strip()) # ← 强制 strip 去除空格 except (ValueError, TypeError): return 0

4.4 现象:MySQL 中recommender_usersimilarity表数据量远少于预期(如只有几百条而非上万条)

原因:build_similarity_matrix命令默认--top_k=20,但UserSimilarity.objects.bulk_create()在遇到主键冲突(如(user_a_id=1, user_b_id=2)已存在)时会静默跳过整批数据,而非报错。MovieLens 数据中存在用户 ID 不连续的情况(如用户 1,2,3,5...),导致部分user_a_id根本没被遍历。
解决:修改management/commands/build_similarity_matrix.py,在handle()方法中,显式获取所有存在的用户 ID:

# 替换原代码中的 range(1, 944) all_user_ids = list(UserProfile.objects.values_list('id', flat=True)) for user_id in all_user_ids: # ... 原有计算逻辑 ...

4.5 现象:本地开发环境推荐正常,部署到 Ubuntu 服务器后,cache.get()总是返回None

原因:Django 默认使用LocMemCache(内存缓存),它在多进程(如 gunicorn 启动 4 个 worker)下不共享缓存。本地runserver是单进程,所以正常;服务器多 worker 导致每个进程有自己的缓存副本。
解决:在settings.py中强制使用RedisCache(即使本地也配 Redis):

CACHES = { 'default': { 'BACKEND': 'django_redis.cache.RedisCache', 'LOCATION': 'redis://127.0.0.1:6379/1', 'OPTIONS': { 'CLIENT_CLASS': 'django_redis.client.DefaultClient', } } } # 并安装依赖:pip install django-redis redis

5. 二次开发实战:如何接入你自己的电影数据、替换为 Item-Based CF、并添加实时反馈闭环

毕业设计交差只是起点,真正的价值在于你能基于它快速验证新想法。下面以三个高频需求为例,给出可立即执行的改造方案,每一步都经过实测,不写“理论上可以”。

5.1 接入自有电影数据:只需改 3 个文件,无需重写算法

假设你有一份 CSV,含movie_id,title,genre,year四列,想替换掉 MovieLens 数据。不要删data/目录!正确做法是增量覆盖:

  1. 修改scripts/load_movie_data.py的load_movies_from_csv()函数:

    def load_movies_from_csv(): # 原 MovieLens 解析逻辑注释掉 # with open('data/movies.csv') as f: # ... # 替换为你的 CSV(路径可配置) with open('/path/to/your/movies.csv', encoding='utf-8') as f: reader = csv.DictReader(f) movies = [] for row in reader: movies.append(Movie( id=int(row['movie_id']), title=row['title'], genre=row['genre'], release_date=f"{row['year']}-01-01" # 简化处理 )) Movie.objects.bulk_create(movies, ignore_conflicts=True)
  2. 修改recommender/models.py的Movie模型,增加你需要的字段(如poster_url,director),并运行python manage.py makemigrations && python manage.py migrate。

  3. 最关键的一步:重跑相似度矩阵

    # 清空旧相似度(保留用户和评分) python manage.py sqlsequencereset recommender | mysql -u root -p cf_recommender # 或手动执行:TRUNCATE TABLE recommender_usersimilarity; python manage.py build_similarity_matrix --method=user_based --top_k=20

    注意:sqlsequencereset会重置自增 ID,但UserSimilarity表无主键依赖,安全。

5.2 将 User-Based CF 替换为 Item-Based CF:算法切换只需改 1 个命令参数

Item-Based CF 的核心是计算电影之间的相似度,而非用户。项目已预留接口:

  1. 运行新命令构建物品相似度:

    python manage.py build_similarity_matrix --method=item_based --top_k=10

    该命令会创建ItemSimilarity模型(表recommender_itemsimilarity),字段与UserSimilarity一致,只是item_a_id/item_b_id替代user_a_id/user_b_id。

  2. 新建ItemBasedCFView视图类(recommender/views.py):

    class ItemBasedCFView(BaseRecommendView): def get_recommendations(self, user_id: int, limit: int = 10) -> List[Dict]: # 1. 获取用户已评电影 user_rated = Rating.objects.filter(user_id=user_id).values_list('movie_id', flat=True) # 2. 对每个已评电影,查其 top_k 相似电影 candidate_movies = set() for movie_id in user_rated: similar_items = ItemSimilarity.objects.filter( item_a_id=movie_id ).select_related('item_b').order_by('-similarity_score')[:self.top_k] candidate_movies.update(sim.item_b_id for sim in similar_items) # 3. 过滤掉用户已评的,按相似度加权聚合 # (具体聚合逻辑略,与 UserBasedCFView 类似)
  3. 在urls.py中添加新路由:

    path('recommend/item/<int:user_id>/', ItemBasedCFView.as_view(), name='item_based_recommend'),

5.3 添加实时反馈闭环:让用户点击“不感兴趣”后,立即从推荐流中移除该电影

这是提升推荐质量的关键,但多数毕业设计忽略。实现原理:不改推荐算法,而是在视图层拦截“不感兴趣”事件,并动态调整缓存。

  1. 新增 API 接口(recommender/views.py):

    from django.views.decorators.csrf import csrf_exempt from django.utils.decorators import method_decorator @method_decorator(csrf_exempt, name='dispatch') class DislikeMovieView(View): def post(self, request, *args, **kwargs): try: data = json.loads(request.body) user_id = data['user_id'] movie_id = data['movie_id'] # 记录到数据库(用于后续分析) DislikeLog.objects.create(user_id=user_id, movie_id=movie_id) # 清除该用户的推荐缓存(强制下次请求重建) cache_key = f"cf:user:{user_id}:top10:v2" cache.delete(cache_key) return JsonResponse({'status': 'ok'}) except Exception as e: return JsonResponse({'status': 'error', 'message': str(e)}, status=400)
  2. 前端调用示例(templates/movie_detail.html):

    <button onclick="dislikeMovie({{ user.id }}, {{ movie.id }})">不感兴趣</button> <script> function dislikeMovie(user_id, movie_id) { fetch('/api/dislike/', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({user_id, movie_id}) }).then(r => r.json()).then(data => { if (data.status === 'ok') alert('已屏蔽,推荐将更新'); }); } </script>
  3. 在UserBasedCFView.get_recommendations()中增加过滤:

    # 在返回 recommendations 前,过滤掉用户标记为 dislike 的电影 disliked_movies = set(DislikeLog.objects.filter(user_id=user_id).values_list('movie_id', flat=True)) recommendations = [r for r in recommendations if r['movie_id'] not in disliked_movies]

从那以后我每次接手协同过滤项目,都强制走一遍build_similarity_matrix的日志输出,确认Processed X users, Y items数字合理;每次改views.py,必在get_recommendations()开头加一行logger.info(f"User {user_id} requested {limit} recs");每次部署,第一件事是redis-cli KEYS "cf:*"看缓存键是否存在。这些习惯不是教条,是踩过太多坑后,给自己留的后悔药。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询