ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Python音乐推荐系统实战:从内容特征到协同过滤与混合重排

Python音乐推荐系统实战:从内容特征到协同过滤与混合重排 简介面向希望掌握推荐系统落地实现的Python开发者一份完整的音乐推荐系统项目包包含源码与测试数据。项目围绕数据收集、用户画像、特征工程、相似度计算等关键环节展示Pandas、Scikit-learn等库在协同过滤、基于内容的推荐及深度学习方法中的应用并配有song_playcount_df.csv等播放记录与曲目元数据便于对照理解推荐准确率、覆盖率等评估指标以及冷启动、实时性等优化思路。压缩包共12个文件含2个Python核心脚本Recommenders.py、recommendation_engines.py、2个CSV数据集和8张分析图整体大小6.31MB目录紧凑图片直观呈现数据分布与相似度矩阵适合快速定位模块与二次开发。已有1460人学习浏览适合想通过实战巩固数据分析与机器学习能力的中级开发者。1. 音乐推荐系统到底在解决什么问题用 Python 实现音乐推荐系统真正的难点不在算法本身而在把「用户接下来想听什么」翻译成一个能反复迭代的评分管道。最开始跑出来的结果往往是这样的模型没报错推荐出来的歌也都在数据库里但点开一看全是热歌榜上的那些新上架的歌一首都排不进前二十。这是因为只套一个协同过滤模型天然偏向头部热门内容而音乐场景的行为数据又特别稀疏——大多数用户只会标记几十首、上百首歌和几万、几十万首歌的候选集一比矩阵里 99% 以上的格子是空的。这篇文章按照内容推荐、协同过滤、混合重排、服务化这条路径往下走每一章的代码和参数都能直接跑起来参考。读完你可以在本地用 Python 跑通一个完整的音乐推荐系统也知道把它接到线上时哪些坑值得提前避掉。2. 基于内容的音乐推荐用歌曲特征算相似度在没有任何用户行为数据的前提下唯一能用的就是歌曲本身的属性。基于内容的音乐推荐核心思路是把每首歌表示成一个特征向量然后计算向量之间的相似度用它替代「人肉打标签」的排序过程。这一节从特征构造说起给出一个最小可复现的实现。2.1 歌曲特征怎么构造把属性拼成文本再做 TF-IDF大多数音乐元数据里会带演唱或演奏者、风格分类、标签和文案摘要。常见做法是先把它做成一张扁平表然后用 TF-IDF 把这些字段拼起来的文本转成向量。2.1.1 维度表字段设计与作用先看数据结构。音乐推荐系统的第一张维度表通常长这样字段示例作用song_ids001全库唯一主键推荐结果和埋点都用它对齐artist林海歌手或作曲者内容推荐里权重较高的信号genre纯音乐粗粒度风格决定推荐的大方向tags钢琴 轻音乐 治愈编辑或算法打的细分标签建议用空格分词lyrics_summary城市夜景 独处 安静歌词或文案的摘要关键词用来刻画情绪氛围这里不建议只拿 genre 来算相似度genre 的取值范围太窄纯音乐和民谣之间隔着一个新古典字符串匹配是匹配不出来的。我一般会把 genre、tags、lyrics_summary 三段文本用空格拼成一个长串让 TF-IDF 自己去抓共现关系分词粒度则交给 n-gram 窗口处理。2.2 余弦相似度与 Top-N 推荐的完整实现下面的代码把上一小节的内容特征落实成可执行的 Python 脚本核心是 TF-IDF 向量化和余弦相似度排序# content_based.py import pandas as pd from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.metrics.pairwise import cosine_similarity song_meta pd.DataFrame([ { song_id: s001, artist: 林海, genre: 纯音乐, tags: 钢琴 轻音乐 治愈, lyrics_summary: 城市夜景 独处 安静, }, { song_id: s002, artist: 坂本龙一, genre: 新古典, tags: 钢琴 电影原声 氛围, lyrics_summary: 末代皇帝 冬夜 东瀛, }, { song_id: s003, artist: 陈鸿宇, genre: 民谣, tags: 吉他 男声 叙事, lyrics_summary: 理想三旬 青春 远方, }, ]) def build_profile(df): df[profile] df[[genre, tags, lyrics_summary]].fillna().agg( .join, axis1) return df song_meta build_profile(song_meta) tfidf TfidfVectorizer(analyzerchar_wb, ngram_range(2, 3), max_features2000) tfidf_matrix tfidf.fit_transform(song_meta[profile]) def recommend_by_song_id(song_id, top_n3): idx song_meta[song_meta[song_id] song_id].index[0] sim cosine_similarity(tfidf_matrix[idx], tfidf_matrix).flatten() # 去掉自身sim 中自身的相似度恒为 1.0需要从第 1 位开始取 top_idx sim.argsort()[::-1][1 : top_n 1] return song_meta.iloc[top_idx][[song_id, artist, tags]].to_dict(records) print(recommend_by_song_id(s001, top_n2))这里有几个参数值得单独说。analyzerchar_wb对中文更友好按词切分在缺少分词器时会切出一堆无意义的单字按 2 到 3 个字符的滑窗切能保留「钢琴」「治愈」这类有效组合char_wb只在词边界内取字符窗口不会跨标点或空格。ngram_range(2, 3)同时保留二元和三元窗口最终维度靠max_features2000压住避免稀疏向量过宽。cosine_similarity一次调用会把当前行和全量矩阵都算一遍返回的数组里包含自身所以取 Top-N 时从下标 1 开始切片。2.3 基于内容推荐的边界为什么不能只靠它基于内容的方案有个立刻就能感受到的问题推荐出来的歌会越来越像用户已经听过的那些。听了一周钢琴曲系统就会一直给钢琴曲一旦用户今天想换口味听电子内容推荐完全没有感知。它没有用户行为信号自然也看不到「这个用户最近一周反复播放同一首歌」这种强意图。所以在真实音乐推荐系统里内容相似度更多是当作冷启动和召回路来用新歌没有任何播放记录靠内容特征先把它送进候选集老用户则主要交给行为信号驱动的协同过滤来排序。下一章把行为信号加进来。3. 协同过滤给音乐推荐系统补上行为信号协同过滤的核心假设是过去行为相似的用户未来偏好也相似。它不关心歌长什么样只关心「谁在什么时候播了哪首歌」。对音乐推荐系统来说播放、收藏、跳过都是信号但它们的权重完全不同——播放只能说明不反感收藏和循环播放才是强偏好。这一节先解决选型问题再落到可以复现的训练代码。3.1 User-based、Item-based 和 SVD 怎么选同样是协同过滤三种常见做法的适用边界差别很大方案适用规模对稀疏矩阵的容忍度可解释性更新成本User-based CF用户数少的内部场景低用户兴趣漂移影响大高可以解释为「和你口味相似的人也在听」每次要重算用户相似度矩阵Item-based CF歌曲数量可控的场景中靠歌与歌的共现关系高可以解释为「听过这首歌的人也听那首」增量更新单歌相似度即可SVD / 矩阵分解中大规模生产环境较高能学到隐含因子低隐向量不能直接解释需要周期性全量训练音乐场景我一般会优先考虑 Item-based 或者 SVD。原因是歌曲的偏好比用户的口味更固定一首歌的风格不会三天两头变而用户的听歌兴趣可能按月漂移。User-based 在用户量上来之后相似度矩阵的计算量是用户数的平方更新一次的成本很高SVD 则把用户和歌曲都压到同一套低维隐空间里既能处理稀疏矩阵又能顺带做召回。3.2 用 surprise 跑通 SVD 的最小流程surprise 是 Python 生态里做协同过滤比较省事的库内置了 SVD、NMF 和多种评估指标。安装用pip install scikit-surprise即可。下面基于一张行为表跑一次完整的训练和评估# cf_svd.py import pandas as pd from surprise import Dataset, Reader, SVD from surprise.model_selection import train_test_split from surprise import accuracy # 原始行为表user_id, song_id, play_count plays pd.DataFrame([ {user_id: u001, song_id: s001, play_count: 35}, {user_id: u001, song_id: s002, play_count: 12}, {user_id: u002, song_id: s001, play_count: 8}, {user_id: u002, song_id: s003, play_count: 27}, {user_id: u003, song_id: s002, play_count: 40}, {user_id: u003, song_id: s003, play_count: 3}, ]) # 播放次数转评分次数的开方后取整压到 1..10 plays[rating] plays[play_count].apply( lambda x: min(10, max(1, int(x ** 0.5))) ) reader Reader(rating_scale(1, 10)) data Dataset.load_from_df(plays[[user_id, song_id, rating]], reader) trainset, testset train_test_split(data, test_size0.2, random_state42) model SVD(n_factors50, lr_all0.005, reg_all0.02, random_state42) model.fit(trainset) predictions model.test(testset) rmse accuracy.rmse(predictions, verboseTrue) print(fRMSE: {rmse:.4f})3.2.1 播放次数转评分的映射逻辑代码里最容易改错的是评分映射。播放次数是右偏分布热门歌可能被播了上千次长尾歌只有一两次直接把play_count丢给 SVD会让热门歌把所有用户的预测分数都拉高模型学不到偏好差异。常见做法是取开方或log1p变换后再映射到评分区间min(10, max(1, int(x ** 0.5)))会把 35 次压到 5 分、100 次压到 10 分既保留了相对关系又把量纲限制在Reader声明的评分范围内。rating_scale(1, 10)必须和实际评分范围一致否则 surprise 的归一化逻辑会算出越界的预测值。random_state42保证切分和模型初始化都可复现对比不同轮次的指标时不要省略。提示Dataset.load_from_df要求 DataFrame 列顺序固定为 user_id、item_id、rating列名可以换顺序不能变。3.3 离线评估RMSE 和 PrecisionK 各自说明什么RMSE 衡量的是「预测分数和真实分数差多少」适合判断模型是否学偏但和推荐质量不是一回事。用户不会因为一首歌预测分是 4.2 而实际是 4.8 就觉得推荐不好更关键的是推荐列表里有没有他真正会收藏的歌。所以实际做音乐推荐系统离线评估时会在 RMSE 之外再算 PrecisionK# evaluate.py from collections import defaultdict def precision_at_k(predictions, test_true, k10, threshold7): predictions: surprise 的预测对象列表 test_true: {(user_id, song_id): true_rating} 的映射 threshold: 真实评分不低于该值的歌才算用户喜欢 pred_by_user defaultdict(list) for uid, iid, _, est, _ in predictions: pred_by_user[uid].append((est, iid)) hit 0 count 0 for uid, items in pred_by_user.items(): items.sort(keylambda x: x[0], reverseTrue) for _, iid in items[:k]: true_r test_true.get((uid, iid)) if true_r is not None and true_r threshold: hit 1 count k return hit / count if count else 0.0 # 用法test_true 从测试集构造 # test_true {(uid, iid): true_r for uid, iid, true_r, _ in testset} # print(precision_at_k(predictions, test_true, k10, threshold7))这段代码里test_true必须单独构造不能在predictions里直接拿true_r判断命中因为true_r是测试集里的历史真实分而我们要查的是 Top-K 推荐列表里那几首歌是否恰好落在用户高分的集合中。PrecisionK 对列表长度敏感K 一般取 10 或 20阈值取评分区间里偏高的值本例子中是 7 分以上才算喜欢。两个指标配合看RMSE 高但 PrecisionK 不错说明模型在热门歌上分数偏差大但在长尾偏好上抓得准这种情况在音乐场景里通常可以接受。4. 混合推荐让音乐推荐系统同时用好两类信号内容特征解决冷启动协同过滤解决兴趣刻画但单独用任何一路都有明显短板内容路看不到用户口味变化协同过滤路遇到新歌就哑火。混合推荐的目标不是把两路分数简单相加而是把内容、行为、流行度三路信号压到同一个尺度之后做加权再做一次多样性控制。这一章的代码可以直接当作一个 hybrid_rank 模块复用。4.1 分数归一化与加权融合内容相似度和 SVD 预测分数根本不是同一套量纲。前者是 0 到 1 的余弦值后者是 1 到 10 的评分直接相加等于让 SVD 那一路占据绝对主导。常见做法是做 min-max 归一化把每路分数都压到 0 到 1再按经验权重相加# hybrid_rank.py import numpy as np def norm_score(score_map): 把 {song_id: score} 压到 [0, 1]全等分时返回全 0 if not score_map: return {} scores np.array(list(score_map.values())) if scores.max() scores.min(): return {k: 0.0 for k in score_map} s_min, s_max scores.min(), scores.max() return {k: (v - s_min) / (s_max - s_min) for k, v in score_map.items()} def hybrid_rank(user_id, content_scores, cf_scores, pop_scores, alpha0.4, beta0.4, gamma0.2, top_n20): content norm_score(content_scores.get(user_id, {})) cf norm_score(cf_scores.get(user_id, {})) pop norm_score(pop_scores) merged {} for song_id in set(content) | set(cf) | set(pop): merged[song_id] ( alpha * content.get(song_id, 0.0) beta * cf.get(song_id, 0.0) gamma * pop.get(song_id, 0.0) ) return sorted(merged.items(), keylambda x: x[1], reverseTrue)[:top_n]三个权重加起来等于 1含义是「更信任哪一路信号」。alpha 和 beta 通常落在 0.3 到 0.5gamma 控制在 0.1 到 0.3流行度权重太小压不住冷门噪音太大会让结果退回到热歌榜。新用户因为cf_scores里没有数据cf归一化后全是 0相当于自动退化成「内容加流行度」的冷启动模式不需要单独写分支。注意三路分数取并集时如果某首歌只在流行度列表里出现content和cf的取值就是 0不会报错但任何一路传入空字典时norm_score需要提前返回否则np.array([]).max()会直接抛异常。4.2 流行度兜底与 MMR 多样性重排加权融合之后还有一个常见问题排序结果千篇一律。协同过滤和内容路如果共用同一批热门种子Top-20 里可能 15 首是同质化的钢琴曲。这时候需要把「多样性」显式放进排序目标里常用做法是 MMR最大边际相关性# mmr_rerank.py def mmr_rerank(ranked, sim_func, lambda_0.6, top_n10): ranked: [(song_id, score)]; sim_func: (song_a, song_b) - 相似度分数 selected, candidate [], list(ranked) while len(selected) top_n and candidate: best, best_score None, -float(inf) for song, score in candidate: if selected: max_sim max(sim_func(song, s[0]) for s in selected) mmr lambda_ * score - (1 - lambda_) * max_sim else: mmr lambda_ * score if mmr best_score: best, best_score (song, score), mmr selected.append(best) candidate.remove(best) return selectedlambda_是相关性权重0.6 表示 60% 的相关性加 40% 的多样性惩罚。调大时结果更贴近原始排序调小时列表会变杂。sim_func直接复用第 2 章算好的内容相似度按歌曲 ID 查表即可。每次循环把一首歌加入结果集时都要算它和已选集合里所有歌的最大相似度再从相关性分数里扣掉这部分作为惩罚保证后选进去的歌和前面的歌有差异。4.3 线上推荐请求的处理链路把上面的代码串起来线上一个推荐请求会走三步。第一步是召回取内容相似度 Top-N、SVD 预测 Top-N加上流行度兜底列表三路取并集这一步要把候选从几十万首歌缩小到几百首。第二步是过滤去掉用户已经收藏的、近期播过很多次的以及带负面信号多次跳过的歌这一步和推荐算法无关但筛掉的口味比模型任何优化都直接。第三步是排序先做分数归一化和加权拿到粗排结果后用 MMR 做重排再截断到 20 条返回。召回、过滤、排序三步分开写还有一个实际好处每一层的改动都可以单独做线上对照不需要整条链路重建。5. 调参与离线评估Python 音乐推荐系统的三个必踩点模型能跑通只是第一步。SVD 的隐因子数量、学习率、正则项以及相似度矩阵的存储方式直接决定结果质量和迭代效率。这一章把调参、内存和指标三个最常见的麻烦一次性说清楚。5.1 用 GridSearchCV 找 SVD 的最优参数surprise 里 SVD 需要调的核心参数是隐因子数n_factors、训练轮数n_epochs、学习率lr_all和正则系数reg_all。手工试错太慢直接用内置网格搜索# grid_search.py from surprise import SVD from surprise.model_selection import GridSearchCV param_grid { n_factors: [20, 50, 100], n_epochs: [20, 30], lr_all: [0.002, 0.005], reg_all: [0.01, 0.02], } grid GridSearchCV( SVD, param_grid, measures[rmse, mae], cv3, n_jobs-1, ) grid.fit(data) # data 是第 3 章构造好的 Dataset 对象 print(grid.best_params[rmse]) print(grid.best_score[rmse])参数范围的经验取值n_factors从 20 试到 100超过 100 在音乐这种稀疏场景里容易过拟合lr_all只在 0.002 到 0.005 区间里选学习率太大时 loss 训练曲线会震荡reg_all用 0.01 到 0.02正则太小时隐向量会过度拟合个别用户。cv3是折中音乐数据往往有很强的用户分布偏斜折数太少评估不稳太多训练时间成倍增长。n_jobs-1让所有网格任务并行跑但要注意机器内存不大时n_factors100且数据量大时会同时吃多份内存建议先压到n_jobs2试跑一轮确认单次训练的内存占用后再放开。5.2 稀疏矩阵与内存10 万首歌时怎么存相似度10 万首歌的内容相似度矩阵用稠密ndarray存是 10 万乘 10 万光 float32 就需要 40 GB这还没算上层业务进程。常见做法是转成稀疏矩阵只保留每首歌 Top-K 的相似度# sparse_sim.py import numpy as np from scipy.sparse import csr_matrix def build_sparse_similarity(tfidf_matrix, top_k50): 只保留每首歌相似度最高的 top_k 个邻居其余置零 sim tfidf_matrix tfidf_matrix.T sim sim.tocsr() n sim.shape[0] rows, cols, data [], [], [] for i in range(n): row_start, row_end sim.indptr[i], sim.indptr[i 1] row_scores sim.data[row_start:row_end] if len(row_scores) top_k: rows.extend([i] * len(row_scores)) cols.extend(sim.indices[row_start:row_end]) data.extend(row_scores) continue # 局部排序取前 K k_indices np.argpartition(row_scores, -top_k)[-top_k:] rows.extend([i] * top_k) cols.extend(sim.indices[row_start:row_end][k_indices]) data.extend(row_scores[k_indices]) return csr_matrix((data, (rows, cols)), shapesim.shape)这里有个技巧tfidf_matrix tfidf_matrix.T本身执行的是稀疏矩阵乘法不会先展开成稠密矩阵但argpartition只能处理稠密数组所以对每一行做局部排序时要把该行的非零项单独取出来。只保留每首歌 50 个近邻之后矩阵的非零元素从 10^10 量级降到 5×10^6 量级内存从几十 GB 降到几十 MB。如果希望近邻列表里不包含歌曲自身还需要在取 Top-K 之前把对角线位置剔除。如果在当前 Python 环境里用 pip 安装 surprise 时遇到编译失败通常不是代码问题而是环境里没有对应预编译 wheelpip 现场编译时缺了gcc或 Python 头文件。常见做法是换用 conda 环境安装scikit-surprise或者用pip install --only-binary :all: scikit-surprise强制要求使用预编译包。5.3 离线指标与线上效果之间的常见偏差离线 RMSE 做得再漂亮也不能保证线上点击率和收藏率涨。第一个原因是曝光偏差离线测试集里只有用户听过、且系统曾经给他展示过的歌没曝光过的好歌永远不会出现在负样本里模型学到的是「被展示过的歌里哪些被喜欢」而不是「全库哪些歌值得推荐」。第二个原因是时间偏差音乐口味按月漂移用上个月的数据训练、这个月验证结果往往比同月切分的评估差很多。这里有两个落地建议训练集和测试集按时间切分而不是随机切分用前 80% 时间窗口训练、后 20% 预测这更接近线上行为离线指标只用来防回归判断一个改动是否真正上线最终还是要靠线上小流量对照「收藏率」和「完播率」这两个业务指标。6. 音乐推荐系统的服务化接口把结果做成 API离线流程跑通之后最后一步是把推荐结果提供给业务方调用。直接在线上实时计算相似度和预测分在音乐场景里不划算常见做法是离线算好、在线查表。6.1 缓存 Top-N 与增量更新相似度每天定时任务全量跑一次混合推荐把每个用户的 Top-20 结果序列化存下来线上请求只做查找。数据量在百万级以内时pickle 文件加内存加载就够用到千万级或需要多机共享时再换 Rediskey 按rec:user:{user_id}设计value 直接存 JSON 字符串TTL 设 24 小时过期后由定时任务重建。增量更新具体做法是新歌入库时只计算它与其他歌的相似度那一列合并进已有的稀疏相似度矩阵用户 Top-N 列表保持每周全量重建一次。这样把每天一次的全量计算摊到「单列增量」上重算成本可以忽略。6.2 最小可用的 Flask 推荐接口# app.py import pickle from flask import Flask, request, jsonify app Flask(__name__) # 启动时预热加载线上请求不碰磁盘 with open(user_topn.pkl, rb) as f: user_topn pickle.load(f) with open(default_topn.pkl, rb) as f: default_topn pickle.load(f) app.route(/api/recommend, methods[GET]) def recommend(): user_id request.args.get(user_id, ).strip() top_n min(int(request.args.get(top_n, 20)), 50) if not user_id: return jsonify({code: 400, msg: user_id is required}), 400 items user_topn.get(user_id) if not items: # 兜底用户不在全量结果中时返回热门列表避免空响应 items default_topn return jsonify({ code: 0, data: [ {song_id: song_id, score: round(float(score), 4)} for song_id, score in items[:top_n] ], }) if __name__ __main__: app.run(host0.0.0.0, port8000, debugFalse)6.3 接口字段约定与前端联调返回的score统一保留 4 位小数前端拿到后可以直接按值做展示排序song_id同时用于歌曲卡片跳转和播放器加载不要在这里把整首歌曲的元数据都塞回去否则接口包体膨胀联调时每次都要等很久。请求参数top_n必须限制上限上面代码限到 50防止被传入 10 万导致响应超时。code固定为 0 表示正常业务错误走 HTTP 状态码这样前端可以统一用响应拦截器处理异常不走业务分支。如果后续要做推荐理由展示可以再加一个explain字段把命中的歌来自内容路还是协同过滤路带出去前端在卡片上渲染「因为你常听钢琴曲」这类文案这个能力对点击率的影响往往比继续压 RMSE 更直接。整个接口结构配合增量更新和缓存预热在没有额外中间件的单机上就能撑住一个不小的请求量再往后要把相似度矩阵换成向量检索库推荐逻辑本身也不需要改动。本文还有配套的精品资源点击获取
返回列表