先说个可能让你意外的观察:搜“基于Django+协同过滤算法的歌曲推荐系统”这个组合关键词,能找到的方案一大把,但绝大多数都停留在一个能注册、能登录、能选几首歌、能返回一个推荐列表的Demo层面。代码能跑,你不能深问。比如相似度矩阵怎么算、稀疏评分矩阵怎么办、冷启动用户返回什么、评分数据从哪来,很多人一问就会卡壳。这篇博文是我自己从零开发这套系统的完整复盘,会讲清楚每个设计决策背后“为什么这么选”,目标是让它真正变成能写进简历、能应付课程设计答辩、日后还能往生产系统方向扩展的完整项目。
如果你正准备做一个和推荐相关的Web项目,或者已经写了一半但总觉得“算法是被硬塞进Django里的”,那这篇文章值得你读完整。我不打算只给一份能跑的代码,更多是把我踩过的坑和最终采用的工程方案摊开来讲。
1. 歌曲推荐这个选题为什么适合做Django实战
1.1 这类项目真正练的是什么能力
Django本身是一个“快速搭建完整应用”的Web框架,很多教程会教你ORM、模板渲染、表单和认证,但很少让你认真去考虑数据结构和算法。协同过滤算法单独学又很容易陷进数学推导里,不知道数据到底怎么进、怎么出。把两者放在一个项目里,刚好补齐了这条完整链路:数据从哪来、模型怎么设计、算法怎么算、结果怎么通过API发给前端。
我见过不少课程设计和求职项目,推荐逻辑就是“找到同一首歌的所有评分,求平均然后排个序”,这本质上是排行榜,不是协同过滤。真正的协同过滤要求你处理用户与物品的关系矩阵、计算相似度、做加权预测。这个过程里牵扯到的数据建模、性能优化、缓存策略,才是这个项目真正的价值所在。
1.2 为什么是歌曲,不是电影、图书或者商品
我最早其实想写电影推荐,后来发现歌曲场景有一个别人很少提的优势:元数据极简。电影推荐需要导演、演员、剧情简介、海报甚至获奖信息,准备数据的过程比写算法还痛苦。图书更麻烦,一本书的主题归属模糊,评分人群和使用场景差异极大。商品推荐则要面对大量属性维度以及价格、促销等动态信息。
歌曲不一样,一首歌的核心字段就四个:歌名、歌手、流派、时长。一套几百首歌的数据集,手动构造都能有模有样。更关键的是歌曲的行为数据密集,一个小型音乐社区里,用户每天点播几十次很常见,几百个用户几周就能攒下数万条交互记录。密度越高,协同过滤越容易算出有价值的结果。而且推荐结果特别好自检——你听完推荐列表里的“相似歌曲”,会马上判断这推荐靠不靠谱,不用借助任何评测工具。
2. 协同过滤的核心计算:从评分矩阵到TopN推荐
2.1 先别急着写代码:UserCF和ItemCF的区别
很多人一听到协同过滤就开始写余弦相似度函数,写了半天都不知道自己在做“基于用户”还是“基于物品”。这两者从数据结构上说,只差一个转置,但业务含义完全不同。
| 算法类型 | 核心假设 | 适用场景 | 工程特点 |
|---|---|---|---|
| UserCF(基于用户) | 和你口味相似的人喜欢的,你也可能喜欢 | 用户数远小于物品数、用户画像稳定的社区 | 在线找相似用户成本高,实时性差 |
| ItemCF(基于物品) | 喜欢你听过的歌的人,也喜欢另一首歌 | 物品数量稳定、用户行为充足的场景 | 相似度矩阵可离线算好,线上直接查表 |
歌曲场景里,歌的数量通常比用户数量少一个量级。一个只有几千用户的社区,歌曲可能只有几百首到几千首;而且一首歌的风格不会频繁变化,歌与歌之间的相似关系一周之内都很稳定。这正好是ItemCF的主场。算法可以提前把“歌与歌之间的相似度矩阵”算好,用户请求推荐时,只做一个矩阵乘法加排序,性能非常可观。
2.2 一次手算彻底搞懂相似度与预测分数
我始终觉得,想理解协同过滤,与其读十篇公式推导,不如亲手算一遍小样本。
假设有三个用户和四首歌,数据如下:
| 用户 | 歌A | 歌B | 歌C | 歌D |
|---|---|---|---|---|
| U1 | 5 | 3 | 未评分 | 未评分 |
| U2 | 5 | 未评分 | 4 | 2 |
| U3 | 未评分 | 3 | 5 | 4 |
这里“未评分”不参与计算,我把它当作占位符。目标是为U1预测对歌C和歌D的偏好分数。
先用余弦相似度衡量用户之间的口味相似度。U1的向量是(5, 3),U2在共同维度上只有歌A有评分,向量取(5, 0);真正参与余弦计算的只有两个向量的非零共同维度,也就是歌A。U2向量在B、C、D上虽然都有值,但U1没有评分,就不能直接拿来算内积。不过简化起见,工程实现里一般会把未评分置为0,然后对整个向量算余弦,这里我按完整向量算一遍方便你核对数字。
向量形式:
- U1 = (5, 3, 0, 0)
- U2 = (5, 0, 4, 2)
- U3 = (0, 3, 5, 4)
cos(U1, U2) = 5×5 / (√34 × √45) ≈ 25 / 39.12 ≈ 0.639
cos(U1, U3) = 3×3 / (√34 × √50) ≈ 9 / 41.23 ≈ 0.218
然后预测U1对歌C的评分:
score(U1, C) = (0.639×4 + 0.218×5) / (0.639 + 0.218) ≈ 4.25
对歌D:
score(U1, D) = (0.639×2 + 0.218×4) / (0.639 + 0.218) ≈ 2.50
计算结果是歌C明显高于歌D,把歌C放进Top1推荐。整个过程里要注意的是,0.639这个相似度权重很大,因为U2和U1共同喜欢歌A;U3虽然也给了歌B高分,但它只和U1有一首共同评分,所以权重低。这个例子很直观地展示了协同过滤的本质:用共同偏好加权投票。
2.3 为什么选余弦相似度而不是欧氏距离
初学者容易在这里踩坑。欧氏距离衡量的是两个向量之间的绝对距离,但评分体系里有一个人习惯打3分算合格、5分算神作,另一个人觉得4分以下都是垃圾,两人的绝对分差很大,欧氏距离会认为他们完全不同。可实际上,他们对歌曲的排序偏好可能高度一致。
余弦相似度只关心向量的方向,把每一行的数值整体缩放之后,方向和口味才会显露出来。换句话说,一个用户给几首歌打(3, 4, 5),另一个打(4, 5, 6),虽然绝对分差完全不同,但余弦相似度会接近1。这一点在真实数据里极其重要,因为用户打分的尺度不可能统一。
另外,真实工程里还会用皮尔逊相关系数做中心化处理,等于是先把每个用户的平均分减掉再算相似,对“习惯性低分用户”和“习惯性高分用户”的容忍度更高。如果只是做一个课程设计,余弦相似度已经完全够用。
3. Django数据模型与查询性能:推荐系统的底座
3.1 三个核心模型这样设计
Django模型是整个推荐系统的地基。很多教程会让你把“用户收藏的歌曲”存成一个JSON字段或者多对多关系,当时觉得很方便,后面做算法时想死的心都有。我的方案是拆成三个模型:歌曲表、评分表、以及系统自带的用户表。
from django.db import models from django.contrib.auth.models import User class Song(models.Model): title = models.CharField(max_length=200) artist = models.CharField(max_length=100, db_index=True) genre = models.CharField(max_length=50, blank=True) duration = models.IntegerField(default=0) # 单位:秒 play_count = models.IntegerField(default=0) # 全局播放次数,用于热门兜底 class Meta: indexes = [ models.Index(fields=["artist", "genre"]), ] def __str__(self): return self.title class Rating(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="ratings") song = models.ForeignKey(Song, on_delete=models.CASCADE, related_name="ratings") score = models.FloatField(default=0) # 1~5,用于协同过滤 created_at = models.DateTimeField(auto_now_add=True) class Meta: unique_together = ("user", "song") indexes = [ models.Index(fields=["user", "score"]), ]为什么评分必须独立成表,而不是在Song表上挂一个用户外键?因为推荐系统的核心是“用户-物品”二元关系。一个用户对很多首歌产生很多条评分,一首歌也被很多用户评分,天然是多对多关系。独立成Rating表之后,Django的ORM查询、算法取数、后续加行为日志字段,都会轻松很多。
unique_together保证同一个用户对同一首歌只有一条评分记录,防止前端重复提交或者脚本重复写入。索引里我特意加了(user, score),因为系统最常见的查询是“这个用户评过哪些歌”,这个查询会直接作为ItemCF的输入。
3.2 灌入数据:用脚本模拟真实用户行为
没有真实业务数据,就得自己造。很多项目死在这一步:直接随机生成评分,最后算出来的推荐结果完全是一堆噪声。
我写的模拟脚本只做三件事。第一,给虚拟用户随机分配两个主要流派偏好,比如“喜欢摇滚和电子,对民谣基本无感”。第二,只从用户偏好的流派里挑歌来评分,分数服从偏正态分布,喜欢的歌打高分,一般的歌打中分。第三,给每个用户留出10%左右的歌完全不打分,模拟真实场景里的“听都没听过”。
这样做生成的矩阵虽然不是真实数据,但具备真实数据最关键的群聚效应:相似的人确实有相似偏好。有了这个特征,协同过滤算出来才有意义。如果完全随机打分,你再怎么调算法,推荐结果都是一堆莫名其妙的歌。这一点在项目演示里特别重要,因为评委通常会随机抽查几个用户看推荐列表,如果明显有偏好的用户得到的是混乱推荐,项目可信度会大打折扣。
3.3 推荐结果展示时的N+1查询问题
Django的ORM用起来很顺手,但一个经典问题会在推荐场景里被放大:拿到推荐歌曲id列表之后,很多人会写for循环逐个查Song表。
# 错误示范:每首歌都产生一次数据库查询 songs = [] for song_id in recommended_ids: songs.append(Song.objects.get(id=song_id))如果推荐10首歌,就会出现1次获取推荐列表的查询,再加上10次Song查询。请求一多,数据库压力立刻上来。正确做法是把id列表一次传给ORM做批量查询:
from songs.models import Song song_ids = [101, 208, 315, 402, 509, 610, 707, 808, 906, 1003] songs = Song.objects.filter(id__in=song_ids)然后你需要保持顺序时,可以在Python里按id重新排一下。别小看这个细节,很多“推荐接口越到后面越慢”的案例,根因就在这里。
4. 推荐引擎的工程化实现:离线计算与在线查询
4.1 为什么歌曲场景必须离线算相似度矩阵
如果用户请求推荐时,现算“这首歌和他听过的每一首歌的相似度”,那响应时间会非常感人。假设有5000首歌,一次用户请求就要做几千次向量点积,数据库和Python的循环都会被拖垮。
歌曲场景的幸运之处在于,歌与歌之间的相似关系是慢变量。一首歌的风格、受众群体在几天内基本不会发生结构性变化。所以工程上一定要把计算拆成两层:离线层负责定期构建相似度矩阵,在线层只负责查表和排序。
离线任务可以每天凌晨跑一次,也可以每六小时跑一次。构建结果存成numpy文件或者直接丢进Redis。线上接口读取的是一份已就绪的矩阵,不用自己现场算相似度。
拿一个中等规模估算:5000首歌,相似度矩阵是5000×5000,也就是2500万个浮点数。用float32存储,大约100MB内存,单机完全扛得住。所以歌曲推荐这类物品数量可控的场景,用离线矩阵方案再合适不过。
4.2 ItemCF的核心代码结构
我工程里用的不是纯手工for循环,而是基于scipy稀疏矩阵和numpy向量化实现。思路是先构建用户-歌曲稀疏矩阵,然后计算歌曲之间的余弦相似度矩阵,最后用一次矩阵乘法完成推荐打分。
import numpy as np from scipy.sparse import csr_matrix from scipy.spatial.distance import cosine def build_item_similarity(user_item_matrix): """ user_item_matrix: csr_matrix,形状为 (n_users, n_items) 返回: item_sim,numpy数组,形状为 (n_items, n_items) """ n_items = user_item_matrix.shape[1] item_sim = np.zeros((n_items, n_items)) # 工程优化版不好读,先用直白循环帮助你理解 for i in range(n_items): for j in range(i + 1, n_items): vec_i = user_item_matrix[:, i].toarray().flatten() vec_j = user_item_matrix[:, j].toarray().flatten() # 这一步可以过滤掉都为零的维度,避免除零 mask = (vec_i > 0) & (vec_j > 0) if mask.sum() < 2: continue sim = 1 - cosine(vec_i[mask], vec_j[mask]) item_sim[i, j] = item_sim[j, i] = sim return item_sim def recommend_by_item(user_rating_vec, item_sim, user_rated_songs, top_k=10): """ user_rating_vec: 当前用户的评分向量,长度为 n_items item_sim: 由上面函数生成的相似度矩阵 user_rated_songs: 用户已经评过分的歌曲索引集合 """ scores = item_sim.dot(user_rating_vec) # 已经听过的歌不需要再次推荐 scores[list(user_rated_songs)] = -np.inf return np.argsort(scores)[-top_k:][::-1].tolist()这段代码的核心就是最后那个矩阵乘法scores = item_sim.dot(user_rating_vec)。它等价于:遍历用户评过分的每一首歌,把它的相似歌曲分数乘以用户对该歌的评分后累加。这就是最经典的ItemCF加权投票,也是前面手算例子的向量化版本。
注释里我已经留了一个优化点:计算余弦相似度时,先通过mask过滤掉两边都是零的维度。原因下一章会详细讲,这里先记住结论——0在评分矩阵里不代表“不喜欢”,它代表“没有反馈”,硬算会把相似度拉向0。
4.3 一次完整的推荐请求链路
从用户打开推荐页面到看到歌曲列表,整个链路我在工程里拆成六步:
- 请求进入Django视图,先通过request.user拿到当前用户。
- 查询Rating表,取出该用户所有评分记录。
- 如果一条记录都没有,直接走热门歌曲兜底接口,返回play_count全局排行。
- 有评分记录,就读取离线算好的相似度矩阵(内存变量或Redis缓存)。
- 把用户评分向量和相似度矩阵做矩阵乘法,得到候选歌曲分数,再过滤掉已评分的歌。
- 取TopN歌曲id,批量查Song表,序列化返回给前端。
这个链路里,真正耗时的地方只有第2步的一次数据库查询和第5步的一次矩阵乘法。十万级数据量下耗时基本在几十毫秒以内,对于个人项目和课程设计来说已经是很好的表现。
5. 推荐效果翻车的几个经典场景与修正策略
5.1 零分和“未评分”被混为一谈
这是我自己第一版代码最大的坑。你去看很多开源项目,评分矩阵都是把没评过分的格子直接填0,然后拿去算余弦相似度。表面上看矩阵很完整,算起来也不报错,但结果会逐渐变得莫名其妙。
原因是:用户A没听过歌X,并不代表他不喜欢歌X。如果矩阵里把“未评分”和“用户主动打了0分”都存成0,计算相似度时,这些大量无意义的零维度会被当成“共同偏好”处理。喜欢听周杰伦的用户和喜欢听德云社的用户,他们的评分向量里绝大多数元素都是0,余弦相似度的分子是共同非零维度上的内积,分母却带着双方庞大的零元素,计算结果会被无谓地稀释,相似度趋近于0。
修正方法有两类。第一类是在算余弦时只取两个向量共同非零的维度做内积,像4.2节代码里那个mask操作;第二类是用稀疏矩阵存储,不要手动给所有没评分的格子补0。我从头到尾只保留Rating表里有值的数据,构造稀疏矩阵时也只填充有评分的位置。
5.2 热门歌曲霸榜:需要逆流行度惩罚
ItemCF天然有一个毛病:热门歌曲的相似度矩阵里,它跟谁都“相似”。因为大多数人听过热门歌,热门歌与其他歌曲的同现次数极高,相似度容易被高估。结果就是不管用户听什么,推荐列表都会被头部爆款占掉大部分名额,冷门歌曲永远没有出头机会。
我在项目里加了一个简单的逆流行度惩罚因子,思路和TF-IDF一样:一首歌如果出现在太多用户的评分记录里,它的权重就降低。修正后的推荐分数变成下面这样:
[ score(u, s) = \sum_{i \in rated(u)} sim(s, i) \cdot r_{u,i} \cdot \frac{1}{\log(2 + freq(s))} ]
其中freq(s)是歌曲s在全站评分记录里出现的次数。歌曲越热门,分母越大,被推荐的优先级就越低。加了这一层之后,推荐列表里开始出现一些没那么大众但确实和用户口味匹配的歌,整体观感和实用性都提升明显。
要注意惩罚也不能太狠,否则会把热门歌曲完全排除,反而走向另一个极端。实际调参的时候,我卡在log底数和内层常数上试了好几轮,最后采用的方案是freq按整个评分集统计,热门度的阈值用全站评分数量的中位数做参照。
5.3 冷启动用户和冷启动歌曲的兜底策略
冷启动是任何推荐系统都躲不开的问题,也是最容易被课程设计忽略的一环。一个刚注册的用户,评分表里什么都没有,协同过滤拿什么算?直接报错或者返回空列表是最差的做法。
我做了三层兜底。第一层,新用户无评分时返回全站热门歌曲列表,按play_count排序;第二层,如果用户只评过一两首歌,相似度矩阵里那首歌的邻居数量不够,就结合歌曲的genre标签做同流派补全,把流派匹配的歌曲混进候选;第三层,前端引导用户先给三首歌打分,再进入首页推荐。
我实测下来的一个经验是:让新用户先选三首喜欢的歌再进首页,比直接丢一个空白推荐页效果好很多。这不光是算法问题,还是产品逻辑问题。三首评分足够让ItemCF产生第一批候选,推荐列表就不再是空白的。
5.4 评分习惯偏差:先中心化再算相似度
真实评分数据里,有的人打分范围就是4到5,有的人更愿意给2到4。前者和后者即使喜欢完全相同的歌,余弦相似度也会被绝对评分尺度干扰。处理思路是中心化:计算每条评分与该用户平均分的差值,再对差值矩阵算相似度。
中心化公式:
[ r'{u,i} = r{u,i} - \bar{r_u} ]
这样每个用户的评分分布都归到以0为中心,评分偏差方向相同的人相似度才会高。实现起来很简单,算相似度之前把Rating表里的分数减去用户平均分即可。歌曲侧也可以做类似处理,比如每首歌减去歌曲的平均分,能缓解“全体用户都给高分”的歌曲虚高问题。
6. 从单机Demo到可部署服务:接口、缓存与扩展方向
6.1 用DRF把推荐结果封成接口
Django自带视图函数可以直接返回JsonResponse,但一旦系统里有好几个数据模型和多个API,Django REST Framework会让你省很多事。我用了DRF的ModelSerializer和APIView来组织推荐接口。
from rest_framework import serializers, views, response from songs.models import Song class SongSerializer(serializers.ModelSerializer): class Meta: model = Song fields = ["id", "title", "artist", "genre", "duration"] class RecommendView(views.APIView): def get(self, request): user = request.user song_ids = get_recommendation_for_user(user.id) # 推荐引擎入口 if not song_ids: return response.Response({"items": []}) songs = Song.objects.filter(id__in=song_ids) return response.Response({ "items": SongSerializer(songs, many=True).data, "count": len(song_ids), })选择APIView而不是ViewSet,是因为推荐接口逻辑本身不算复杂,不需要默认的CRUD范式。APIView里写一个get方法就足够清晰。这里最值得注意的地方还是song查询必须批量进行,要避免serializer内部逐条数据库查询。
6.2 缓存推荐结果:计算一次,服务多次
推荐结果的缓存设计有个容易忽略的点:不是每次请求都重新算一遍完整推荐。单个用户的推荐列表肯定要缓存,但相似度矩阵更要缓存。
我按两层来做缓存。第一层是相似度矩阵,这是全站共享的全局数据,离线任务跑完之后写入Redis,key设计成item_sim:v1:{timestamp}这样的带版本号形式,方便任务更新时原子切换。第二层是单用户推荐结果,key设计成user_rec:{user_id},过期时间设成30分钟。用户每打一次分,就在打分视图里主动删除该用户的推荐缓存,保证下一次请求能看到最新推荐。
from django.core.cache import cache def get_user_recommendation(user_id): cache_key = f"user_rec:{user_id}" cached = cache.get(cache_key) if cached is not None: return cached song_ids = compute_recommendation(user_id) cache.set(cache_key, song_ids, timeout=60 * 30) return song_ids这个方案在产品原型阶段完全够用。真正上生产后,如果用户量变大,可以考虑把相似度矩阵直接加载到所有进程的共享内存里,再用Celery定时任务做离线重建,那就超出本文范围了。
6.3 下一步:从评分到行为,从ItemCF到混合推荐
课程设计做到这一步已经可以说是一个闭环项目,但真实场景里几乎不会有人手动给歌曲打1到5分。用户在音乐App里的行为是播放、收藏、跳过、完播。这些行为可以被映射成伪评分:播放一次+1,完整听完+2,收藏+5,跳过-3。映射完之后的矩阵依然是稀疏的,但比手动评分的覆盖面大得多。
我自己的一个体会是,从“能跑的Demo”往“可用的系统”走,最大的变化不是算法变复杂,而是数据来源变了。行为数据进来之后,推荐频率会远高于评分数据,冷启动问题更好解,系统的整体推荐质量也会有明显提升。
再往后就是混合推荐。ItemCF输出召回候选,排序层用一个简单逻辑回归或者XGBoost模型,输入特征是歌手重合度、流派重合度、歌曲热门度、用户历史平均评分等,最后再做一次多样性重排,保证一个页面上不会出现十首同风格的歌。如果你对AI开发感兴趣,现在也有AI Agent能帮你生成项目脚手架,但骨架可以自动搭,推荐算法的核心思路和数据结构必须自己吃透,不然出了问题,你可能连矩阵的shape都对不上。
最后分享一个带人的经验。我每次让新同学上手这个项目,都先要求把那三用户四首歌的手算案例徒手算一遍。算过的人,后面看任何ItemCF源码都不会懵;没算过的人,往往会卡在“为什么矩阵乘法一次就能算出加权总分”这一步上。你如果今天刚开始,建议也从这个手算开始,不要急着复制代码。