音乐协同过滤推荐算法:从原理到UserCF/ItemCF代码实现
2026/9/20 1:55:11 网站建设 项目流程

简介:PDF文档《基于音乐的协同过滤推荐算法代码实现》面向推荐系统研究者、算法工程师和机器学习爱好者,系统讲解音乐场景下协同过滤推荐算法的原理与工程实现。内容涵盖基于用户评分、用户收藏、音乐特征、SlopeOne、SVD及平均加权混合等多种推荐策略,并对比基于用户与基于物品两类协同过滤的适用差异;同时结合音乐风格、歌手、节奏等特征,说明如何将音频数据数值化。文档共1个PDF文件,容量424KB,已有526人浏览学习;实现部分围绕MyEclipse、Tomcat7、MySQL、Mahout API等工具展开,详细介绍数据库设计、用户登录、前台首页、推荐结果、我的收藏、我的评分、歌单详情与评论等系统模块。代码解析覆盖数据预处理、余弦相似度、皮尔逊相关系数等关键步骤,并基于网易云音乐5000余首真实歌曲数据展示推荐列表生成与效果优化思路。对想从零构建个性化音乐推荐系统或需要工程参考的读者,资料提供了从理论到代码的完整闭环,可直接借鉴其算法选型与模块划分。

1. 基于音乐的协同过滤推荐算法:从一份 PDF 标题到可运行的推荐系统

打开音乐 App,每日推荐里排在靠前的位置,往往不是你最近单曲循环的那几首,而是“和你有相同收听习惯的用户”正在反复听的歌。这个现象的背后,就是标题里那三件事绑在一起的结果:音乐、协同过滤、代码实现。协同过滤不是近几年才出现的推荐算法,但直到今天,它依然是冷启动之外最稳、最容易解释、也最适合入门复现的推荐策略。这篇博文的目标很直接:把一个基于音乐的 UserCF 和 ItemCF 完整推演出来,给出能跑的代码、参数细节和踩坑点。新手可以照着敲完拿到自己的推荐结果,熟手则能在相似度选型、矩阵稀疏处理和评测口径上找到可以再抠的细节。

2. 算法选型:音乐场景下协同过滤的数学模型与相似度公式

2.1 为什么音乐推荐先看协同过滤:从显式反馈到隐式反馈

协同过滤假设的核心是“历史行为会重复”:两个用户过去听了相似的歌,未来也会继续相似;一首歌被某一群用户反复播放,它和这群用户听过的其他歌之间存在潜在关联。这个假设在音乐场景里天然成立。MV、影视、电商的评分数据偏稀疏,但音乐 App 的播放、跳过、收藏、循环四个行为每天都在大量产生,属于典型的隐式反馈。

和显式评分不同,隐式反馈没有 1 到 5 分,只有“听了多少次”“收藏没有”“切歌发生在第几秒”。处理隐式反馈时,不能把播放次数直接当作评分,因为用户 A 一天听 200 首歌,用户 B 一天只听 20 首,两者的播放量级差了一个数量级。常见做法是给行为加权:收藏计 5 分,完整播放计 3 分,试听 30 秒计 1 分,再乘上播放次数,得到每条记录的“信号强度”。这个加权后的矩阵,是后面所有相似度计算的数据基础。

2.2 基于用户(UserCF)与基于物品(ItemCF)的根本差异

协同过滤的两条主线是 User-based Collaborative Filtering 和 Item-based Collaborative Filtering。UserCF 先找“和我口味相近的一群用户”,再把他们听过的、我没听过的歌按热度聚合;ItemCF 则是“和我听过的歌最相似的歌”,再按相似度加权生成候选。音乐场景里这两者差异很重要。

维度UserCFItemCF
离线计算对象用户间相似度矩阵歌曲间相似度矩阵
用户数/物品数影响用户数增长成本高物品数增长成本高
可解释性较弱强,“因为你听过《XX》”
音乐场景适配适合发现新风格、新圈层适合稳定口味、相似曲风推荐
新歌冷启动稍好(新歌也可能被相同用户带动)差(新歌没有行为记录)

我一般会给音乐项目同时保留两套结果:线上主推 ItemCF,因为它稳定、快、解释文案好写;UserCF 作为探索性推荐入口,放在“相似用户也在听”的板块。两个算法共用一个交互矩阵,代码层面不必拆成两套数据管线。

2.3 相似度公式选型:余弦、皮尔逊与 Jaccard

选相似度公式前先明确矩阵长什么样:每行是一个用户,每列是一首歌,值是加权后的行为强度。三个常见指标在这个矩阵上表现完全不同:

  • 余弦相似度:直接计算两个向量夹角的余弦值,对播放次数的绝对值做归一化,适合“次数代表偏好强度”的假设。
  • 皮尔逊相关系数:先减去用户自身均值再算余弦,能消除“有些人天生听得频繁”的整体偏差。
  • Jaccard 相似度:只看是否听过,不看次数,数据稀疏时更稳健,但信息量损失大。

下面的代码把三种相似度放在一起,方便你在同一份日志上直接对比:

import numpy as np def cosine_sim(a: np.ndarray, b: np.ndarray) -> float: """对两条播放次数向量做余弦相似度。""" denom = np.linalg.norm(a) * np.linalg.norm(b) if denom == 0: return 0.0 return float(a @ b / denom) def pearson_sim(a: np.ndarray, b: np.ndarray) -> float: """皮尔逊相关:减去各自均值后再做余弦。""" a_center = a - a.mean() b_center = b - b.mean() denom = np.linalg.norm(a_center) * np.linalg.norm(b_center) if denom == 0: return 0.0 return float(a_center @ b_center / denom) def jaccard_binary(a: np.ndarray, b: np.ndarray) -> float: """只看是否听过,适合极度稀疏的冷数据。""" inter = np.logical_and(a > 0, b > 0).sum() union = np.logical_or(a > 0, b > 0).sum() return inter / union if union else 0.0

代码逻辑说明:三个函数接收的是用户向量或歌曲向量,返回值都在 0 到 1 之间。余弦对数值差异敏感,播放量差异会影响结果;皮尔逊在减去均值后,把“两个人的播放习惯都偏少”这一层影响剥离开;Jaccard 只输出是否听过,当矩阵稀疏到每行只有十几个非零值时,它反而比余弦稳定。实际项目里,我会先用余弦跑通全链路,再在评测阶段对比皮尔逊,不会一上来就全部实现。

3. 代码实现:用 Pandas 和 NumPy 写一个可运行的 UserCF 与 ItemCF

3.1 构造交互矩阵:从播放日志到可以计算的 score_matrix

先把原始日志整理成结构化数据。假设你手里有一份 CSV,字段至少包含user_idsong_idplay_countis_collecttimestamp。第一步是清洗和加权:

import pandas as pd import numpy as np logs = pd.read_csv("user_song_logs.csv") # 行为加权:完整播放权重 3,收藏权重额外加 5 logs["signal"] = logs["play_count"] * 3 + logs["is_collect"] * 5 inter = ( logs.groupby(["user_id", "song_id"], as_index=False)["signal"] .sum() )

这段代码里,play_count是这首歌被该用户播放的次数,is_collect是 0/1 标记。多首同名歌曲要提前用歌曲 ID 去重,同时把时长小于 30 秒的试听行为降权,因为它不代表真实偏好。groupby按用户和歌曲聚合signal,得到的inter就是用户-歌曲-行为强度三列结构。

接下来把它转成矩阵:

matrix = inter.pivot_table( index="user_id", columns="song_id", values="signal", fill_value=0, ).astype(np.float32) print(matrix.shape) # (用户数, 歌曲数)

pivot_table生成的是稀疏度极高的二维表,行是用户、列是歌曲、值是行为强度。这里注意两点:第一,fill_value=0会把缺失播放记录补为 0,但判断“用户不喜欢”还是“用户没听过”需要额外时间戳字段;第二,矩阵直接用 numpy 的float32保存能省一半内存,这个细节在 10 万用户百万歌曲规模下会明显影响训练速度。

3.2 UserCF 核心实现:找相似用户,聚合候选歌曲

UserCF 实现分三步:算用户两两相似度、取出最相似的 K 个邻居、把邻居听过的歌加权汇总。这段代码完全基于前面构造的matrix

def user_cf_recommend(matrix, user_id, top_n=10, neighbor_k=30): user_vec = matrix.loc[user_id].values.reshape(1, -1) # 所有用户向量做余弦相似度,结果是一维数组 norms = np.linalg.norm(matrix.values, axis=1, keepdims=True) norms[norms == 0] = 1 # 防止除零 user_norm = np.linalg.norm(user_vec) sims = (matrix.values @ user_vec.T).ravel() / (norms.ravel() * user_norm) # 排除自己 sims[matrix.index == user_id] = -1 # 取相似度最高的 neighbor_k 个邻居 neighbor_idx = np.argsort(sims)[-neighbor_k:] # 邻居歌曲信号按相似度加权累加 scores = np.zeros(matrix.shape[1], dtype=np.float32) for idx in neighbor_idx: if sims[idx] <= 0: continue scores += matrix.values[idx] * sims[idx] # 用户已经听过的歌不再推荐 scores[user_vec.ravel() > 0] = -1 top_items = np.argsort(scores)[-top_n:][::-1] return matrix.columns[top_items].tolist(), scores[top_items]

逻辑说明:matrix.values @ user_vec.T用矩阵乘法一次性算出所有用户与目标用户的点积,再用范数归一化得到余弦相似度。neighbor_k控制邻居数量,一般取 20 到 50。如果你把neighbor_k设置得过大,推荐列表会偏向热门歌;设置过小,则容易只围绕单一圈层。scores[user_vec.ravel() > 0] = -1这一步很关键,否则用户自己听过的歌会反复出现在候选里。

3.3 ItemCF 核心实现:从物品相似度生成推荐列表

ItemCF 需要先建立歌曲与歌曲的相似度矩阵,再根据用户历史听歌记录做加权:

def item_cf_recommend(matrix, user_id, top_n=10, sim_threshold=0.2): # 转置成 歌曲 x 用户 的矩阵 item_mat = matrix.T.values norms = np.linalg.norm(item_mat, axis=1, keepdims=True) norms[norms == 0] = 1 # 物品间余弦相似度矩阵 item_sim = (item_mat @ item_mat.T) / (norms @ norms.T) np.fill_diagonal(item_sim, 0) user_items = matrix.loc[user_id] heard_songs = user_items[user_items > 0] scores = np.zeros(matrix.shape[1], dtype=np.float32) for song_id, weight in heard_songs.items(): col_idx = matrix.columns.get_loc(song_id) sim_vec = item_sim[col_idx].copy() sim_vec[sim_vec < sim_threshold] = 0 # 过滤太低相似的歌 scores += sim_vec * weight scores[user_items.values > 0] = -1 top_items = np.argsort(scores)[-top_n:][::-1] return matrix.columns[top_items].tolist(), scores[top_items]

逻辑说明:item_mat @ item_mat.T得到歌曲间点积矩阵,除以范数外积就是余弦相似度。sim_threshold是相似度门槛,低于它的歌曲对直接丢弃,避免噪声进入候选。每首听过歌曲的贡献是“相似度向量 × 该歌曲的行为权重”,所以高播放歌曲对最终排序的影响更大。

3.4 评分加权公式与 Top-N 截断参数

两套代码跑通后,要调的参数集中在几个点:

参数默认范围作用调参方向
neighbor_k20 ~ 50UserCF 邻居数越大越偏热门,越小越窄
sim_threshold0.1 ~ 0.3ItemCF 相似度截断越小候选越多,噪声越大
top_n10 ~ 20最终推荐条目数评测时固定为 10 便于算指标
行为权重1 / 3 / 5试听/播放/收藏的比值取决于日志质量,需要 A/B 验证

热门物品偏移是这里最常见的坑。一首歌被 80% 用户播放过,它的相似度天然就容易排前面。常见做法是给最终得分乘一个惩罚项,比如把过热歌曲的再排序分压到原来的 0.3 倍。代码上只需要在scores更新后、argsort前,对热门歌曲下标做一次缩放:scores[too_hot_idx] *= 0.3。至于“多热算热”,可以直接取播放总量分位数的 95% 作为阈值。

4. 验证与优化:评测指标、冷启动与增量更新的三个坑

4.1 离线评测:用时间戳分割算 Precision 和 Recall

推荐算法的离线评测不建议随机划分训练集和测试集,因为用户听歌行为有强时序性:上周听的歌和下周听的歌高度相关,随机划分会高估效果。更好的做法是按时间戳分割,用前 80% 时间的数据训练,后 20% 做测试:

def evaluate_recall(matrix, test_df, recommend_func, n_users=200): hits = 0 total = 0 for uid in test_df["user_id"].unique()[:n_users]: recs, _ = recommend_func(matrix, uid, top_n=10) ground_truth = set(test_df[test_df["user_id"] == uid]["song_id"]) hits += len(set(recs) & ground_truth) total += len(ground_truth) recall = hits / max(total, 1) precision = hits / (n_users * 10) return precision, recall

参数说明:n_users限制评测用户数,防止循环太慢;recommend_func可以是前面任一推荐函数,这样可以在同一份数据上横向比较 UserCF 与 ItemCF。加一个覆盖率指标也很容易:coverage = len(set(recs_all)) / matrix.shape[1],它反映推荐结果是否集中在头部。三个指标要一起看,单独追求 Recall 会把结果推向热门歌,覆盖率会暴跌。

4.2 冷启动:新歌无播放记录时的候选兜底策略

协同过滤对“零行为歌曲”完全失效。新歌刚入库时没有播放记录,ItemCF 算不出任何相似度,UserCF 也带不动它。常见的兜底方案是把冷启动拆成两层:

第一层,用歌曲本身的元数据做基于内容的过渡。把风格标签、歌手、语种、BPM 转成 one-hot 特征,计算新歌与已有歌的余弦相似度,暂时顶替行为相似度。第二层,给新歌一个热度保底,让它们在 Top-N 截断前混入一定比例的候选位置:

popular_songs = ( inter.groupby("song_id", as_index=False)["signal"] .sum() .sort_values("signal", ascending=False) .head(50) ) def hybrid_recommend(matrix, uid, alpha=0.1): recs, _ = item_cf_recommend(matrix, uid, top_n=9) # 10% 的位置留给新歌/热门兜底 return recs + popular_songs["song_id"].tolist()[:1]

逻辑说明:alpha控制兜底比例,0.1 表示 10 首歌里留 1 个位置给冷启动候选。这个值不宜调大,否则推荐精度会被明显拖低。新歌上线一周后有了足够播放记录,就可以把它从兜底池里移除,回归正常 ItemCF 流程。

4.3 增量更新:滑动窗口与矩阵重建策略

音乐推荐的事实规律是:用户口味会漂移,三个月前的播放历史和现在的关系很弱。每次全量重建相似度矩阵在大规模数据上代价很高,常见做法是引入滑动窗口,只保留最近 N 天的日志:

class WindowedItemCF: def __init__(self, window_days=7, decay=0.99): self.window_days = window_days self.decay = decay def rebuild(self, logs): cutoff = logs["timestamp"].max() - pd.Timedelta(days=self.window_days) recent = logs[logs["timestamp"] >= cutoff].copy() # 时间衰减:越早的行为权重越小 age_days = (logs["timestamp"].max() - recent["timestamp"]).dt.days recent["signal"] *= np.power(self.decay, age_days) inter = recent.groupby(["user_id", "song_id"], as_index=False)["signal"].sum() self.matrix = inter.pivot_table( index="user_id", columns="song_id", values="signal", fill_value=0 ).astype(np.float32)

逻辑说明:窗口天数越小,推荐对近期行为越敏感,但矩阵会更稀疏;decay=0.99意味着 30 天前的行为权重衰减到原来的约 0.74。重建频率可以设为每 6 小时一次,而不需要每次请求都重新算相似度。真正要避免的是把全部历史数据每天重算,那种做法在百万歌曲规模下会吃掉大量计算资源,收益却很低。

5. 把代码实现沉淀成一套可复现交付物

标题里带着.pdf,说明这份代码实现的最终形态是文档加源码的组合。我个人的交付习惯是:项目目录里保留data/src/tests/三层,推荐结果导出成 Markdown 报告,再通过一条命令转成 PDF,这样团队里任何一个人 clone 下来都能复现。

def export_report(recs: dict, output_path="recommend_report.md"): """把多位用户的推荐结果写成 Markdown 报告。""" with open(output_path, "w") as f: f.write("# Music Collaborative Filtering Report\n\n") for uid, songs in recs.items(): f.write(f"## User {uid}\n") for rank, song in enumerate(songs, 1): f.write(f"{rank}. {song}\n") f.write("\n")

代码逻辑说明:接收recs字典,key 是用户 ID,value 是歌曲列表;输出结构化 Markdown。生成 PDF 时用pandoc recommend_report.md -o recommend_report.pdf一条命令完成,不需要在项目里引入重量级文档框架。这个技巧要点的价值在于:推荐算法项目最难复现的部分往往不是算法本身,而是数据路径、参数和评测口径。把这三个东西写进报告开头,别人拿到手之后才不会问“你这个结果怎么跑出来的”。

最后一个实操技巧:把相似度公式、邻居数、行为权重全部设计成外部配置参数,不要硬编码在推荐函数里。用dataclass定义一份RecommendConfig,每次实验跑完自动把配置和评测指标追加到报告末尾。这样你能在三天后重新打开项目时,立刻看出neighbor_k=40neighbor_k=30的差异到底来自参数还是数据,而不是靠记忆猜。

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

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

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

立即咨询