资讯动态

用Python实现音乐推荐系统:协同过滤、矩阵分解与评估

发布时间:2026/9/11 23:52:08 来源:尧图企业网站定制
简介面向推荐系统开发者的 Python 音乐推荐系统完整实现包包含核心推荐引擎源码和配套测试数据。压缩包共 12 个文件既有 2 个 Python 程序负责算法逻辑也有 2 份 CSV 数据集提供用户播放记录与歌曲元数据还有 8 张可视化图片帮助理解数据分布和推荐结果整体大小 6.31MB。已有 1460 人浏览学习。通过该工程可以系统掌握 Pandas 数据清洗、Scikit-learn 协同过滤建模、基于内容推荐的实践方法并了解冷启动、实时性等真实系统优化难点。适合推荐系统初学者作为第一个完整项目练习也能为中级开发者提供可扩展的代码结构。1. 音乐推荐系统的第一课把听歌记录变成一个排序问题做音乐推荐系统时第一件要认清的事是你手里几乎没有评分数据。用户不会像在电商里那样给歌打五星平台上留下的大多是播放次数、跳过行为、收藏动作和一首歌听了几秒就切掉。这就决定了用 Python 实现音乐推荐系统的主流路线把埋点日志折叠成用户-歌曲的行为矩阵再在矩阵上做协同过滤或矩阵分解最后输出一个候选歌曲的排序列表。本文面向想独立搭出一版可评估原型的数据工程师或后端开发从头到尾不依赖任何商业推荐服务从日志清洗、稀疏矩阵构建、item-based KNN一路写到 SVD / ALS 与 recallk、NDCG 验证每一步都有可直接运行的最小代码。2. 从播放日志到用户-歌曲矩阵Python 数据清洗与稀疏矩阵构建推荐系统的数据质量直接决定模型上限而音乐场景的数据天然是长尾 偏态 稀疏。这一章先把脏日志变成可喂给算法的三元组user_id, song_id, play_count再构建出内存可控的稀疏矩阵。2.1 数据怎么选Last.fm 播放日志与自建埋点日志的字段差异公开数据集和自建日志的字段设计差别很大选型前先看反馈类型是显式喜欢/评分还是隐式播放、跳过。常见的几类来源对比如下数据源关键字段反馈类型适合阶段Last.fm 360Kuser_id, artist, song, timestamp隐式播放原型验证、算法对比Million Song Dataset用户-歌曲计数、音频特征隐式播放内容特征实验自建埋点日志user_id, song_id, play_count, skip, ts, source隐式 半显式上线路由与个性化自建日志里source字段常被忽略但它非常有用凡是来自推荐位或歌单的播放在评估时需要单独拆出来否则你会在指标里把推荐带来的播放和用户主动搜索的播放混在一起后者的意愿强度完全不同。公开数据集则直接用timestamp近似播放顺序。2.2 用 pandas 清洗播放记录去重、过滤长尾与时间分桶拿到原始日志后第一步不是建模而是清洗。播放日志最常见的脏数据是重复上报、异常短播放和爬虫造成的灌水行为清洗逻辑一般长这样import pandas as pd import numpy as np df pd.read_csv(play_log.csv, parse_dates[ts]) df df.drop_duplicates(subset[user_id, song_id, ts]) # 埋点里有时会有不足 30 秒的试听即退出这类噪声在隐式反馈里应当剔除 if duration_ms in df.columns: df df[df[duration_ms] 30000] # 用户和歌曲都要有最低行为量否则矩阵会过稀 user_cnt df.groupby(user_id)[song_id].nunique() song_cnt df.groupby(song_id)[user_id].nunique() df df[df[user_id].isin(user_cnt[user_cnt 20].index)] df df[df[song_id].isin(song_cnt[song_cnt 5].index)] # 同一用户对同一首歌的多次播放聚合成一条记录 df df.groupby([user_id, song_id], as_indexFalse).agg( play_count(play_count, sum), last_ts(ts, max), )drop_duplicates针对的是客户端重试导致的重复上报duration_ms 30000这个阈值是把误触播放和真正听歌分开的常用做法。用户侧过滤阈值 20、歌曲侧 5 是经验值数据量大可以提高到 50/10冷启动阶段则要降下来否则新用户和新歌会被一并滤掉后面模型永远学不到长尾。2.2.1 播放次数取 log 还是保留原值播放次数的分布极度偏态头部歌曲被播放上万次长尾歌曲只有一两次。直接拿原始值当权重头部歌曲会在相似度和矩阵分解里统治一切。常见做法是做压缩变换df[play_weight] 1.0 2.0 * np.log1p(df[play_count])log1p即 log(1 x)保证播放次数为 0 时权重仍为 1。系数 2.0 控制压缩强度越大越接近原始计数。之后无论喂给 surprise 还是 implicit都用play_weight而不是原始次数。2.3 构建稀疏矩阵pivot_table 的坑与 scipy.csr_matrix 的正确打开方式新手最容易在这里踩爆内存直接用df.pivot_table(indexuser_id, columnssong_id, valuesplay_count)。假设 10 万用户、50 万首歌pivot 会产生 500 亿个格子即使大部分是 NaNpandas 仍会为这些位置分配索引结构内存直接失控。正确做法是只存非零三元组from scipy.sparse import coo_matrix, csr_matrix # 把字符串 id 转成连续整数索引 uid_cat df[user_id].astype(category) sid_cat df[song_id].astype(category) row uid_cat.cat.codes.to_numpy() col sid_cat.cat.codes.to_numpy() val df[play_weight].to_numpy() user_item coo_matrix( (val, (row, col)), shape(len(uid_cat.categories), len(sid_cat.categories)), ).tocsr() # 保留 id 与索引的映射后面评估和上线都要用 uid_map {v: i for i, v in enumerate(uid_cat.categories)} sid_map {v: i for i, v in enumerate(sid_cat.categories)}coo_matrix只存三个数组行号、列号、值tocsr()转成行压缩格式后续按用户取行做推荐会非常快。如果要估算内存按nnz * 4 字节int32 索引* 2 nnz * 8 字节float64 值粗算1000 万非零项大约占用 160MB这比 pivot 小两个数量级。提示不要把user_item直接.toarray()转成 numpy 稠密数组除非你确认n_users * n_items * 8 字节在你的内存预算内。调试时只切片某几行转稠密即可。3. 协同过滤选型为什么音乐场景优先做 item-based KNN矩阵建好后最朴素的推荐算法是协同过滤。协同过滤分用户协同User-based和物品协同Item-based在音乐场景里工程上几乎总是先试 item-based理由不是精度而是稳定性和可解释性。3.1 用户协同与物品协同在长短尾分布上的表现差异用户协同的思路是找口味相似的用户把他们听过的歌推荐给你。问题在于用户兴趣漂移快这个月在听摇滚下个月切到 Lo-fi相似用户集合随活跃度变化剧烈线上需要频繁重算用户相似度。而歌曲是相对稳定的实体一首歌的邻居在几个月内不会大变item-item 相似度可以离线全量算好线上只做查表。加上音乐推荐最常见的产品形态是相似歌曲续播和歌单补全物品协同的结果天然带解释因为你听过 A所以推荐与 A 相似的 B。3.2 相似度计算余弦、皮尔逊与调整余弦的适用边界相似度的选择比想象中影响更大。音乐场景里最常用的是余弦相似度但在隐式计数矩阵上三种相似度的表现差异明显相似度是否中心化适用数据典型问题cosine否播放次数高频用户和头部歌曲主导结果pearson对用户均值中心化显式评分共同交互对少时方差大adjusted cosine对用户均值中心化后算余弦隐式计数需要先算行均值多一步预处理pearson 在显式评分场景表现好但音乐数据的共同播放对往往很少两个用户都听过同一首歌绝不代表口味一致相关系数在样本量小时极不稳定。adjusted cosine 通过减去用户平均播放权重来消除有人什么歌都听的评分膨胀在 item-based 场景里更稳。3.3 用 scikit-surprise 跑通 item-based 协同过滤自己手写 KNN 需要处理相似度矩阵存储、邻居聚合、归一化一堆细节。用 scikit-surprise 可以在一段代码里跑通完整流程它是 Python 生态里最常用的推荐算法库封装了数据集划分、交叉验证和多种算法from surprise import Dataset, Reader, KNNWithMeans, accuracy from surprise.model_selection import train_test_split # surprise 只接收 user/item/rating 三列rating 列可以是压缩后的播放权重 reader Reader(rating_scale(1.0, 20.0)) data Dataset.load_from_df( df[[user_id, song_id, play_weight]], reader ) trainset, testset train_test_split(data, test_size0.2, random_state42) sim_options { name: cosine, # 相似度度量 user_based: False, # False item-based min_support: 3, # 两个 item 至少共同出现 3 次才算邻居候选 } algo KNNWithMeans(k40, min_k1, sim_optionssim_options) algo.fit(trainset) predictions algo.test(testset) rmse accuracy.rmse(predictions) print(fitem-based KNN RMSE: {rmse:.4f})代码里user_basedFalse是核心开关它让算法按歌曲计算相似度而不是按用户min_support3过滤掉只共现过一两次的偶然邻居。rating_scale(1.0, 20.0)要与play_weight的实际取值范围匹配它影响 surprise 内部对预测值的裁剪。3.3.1 KNNWithMeans 与 KNNBasic 的选择surprise 里有两个容易混淆的类KNNBasic直接取邻居的原始值做平均KNNWithMeans会先对每个 item 的得分做均值中心化再在邻居聚合后把均值加回来。播放次数是偏态分布用KNNWithMeans通常能比KNNBasic在 RMSE 上低 5% 到 10%因为它捕捉的是偏离平均水平的程度而不是绝对值。3.3.2 关键参数k、min_k、min_support 的联动这三个参数不是独立调的它们一起控制邻居集合的质量。经验顺序是先把min_support定在 3 到 5过滤掉噪声共现再扫k常见范围 20 到 80min_k是当候选邻居不足时的兜底值设 1 表示哪怕只有一个邻居也给出预测避免冷门歌曲直接预测失败。k 偏小会让推荐集中在小圈子k 偏大则会把无关歌曲拉进邻居集合实际中 40 到 60 是音乐场景比较平衡的区间。提示surprise 的train_test_split是随机切分。随机切分会把用户未来会听的歌泄漏进训练集导致评估虚高。真实项目里应该按时间切分做法在第 5 章给出。4. 矩阵分解与隐式反馈SVD 和 ALS 在音乐推荐里的分工KNN 的问题在于它只能利用共同出现的关系两首歌只要没有共同听众相似度就是零。矩阵分解通过把用户和歌曲映射到同一个低维隐空间让没有直接交互的 pair 也能算出得分这是它相对 KNN 的核心优势。4.1 矩阵分解到底分解了什么隐因子与口味漂移矩阵分解的数学形式是 R ≈ U × V^T其中 U 的每一行是用户在隐空间的向量V 的每一列是歌曲的向量预测得分就是两个向量的点积。隐因子没有名字但在音乐场景里通常对应流派、年代、人声/器乐比重、BPM 范围、情绪色彩这类潜在维度。用户口味漂移在隐空间里的表现就是用户向量随行为数据更新而移动因此线上一般会周期性重训而不是让模型无限累积旧行为。4.2 用 surprise 的 SVD 做评分预测超参数与验证在 surprise 里跑 SVD 只需要几行代码配合交叉验证可以直接看到稳定性和方差from surprise import SVD from surprise.model_selection import cross_validate svd SVD( n_factors50, # 隐因子数量 n_epochs30, # 全量数据迭代轮数 lr_all0.01, # 全局学习率 reg_all0.02, # 全局正则化系数 random_state42, ) cv cross_validate(svd, data, measures[RMSE, MAE], cv5, n_jobs-1) print(fRMSE: {cv[test_rmse].mean():.4f} ± {cv[test_rmse].std():.4f})四个核心超参数的作用和调试方向如下参数作用常见范围调参方向n_factors隐因子数量20 - 100过小欠拟合过大过拟合且训练变慢n_epochs迭代轮数20 - 50看验证集是否还在下降早停比堆轮数有效lr_all学习率0.005 - 0.02过大 loss 震荡不收敛过小收敛极慢reg_all正则化强度0.02 - 0.1数据越稀疏正则化越要加大音乐场景 50 个隐因子通常是够用的起点超过 100 之后 RMSE 的提升非常有限反而会把训练时间拉长几倍。判断过拟合的方法很简单比较train_rmse和test_rmse两者差距持续拉大时就增加reg_all。4.3 播放次数不是评分用 implicit 的 ALS 处理隐式反馈surprise 的 SVD 把播放权重当作评分来优化但播放次数在语义上不是绝对刻度——一个每天听 8 小时的重度用户和一个偶尔打开 App 的轻用户同样的 10 次播放含义完全不同。处理隐式反馈更标准的做法是 ALS交替最小二乘Python 生态里最常用的是 implicit 库它用置信度加权区分没听过和听过但不喜欢import implicit # implicit 拟合需要 item-user 形状物品行、用户列 item_user user_item.T.tocsr() model implicit.als.AlternatingLeastSquares( factors50, # 隐因子数量 regularization0.03, # 正则化 iterations30, # 交替迭代轮数 alpha40.0, # 置信度缩放系数 random_state42, ) model.fit(item_user) def recommend_for_user(user_idx, top_n20): # 返回 (item_id, score)同时把已听过的歌从结果里滤掉 item_ids, scores model.recommend( user_idx, item_user, Ntop_n, filter_already_liked_itemsTrue ) return list(zip(item_ids, scores))implicit 的置信度公式是 c 1 alpha * rr 是原始播放次数alpha 控制多听一次到底多重要。alpha40 是默认值调高会让模型偏向用户的高频偏好类别调低则更均衡。注意fit接收的是 item-user 矩阵这跟直觉相反写代码时容易弄反recommend的第二个参数也必须是同一个矩阵它同时承担过滤已听的职责。注意filter_already_liked_itemsTrue意味着不给用户推荐任何听过的歌。在探索发现场景可以关掉让模型从已听歌曲中找出最值得重复的但指标评估时一般保持开启让推荐列表和测试集的重合率真实反映泛化能力。4.4 冷启动的绕行方案内容相似与热度兜底矩阵分解对没有任何行为的新歌完全失效新歌不存在于任何训练 pair 里。常见做法是补一路内容推荐用标签、艺人、时长、BPM或者用 librosa 提取 MFCC 音频特征拼成内容向量对内容向量做最近邻检索得到听起来像的歌。线上把内容相似得分和行为模型得分做加权融合新歌先靠内容分进入候选池积累到足够播放后再交给行为模型主导。这个方案不需要训练复杂的深度模型一辆 8 核机器就能跑完百万级歌曲的离线相似度计算。5. 评估与上线前验证用 recallk 与 NDCG 度量推荐质量推荐系统评估最容易犯的错是用 RMSE 衡量排序质量。RMSE 衡量的是预测得分和实际得分差多少但推荐系统最终交付的是一个排序列表用户不会因为你的预测分差了 0.3 而感知到差异却会因为第 5 位本该是金曲而感知到推荐不靠谱。音乐场景更合适的指标是 recallk 和 NDCG。5.1 时间切分为什么比随机切分更接近线上随机切分在隐式反馈里是致命的它把用户后听的歌放进训练集模型等于提前看到了未来。线上真实环境里模型只能见过过去所以验证切分应该按时间走df df.sort_values(last_ts) train_parts, test_parts [], [] for uid, group in df.groupby(user_id): cutoff group[last_ts].quantile(0.8) train_parts.append(group[group[last_ts] cutoff]) test_parts.append(group[group[last_ts] cutoff]) train_df pd.concat(train_parts) test_df pd.concat(test_parts)每个用户取前 80% 时间的播放做训练、后 20% 做验证这样评估的正是模型能否从过去预测未来的听歌行为。对每个测试用户把测试期内播放过的歌集合当作 ground truth用训练好的模型生成 top-k 推荐再计算排序指标。5.2 用一段 Python 代码计算 recallk 与 NDCGrecallk 回答用户真正听到的歌里有多大比例进了推荐前 k 位NDCG 在这个基础上加了位置惩罚排得越靠前、命中带来的增益越大这更贴近真实产品逻辑。import numpy as np def recall_at_k(pred_items, heldout_items, k10): held set(heldout_items) if len(held) 0: return 0.0 return len(set(pred_items[:k]) held) / len(held) def ndcg_at_k(pred_items, heldout_items, k10): held set(heldout_items) dcg sum( 1.0 / np.log2(i 2) # i 从 0 开始所以位置 0 的增益是 1/log2(2) for i, sid in enumerate(pred_items[:k]) if sid in held ) idcg sum( 1.0 / np.log2(i 2) for i in range(min(len(held), k)) ) return dcg / idcg if idcg 0 else 0.0NDCG 的分子 DCG 对命中位置做了对数衰减分母 IDCG 是理想排序下的最大 DCG所以 NDCG 永远落在 0 到 1。k 取 10 还是 20 要看产品形态搜索结果页首屏能看到 5 到 10 首推荐歌单展开能看到 20 到 50 首。5.3 上线前的通关检查跑一个热度 baseline 对比脚本最后给一个强制检查任何推荐模型在离线评估里都该先跟最简单的热度榜比。热度榜就是把全局播放次数排序直接取 top不涉及任何个性化。如果协同过滤或矩阵分解在 recall10 上打不过热度榜超过 10 个百分点先别调参回去查数据泄漏、相似度参数和稀疏度而不是继续堆模型复杂度。# 全局热度榜 baseline pop_top ( df.groupby(song_id)[play_count].sum() .nlargest(10).index.tolist() ) pop_recall np.mean([ recall_at_k(pop_top, held, k10) for held in test_df.groupby(user_id)[song_id].apply(list) ]) svd_recall 0.0 # 用训练好的 SVD 对每个测试用户生成 top-10 后计算 lift (svd_recall - pop_recall) / pop_recall print(fpopularity recall10: {pop_recall:.4f}) print(fmodel lift: {lift * 100:.1f}%)一个合理的检查标准是个性化模型在 recall10 上相对热度榜至少有 20% 到 30% 的提升才说明模型真正学到了用户差异。低于这个数问题几乎都在特征或数据口径上。上线做 AB 时也可以沿用它把离线相对提升率当作切量阈值的参考通常要求线上核心指标相对提升超过 5% 才值得把流量从旧策略切过来。这个 baseline 脚本保持可复用后续每换一次特征或算法第一件事就是重跑它。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价