简介:一套基于深度学习的音乐推荐系统毕设/课程作业实现,面向计算机相关专业学生,覆盖音乐数据处理、推荐模型构建与系统部署全流程。压缩包共 543 个文件,总大小 5.44MB,以 Java 源码、JSP/XML 配置、SQL 数据库脚本、LRC 歌词资源为主,同时包含 CSS/JS 前端样式、JPG/PNG 图片及项目工程配置,目录结构清晰,便于分模块理解推荐系统的完整实现。资料整合了协同过滤、RNN 等深度学习模型的设计思路,以及模型训练中的超参数调优、系统 API 部署等关键环节,可直接用于毕设参考、课程作业提交或入门实践。目前已有 190 人学习,适合需要完整项目案例来快速上手推荐系统开发的学生。
1. 深度学习音乐推荐:课程作业与毕设的最佳选题
"基于深度学习的音乐推荐系统"可以说是毕设和课程作业里的常青树,它既不像纯CV分类那样容易陷入内卷刷点,又能把深度学习的核心技术——Embedding、序列建模、多任务学习——在一个场景里完整串起来。更实在的是,做这个方向用的数据(Last.fm、网易云歌单公开集)获取门槛低,跑通一个基础版本(召回+排序)用单张消费级显卡就足够,这决定了它适合 "一个人、一台机器、一个半月" 的节奏。我见过太多作业把精力耗在模型堆点上,结果离线指标好看、一上真实场景就露馅。这篇笔记会把数据清洗、负采样、召回/排序两阶段落地、评估指标和踩过的坑一次讲完,照着这个链路做,你的系统至少是个"能演示、能写进论文、答辩扛得住追问"的完整方案,而不是一个只能跑通MNIST的玩具。
2. 技术选型的底层逻辑:为什么深度学习模型能赢过经典协同过滤
2.1 从矩阵分解到特征交叉:模型能力的分水岭
任何推荐系统入门都会从协同过滤讲起,矩阵分解(MF)把用户和物品映射到同一个隐向量空间,用内积预测交互概率。它的优点是简洁、可解释,但瓶颈也很明显——它只能利用"用户ID-物品ID"这一条交互信号,完全忽略了用户年龄、歌曲流派、播放时长、季节这类上下文。你没法告诉MF模型"这首歌是电音,用户最近常听电子",因为它压根没有特征输入的入口。
深度学习模型解决的就是这个事:把一切信号变成Embedding向量,再通过网络结构去学习特征之间的非线性交互。以DeepFM为代表的模型直接将稀疏特征的Embedding拼接,通过FM层做二阶交叉、深度网络做高阶交叉,让"用户听歌时段"和"歌曲BPM"这些原始特征自动组合出有意义的高维表征。具体到音乐推荐,这意味着你可以把歌曲的声学特征(调性、能量值)也塞进模型——这是我做过的方案里最实用的一步,因为纯交互数据稀疏到没法覆盖所有新歌,但声学特征不需要交互就能算出来。
选型时我的建议是分档:课程作业求稳,用DeepFM或双塔模型(召回侧)就能拿不错的分数;毕设想体现工作量,就上序列模型(GRU4Rec或它的改进版),因为它能建模"用户在会话内的连续听歌行为"——这是音乐场景区别于电商场景最大的特点,也是答辩时最有讲头的点。
2.2 召回与排序分开做:工程上必须拆开的两个阶段
很多人第一次做推荐系统会犯一个认知错误:试图用一个模型直接给全量歌曲打分排序。假设你有10万首歌,每个候选都要过一遍神经网络前向计算,单次请求延迟是灾难级的。工业界的标准做法是拆成两段——这不仅是性能考虑,也是模型分工的需要:
召回阶段追求"一个都不能少",用轻量模型从全量候选中捞回几百个可能相关的物品,常见做法是双塔模型或改进的协同过滤,这一阶段不追求精度,但必须保证覆盖率;排序阶段追求"排序精准",对召回回来的几百个候选做精细打分,模型可以更重,特征可以更丰富,比如加入实时的上下文特征。
双塔模型的结构值得细说:用户塔和物品塔各是一个独立的Embedding+全连接网络,两塔的输出向量做内积作为匹配分数。离线训练时用负采样构造"正样本(用户听过的歌)和负样本(随机采样的歌)"来训练塔的向量表征。在线服务时,物品塔的向量可以全部预先算好存进向量检索库,用户塔输入用户特征实时算一次,然后做内积检索。我在实际项目中,召回部分用双塔(物品塔256维,用户塔256维),排序部分用DeepFM,配合一个简单的规则层(去除用户已听过的、控制风格多样性),这个组合在离线评估里比单独用任何一个模型都稳定。
2.3 为什么序列建模对音乐场景尤其重要
音乐推荐的独特之处在于它的"消费行为"有强顺序依赖。用户点了一首伤感情歌,大概率会连续再听几首同类型的;在运动场景会切到快节奏摇滚。这种信息在传统的"用户-物品"二部图里完全丢失,只有把用户的听歌历史按时间排序作为序列输入,模型才能捕捉"下一次播放"的概率。
GRU4Rec是这类模型的基础款,它的核心是用GRU循环网络读取用户最近N次交互的物品序列,在每一步输出对下一首歌的预测概率。实际训练时用的是Session-Parallel Mini-Batch:把同一天内不同用户的操作合并成一个batch,保证序列的连续性同时提升训练效率。这个模型改造成本不高,但收益明显——我做过对比,在Last.fm的1万用户子集上,GRU4Rec的Recall@20比非序列的DeepFM高5-7个点,且对"冷门歌曲"的召回贡献更大,这正是音乐推荐里最难啃的部分。
3. 数据工程与模型训练:把公开数据集变成可训练的样本
3.1 数据获取和预处理:不爬山直接进实验室
公开数据选Last.fm的1K用户交互集。它虽然数据量不算大,但胜在字段干净、格式稳定,而且包含时间戳,这对序列模型是必需品。注意不要去碰那些爬虫抓下来的"全网歌单压缩包",往往解压路径全是中文和特殊字符,清洗成本远超训练成本。
拿到数据后的第一件事不是建模,是检查三个东西:交互记录的时间跨度、每个用户的最少交互次数、每首歌的全局播放次数。我通常直接用Python筛选而不是SQL:
import pandas as pd # 原始交互数据列:user_id, track_id, timestamp df = pd.read_csv("lastfm_history.csv") df["timestamp"] = pd.to_datetime(df["timestamp"]) # 过滤掉交互次数过少的用户和歌曲,保留活跃部分 user_counts = df.groupby("user_id").size() track_counts = df.groupby("track_id").size() active_users = user_counts[user_counts >= 20].index active_tracks = track_counts[track_counts >= 15].index df = df[df["user_id"].isin(active_users) & df["track_id"].isin(active_tracks)] # 按时间排序,这是序列模型的前提 df = df.sort_values(["user_id", "timestamp"]).reset_index(drop=True) df.to_csv("filtered_history.csv", index=False)这段代码背后的逻辑是:过滤阈值20和15是我试出来的经验值,太小会导致序列太短模型学不到模式,太大会砍掉大量长尾歌曲,削弱推荐多样性。如果你追求交作业更容易,阈值可以放宽到10和5——但是答辩时老师问"为什么这么砍"要有话接,就说是为了保序列长度。
下一步是构造训练样本。对序列模型来说,正样本是"一个用户历史上听过的、排在当前位置的歌",负样本是从未听过的歌里随机抽——但这里有个关键点,全局随机采样会让负样本"太简单",模型学到的是 "热门歌就是正样本" 这种错觉。我用热度加权采样来对冲:
import numpy as np # 计算每首歌的全局播放热度,作为负采样权重 track_popularity = df.groupby("track_id").size() track_popularity = track_popularity / track_popularity.sum() # 对每个交互构造负样本:按热度分布采样,而不是均匀采样 negative_count = 3 # 每个正样本搭配 3 个负样本 negatives = np.random.choice( track_popularity.index, size=len(df) * negative_count, p=track_popularity.values )参数说明:negative_count=3是性价比比较高的值,太大会让模型训练变慢而且正样本占比过低,太小则容易欠拟合。热度采样能在"避免简单负样本"和"保持样本分布真实性"之间找到平衡。
3.2 训练一个双塔召回模型:最小可跑通的PyTorch实现
这里给一个标准的双塔代码骨架,粒度到可以直接跑通。用户塔和物品塔各自维护一个Embedding层和几层MLP,两个塔的输出做内积后加sigmoid作为点击概率。
import torch import torch.nn as nn class TwoTower(nn.Module): def __init__(self, num_users, num_tracks, emb_dim=64): super().__init__() # 用户侧特征:只有用户ID self.user_emb = nn.Embedding(num_users, emb_dim) self.user_mlp = nn.Sequential( nn.Linear(emb_dim, 128), nn.ReLU(), nn.Linear(128, 64), nn.ReLU(), nn.Linear(64, 32) ) # 物品侧特征:歌曲ID + 可选的声学特征 self.track_emb = nn.Embedding(num_tracks, emb_dim) self.track_mlp = nn.Sequential( nn.Linear(emb_dim, 128), nn.ReLU(), nn.Linear(128, 64), nn.ReLU(), nn.Linear(64, 32) ) # 两塔输出维度要一致,最后用内积 def forward(self, user_ids, track_ids): u = self.user_mlp(self.user_emb(user_ids)) t = self.track_mlp(self.track_emb(track_ids)) return (u * t).sum(dim=1) # 内积作为匹配分数注意输出层没有接sigmoid,训练时配合BCEWithLogitsLoss即可,这比直接在最后一层加sigmoid数值更稳定。这里有两个容易被忽略的细节:一是Embedding层要加L2正则,否则长尾歌曲的向量会学飞;二是用户塔和物品塔的MLP结构不必一致,物品塔可以更深,因为物品侧特征往往更复杂(流派、声学特征都要进去)。训练时用Adam优化器,学习率设1e-3,训练10个epoch后看验证集Recall@20是否还在涨,如果过拟合就提前停。
3.3 排序模型的训练:特征交叉的关键在构造特征
排序阶段和召回的不同地方在于:它需要同时看到用户特征、物品特征、以及两者的交叉特征。最常见的做法是把用户最近听的N首歌的Track ID作为序列特征,跟候选物品的特征一起输入DeepFM。这里我直接给一个DeepFM的PyTorch轻量版:
import torch import torch.nn as nn class DeepFM(nn.Module): def __init__(self, feature_nums, embedding_dim): super().__init__() # 所有稀疏特征共用一个Embedding表,训练稳定 self.embeddings = nn.Embedding(feature_nums, embedding_dim) self.fm_linear = nn.Linear(feature_nums, 1) # FM的一阶项 self.deep = nn.Sequential( nn.Linear(feature_nums * embedding_dim, 256), nn.ReLU(), nn.Dropout(0.3), nn.Linear(256, 64), nn.ReLU(), nn.Linear(64, 1) ) def forward(self, feature_ids): emb = self.embeddings(feature_ids) # [batch, n_features, emb_dim] # FM二阶交叉:平方和减去和的平方 summed = emb.sum(dim=1) # [batch, emb_dim] squared_sum = summed ** 2 sum_squared = (emb ** 2).sum(dim=1) fm_interaction = (squared_sum - sum_squared).sum(dim=1, keepdim=True) # 深度部分:展平后过MLP deep_out = self.deep(emb.view(feature_ids.size(0), -1)) return self.fm_linear(feature_ids).sum(dim=1, keepdim=True) + fm_interaction + deep_out这个实现去掉了很多论文里的装饰,但保留了DeepFM最核心的两个计算:FM二阶交叉项捕获特征两两组合,深度网络捕获更高阶的非线性。注意特征编号时把用户ID、Track ID、时间段、流派都map到同一个连续整数空间。
4. 模型评估与关键参数调优:怎么证明你的系统真的有用
4.1 离线评估指标:别只看准确率
推荐系统的评估常犯的错是把分类准确率当核心指标。试想一下,如果用户只听了100首歌而全库有10万首,模型全预测负样本准确率也有99.9%,这个数字毫无意义。正确指标是排名类指标,最常用的是Recall@K和NDCG@K。Recall@K衡量的是“用户真实听的歌里,有多少比例出现在模型预测的前K个推荐里”;NDCG@K在它的基础上增加了位置权重,排序靠前且命中的得分更高。
计算方式建议直接用Rekk或者是自己手写,代码量不大:
def recall_at_k(model, test_seq, all_track_ids, k=20): hits, total = 0, 0 for seq, true_track in test_seq: # 模型对候选集打分 scores = model.predict(seq, all_track_ids) top_k = scores.topk(k).indices if true_track in top_k: hits += 1 total += 1 return hits / total这里有一个工程细节:如果候选集全量是10万首歌,每次predict都要过一遍前向传播,评估会非常慢。正确做法是构建一个评估用的候选集——每个测试样本从随机负采样1000首歌加真实正样本组成,这样既可控又快。我见过有人全量评估跑了一夜,其实没这个必要,论文里标注"Hit Rate@20"时也默认是采样评估。
4.2 四个必调参数:照着这个顺序调能少走弯路
根据经验,参数调优的优先级应该是:负采样比例 > 序列长度 > Embedding维度 > 学习率。负采样比例前面已经讲过,3是最常用的起步值,如果你的场景关注长尾召回,可以试到5;序列长度直接影响GRU的建模能力,我一般取20-30,太短学不到长期依赖,太长引入噪声且显存压力大;Embedding维度64起步,数据集大上一个量级再翻到128,不要一上来就512,除了过拟合没有别的好处;学习率用1e-3配Adam,如果损失震荡就把batch size调大一些而不是调小学习率。
4.3 离线指标和真实体验的偏差问题
这个偏差是推荐系统里面最玄学的地方。你离线测Recall@20有0.35,感觉不错,但实际上线用户不一定会多听歌。原因是离线数据本身是带了选择偏差的——你只能看到平台推了什么用户才点什么,用户没看到的歌不等于用户不喜欢。网上那些号称把指标刷到多高的开源项目,多数在评估里做了"数据泄漏",比如拿明天的交互预测今天的歌。我养成的一个习惯是离线评估时严格按时间切分:用前80%的时间训练,后20%测试,而且排序模型评估时,要把召回阶段未选中的真实正样本从候选集中排除掉,否则会高估排序能力。
5. 避坑篇:五个让项目翻车的常见问题与排查方法
5.1 序列模型的灾难性遗忘:预测全是热门歌
现象:GRU4Rec训练5个epoch后输出列表几乎被热门歌占满,Recall@20上升但在NDCG@20上反而下降。
原因:热门歌在训练样本里出现频率过高,模型把"热门"当作了最强的信号,而不是真正学习用户的个性化序列偏好。我排查时打印了预测序列的ID,发现它在给古典乐爱好者推荐说唱。
解决:加大负采样里困难样本的比例,把负样本主要从"用户没听过但整体偏热门"的歌里采;同时给序列输入加上位置编码,让模型更关注短期最近行为。
5.2 解压数据就翻车:中文路径、压缩包损坏与文件编码
现象:从网上下载的zip压缩包解压后,Python报UnicodeDecodeError或者文件路径找不到。
原因:很多公开数据集压缩包不是UTF-8编码打包的,Windows默认GBK路径,导致file.exists()返回False但资源管理器里能看到。加上下载不完整,zipfile会报CRC校验错误。
解决:第一件事检查文件完整性,用Python验证而不是双击解压:
import zipfile with zipfile.ZipFile("music_data.zip") as zf: bad_file = zf.testzip() if bad_file: print(f"损坏文件: {bad_file}") else: zf.extractall(path="data/") # 确保目标路径不含中文和空格路径不要带空格和中文是我的血泪经验,因为很多框架依赖的C++底层库对非ASCII路径支持不佳,报错信息还特别迷惑。解压完立刻用file命令或head看原始文件格式,别直接用pandas去读。
5.3 负样本太简单导致模型假性收敛
现象:训练损失快速下降,但验证集Recall@20不涨。
原因:负采样全部用全局均匀随机,模型学到的是"识别高频物品"而不是"识别用户偏好"。均匀随机采样下的负样本绝大多数是用户根本没见过的长尾歌,模型区分正负样本太容易。
解决:负采样改为按物品热度分布采样,并引入"上一轮被召回但未被点击"的样本作为负样本,这是最接近工业界的做法。
5.4 评估时数据泄漏:指标虚高到不真实
现象:评估指标高得离谱,怀疑人生。排查发现Time Series Split时用了随机打乱的session。
原因:序列预测的本质是预测"下一个"交互,如果测试样本混入了用户早期的交互,就等于答案在路上。一些网上流传的处理代码为了省事,直接shuffle数据。
解决:切分必须是"按时间按用户"严格切分。如果发现代码里有global shuffle,直接删掉。
5.5 排序模型特征穿越:把用户未来的行为喂进了模型
现象:排序模型在离线评估时AUC高达0.95,但线上效果不如基础的LR。
原因:给排序模型构造特征时,计算了"这个用户听这首歌的累计时长(包含未来记录)",这本质上是拿考试答案给模型抄。
解决:所有特征计算必须保证时间一致性。构建特征时对每个用户先按时间排序再切分,特征计算只允许使用"当前时间之前"的交互记录。严格检查用户侧的统计类特征。
6. 增量更新技巧:让模型跟上用户口味变化
最后分享一个我常用的落地技巧——增量更新。课程作业和毕设里很少有人做这一块,但它恰恰是答辩时最容易出彩的 "加分项"。
模型训练好之后遇到的一个天然问题是:用户的听歌口味会漂移(上个月爱听民谣,这个月全在听摇滚),而歌曲库也在持续更新。全量重训每星期跑一次可以接受,但错过的是"今天刚上架的新歌"和"最近几天口味变化快速的活跃用户"。
常用做法是Embedding增量更新:冻结模型深层的全连接层参数,只用新数据进行几步的Embedding层微调。代码里这样写:
# 冻结深层参数,只对Embedding做增量更新 for name, param in model.named_parameters(): if "emb" not in name: param.requires_grad = False optimizer = torch.optim.Adam( filter(lambda p: p.requires_grad, model.parameters()), lr=1e-4 # 增量更新的学习率要更小,防止破坏旧向量 )注意增量更新的学习率要调小一个量级,否则新样本会把之前学好的Embedding空间搅乱。我习惯每天用当天新增交互数据做5个epoch的增量更新,同时对热门物品的表项做正则约束,防止某个物品的Embedding因为单日暴涨而抖动。
这个方案顺手解决新歌冷启动问题——新歌没有交互数据时,它的Embedding可以从声学特征生成一个初始向量(用音频特征跑一个浅层映射),等有首次交互后再进入增量更新流程。这条链路补全后,你的系统才算真正能持续工作。
做完这个项目复盘时,我自己最大的教训是:别把时间花在调一个花哨的模型上,数据是黑的,边界要自己踩亮。一个推荐系统80%的工程精力在数据、评估和更新机制上,模型只要用被验证过的结构就足够稳。希望帮到你。
本文还有配套的精品资源,点击获取