用户协同过滤实战:用Django和SQLite构建音乐推荐系统
做推荐系统的念头是从一份播放日志开始的。当时我拿到一批脱敏后的音乐平台日志几百个用户、几千首歌、十几万条播放记录。我的初始想法特别朴素用户 A 把一首歌听了 20 遍那这首歌大概率是真爱和 A 口味相近的用户 B 在单曲循环什么可能也正是 A 想听的。沿着这个直觉往下走我花了两个星期用 Python、SQLite 和 Django 把一套基于用户协同过滤的音乐推荐系统完整跑通了。第一个周末算法能出推荐结果第二个星期把冷启动、缓存、并发这些工程问题也填得差不多。这篇文章不是教科书式讲解而是我从零搭这个项目的完整记录。我会讲清楚为什么放着矩阵分解、物品协同过滤、内容推荐这些方案不用偏偏挑用户协同过滤会给出能直接改改就用的 Django 模型和 Python 算法代码也会把 SQLite 锁、缓存穿透、推荐结果太热这些实测踩过的坑一个个拆开。适合谁看想拿推荐系统练手的同学、用 Django 做项目但不想只写增删改查的开发者以及所有想知道协同过滤到底怎么落地的人。读完之后你至少能复刻出一个能跑、能解释、能看评测指标的完整闭环。1. 为什么选用户协同过滤而不是矩阵分解方案对比与选型逻辑1.1 先聊一个反直觉的事实动手之前我问过自己一个问题推荐系统一定要很懂音乐吗基于内容的推荐得先给歌曲打标签流派、情绪、乐器、歌词主题。这一套做下来光数据清洗和特征工程就能把人耗死而且标签体系主观性很强——同一首歌有人觉得是民谣有人觉得是流行标准根本统一不了。基于物品的协同过滤ItemCF是成熟方案很多电商平台在用。它算的是物品之间的相似度用户买了 A 又买了 B那 A 和 B 算相似。音乐场景里这个逻辑成立但有个小毛病物品相似度矩阵随歌曲数量增长得很快几千首歌还好几十万首歌时矩阵规模就很夸张。而且物品关系相对稳定适合做猜你喜欢但不太能解释你为什么听到这首歌。用户协同过滤UserCF思路正好反过来它不关心歌曲长什么样只关心人之间的关系。如果用户 A 和我听了大量重叠的歌那么 A 单曲循环的那些歌哪怕我从来没听过也值得推给我。这个逻辑非常贴近现实里的安利行为——和你口味差不多的人推荐的歌往往比全网热门榜单更有惊喜感。1.2 五条路线的取舍表当时我认真对比了五条路线列成一张表贴在项目笔记里| 方案 | 核心思路 | 优点 | 缺点 | 实现成本 | | 基于热度 | 全站播放量排序 | 简单、稳定、冷启动友好 | 千人一面没有个性 | 最低 | | 基于内容 | 根据歌曲标签或文本特征找相似歌曲 | 不依赖用户行为数据 | 依赖高质量歌曲特征 | 中 | | 物品协同过滤 | 喜欢物品 A 的用户也喜欢物品 B | 精度稳定、适合猜你喜欢 | 物品矩阵大、解释性一般 | 中高 | | 用户协同过滤 | 口味相似用户听的歌互相推荐 | 可解释性强、贴近社交口碑 | 新用户冷启动难 | 中 | | 矩阵分解 | 把用户-物品评分矩阵分解成隐向量 | 精度天花板高 | 调参复杂、可解释性差 | 高 |选 UserCF 有四个具体原因。第一我的数据规模是几百用户、几千首歌用户相似度矩阵撑死了几万对内存完全扛得住算法跑一遍只要几秒。第二这个场景对可解释性要求高推荐结果可以直接说因为口味相近的用户在听某某歌调试和演示都直观。第三UserCF 的数学门槛低余弦相似度加加权平均一个下午就能写通非常适合作为理解推荐系统的起点。第四它和社交口碑传播的直觉一致。在音乐这种强口味依赖的场景里朋友和口味相近的陌生人的推荐往往比算法算出来的隐向量更有说服力。1.3 Django SQLite 的边界在哪里技术栈选 Django SQLite不是因为它有多强而是因为对演示级系统来说它足够清爽。SQLite 是单文件数据库零配置文件把文件拷走整个库都能带走对本地开发和教学场景很友好。十几万条播放记录、几千首歌这个数据量对 SQLite 来说完全是毛毛雨常规聚合查询毫秒级返回。Django ORM 又能把聚合查询写得很直观group by、annotate、sum 一套组合下来不用写一行原生 SQL。但边界也清楚SQLite 对并发写支持弱多个进程同时写同一个数据库文件会报 locked。我一开始没注意压测时遇到一堆database is locked所以后来专门去开 WAL 模式。如果这个项目要上生产换生产级数据库是必然的但算法代码、模型结构、推荐流程都不用动只改数据库配置。因此我把它定位成能把推荐原理完整跑通的参考实现而不是高并发生产系统。2. 播放日志到评分矩阵数据建模的四个关键决策2.1 隐式反馈怎么变成分数音乐平台很少让用户给歌打分常见行为是播放次数、播放时长、跳过行为、收藏、分享。这些统称隐式反馈和给电影打五星的显式反馈不同没有天然的 1 到 5 分刻度。如果把播放次数直接当分数会有两个问题。第一播放次数受歌曲时长影响三分钟的口水歌和十分钟的纯音乐听完一次的时间成本完全不同。第二单曲循环的用户和浅尝辄止的用户同样播放 10 次代表的口味强度完全不同。我用的公式是对数刻度score min(5.0, 1 log2(play_count))。播放 3 次以上才进入评分矩阵低于 3 次视为噪音。播放 3 次约得 2.58 分播放 10 次约 4.32 分32 次封顶到 5 分。对数函数保证越听越多边际收益递减——从 3 次到 10 次是明显口味升级但从 100 次到 200 次差别就不大了。阈值取 3是我在数据上扫过一圈后的选择低于 3 次的记录里相当一部分是用户随手点开又切走的无效行为。2.2 Django 表结构与 unique_together整个项目只用了三张核心表用户表、歌曲表、播放记录表。from django.db import models class User(models.Model): username models.CharField(max_length50, uniqueTrue) created_at models.DateTimeField(auto_now_addTrue) class Meta: db_table rec_user class Song(models.Model): title models.CharField(max_length200) artist models.CharField(max_length100) duration models.IntegerField(help_text歌曲时长单位秒) class Meta: db_table rec_song class ListenRecord(models.Model): user models.ForeignKey(User, on_deletemodels.CASCADE, related_namerecords) song models.ForeignKey(Song, on_deletemodels.CASCADE, related_namerecords) play_count models.IntegerField(default0) play_seconds models.IntegerField(default0) updated_at models.DateTimeField(auto_nowTrue) class Meta: db_table rec_listen unique_together (user, song)ListenRecord 的设计有个关键点它不是一次播放一行而是一个用户对一首歌一行play_count 存累计次数。每次播放事件进来做 upsert次数加一表行数不会爆炸。unique_together 约束保证了同一对 user-song 不会出现两行后面构建评分矩阵时不需要额外去重。play_seconds 字段在第一个版本里没有参与算分但我特意保留下来为后续引入完整播放率评分留了口子。数据建模时多留一个原始字段比将来要加的时候再迁移表结构便宜得多。2.3 评分矩阵的构建为什么放内存而不是放数据库算法计算时要反复读取每个用户的评分向量这种频繁的随机访问放在 ORM 查询里会非常痛苦。我的做法是启动时一次性把所有有效评分加载成 Python 的字典嵌套结构之后所有计算都在内存里完成。from collections import defaultdict import math def build_rating_matrix(): records ( ListenRecord.objects .filter(play_count__gte3) .values_list(user_id, song_id, play_count) ) matrix defaultdict(dict) for user_id, song_id, play_count in records: score min(5.0, 1.0 math.log2(play_count)) matrix[user_id][song_id] score return {uid: dict(items) for uid, items in matrix.items()}为什么不用数据库临时表因为相似度计算需要按用户取向量和按歌曲找用户列表两种视角交错访问。关系数据库的行存储对第二种访问不友好而内存里的双重字典可以在常数时间内取到任意用户向量。几百个用户、几千首歌的规模这个结构只占几 MB 内存完全没问题。这里有个容易被忽略的细节最后一行我把 defaultdict 转成了普通 dict。因为在调试时如果代码不小心访问了不存在的 keydefaultdict 会静默创建一个空字典把逻辑错误藏起来。转成普通 dict 后同样的错误立刻抛 KeyError排查效率高很多。3. 相似度计算与推荐生成把算法公式翻译成代码3.1 余弦相似度与皮尔逊系数的选择相似度公式是协同过滤的灵魂。两个用户口味像不像在数学上就是两个评分向量之间的相似度。最常用的两个是余弦相似度和皮尔逊相关系数。余弦相似度看的是两个向量的夹角向量越接近夹角越小cos 值越接近 1。它天然适合稀疏向量不要求两个用户给同样数量的歌打过分数。皮尔逊相关系数在此基础上多一步先各自减去均值再算夹角相当于只比较偏离自己习惯的部分。我最初直接用余弦相似度后来对比了皮尔逊发现结果差异不大。原因也好理解我的评分来自播放次数的对数变换都是正数用户间的系统性偏差没有显式评分场景那么大。最终代码保留余弦相似度但加了两条工程保护两个用户共同听过的歌少于 2 首直接判定相似度为 0避免单点重合带来的偶发高分。相似度低于 0.05 的邻居直接丢弃减少噪音。3.2 邻居选取与相似度代码相似度函数本身很直接def cosine_similarity(vec_a, vec_b): common set(vec_a.keys()) set(vec_b.keys()) if len(common) 2: return 0.0 dot sum(vec_a[s] * vec_b[s] for s in common) norm_a math.sqrt(sum(v * v for v in vec_a.values())) norm_b math.sqrt(sum(v * v for v in vec_b.values())) if norm_a 0 or norm_b 0: return 0.0 return dot / (norm_a * norm_b)有了相似度函数就可以给每个用户找 Top-K 邻居。K 我取了 30这个参数不需要太纠结K 太小邻居代表性不足K 太大会把弱相关用户也拉进来。30 到 50 在小规模数据集上表现都稳定。def build_neighbors(rating_matrix, top_k30): users list(rating_matrix.keys()) neighbors {} for i in range(len(users)): scored [] for j in range(i 1, len(users)): sim cosine_similarity(rating_matrix[users[i]], rating_matrix[users[j]]) if sim 0.05: scored.append((users[j], sim)) scored.sort(keylambda item: item[1], reverseTrue) neighbors[users[i]] scored[:top_k] return neighbors这个双层循环的复杂度是 O(U² × 平均歌曲数)几百个用户毫无压力。但必须说清楚用户量到几万这个写法就跑不动了需要 MinHash、LSH 这类近似算法或者换成离线任务。这条是重要的扩展边界演示项目可以忽略生产项目必须在架构设计时就想到。3.3 预测分数的均值偏移写法有了邻居和相似度预测用户对某首歌的评分就是把邻居里听过这首歌的用户的评分按相似度加权平均。但直接加权平均有个问题——有些人习惯给高分有些人天生手紧平均值会带上个人习惯的偏差。所以我用均值偏移版本pred(u, s) mean_u Σ sim(u, v) × (r_vs − mean_v) / Σ |sim(u, v)|意思是先看邻居对这首歌的评分相对他自己平均偏好偏离多少再把偏移量按相似度加权最后加回用户自己的平均偏好。def predict_score(user_id, song_id, rating_matrix, neighbors, mean_scores): weighted 0.0 total_abs_sim 0.0 for neighbor_id, sim in neighbors.get(user_id, []): score rating_matrix[neighbor_id].get(song_id) if score is None: continue weighted sim * (score - mean_scores[neighbor_id]) total_abs_sim abs(sim) if total_abs_sim 0: return mean_scores[user_id] return mean_scores[user_id] weighted / total_abs_sim分母用 abs(sim) 而不是 sim是为了防止负相似度邻居把加权和抵消成零。负相似度在数学上表示口味相反但推荐场景里相反信息用处不大直接保留绝对值权重更稳妥。这个细节是我调 bug 时发现的用 sim 做分母结果被几个负相关用户拉得忽高忽低改成 abs 之后立刻稳定。3.4 Top-N 排序与去已听过滤预测函数写好后推荐生成就是三步选候选集、批量预测、排序取前 N。候选集我取的是所有邻居听过的歌里当前用户没听过的。这里如果写得太粗暴会出现一个问题某些热门歌被大量用户收进向量反复出现在候选集里最后推荐结果被热门歌霸榜。所以做了两步缓解过滤掉用户已经听过的歌这是必须的否则推荐会推已知歌曲毫无意义。对候选池里全站播放量过高的歌做降权或者在 Top-50 里做一次带随机性的重排让结果不至于太集中。def recommend(user_id, rating_matrix, neighbors, mean_scores, top_n20): heard set(rating_matrix.get(user_id, {}).keys()) candidates {} for neighbor_id, sim in neighbors.get(user_id, []): for song_id, score in rating_matrix[neighbor_id].items(): if song_id in heard: continue pred predict_score(user_id, song_id, rating_matrix, neighbors, mean_scores) if pred candidates.get(song_id, 0): candidates[song_id] pred ranked sorted(candidates.items(), keylambda item: item[1], reverseTrue) return [song_id for song_id, _ in ranked[:top_n]]这段代码的性能不是最优的每个候选歌都重新扫一遍邻居实际可以复用预测中间结果。但对演示规模来说一次推荐请求几十毫秒就完成优化反而增加复杂度。我的建议是先跑通再用性能工具定位瓶颈不要一上来就过度设计。4. Django 集成把协同过滤变成可以访问的 Web 服务4.1 算法层与 Web 层分离最开始我把算法代码直接写在 views.py 里结果视图函数越来越臃肿算法参数和 HTTP 逻辑混在一起调试一次要翻好几个函数。后来我把算法拆成独立的 service 模块目录结构如下music_reco/ ├── manage.py ├── reco/ │ ├── models.py │ ├── views.py │ ├── urls.py │ └── services/ │ ├── cf_engine.py # 相似度、邻居、预测、推荐 │ ├── matrix.py # 评分矩阵构建 │ └── cache_helper.py # 缓存读写封装分层的直接收益是算法逻辑可以被 Django 管理命令、接口视图、单元测试三个地方共同复用不会出现测试环境和线上环境算法不一致的尴尬局面。4.2 接口设计与模板渲染推荐接口我做了两个入口一个返回 JSON 给前端调用一个走 Django 模板直接渲染页面。核心逻辑完全一致只差出口。from django.http import JsonResponse from django.views.decorators.http import require_GET from reco.services.cache_helper import get_user_recs require_GET def api_recommend(request): if not request.user.is_authenticated: return JsonResponse({code: 401, message: 需要登录}, status401) songs get_user_recs(request.user.id, top_n20) data [{id: s.id, title: s.title, artist: s.artist} for s in songs] return JsonResponse({code: 0, data: data})JSON 接口的好处是跟前端解耦后续换成任何前端框架甚至客户端都很容易。模板渲染的好处是演示方便浏览器直接打开就能看到完整推荐页。两个都留是因为这本来就是给人看的 demo多一个展示入口就多一份说服力。4.3 缓存与预计算让慢接口变成秒开第一版接口慢到让人绝望每次请求都重建评分矩阵、算所有用户邻居耗时接近两秒。本地单用户访问还能忍刷新几次页面就开始烦躁。解法分两层。第一层邻居表预计算并缓存。相似度矩阵在数据不变时是恒定的完全没必要每次请求都算。我用 Django 缓存框架把邻居表序列化后存起来设 6 小时过期。第二层每个用户的 Top-N 推荐结果也缓存键是用户 ID过期时间同样是 6 小时。这样大部分请求命中的是纯内存读取响应时间从两秒降到几十毫秒。def get_user_recs(user_id, top_n20): cache_key freco:user:{user_id}:v2 song_ids cache.get(cache_key) if song_ids: return Song.objects.filter(id__insong_ids) song_ids compute_and_cache(user_id, top_n) return Song.objects.filter(id__insong_ids)缓存键里我特意加了 v2 后缀这是版本号。以后改了算法或者调了参数只要把版本号改成 v3所有用户缓存自动全部失效不用手动清缓存。这个习惯救过我一次有一次改了相似度阈值没清缓存页面还是旧结果排查半天才想到是缓存问题。加上版本号后这类问题彻底消失。4.4 管理命令做定时重建缓存过期后第一个访问的用户还是会踩一次全量计算。为了避免这个冷缓存惩罚我写了一个 Django 管理命令可以手动或通过计划任务定时重建邻居表并写入缓存# reco/management/commands/rebuild_neighbors.py from django.core.management.base import BaseCommand from reco.services.matrix import build_rating_matrix from reco.services.cf_engine import build_neighbors from reco.services.cache_helper import save_neighbors class Command(BaseCommand): help 重建用户相似度表并写入缓存 def handle(self, *args, **options): matrix build_rating_matrix() neighbors build_neighbors(matrix, top_k30) save_neighbors(neighbors) self.stdout.write(f重建完成共 {len(neighbors)} 个用户的邻居表)配合系统计划任务每天凌晨跑一次白天所有请求都打缓存整体体验接近静态页面。对演示项目来说这个方案足够不需要上消息队列、任务队列一类的重型组件。5. 冷启动、稀疏性与效果评估实测中绕不开的四个坎5.1 新用户没数据怎么办多级兜底协同过滤最大的天敌是没有数据的用户。新用户没有播放记录就没有评分向量也就找不到邻居推荐系统在这一刻是失明的。我的兜底方案分三级新用户无任何记录返回全网热门歌曲按播放量排序。新用户有零星记录但不足 3 首返回他听过的歌所属歌手的其他热门歌曲。新用户记录达到阈值后切换到 UserCF 推荐。第三级其实是 UserCF 的天然特性只要用户有向量哪怕只有几首歌也能和老用户算出相似度只是结果相对粗糙。我在阈值上选择评分矩阵里存在该用户的条目也就是至少有一首歌播放次数达到 3 次。另外评分记录很稀疏的用户缓存时间缩短为 1 小时保证推荐能跟上用户口味的快速变化。5.2 新歌永远出不来需要内容侧信息新歌的问题是另一个方向一首歌刚上线还没有任何人听过没有任何用户向量包含它它在协同过滤里是隐形的。任何基于行为数据的算法都无法处理这个场景这是协同过滤的先天缺陷。我在这版项目里采用了一个折中方案候选集生成后按用户历史最喜欢的歌手的 Top-N 歌曲做一次补充注入保证新歌也有少量曝光机会。真正完整的解法需要内容特征参与也就是混合推荐——先用歌曲的声学特征、文本特征做基于内容的召回再用协同过滤做精排。这块我留在后续计划里没有在当前版本落地但设计文档里把事情讲清楚比假装系统很完美重要得多。5.3 相似度噪音与稀疏矩阵几百用户、几千首歌的矩阵非常稀疏。我的数据里平均每个用户只听过 40 首左右的歌占歌曲总量的 1% 都不到。两个用户共同听过的歌经常只有个位数。这种条件下相似度很不稳定两个人可能只是偶然都听了某首爆款歌就莫名其妙成了最相似用户。应对手段就是前面提到的两条共同歌曲数少于 2 直接判为 0以及用对数变换后的平滑分数而不是原始播放次数。另外相似度阈值设 0.05看似低但在稀疏数据里已经能滤掉一大半无效邻居。如果矩阵更稀疏还可以引入 damping 系数形如 sim × common / (common 5)让共同歌曲数少的相似度进一步缩水。这个公式里的 5 是经验值需要根据数据分布调整。5.4 离线评测怎么验证推荐不是瞎猜不评测的推荐系统等于没有反馈。我采用的方案是时间切分把播放记录按时间排序前 70% 做训练集后 30% 做测试集。训练集构建矩阵和邻居表测试集里把用户真实听过的新歌和推荐结果做匹配。重点看了四个指标| 指标 | 含义 | 我这里的实测参考值 | | Precision10 | 推荐 10 首里用户真正听了多少 | 约 12% | | Recall20 | 测试期用户听过的歌有多少被推荐命中 | 约 18% | | Coverage | 被推荐过的歌曲占歌曲总量比例 | 约 45% | | 歌手多样性 | 推荐列表里的歌手数量 | 平均约 8.6/10 |这几个数字谈不上好看但对几百用户、几千首歌的演示系统来说已经能证明算法确实在起作用远比随机推荐和纯热门推荐有区分度。真正重要的是有了这套评测管线以后改参数、换公式都能用同一标准对比而不是靠感觉好像推荐得不错。6. 踩坑记录与收尾SQLite 锁、缓存穿透、推荐太热和后续方向6.1 SQLite 并发写的坑与 WAL 模式压测时暴露的问题用并发工具同时打几十个请求服务端频繁报 database is locked。原因很明确SQLite 默认的 rollback journal 模式下写操作锁住整个数据库文件高并发写入直接撞锁。解法是开 WAL 模式并设置 busy timeout。WAL 模式下读操作不阻塞写操作写操作之间改用 WAL 文件的锁协调并发能力大幅提升。我用 Django 的 connection_created 信号在任何数据库连接建立时自动执行 PRAGMAfrom django.db.backends.signals import connection_created def activate_wal(sender, connection, **kwargs): if connection.vendor sqlite: cursor connection.cursor() cursor.execute(PRAGMA journal_modeWAL;) cursor.execute(PRAGMA busy_timeout5000;) connection_created.connect(activate_wal)同时 settings.py 里给 SQLite 连接加了超时时间双保险。改完后再压测锁错误消失。这个坑很典型本地开发单线程完全正常一上并发出问题。最重要的经验是别相信开发环境没问题就等于系统没问题。6.2 缓存穿透与锁标记缓存方案做完了又踩了一个新坑邻居表缓存过期后第一个请求触发全量计算耗时特别长。如果这时候多个请求同时来它们都发现缓存为空于是各自算一遍全量数据数据库和 CPU 瞬间被打满。这是典型的缓存穿透。处理办法不复杂计算之前用一个临时占位标记标记存在时其他请求直接返回热门兜底而不是重复计算。def compute_and_cache(user_id, top_n20): lock_key freco:building:{user_id} if cache.add(lock_key, True, timeout300): try: # 实际计算逻辑... cache.set(freco:user:{user_id}:v2, song_ids, timeout6 * 3600) return song_ids finally: cache.delete(lock_key) return hot_song_ids(top_n)cache.add 只有在键不存在时才会成功所以同时来的多个请求里只有一个能进入计算逻辑其余都走热门兜底。这个模式在分布式环境里就是最简单的锁足够应付演示项目。配合前面的缓存版本号缓存体系才算闭环。6.3 推荐结果太热的体验问题与多样化系统跑通后我发现一个体验问题推荐结果里经常七八首歌都是热门榜上的歌用户反馈这跟排行榜有什么区别。原因前面说过热门歌被大量用户收录进评分向量在候选集里出现频率高预测分数也容易被拉高。解决办法是排序后加一步多样化按歌手聚类限制同一个歌手最多出现两首歌然后把 Top-50 结果做一次带权重的随机重排权重和预测分数挂钩但保留一定随机概率。改动后歌手多样性从平均 6 首提升到 8.6 首推荐列表终于像个私人歌单而不是榜单搬运。这个改动不动算法核心但用户感知差异巨大——推荐系统里的多样性往往比多一两个百分点的精度重要得多。6.4 项目后续还能往哪走这个项目到能稳定跑通接口、有评测指标、扛得住并发压测就算到了可以收尾的状态。如果继续深入我自己的优先级排序是加基于内容的召回通道解决新歌冷启动。用矩阵分解替换 UserCF看评测指标能否提升。把 SQLite 换成生产级数据库把单机缓存换成独立缓存服务解决部署层面的扩展性。给推荐结果加为什么推荐这首歌的解释功能UserCF 的可解释性本来就是强项。这四件事每件都是一个独立的小项目但底层的模型、接口、评测管线都不用推翻重来。这也是我坚持把算法层和 Web 层分离的原因想换推荐算法时不需要动页面代码不需要动数据库表只需要换一个 service 模块的内部实现。回头再看这套系统我最大的体会是推荐算法从论文到能用中间隔着的不是数学公式而是数据理解、工程取舍和产品直觉。UserCF 的公式几行就能写完但评分矩阵怎么从日志里抠出来、邻居表要不要缓存、推荐结果怎么不让热门歌霸榜这些问题才是真正花时间的地方。如果你也想动手做一个音乐推荐系统我的建议很直接先找一份真实的历史数据别用拍脑袋构造的假数据硬着头皮跑通一遍完整闭环。踩坑过程本身才是这个项目最有价值的教程。