
简介这套基于机器学习的音乐推荐系统项目工程面向毕业设计、课程设计、工程实训与大作业等开发场景适合需要完整可运行项目用于复现或二次扩展的学生与开发者。资源共1106个文件压缩包约73.94MB以Java/JSP后端源码、JS/CSS/HTML前端页面及class编译文件为主附有XML配置、JAR依赖库与说明文档其中94个Java源文件对应后端逻辑、189个JS文件支撑前端交互、112个XML文件用于配置管理目录结构清晰经测试运行功能正常。系统涵盖音乐推荐核心流程与界面答辩评审平均分96分设计报告亦可供借鉴。目前已有46人学习浏览下载后按说明文件即可快速搭建环境也可在此基础上改造扩展用于竞赛、实训、大创或初期立项是一份优质的开源学习与技术交流资料。1. 拿到“基于机器学习的音乐推荐系统”项目包先别急着解压在学校里流传的毕设资料里“基于机器学习的音乐推荐系统.zip”是很常见的一类项目包。多数人拿到手第一反应是解压、跑 main.py看到界面里出现推荐列表就以为可以交差。但这类项目真正考验你的不是把代码跑通而是能不能说清楚推荐结果是怎么算出来的。这个项目方向的本质是用机器学习模型根据用户历史行为预测他下一步会听什么。它适合课程设计、实训、大作业也适合想从《机器学习》理论走向“机器学习项目”的入门者。这篇文章会按数据清洗、模型训练、评估和避坑的顺序把基于音乐场景的常见做法讲透让新手能照着复现也让有基础的人能直接改参数继续往下做。2. 音乐推荐系统的机器学习路线先让数据变成可以训练的样本拿到项目包以后第一步一定不是改模型而是先看行为数据长什么样。音乐推荐和电影推荐最大的差异在数据尺度电影一天至多几百个评分音乐每天能产生上千次播放、跳过、收藏、切歌。这个差异决定了后面所有模型选型。常见做法是先把数据压缩成训练模型真正需要的三列user_id、item_id、value其中 value 可以是评分、播放次数或者归一化后的兴趣强度。很多课程项目翻车不是因为算法选错而是因为数据清洗时把用户和歌曲之间的关联语义弄丢了。如果只盯着算法库看很容易把机器学习应用流程理解成 fit/predict 两行命令。实际上一个音乐推荐项目里 70% 的工作都在数据准备哪些行为算正反馈哪些算噪音播放次数要不要压缩冷门歌要不要保留。这些问题没定下来矩阵分解也好深度模型也好都只是在错误的输入上反复过拟合。2.1 显式反馈与隐式反馈不要把播放次数直接当评分音乐推荐里的反馈可以分成两类。显式反馈是用户主动表达的比如点亮红心、打了 1 到 5 星数量少但意图明确隐式反馈是用户被动产生的比如播放次数、播放完成度、是否切歌量大但噪音多。现在面向真实场景的项目绝大多数数据都来自隐式反馈所以需要先把日志表转成一个可训练的评分矩阵。这里有个非常容易被忽略的判断隐式反馈没有真正的“负样本”只有“用户听过 3 次”和“用户没听过”两种状态。没听过既可能是不喜欢也可能是根本没被推荐过所以不能把 0 直接当负样本灌进模型。后续做排序任务时负样本要靠采样产生不能简单取全量缺失值。我一般会把清理动作放在最前面用一段很小但关键的预处理代码import numpy as np import pandas as pd logs pd.read_csv(user_song_log.csv, usecols[user_id, song_id, play_count, listen_seconds]) # 听歌时间不足 30 秒的播放很难说明用户真的喜欢这首歌直接过滤 logs logs[logs[listen_seconds] 30] # 播放次数长尾严重用 log 压缩再用 clip 限制上界避免个别热门歌左右全局梯度 logs[rating] np.log1p(logs[play_count]).clip(upper5) logs logs[[user_id, song_id, rating]] logs.to_parquet(data/train_ratings.parquet)这段代码解决的是“喧宾夺主”问题。np.log1p把 0、3、1000 次播放映射成 0、1.386、6.91缓解了歌曲热度分布不平衡.clip(upper5)确保那些全民级热单不会把分数的量纲拉到离群。过滤 30 秒是统计上的惯例你可以在 20 到 60 秒之间调但不要不过滤就直接建模。如果原始表里没有 listen_seconds 字段就跳过这一步直接用 play_count。2.2 矩阵分解学用户向量和歌曲向量而不是查字典清洗完后你会得到一张行为矩阵行列分别是用户和歌曲格子里是经过压缩的兴趣度。这个矩阵有两个特点稀疏和潜在相关。多数用户只会和上百首歌产生交互而全库可能有几十万首歌直接算用户或歌曲的 K 近邻不但慢结果也很难泛化到没见过的组合。矩阵分解的做法是把用户和歌曲都映射到一个低维空间学两个向量用户向量表示“这个人偏向哪种音乐口味”歌曲向量表示“这首歌有哪些潜在属性”。预测分就是点积R_hat U_i · V_j。这个思路如果你上过吴恩达的机器学习课程应该很眼熟在协同过滤那一章节里课程先用电影评分做例子再把它迁移到任意物品。音乐推荐系统的矩阵分解本质上是一样的只是评分被替换成播放行为换算出的置信度。SVD 是这种思路里最常用的实现它同时计算用户向量和歌曲向量并加入正则约束。训练时的损失可以写成L sum( (r_ij - U_i · V_j)^2 ) lambda * (||U_i||^2 ||V_j||^2)前一项负责让预测分贴近真实行为后一项负责抑制用户和歌曲向量过分膨胀。lambda就是后面调参时的 reg_all。理解这一点比背算法重要因为答辩老师很喜欢追问“过拟合在这个模型里体现在哪里”。2.3 最小可执行步骤从原始日志到三列训练样本有了宽表紧接着做两步过滤。第一步是去掉“沉睡用户”也就是历史交互太少的用户因为这类用户的向量很难被学到第二步是去掉“一次性歌曲”也就是被播放次数低于阈值的冷门歌保留它们只会让矩阵更稀疏。不同的项目对阈值很敏感我的默认值是用户至少 10 条记录、歌曲至少 5 个用户数据量大的场景可以放宽到用户 20、歌曲 10。user_cnt logs.groupby(user_id)[song_id].count() keep_user user_cnt[user_cnt 10].index logs logs[logs[user_id].isin(keep_user)] song_cnt logs.groupby(song_id)[user_id].count() keep_song song_cnt[song_cnt 5].index logs logs[logs[song_id].isin(keep_song)] # 重新编号让 user_id/song_id 连续且从 0 开始省内存也方便后续 Embedding logs[user_id] pd.factorize(logs[user_id])[0] logs[song_id] pd.factorize(logs[song_id])[0] logs.to_csv(data/train_ratings.csv, indexFalse)这里要留意pd.factorize和LabelEncoder的区别前者直接生成新的一列并保持自然顺序后者要求先 fit 再 transform。在毕设项目里我一般直接用 factorize因为过滤之后本来就要丢弃旧 id。最后把用户交互数、歌曲覆盖数和稀疏度打印出来如果稀疏度大于 99.5%就需要考虑是不是过滤条件太宽松了。完成这一步数据才真正变成模型能吃的样子后面所有模型训练和评估都基于这个 csv不再碰原始日志。3. 跑通最小可复现的推荐引擎依赖、训练命令与评估指标数据就绪之后下一步是选一个能落地的模型。对大多数毕设和课设项目我不建议一上来就上 GRU、Transformer 之类的深度模型原因不是学不会而是数据量和算力撑不住。矩阵分解加排序后处理已经足够撑起一个完整项目而且每一步都能讲清楚。下面这是我在本地跑通一套音乐推荐系统的最小流程。3.1 用虚拟环境隔离依赖避免一个 Python 里装两套版本项目开始前先建虚拟环境这看起来是老生常谈但每次都会有人贪图方便直接在全局环境里装包最后发现 scikit-learn 版本把 surprise 依赖弄崩了白白浪费半天。下面这几行命令在任何操作系统上都值得先跑一遍。python -m venv venv source venv/bin/activate # Windows 下用 venv\Scripts\activate pip install pandas numpy scikit-learn surprise implicit joblib依赖的作用要分清pandas 和 numpy 处理数据surprise 提供 SVD 和交叉验证implicit 用来做大规模 ALS 召回joblib 负责保存模型。implicit 在最小 Demo 里可以不安但建议一起装上后面做“用户最近听歌序列召回”时会用到。不要在这一步顺手把深度学习框架装上至少先让推荐链路跑通再考虑更重的模型。3.2 用 Surprise 训练 SVD一个脚本跑出 RMSESurprise 是一个专门做协同过滤评估的库经常出现在“机器学习入门”和“吴恩达机器学习作业”之后的拓展练习里。它封装了数据集加载、训练集测试集切分和交叉验证适合课程设计阶段快速验证。下面这段代码会读取上一步生成的 train_ratings.csv训练一个 SVD 模型并输出五折交叉验证的 RMSE 和 MAE。import pandas as pd from surprise import Dataset, Reader, SVD from surprise.model_selection import cross_validate df pd.read_csv(data/train_ratings.csv) # rating_scale 必须和上一步 clip 后的范围一致这里最小 0最大 5 reader Reader(rating_scale(0, 5)) data Dataset.load_from_df(df[[user_id, song_id, rating]], reader) algo SVD(n_factors80, n_epochs30, lr_all0.005, reg_all0.02, random_state42) cv_result cross_validate(algo, data, measures[rmse, mae], cv5, verboseTrue) print(RMSE , cv_result[test_rmse].mean())代码里的rating_scale必须和数据结构对应否则 Surprise 会报错或者按错误区间显示统计量。n_factors80表示用户向量和歌曲向量的维度维度越高模型容量越大但太大会导致过拟合lr_all0.005是 SGD 的学习率reg_all0.02对应前面提到的正则系数。random_state42保证每次训练结果一致答辩时如果被问到“结果能不能复现”这就是关键。3.3 除了 RMSE还要看 RecallK 和 Top-N 是否符合直觉RMSE 低只能说明模型预测分和真实分之间的偏差小但推荐系统真正交付的是 Top-N 列表。用户不会关心某首歌你预测成 4.8 分还是 4.9 分只关心第一屏推荐的歌他到底喜不喜欢。所以在项目里我会额外算一个 RecallK每个用户留出几首真实听过的歌看它们有没有出现在模型推荐的前 K 首里。from collections import defaultdict from surprise.model_selection import train_test_split from surprise import accuracy trainset, testset train_test_split(data, test_size0.2, random_state42) algo.fit(trainset) predictions algo.test(testset) _ accuracy.rmse(predictions, verboseTrue) # 把测试集按用户留出项合并 user_true defaultdict(set) for uid, iid, _ in testset: user_true[uid].add(iid) def recall_at_k(algo, trainset, user_true, k10): hit 0 total 0 for uid, true_items in user_true.items(): seen {pair[0] for pair in trainset.ur[uid]} candidates [iid for iid in trainset.all_items() if iid not in seen] pairs [(uid, iid, 0.0) for iid in candidates] preds sorted(algo.test(pairs), keylambda x: x.est, reverseTrue)[:k] hit len({p.iid for p in preds} true_items) total len(true_items) return hit / total print(Recall10 , recall_at_k(algo, trainset, user_true, k10))这段代码会比单纯 cross_validate 慢因为它要对每个用户的全部候选歌做预测。如果数据量大可以只抽 100 个用户来评估不需要全量跑。这里的关键是候选集里不能包含训练中出现过的歌否则模型等于“开卷考试”Recall 虚高答辩现场一问就穿帮。3.4 保存模型并生成推荐清单交付项目不只要能 predict训练完成后把模型和 id 映射一起保存。很多项目死在“模型训练时很准下次启动就报错”上原因多半是只存了模型没存用户和歌曲的编码关系。推荐阶段没有原始 id 的映射预测接口只能随机到一个内部编码结果自然全乱。import joblib # 保存模型模型里包含训练时学到的用户向量和歌曲向量 joblib.dump(algo, model/svd_model.pkl) # 保存 id 映射用于线上预测时把字符串 id 转成内部 id user_mapping {old_id: new_id for new_id, old_id in enumerate(keep_user)} song_mapping {old_id: new_id for new_id, old_id in enumerate(keep_song)} joblib.dump({user: user_mapping, song: song_mapping}, model/id_mapping.pkl)重新加载时顺序很重要先 load 模型再 load 映射预测前把原始 user_id 和 song_id 走一遍映射转换。如果你发现加载后预测结果完全不对优先检查是不是 mapping 里的旧 id 和训练时的原始 id 不一致。4. 从能跑到能答辩冷启动、混合推荐与三个必调参数把 SVD 基线跑通以后项目才完成了一半。答辩老师最常问的三个问题其实是新用户来了怎么办新歌入库怎么办为什么你的推荐和别人的不一样。这些问题都属于冷启动和个性化边界的范畴下面逐个说清楚。4.1 冷启动新用户和新歌都推不了是最容易被追问的短板矩阵分解模型需要用户历史行为才能生成用户向量所以新用户天然拿不到个性化推荐。常见做法有三个第一新用户进入时先推全局热门榜等他有了一些播放行为后再切换第二用注册时选择的风格偏好当特征做基于内容的粗排序第三用“最近 30 分钟听过的歌”当场构建一个临时向量用歌曲相似度补推荐。很多毕设项目只做了第一种这本身没有问题但你需要把它写成一个策略而不是一句空话。我一般会在推荐接口里加一个判断如果该用户历史交互少于 5 条直接返回热度榜。热度榜可以是全库播放次数最高的前 50 首也可以进一步剔除已经在用户本地播放过的歌。这个简单的规则能挡住绝大多数冷启动追问。# 热度榜兜底先保证新用户有东西可听 popularity df.groupby(song_id)[rating].sum() candidate popularity.sort_values(ascendingFalse).head(50).index.tolist() # 接口里如果用户历史不足直接返回 candidate不走模型预测这里的阈值“5”是我在实际项目里常用的值你可以根据数据量调整。太小会让用户几乎没有行为就进入模型预测向量退化成随机向量太大又会让热门榜取代个性化失去了推荐的意义。4.2 混合推荐用歌曲标签把矩阵分解救回来矩阵分解只使用行为数据不会理解歌曲风格。解决这个问题的常用方案是把歌曲的标签、歌手、语种、时长等元数据变成内容特征再做一次相似度召回最后和 SVD 分数融合。这样做的好处是即便用户还没有收听很多新歌只要他曾经表现出喜欢某类风格就能把风格相近的新歌捞出来。用 TF-IDF 处理歌曲标签是成本很低的方式。假设你有一张 song_tags.csv每一行是 song_id 和空格分隔的标签字符串比如“摇滚 吉他 经典 现场”可以用下面的代码计算歌曲之间的内容相似度from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.metrics.pairwise import cosine_similarity song_tags pd.read_csv(data/song_tags.csv) tfidf TfidfVectorizer() tag_matrix tfidf.fit_transform(song_tags[tags]) content_sim cosine_similarity(tag_matrix) def content_score(user_liked_ids, candidate_idx): # 取用户最近喜欢的几首歌取与候选歌最大相似度 scores [content_sim[x, candidate_idx] for x in user_liked_ids] return max(scores) if scores else 0.0注意 content_score 的取值范围在 0 到 1 之间而 SVD 预测分在 0 到 5 之间直接相加会削弱内容分数的作用。正确做法是先对候选池里的 SVD 分做 min-max 归一化再按权重融合final_score 0.7 * norm_svd_score 0.3 * content_score权重 0.7 和 0.3 是初始值常见做法是先固定 SVD 主路径然后一点点提高内容权重观察 RecallK 回落的位置。如果内容权重超过 0.5 后推荐列表开始变得千篇一律说明标签过于粗糙需要换成更细分的风格向量。4.3 答辩前先调这三组参数n_factors、reg_all、lr_all很多项目交上去以后老师说推荐效果“说不出来有什么个性”原因不是代码有 bug而是参数没调。下面这三组参数最值得你在答辩前认真试一遍。参数初始值调低时现象调高时现象n_factors80欠拟合用户和歌曲的区分度不够过拟合训练集 RMSE 很低但测试集涨reg_all0.02向量范数膨胀训练集和测试集差距加大向量被压得太小推荐结果趋同lr_all0.005收敛太慢30 轮后 loss 还没降完训练震荡loss 曲线上下抖动n_epochs30模型没学够用户向量偏向随机后期过拟合增加训练时间调参的方法不是只看最终 RMSE而要看训练集和测试集之间的差。如果测试集 RMSE 明显高于训练集优先增大 reg_all如果两者都很高说明模型容量不够增大 n_factors。答辩时能说出“我观察到训练集 RMSE 从 0.75 降到 0.62但测试集只降到 0.72所以我把 reg_all 从 0.01 调到 0.02”比单纯说“我用默认参数跑了一下”可信得多。5. 音乐推荐系统避坑指南5 个隐蔽但常见的翻车点这一章集中写我在类似项目里踩过、也帮别人排查过的坑。每一条都不是“改一行代码就好”的玄学而是带着现象、原因和解决步骤。5.1 RMSE 很低推荐 Top-N 却很傻指标选错了现象模型评估打印出 RMSE 只有 0.72看起来很漂亮但打开推荐列表一看全是热门的流行歌用户明明偏爱民谣结果一个民谣都没推上来。原因RMSE 衡量的是评分预测误差不是排序质量。热门歌历史交互多模型有足够数据把它们推到中高分区因此在均方误差指标上不差但这对用户来说等于没推荐。解决在项目里加入 RecallK 和 NDCGK。用第 3.3 节里的评估代码每个用户留出 5 首真实收听过的歌看这 5 首有没有出现在前 20 的推荐结果里。如果 Recall20 低于 5%说明模型只是在复述全局热度。另一个做法是评估时把每个用户的平均热门度作为基线模型必须明显超过热度榜才是有效个性化。5.2 训练到一半内存爆炸有人把稀疏矩阵转成了密集矩阵现象日志表只有 10 万用户、3 万首歌非零条目也才 200 万条但模型训练或相似度计算时内存直接占满电脑风扇狂转。原因有人为了用 sklearn 里的 NMF 或 TruncatedSVD先调用toarray()把稀疏矩阵变成了密集矩阵。10 万乘 3 万再乘以 8 字节哪怕全 0 也占 24GB当然扛不住。解决不要做全量 dense 化。使用 scipy.sparse 的 csr_matrix 保存行为矩阵然后用 surprise 的 SVD 或者 implicit 的 ALS 训练如果一定要用 sklearn 的降维方法改用TruncatedSVD它接受 sparse 输入不需要toarray()。另外把 2.3 节的过滤阈值提高到“用户至少 20 条记录”对缓解内存也立竿见影。5.3 播放次数直接当评分长尾数据让 SGD 不收敛现象训练时 loss 一直在高位震荡训练集 RMSE 也很高把 n_epochs 调到 100 也没有明显下降。原因play_count 的分布极度偏斜有的歌被播放上万次有的歌只播放 1 次。SGD 的梯度被极大值主导参数在每一步都被离群样本拉扯。这在机器学习基础里叫特征尺度不一致在推荐系统里体现得尤其明显。解决先做变换。播放次数用np.log1p压缩再按用户做 z-score 归一化或者直接设定一个上界。你可以在预处理阶段保留两套版本一套用原始 play_count 做对齐一套用 log 变换后的分数做训练对比一下测试集 RMSE几乎一定是变换后的版本更好。5.4 预测时 user_id 错位模型能跑但推荐结果全乱现象训练阶段一切正常然后在实际预测接口里调用algo.predict(uid, iid)要么报user 999 is unknown要么返回一个莫名其妙的低分把热门歌排到后面。原因数据清洗时用pd.factorize把原始 id 换成了 0 到 N-1 的内部编号但预测接口传进来的还是原始字符串 id或者训练前过滤了一次数据过滤后没有重新编码导致同一个原始 id 在训练集和预测集里对应不同编号。解决把 id 映射保存下来预测前先转换。更省事的做法是在整个项目里始终使用字符串 id让 Surprise 内部自己维护映射。但一旦你需要把模型部署成服务就必须显式保存映射否则每次重启 id 都会变。推荐用 joblib 保存字典预测时统一走一个encode_id(raw_uid)函数这样日志里也能看到原始 id。5.5 不同数据源直接拼接用户分数量纲不一致现象训练集一部分来自用户主动评分1 到 5 分另一部分来自播放次数换算的分数0 到 5 分两条来源拼在一起后模型约等于没分清谁更可信。原因不同渠道的行为强度不同。播放 3 次和点了 4 星的“喜欢”程度完全不同直接放在同一个rating列里等于给模型喂了相互矛盾的标签。解决按来源打上权重列或者干脆分开训练两个基线模型再做融合。简单一些的做法是评分数据的分数保持原样播放次数经过 log1p 之后乘一个 0.6 的衰减系数让显式反馈的“星星”比隐式反馈的“收听次数”更值钱。调整后重新跑一次 RecallK如果收益不明显再考虑用加权矩阵分解。6. 做点差异化我会把听歌序列当成一个独立召回任务基线 SVD 能解决的问题是“用户喜欢什么风格”但它对“用户此刻正在听什么”不敏感。音乐推荐和其他物品推荐最大的区别在于听歌行为有强连续性一个人可能上一首还在听摇滚下一首就切到舒缓钢琴曲这两首歌的风格不一定接近但出现在同一个 session 里不是偶然。所以我自己做这类项目在 SVD 能交付之后会再加一个 session 维度的召回模型。做法很简单把用户一天内的听歌记录按时间排序切成长度不超过 20 首的 session然后把每个 session 当成一个句子每首歌当成一个词训练一个 Item2Vec。这个模型没给评分学出来的 64 维向量用来描述“这首歌通常出现在什么前后文里”。from gensim.models import Word2Vec # item_seqs 是一个二维列表每个元素是当前 session 内的歌曲 id 序列 model Word2Vec(item_seqs, vector_size64, window5, min_count3, epochs10, sg1) song_vec model.wv[encoded_song_id]训练完以后SVD 负责“猜你喜欢”Item2Vec 负责“接着听下去”。线上推荐时把两个候选池各取 50 首再按最终积分排序融合效果会比单独用 SVD 好不少。这个差异点很适合写进实验报告里因为它不需要 GPU也不需要跑很大的模型却能让答辩老师看到你已经理解了行为序列在音乐场景中的作用。我个人的习惯是任何 baseline 跑通后都先问一句“这个模型到底在预测什么目标”。如果目标只是“预测播放次数”那推荐列表很容易讨好热门歌如果把目标变成“预测下一个 session 里用户会听什么”你就会自然走向序列建模。这个思路比调参更有价值哪怕实际落地时只是加了一个 Item2Vec也能让整个项目从“抄Demo”变成“有自己的设计”。希望帮到你。本文还有配套的精品资源点击获取