用Python实现音乐推荐系统:协同过滤、矩阵分解与评估
2026/9/23 3:02:28 网站建设 项目流程

简介:面向推荐系统开发者的 Python 音乐推荐系统完整实现包,包含核心推荐引擎源码和配套测试数据。压缩包共 12 个文件,既有 2 个 Python 程序负责算法逻辑,也有 2 份 CSV 数据集提供用户播放记录与歌曲元数据,还有 8 张可视化图片帮助理解数据分布和推荐结果,整体大小 6.31MB。已有 1460 人浏览学习。通过该工程,可以系统掌握 Pandas 数据清洗、Scikit-learn 协同过滤建模、基于内容推荐的实践方法,并了解冷启动、实时性等真实系统优化难点。适合推荐系统初学者作为第一个完整项目练习,也能为中级开发者提供可扩展的代码结构。

1. 音乐推荐系统的第一课:把"听歌记录"变成一个排序问题

做音乐推荐系统时,第一件要认清的事是:你手里几乎没有评分数据。用户不会像在电商里那样给歌打五星,平台上留下的大多是播放次数、跳过行为、收藏动作和一首歌听了几秒就切掉。这就决定了用 Python 实现音乐推荐系统的主流路线:把埋点日志折叠成用户-歌曲的行为矩阵,再在矩阵上做协同过滤或矩阵分解,最后输出一个候选歌曲的排序列表。本文面向想独立搭出一版可评估原型的数据工程师或后端开发,从头到尾不依赖任何商业推荐服务,从日志清洗、稀疏矩阵构建、item-based KNN,一路写到 SVD / ALS 与 recall@k、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_index=False).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(index="user_id", columns="song_id", values="play_count")。假设 10 万用户、50 万首歌,pivot 会产生 500 亿个格子,即使大部分是 NaN,pandas 仍会为这些位置分配索引结构,内存直接失控。正确做法是只存非零三元组:

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_size=0.2, random_state=42) sim_options = { "name": "cosine", # 相似度度量 "user_based": False, # False = item-based "min_support": 3, # 两个 item 至少共同出现 3 次才算邻居候选 } algo = KNNWithMeans(k=40, min_k=1, sim_options=sim_options) algo.fit(trainset) predictions = algo.test(testset) rmse = accuracy.rmse(predictions) print(f"item-based KNN RMSE: {rmse:.4f}")

代码里user_based=False是核心开关,它让算法按歌曲计算相似度而不是按用户;min_support=3过滤掉只共现过一两次的偶然邻居。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 到 80;min_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_factors=50, # 隐因子数量 n_epochs=30, # 全量数据迭代轮数 lr_all=0.01, # 全局学习率 reg_all=0.02, # 全局正则化系数 random_state=42, ) cv = cross_validate(svd, data, measures=["RMSE", "MAE"], cv=5, n_jobs=-1) print(f"RMSE: {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_rmsetest_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( factors=50, # 隐因子数量 regularization=0.03, # 正则化 iterations=30, # 交替迭代轮数 alpha=40.0, # 置信度缩放系数 random_state=42, ) model.fit(item_user) def recommend_for_user(user_idx, top_n=20): # 返回 (item_id, score),同时把已听过的歌从结果里滤掉 item_ids, scores = model.recommend( user_idx, item_user, N=top_n, filter_already_liked_items=True ) return list(zip(item_ids, scores))

implicit 的置信度公式是 c = 1 + alpha * r,r 是原始播放次数,alpha 控制"多听一次到底多重要"。alpha=40 是默认值,调高会让模型偏向用户的高频偏好类别,调低则更均衡。注意fit接收的是 item-user 矩阵,这跟直觉相反,写代码时容易弄反;recommend的第二个参数也必须是同一个矩阵,它同时承担"过滤已听"的职责。

注意:filter_already_liked_items=True意味着不给用户推荐任何听过的歌。在"探索发现"场景可以关掉,让模型从已听歌曲中找出最值得重复的;但指标评估时一般保持开启,让推荐列表和测试集的重合率真实反映泛化能力。

4.4 冷启动的绕行方案:内容相似与热度兜底

矩阵分解对没有任何行为的新歌完全失效,新歌不存在于任何训练 pair 里。常见做法是补一路内容推荐:用标签、艺人、时长、BPM,或者用 librosa 提取 MFCC 音频特征拼成内容向量,对内容向量做最近邻检索,得到"听起来像"的歌。线上把内容相似得分和行为模型得分做加权融合,新歌先靠内容分进入候选池,积累到足够播放后再交给行为模型主导。这个方案不需要训练复杂的深度模型,一辆 8 核机器就能跑完百万级歌曲的离线相似度计算。

5. 评估与上线前验证:用 recall@k 与 NDCG 度量推荐质量

推荐系统评估最容易犯的错是用 RMSE 衡量排序质量。RMSE 衡量的是"预测得分和实际得分差多少",但推荐系统最终交付的是一个排序列表,用户不会因为你的预测分差了 0.3 而感知到差异,却会因为第 5 位本该是金曲而感知到"推荐不靠谱"。音乐场景更合适的指标是 recall@k 和 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 代码计算 recall@k 与 NDCG

recall@k 回答"用户真正听到的歌里,有多大比例进了推荐前 k 位",NDCG 在这个基础上加了位置惩罚:排得越靠前、命中带来的增益越大,这更贴近真实产品逻辑。

import numpy as np def recall_at_k(pred_items, heldout_items, k=10): 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, k=10): 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.0

NDCG 的分子 DCG 对命中位置做了对数衰减,分母 IDCG 是"理想排序下的最大 DCG",所以 NDCG 永远落在 0 到 1。k 取 10 还是 20 要看产品形态:搜索结果页首屏能看到 5 到 10 首,推荐歌单展开能看到 20 到 50 首。

5.3 上线前的通关检查:跑一个热度 baseline 对比脚本

最后给一个强制检查:任何推荐模型在离线评估里都该先跟最简单的热度榜比。热度榜就是把全局播放次数排序直接取 top,不涉及任何个性化。如果协同过滤或矩阵分解在 recall@10 上打不过热度榜超过 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, k=10) 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(f"popularity recall@10: {pop_recall:.4f}") print(f"model lift: {lift * 100:.1f}%")

一个合理的检查标准是:个性化模型在 recall@10 上相对热度榜至少有 20% 到 30% 的提升,才说明模型真正学到了用户差异。低于这个数,问题几乎都在特征或数据口径上。上线做 AB 时也可以沿用它:把离线相对提升率当作切量阈值的参考,通常要求线上核心指标相对提升超过 5% 才值得把流量从旧策略切过来。这个 baseline 脚本保持可复用,后续每换一次特征或算法,第一件事就是重跑它。

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

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

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

立即咨询