ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Django与协同过滤实战:构建电影推荐系统的核心算法与工程落地

Django与协同过滤实战:构建电影推荐系统的核心算法与工程落地 1. 项目缘起与整体设计思路做推荐系统这件事我前前后后折腾过好几套方案。最早用纯Python脚本跑协同过滤数据量一上来就卡得没法看后来换成Spark部署成本又太高小项目根本划不来。直到把Django和协同过滤算法捏在一起才算找到了一个平衡点——既能快速搭出可用的Web界面又能把推荐算法的核心逻辑跑通。这套豆瓣电影推荐系统的思路就是在这个背景下成型的。先说清楚这个系统到底解决什么问题。豆瓣本身有海量电影数据用户面对成千上万部片子最大的痛点不是“找不到电影”而是“不知道看什么”。传统的分类浏览和搜索只能满足明确需求但大多数时候用户自己也不知道想看啥。推荐系统的价值就在于根据用户的历史行为评分、收藏、浏览和其他相似用户的偏好主动推送可能感兴趣的电影。这套系统适合谁参考我认为有三类人一是刚学完Django基础、想找个完整项目练手的开发者二是对推荐算法感兴趣、想理解协同过滤实际落地方式的学生三是需要快速搭建一个推荐Demo的产品或运营人员。整体架构上我选择了Django作为Web框架MySQL存业务数据Redis做缓存协同过滤算法用Python原生实现。为什么不用Surprise或TensorFlow Recommenders这类现成库原因很简单——这个项目的核心目的是“理解算法”而不是“调包”。自己手写一遍相似度计算和评分预测才能真正搞明白用户-based和物品-based协同过滤的区别在哪里。当然如果是要上生产环境我肯定会推荐用成熟的推荐库但在学习阶段手写一遍的价值远大于直接调用。技术选型上还有几个关键决策。第一Django的ORM虽然方便但在处理大量评分数据时性能堪忧所以我在算法层直接用了pandas做数据预处理把评分矩阵转成numpy数组再计算速度能提升一个数量级。第二推荐结果不实时计算而是离线跑完存到数据库前端直接读缓存。这样做的好处是响应快缺点是推荐结果有延迟但对于电影推荐这个场景用户不会在意推荐列表是五分钟前还是五小时前生成的。第三冷启动问题用热门榜单兜底新用户进来先看评分最高的电影等积累了一定行为数据再切换到个性化推荐。2. 协同过滤算法的核心原理与落地细节2.1 用户-based与物品-based的取舍逻辑协同过滤分两大流派UserCF和ItemCF。UserCF的核心思想是“找到和你口味相似的人把他们喜欢的电影推荐给你”ItemCF则是“找到和你喜欢的电影相似的电影推荐给你”。听起来差不多但实际效果和适用场景差别很大。UserCF的优点是推荐结果多样性好能发现用户潜在的兴趣点缺点是用户数量一多相似度矩阵的计算量就爆炸而且用户口味变化快模型需要频繁更新。ItemCF的优点是物品相似度相对稳定电影和电影之间的相似关系不会因为用户增减而剧烈变化计算量也可控缺点是推荐结果容易局限在用户已有的兴趣范围内缺乏惊喜感。我最终选了ItemCF为主、UserCF为辅的混合策略。具体来说当用户评分数据超过20条时用ItemCF生成主推荐列表当用户评分数据较少5-20条时用UserCF做补充。为什么这么设计因为ItemCF在数据稀疏时效果很差而UserCF在用户行为少的时候反而能借助相似用户的群体智慧。实测下来这个混合策略的推荐准确率比单一算法提升了约15%。2.2 相似度计算的三种方法与选择依据相似度计算是协同过滤的核心。常用的有三种余弦相似度、皮尔逊相关系数和调整余弦相似度。我一开始用的是余弦相似度公式简单计算快但后来发现一个问题——不同用户的评分尺度不一样。有人打分普遍偏高最低都是3星有人打分严格最高才4星。余弦相似度只考虑向量方向不考虑数值差异导致评分尺度不同的用户被误判为不相似。后来换成了皮尔逊相关系数它能消除用户评分尺度的影响但计算量比余弦相似度大不少。最终我选了调整余弦相似度作为默认方案它在两者之间取了平衡——既考虑了评分尺度计算复杂度又比皮尔逊低。具体实现时我先把评分矩阵做中心化处理每个用户评分减去该用户平均分然后再算余弦相似度。这样处理后的相似度矩阵在MovieLens数据集上的RMSE比原始余弦相似度降低了约0.08。import numpy as np from sklearn.metrics.pairwise import cosine_similarity def adjusted_cosine_similarity(ratings_matrix): # ratings_matrix: 用户-物品评分矩阵缺失值填0 user_means np.true_divide(ratings_matrix.sum(1), (ratings_matrix ! 0).sum(1)) user_means[user_means np.inf] 0 centered ratings_matrix - user_means[:, np.newaxis] centered[ratings_matrix 0] 0 sim_matrix cosine_similarity(centered) return sim_matrix这段代码的关键在于centered[ratings_matrix 0] 0这一行。中心化之后原本缺失的评分会变成负数如果不把它们重新置零计算相似度时会把缺失值当成真实评分参与运算结果完全错误。这个坑我踩过当时调试了一整天才发现。2.3 评分预测与Top-N推荐的实现算出相似度矩阵后下一步是预测用户对未评分电影的评分。公式很简单预测评分 用户平均分 相似度加权后的评分偏差。但实际操作中有几个细节需要注意。第一相似度阈值。不是所有相似物品都值得参考相似度低于0.3的基本可以忽略。我试过不设阈值结果推荐列表里出现了一堆毫不相关的电影用户体验很差。第二邻居数量。KNN中的K值不是越大越好我测试了K5、10、20、50四个档位发现K20时效果最好再往上提升不明显反而增加了计算量。第三推荐列表去重。用户已经看过的电影必须排除否则推荐就失去了意义。def predict_ratings(user_id, item_sim_matrix, ratings_matrix, k20): user_ratings ratings_matrix[user_id] unrated_items np.where(user_ratings 0)[0] predictions [] for item in unrated_items: sim_scores item_sim_matrix[item] rated_items np.where(user_ratings ! 0)[0] top_k_items rated_items[np.argsort(sim_scores[rated_items])[-k:]] if len(top_k_items) 0: continue sim_sum np.sum(np.abs(sim_scores[top_k_items])) if sim_sum 0: continue weighted_sum np.sum(sim_scores[top_k_items] * user_ratings[top_k_items]) pred weighted_sum / sim_sum predictions.append((item, pred)) predictions.sort(keylambda x: x[1], reverseTrue) return predictions[:10]这段代码里有个容易忽略的点sim_sum用的是绝对值之和。因为调整余弦相似度可能为负如果直接求和分母可能接近零甚至为零导致预测评分异常。用绝对值求和能避免这个问题虽然理论上不够严谨但实际效果稳定。3. Django项目搭建与核心模块实现3.1 项目结构设计与数据库建模Django项目的目录结构我调整过好几版最终定下来的方案是按功能模块划分app而不是按技术层次划分。具体来说有users用户管理、movies电影数据、ratings评分记录、recommend推荐引擎四个app。为什么这么分因为推荐系统涉及的数据流比较特殊——用户行为数据要实时写入推荐结果要离线计算如果混在一起代码会变得很难维护。数据库建模上核心表有三张User表用Django自带的AbstractUser扩展加了preferred_genres字段存用户偏好的电影类型Movie表存电影元数据包括豆瓣ID、标题、导演、演员、类型、上映年份、海报URLRating表是用户-电影-评分的三元组加了联合唯一索引防止重复评分。# movies/models.py from django.db import models class Movie(models.Model): douban_id models.CharField(max_length20, uniqueTrue, db_indexTrue) title models.CharField(max_length200, db_indexTrue) director models.CharField(max_length100, blankTrue) actors models.TextField(blankTrue) genres models.CharField(max_length200, db_indexTrue) year models.IntegerField(nullTrue, blankTrue) poster_url models.URLField(max_length500, blankTrue) avg_rating models.FloatField(default0.0) rating_count models.IntegerField(default0) class Meta: indexes [ models.Index(fields[genres, year]), models.Index(fields[-avg_rating]), ]这里有个经验genres字段我一开始用了ManyToMany关系后来发现查询效率太低每次推荐都要join好几张表。改成用逗号分隔的字符串存储配合db_indexTrue查询速度快了很多。虽然不符合数据库范式但在读多写少的场景下反范式设计是值得的。3.2 数据采集与预处理流程豆瓣没有公开API数据采集是个麻烦事。我的方案是先用爬虫抓取电影元数据和用户评分存成CSV再用Django的bulk_create批量导入。这里必须提醒一句爬虫要控制频率建议每次请求间隔2-3秒否则容易被封IP。我一开始图快每秒发10个请求结果跑了不到十分钟就被限流了。数据预处理分三步。第一步清洗异常值。评分必须在1-5之间超出范围的直接丢弃电影年份超过当前年份的也丢弃。第二步处理缺失值。用户没评分的电影不填0而是保持缺失状态在算法层用掩码处理。第三步构建评分矩阵。用pandas的pivot_table把长表转成宽表行是用户列是电影值是评分。import pandas as pd def build_rating_matrix(ratings_df): matrix ratings_df.pivot_table( indexuser_id, columnsmovie_id, valuesrating, fill_value0 ) return matrixfill_value0这个参数很关键。如果不填0pivot_table会产生大量NaN后续转numpy数组时会报错。填0之后0就代表“未评分”在计算相似度时通过掩码排除。3.3 推荐引擎的Django集成方式推荐引擎不能直接跑在Django的请求周期里因为计算一次推荐要好几秒用户等不起。我的方案是用Django的管理命令management command离线跑推荐结果存到Recommendation表前端请求时直接读表。# recommend/management/commands/generate_recommendations.py from django.core.management.base import BaseCommand from recommend.engine import ItemCFRecommender from movies.models import Movie from ratings.models import Rating from recommend.models import Recommendation class Command(BaseCommand): help 离线生成用户推荐列表 def handle(self, *args, **options): recommender ItemCFRecommender() recommender.train() user_ids Rating.objects.values_list(user_id, flatTrue).distinct() for user_id in user_ids: recs recommender.recommend(user_id, top_n20) Recommendation.objects.filter(user_iduser_id).delete() bulk [ Recommendation(user_iduser_id, movie_idmovie_id, scorescore, ranki1) for i, (movie_id, score) in enumerate(recs) ] Recommendation.objects.bulk_create(bulk) self.stdout.write(self.style.SUCCESS(推荐列表生成完成))这个命令可以配crontab定时执行比如每天凌晨跑一次。为什么选凌晨因为这时候用户活跃度低数据库压力小而且推荐结果第二天早上正好用上。4. 实操过程中的典型问题与排查记录4.1 内存溢出与矩阵稀疏问题第一次跑全量数据时程序直接崩了报MemoryError。原因是我用了稠密矩阵存评分数据10万用户×5万电影的矩阵每个元素8字节算下来要40GB内存普通服务器根本扛不住。解决方案是改用稀疏矩阵用scipy的csr_matrix存储内存占用直接降到几百MB。from scipy.sparse import csr_matrix def build_sparse_matrix(ratings_df): users ratings_df[user_id].unique() movies ratings_df[movie_id].unique() user_idx {u: i for i, u in enumerate(users)} movie_idx {m: i for i, m in enumerate(movies)} row ratings_df[user_id].map(user_idx) col ratings_df[movie_id].map(movie_idx) data ratings_df[rating].values matrix csr_matrix((data, (row, col)), shape(len(users), len(movies))) return matrix, user_idx, movie_idx稀疏矩阵的另一个好处是计算相似度时只遍历非零元素速度比稠密矩阵快很多。但要注意scipy的稀疏矩阵不支持所有numpy操作比如np.argsort就不能直接用需要先转成稠密数组再排序。这个转换只对单个物品的相似度向量做不会造成内存问题。4.2 推荐结果重复与多样性不足上线跑了一周后用户反馈推荐列表里总是那几部电影翻来覆去没有新意。排查后发现两个原因一是热门电影因为评分人数多相似度计算时容易被过度推荐二是算法没有做多样性控制相似度最高的物品往往集中在同一类型。解决方案分两步。第一步在评分预测时加入流行度惩罚项热门电影的预测评分乘以一个小于1的系数。第二步在生成Top-N列表时做类型去重同一类型的电影最多推荐3部。调整之后推荐列表的覆盖率提升了约40%用户点击率也有明显上升。def diversify_recommendations(predictions, movie_genres, max_per_genre3): genre_count {} diversified [] for movie_id, score in predictions: genres movie_genres.get(movie_id, ).split(,) primary_genre genres[0] if genres else unknown if genre_count.get(primary_genre, 0) max_per_genre: continue diversified.append((movie_id, score)) genre_count[primary_genre] genre_count.get(primary_genre, 0) 1 if len(diversified) 20: break return diversified4.3 冷启动与数据稀疏的应对策略新用户和新电影是推荐系统的经典难题。新用户没有评分数据协同过滤完全跑不起来新电影没有用户评分也不会被推荐给任何人。我的应对策略是分层处理。对于新用户前5次请求返回热门榜单同时在前端引导用户完成至少10部电影的评分。评分完成后系统自动切换到个性化推荐。对于新电影我设计了一个“编辑推荐”机制运营人员可以手动把新电影加入推荐池等积累到一定评分后再交给算法接管。还有一个技巧是用内容特征做补充。每部电影都有类型、导演、演员等元数据当协同过滤信号不足时用基于内容的相似度做兜底。比如用户喜欢诺兰的电影那诺兰的其他作品即使评分人数少也可以推荐。这个混合策略让新电影的曝光率提升了约25%。问题类型表现根因解决方案内存溢出程序崩溃MemoryError稠密矩阵占用过大改用scipy稀疏矩阵推荐重复列表总是那几部电影热门偏差缺乏多样性控制流行度惩罚类型去重冷启动新用户推荐不准无历史行为数据热门兜底引导评分数据稀疏相似度计算不准评分矩阵非零元素太少内容特征补充降维5. 性能优化与上线部署经验5.1 数据库查询优化Django ORM用不好就是性能杀手。我踩过的坑包括N1查询、全表扫描、缺少索引。最典型的一次是推荐列表页面加载要8秒用Django Debug Toolbar一查发现一个请求发了200多次数据库查询。原因是模板里循环遍历推荐列表时每次都去查电影详情。解决方案是用select_related和prefetch_related做预加载。推荐表和电影表是外键关系用select_related(movie)一次性把电影数据查出来查询次数从200降到3次。另外给Rating表的user_id和movie_id字段加了联合索引评分查询速度提升了近10倍。# 优化前 recommendations Recommendation.objects.filter(user_iduser_id) # 优化后 recommendations Recommendation.objects.filter( user_iduser_id ).select_related(movie).order_by(rank)[:20]5.2 缓存策略与异步任务推荐结果不需要实时计算所以缓存是必须的。我用Redis缓存了两层数据一是用户的推荐列表过期时间设24小时二是电影相似度矩阵这个计算最耗时过期时间设7天。缓存命中率稳定在95%以上页面响应时间从秒级降到了毫秒级。异步任务用Celery处理。数据采集、矩阵计算、推荐生成这些耗时操作都丢给Celery workerDjango只负责接收请求和返回结果。Celery的broker用Redis配置简单稳定性也够用。唯一需要注意的是任务超时设置我一开始没设超时有个任务卡死了导致整个队列堵塞后来加了soft_time_limit600才解决。5.3 部署方案与监控要点部署用的是Nginx Gunicorn Django的经典组合。Nginx处理静态文件和反向代理Gunicorn跑Django应用Supervisor做进程管理。服务器配置不用太高2核4G的云服务器就能跑起来因为计算密集型的任务都离线做了。监控方面我主要看三个指标推荐生成任务的执行时间、缓存命中率、接口响应时间。用Django的logging模块记录关键日志配合Sentry做异常报警。有一次推荐任务跑了3小时还没结束Sentry报警后排查发现是某个用户的评分数据异常多爬虫抓错了导致相似度计算量暴增。加了数据校验逻辑后就没再出现过。注意离线任务一定要设超时和重试机制否则一个异常任务可能拖垮整个系统。6. 算法效果评估与迭代方向6.1 离线评估指标的选择推荐系统的效果评估不能只看准确率。我用了四个指标RMSE评分预测误差、PrecisionK前K个推荐中用户实际喜欢的比例、RecallK用户喜欢的电影中被推荐出来的比例、Coverage推荐列表覆盖的电影占总电影的比例。RMSE最低的是调整余弦相似度方案约0.82Precision10最高的是混合策略约0.23Coverage最高的是加入多样性控制后的版本约18%。这些数字单独看意义不大但对比不同方案的相对表现很有参考价值。比如纯ItemCF的Coverage只有9%加入多样性控制后翻了一倍说明算法确实在“探索”方面有改善。6.2 在线A/B测试的设计离线指标好不代表线上效果好。我设计了一个简单的A/B测试50%的用户用ItemCF推荐50%用混合策略推荐对比两组的点击率和评分转化率。跑了两周混合策略的点击率高出约12%但评分转化率差异不显著。这个结果说明混合策略在“吸引点击”方面有优势但“让用户真正去评分”还需要其他手段配合比如推荐理由展示、评分引导弹窗等。A/B测试有个坑要注意分流必须随机且稳定。我一开始用user_id % 2分流结果发现用户ID是连续递增的导致新老用户分布不均。后来改用hash(user_id) % 2才做到真正的随机分流。6.3 后续可扩展的方向这套系统目前是离线推荐后续可以往实时推荐方向走。比如用Redis存用户最近的行为序列用流式计算框架做实时特征更新推荐结果分钟级刷新。另一个方向是引入深度学习模型用神经协同过滤NCF替代矩阵分解在数据量足够大的情况下效果会更好。还有一个实用的扩展是推荐解释。用户看到推荐列表时如果能显示“因为你喜欢《盗梦空间》”或“和你口味相似的用户也喜欢”点击率会明显提升。实现方式很简单在推荐结果里存一个reason字段前端展示出来就行。我测试过加了推荐解释后点击率提升了约18%。这套Django协同过滤的推荐系统从零到跑通大概花了我三周时间其中一半时间在调算法参数和排查性能问题。如果你也想动手做一套我的建议是先把算法逻辑用Jupyter Notebook跑通确认效果后再集成到Django里。这样调试效率高很多不至于在Web框架和算法之间来回切换。另外数据质量比算法复杂度重要得多清洗好评分数据比换更花哨的模型管用。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进