简介:这是一份面向计算机相关专业学生与项目实战学习者的机器学习音乐推荐系统毕业设计资源包,源自个人大四毕设项目,经导师指导并获98分评审认可,可作为课程设计、期末大作业或求职作品集的参考方案。压缩包共1106个文件,约74.47MB,以Java源码、class编译文件、js脚本、html与jsp页面、xml配置、css样式、jpg与png图片、mp3音频素材及jar依赖为主,另含properties配置、csv与db数据文件,完整覆盖推荐算法实现、前后端交互与数据存储等模块。目前已有224人学习下载。资源内含源代码与文档说明,读者可据此理解协同过滤或相似度推荐思路,梳理工程目录结构,掌握从数据预处理到推荐结果展示的完整链路,并借助文档快速定位关键类与配置项,适合需要快速搭建推荐系统原型或进行二次开发的学习者。
1. 音乐推荐系统到底在推什么:从协同过滤到特征工程的落地边界
你打开网易云或者 QQ 音乐,首页那排「每日推荐」背后大概率跑着一套推荐系统。很多人第一次接触「基于机器学习-音乐推荐系统+源代码+文档说明.zip」这类项目时,最直接的疑问是:它到底怎么把一首歌推到我面前的?是猜我喜欢周杰伦,还是算出来我和某个陌生人听歌口味高度重合?答案通常是两者都有,但落地时你得先想清楚一件事——推荐系统的核心不是算法多花哨,而是「用户-物品-行为」这三张表怎么建、怎么洗、怎么喂给模型。
这个标题对应的典型场景是:你手头有一份用户听歌日志(谁在什么时候听了哪首歌、听了几秒、有没有跳过、有没有收藏),想做一个能跑起来的推荐服务,最好还带源代码和文档说明,方便二次开发或者交课程设计。适合的人群包括:刚学完机器学习基础想找个完整项目练手的学生、需要快速搭一个推荐模块的后端工程师、以及想理解推荐系统全链路的算法爱好者。它不解决「推荐效果吊打大厂」的问题,但能让你把数据清洗、特征构造、模型训练、离线评估、简单服务化这条链路完整走一遍,知道每个环节的坑在哪。
我见过太多人拿到这类项目压缩包后,直接python train.py跑一遍,看到 loss 下降就以为成了,结果一上线发现推的全是热门歌曲,冷门好歌永远出不来。这不是代码写错了,是没搞懂推荐系统里「流行度偏差」和「冷启动」这两个玄学问题。所以这篇不打算只给你贴代码,而是按「数据怎么来 → 特征怎么造 → 模型怎么选 → 服务怎么搭 → 坑怎么避」的顺序,把一套能复现的方案讲透。你照着做,至少能跑出一个比「随机推」和「只推最热」强一截的基线系统。
2. 数据准备与特征工程:把听歌日志变成模型能吃的矩阵
2.1 三张核心表:用户、歌曲、行为
任何音乐推荐系统的起点都是行为数据。常见的最小数据集包含三张表:用户表(user_id、年龄、性别、注册时间)、歌曲表(item_id、标题、歌手、流派、时长、发行时间)、行为表(user_id、item_id、播放时间、播放时长、是否收藏、是否跳过)。行为表是核心,其他两张表用来做特征增强。
我一般会先做一次「行为强度」统计,看看每个用户的有效行为有多少条。如果大部分用户只有不到 10 条记录,那协同过滤基本没戏,得换内容特征或者用热门兜底。下面这段 Python 代码用来快速摸清数据分布:
import pandas as pd # 读取行为日志,假设是 CSV 格式 behavior = pd.read_csv('behavior.csv', parse_dates=['play_time']) # 统计每个用户的行为次数 user_counts = behavior.groupby('user_id').size() print('用户行为数分布:') print(user_counts.describe()) # 统计每首歌被听次数 item_counts = behavior.groupby('item_id').size() print('歌曲热度分布:') print(item_counts.describe()) # 计算稀疏度:非零交互占用户-歌曲笛卡尔积的比例 n_users = behavior['user_id'].nunique() n_items = behavior['item_id'].nunique() sparsity = 1 - len(behavior) / (n_users * n_items) print(f'矩阵稀疏度:{sparsity:.4f}')这段代码的逻辑是先看用户和歌曲的活跃度分布,再算稀疏度。参数上没什么要调的,但你要关注两个阈值:如果user_counts的中位数低于 5,说明大部分用户行为太少,隐语义模型会欠拟合;如果item_counts的 90 分位数和最大值差距巨大,说明长尾严重,评估时不能只看准确率,还得看覆盖率。
2.2 从原始日志到评分矩阵:显式与隐式反馈的取舍
音乐场景里,用户很少主动打 1-5 分,更多是「听了 30 秒就切」或者「单曲循环一下午」。所以你得把隐式反馈转成模型能用的信号。常见做法是定义「偏好分数」:播放完成度超过 80% 记 1 分,收藏记 2 分,跳过记 -1 分,没交互记 0 分。然后把这个分数矩阵喂给矩阵分解或者神经协同过滤。
下面是一个构造评分矩阵的示例,同时处理了时间衰减——最近的行为权重更高:
import numpy as np from datetime import datetime # 假设 behavior 表有 play_duration 和 song_duration 字段 behavior['completion'] = behavior['play_duration'] / behavior['song_duration'] behavior['completion'] = behavior['completion'].clip(0, 1) # 基础偏好分 def base_score(row): if row['completion'] >= 0.8: return 1.0 elif row['completion'] <= 0.2: return -0.5 else: return 0.3 behavior['score'] = behavior.apply(base_score, axis=1) # 收藏行为额外加分 behavior.loc[behavior['is_favorite'] == 1, 'score'] += 1.0 # 时间衰减:距离现在越久,权重越低,半衰期设为 30 天 now = behavior['play_time'].max() behavior['days_ago'] = (now - behavior['play_time']).dt.days behavior['weight'] = np.exp(-behavior['days_ago'] / 30) # 最终加权分数 behavior['final_score'] = behavior['score'] * behavior['weight'] # 聚合到用户-歌曲维度,取最大分数(同一首歌多次播放取最强信号) user_item_matrix = behavior.groupby(['user_id', 'item_id'])['final_score'].max().reset_index() print(user_item_matrix.head())这里的关键参数是半衰期 30 天,你可以根据业务节奏调整:如果用户听歌习惯变化快,改成 7 天;如果希望长期兴趣占主导,改成 90 天。另一个容易翻车的地方是completion的计算——有些歌曲时长字段是 0 或者空,除出来是 inf,必须在前面做清洗。我一般会先把song_duration小于 30 秒的记录删掉,避免短音频和纯音乐干扰。
2.3 歌曲侧特征:流派、年代、音频 embedding
只靠行为矩阵,新歌永远没机会曝光。所以还得给歌曲打上内容标签。最省事的是用现成的流派和年代字段做 one-hot,但维度高且稀疏。更好的做法是用音频文件提取梅尔频谱,过一个预训练的 CNN 拿到 embedding 向量,再和协同过滤的隐向量拼接。如果拿不到音频,退而求其次用歌手、专辑、发行年份做 target encoding。
下面是用librosa提取简单音频特征的片段,适合本地小规模实验:
import librosa import numpy as np def extract_audio_features(file_path): # 加载音频,统一采样率 22050 y, sr = librosa.load(file_path, sr=22050, duration=30) # 提取梅尔频谱,取均值作为简化特征 mel = librosa.feature.melspectrogram(y=y, sr=sr, n_mels=64) mel_mean = np.mean(mel, axis=1) # 提取节奏强度 tempo, _ = librosa.beat.beat_track(y=y, sr=sr) # 拼接成 65 维向量 features = np.concatenate([mel_mean, [tempo]]) return features # 对每首歌提取特征,存成字典 audio_feats = {} for item_id, path in song_paths.items(): try: audio_feats[item_id] = extract_audio_features(path) except Exception as e: print(f'处理 {item_id} 失败:{e}') audio_feats[item_id] = np.zeros(65)这段代码的产出是一个 65 维向量,你可以直接拿来做相似度计算,也可以送进一个全连接层降维后和 ID embedding 拼接。注意duration=30只取前 30 秒,对前奏长的歌可能不公平,实际项目里我会取中间 30 秒或者整首分段平均。另外librosa.load对 MP3 的支持依赖底层解码器,如果报错就换成audioread或者提前转成 WAV。
3. 模型选型与训练:矩阵分解、神经协同过滤还是 LightGBM
3.1 矩阵分解做基线:ALS 和 BPR 怎么选
矩阵分解是推荐系统里最稳的基线。ALS(交替最小二乘)适合显式评分,BPR(贝叶斯个性化排序)适合隐式反馈。音乐场景里隐式反馈居多,所以我一般先用 BPR 跑一版。它的核心思想是:对于用户听过的歌和没听过的歌,模型要让听过的歌得分更高。
用implicit库跑 BPR 的代码大概长这样:
import implicit import scipy.sparse as sparse # 构造用户-歌曲稀疏矩阵,分数用前面算的 final_score rows = user_item_matrix['user_id'].astype('category').cat.codes cols = user_item_matrix['item_id'].astype('category').cat.codes values = user_item_matrix['final_score'].values user_item_sparse = sparse.csr_matrix((values, (rows, cols))) # 转成 implicit 要求的格式:用户-物品矩阵 model = implicit.bpr.BayesianPersonalizedRanking( factors=64, # 隐向量维度 learning_rate=0.01, # 学习率 regularization=0.01, # 正则化系数 iterations=100 # 训练轮数 ) model.fit(user_item_sparse) # 给用户 0 推荐 10 首歌 user_id = 0 recommendations = model.recommend(user_id, user_item_sparse[user_id], N=10) print(recommendations)参数上,factors从 32 到 128 都有人用,我一般从 64 起步,看验证集 recall@10 再调。iterations不是越大越好,超过 200 轮后收益很小还容易过拟合。regularization在隐式反馈里很关键,设太小模型会死记硬背交互记录,设太大又学不到东西,0.01 到 0.1 之间比较安全。
3.2 神经协同过滤:什么时候值得上深度学习
如果你的数据量够大(比如百万级交互),而且有 GPU,可以试试 NCF(神经协同过滤)。它把用户和物品的 ID embedding 拼接后过几层 MLP,理论上能拟合更复杂的交互。但血泪经验是:在小数据集上 NCF 经常跑不过调好参的 BPR,因为参数太多、稀疏性太高。
下面是一个简化的 NCF 实现,用 PyTorch:
import torch import torch.nn as nn class NCF(nn.Module): def __init__(self, n_users, n_items, embed_dim=32, hidden_dims=[64, 32]): super().__init__() self.user_embed = nn.Embedding(n_users, embed_dim) self.item_embed = nn.Embedding(n_items, embed_dim) layers = [] input_dim = embed_dim * 2 for h in hidden_dims: layers.append(nn.Linear(input_dim, h)) layers.append(nn.ReLU()) layers.append(nn.Dropout(0.2)) input_dim = h layers.append(nn.Linear(input_dim, 1)) layers.append(nn.Sigmoid()) self.mlp = nn.Sequential(*layers) def forward(self, user_ids, item_ids): u = self.user_embed(user_ids) i = self.item_embed(item_ids) x = torch.cat([u, i], dim=-1) return self.mlp(x).squeeze() # 训练循环里用二元交叉熵,正样本是听过的,负样本随机采 model = NCF(n_users=10000, n_items=5000) optimizer = torch.optim.Adam(model.parameters(), lr=0.001) criterion = nn.BCELoss()关键点在于负采样。音乐推荐里正样本是用户听过的歌,负样本要从用户没听过的歌里随机抽,比例一般 1:4 到 1:10。抽太少模型学不到区分,抽太多训练慢且容易把潜在喜欢的歌当成负样本。我一般会做「热门惩罚」——越热门的歌越容易被抽成负样本,这样模型不会只推热门。
3.3 用 LightGBM 做排序:把推荐当二分类
工业界更常见的做法是两阶段:召回用矩阵分解或协同过滤出几百个候选,排序用 GBDT 精排。LightGBM 在这个阶段很香,因为特征可以塞很多:用户侧(年龄、活跃度、历史流派偏好)、歌曲侧(热度、年代、音频特征)、交叉特征(用户对歌手的偏好分、用户对流派的偏好分)。
下面是一个排序模型的训练框架:
import lightgbm as lgb from sklearn.model_selection import train_test_split # 构造样本:正样本是用户听过的,负样本是召回阶段没听过的 # 特征包括 user_age, item_popularity, user_genre_pref, item_audio_embed_0 等 features = ['user_age', 'item_popularity', 'user_genre_pref', 'item_release_year', 'user_artist_pref', 'audio_embed_0'] X = samples[features] y = samples['label'] # 1 表示喜欢,0 表示不喜欢 X_train, X_val, y_train, y_val = train_test_split(X, y, test_size=0.2) train_data = lgb.Dataset(X_train, label=y_train) val_data = lgb.Dataset(X_val, label=y_val, reference=train_data) params = { 'objective': 'binary', 'metric': 'auc', 'learning_rate': 0.05, 'num_leaves': 31, 'min_data_in_leaf': 50, 'feature_fraction': 0.8, 'bagging_fraction': 0.8, 'bagging_freq': 5 } model = lgb.train(params, train_data, valid_sets=[val_data], num_boost_round=500, early_stopping_rounds=50)num_leaves控制在 31 到 63 之间,太大容易过拟合。min_data_in_leaf设 50 以上,避免模型记住个别用户的怪癖。early_stopping_rounds是后悔药,没有它你根本不知道什么时候该停。AUC 能到 0.75 以上就算不错,但别只看 AUC,还要看 top-K 的命中率。
4. 评估与调参:离线指标怎么算才不骗自己
4.1 召回率、NDCG 和覆盖率:三个必须一起看的指标
离线评估最怕只看准确率。音乐推荐里,准确率高的模型往往只推热门歌,用户听来听去就那几首。所以我一般同时看三个指标:Recall@K(前 K 个推荐里有多少是用户真正听过的)、NDCG@K(考虑排序位置的命中质量)、Coverage(推荐列表里不同歌曲占总歌曲的比例)。
下面是一个计算这三个指标的脚本:
import numpy as np def evaluate(model, test_interactions, user_item_matrix, K=10): recalls, ndcgs = [], [] recommended_items = set() for user_id in test_interactions['user_id'].unique(): # 真实听过的歌(测试集) ground_truth = set(test_interactions[test_interactions['user_id'] == user_id]['item_id']) if not ground_truth: continue # 模型推荐 recs = model.recommend(user_id, user_item_matrix[user_id], N=K) rec_items = [item for item, _ in recs] recommended_items.update(rec_items) # Recall@K hits = len(set(rec_items) & ground_truth) recalls.append(hits / len(ground_truth)) # NDCG@K dcg = 0.0 for i, item in enumerate(rec_items): if item in ground_truth: dcg += 1.0 / np.log2(i + 2) idcg = sum(1.0 / np.log2(i + 2) for i in range(min(len(ground_truth), K))) ndcgs.append(dcg / idcg if idcg > 0 else 0) coverage = len(recommended_items) / user_item_matrix.shape[1] return np.mean(recalls), np.mean(ndcgs), coverage recall, ndcg, coverage = evaluate(model, test_df, user_item_sparse, K=10) print(f'Recall@10: {recall:.4f}, NDCG@10: {ndcg:.4f}, Coverage: {coverage:.4f}')参数 K 一般取 10 或 20,看你的展示位有多少。Coverage 低于 0.1 就说明推荐太集中,得回去检查是不是热门偏差没处理。
4.2 负采样策略对评估的影响
训练时的负采样和评估时的候选集必须一致,否则指标会虚高。常见错误是训练时随机抽负样本,评估时却从全量歌曲里排,导致模型没见过那么多负例,排序能力被高估。我一般会在评估时也做负采样,但采样分布和训练保持一致,或者干脆用全量排序但只取 top-K。
另一个坑是时间泄漏。如果你用随机划分训练集和测试集,未来行为可能泄漏到训练里。正确做法是按时间切:用前 80% 时间的行为训练,后 20% 测试。这样评估出来的指标才接近线上真实表现。
4.3 超参数搜索:网格搜索还是贝叶斯优化
矩阵分解的超参数不多,网格搜索够用。NCF 和 LightGBM 参数多,建议用 Optuna 做贝叶斯优化。下面是一个 Optuna 调 BPR 的示例:
import optuna def objective(trial): factors = trial.suggest_int('factors', 32, 128, step=32) lr = trial.suggest_float('learning_rate', 0.001, 0.05, log=True) reg = trial.suggest_float('regularization', 0.001, 0.1, log=True) model = implicit.bpr.BayesianPersonalizedRanking( factors=factors, learning_rate=lr, regularization=reg, iterations=100 ) model.fit(user_item_sparse) recall, _, _ = evaluate(model, val_df, user_item_sparse, K=10) return recall study = optuna.create_study(direction='maximize') study.optimize(objective, n_trials=30) print(study.best_params)30 次试验大概能跑半小时到一小时,比手动调参靠谱。注意每次试验都要重新训练,所以数据量大的话得控制 trials 数量。
5. 避坑与排查:推荐系统上线前必须过的五道坎
5.1 现象:推荐结果全是热门歌,冷门歌永远不出现
原因:训练数据里热门歌曲的交互多,模型学到「热门=高分」的捷径。加上负采样时热门歌被抽中的概率也高,进一步强化了偏差。
解决:在损失函数里给热门歌曲降权,或者用「逆流行度加权」的负采样。更直接的办法是在召回阶段做多样性重排,比如用 MMR(最大边际相关性)算法,在保证相关性的同时惩罚和已选歌曲相似的候选。
5.2 现象:新用户注册后推荐完全随机,体验极差
原因:协同过滤依赖历史交互,新用户没有行为数据,模型无法给出个性化推荐。
解决:做冷启动兜底。常见做法是用注册时选的偏好标签(比如喜欢的流派)做内容召回,或者推当前最热且质量分高的歌曲。等用户产生几条行为后,再切换到个性化模型。我一般会设一个阈值:行为少于 5 条时走热门+内容策略,超过 5 条走协同过滤。
5.3 现象:离线指标很好,上线后点击率却很低
原因:离线评估用的是历史数据,存在位置偏差——用户只能点击当时展示给他的歌,没展示过的歌永远没机会被点击。模型学到的可能是「展示位置」而不是「真实偏好」。
解决:用逆倾向加权(IPS)做评估,给每个样本按展示概率的倒数加权。或者做在线 A/B 测试,小流量对比新模型和基线。别迷信离线 AUC,它只能筛掉明显不行的模型。
5.4 现象:训练时 loss 正常下降,但验证集指标波动巨大
原因:数据划分有问题,可能是随机划分导致同一用户的行为同时出现在训练和验证集,造成信息泄漏。也可能是负采样每次随机,导致验证集分布不稳定。
解决:按用户划分,保证一个用户的所有行为只出现在训练或验证一侧。负采样固定随机种子,或者用全量负例做验证。另外检查一下有没有把测试集的歌曲 ID 泄漏到训练特征里。
5.5 现象:服务化后响应时间超过 500ms,用户等不及
原因:每次请求都实时跑一遍矩阵分解或者神经网络,计算量太大。或者候选集太大,排序阶段耗时过长。
解决:做离线预计算。用户 embedding 和歌曲 embedding 提前算好存 Redis,线上只做向量检索(用 Faiss 或 Annoy)。召回阶段取 top 200,排序阶段用轻量模型精排。如果还慢,就加缓存,同一用户 5 分钟内的推荐结果直接复用。
6. 从离线到在线:用 Faiss 做向量召回和 Flask 搭一个最小服务
离线模型跑通后,下一步是让它能被调用。我一般会分两步:先把用户和歌曲的隐向量导出,用 Faiss 建索引做近似最近邻搜索;再用 Flask 包一个 HTTP 接口,接收 user_id 返回推荐列表。
先导出 BPR 模型的 embedding:
# 获取用户和歌曲的隐向量 user_embeddings = model.user_factors item_embeddings = model.item_factors # 保存成 npy 文件 np.save('user_embeddings.npy', user_embeddings) np.save('item_embeddings.npy', item_embeddings) # 用 Faiss 建内积索引 import faiss dim = item_embeddings.shape[1] index = faiss.IndexFlatIP(dim) # 内积相似度 # 归一化后内积等价于余弦相似度 faiss.normalize_L2(item_embeddings) index.add(item_embeddings) faiss.write_index(index, 'music_index.faiss')这里用IndexFlatIP做精确搜索,数据量超过百万时换成IndexIVFFlat并调nlist参数。归一化是为了让内积等于余弦相似度,避免向量长度影响排序。
然后写一个 Flask 服务:
from flask import Flask, request, jsonify import numpy as np import faiss app = Flask(__name__) index = faiss.read_index('music_index.faiss') user_emb = np.load('user_embeddings.npy') item_ids = np.load('item_ids.npy', allow_pickle=True) @app.route('/recommend', methods=['GET']) def recommend(): user_id = int(request.args.get('user_id', 0)) top_k = int(request.args.get('top_k', 10)) if user_id >= len(user_emb): return jsonify({'error': 'user not found'}), 404 query = user_emb[user_id:user_id+1].astype('float32') faiss.normalize_L2(query) scores, indices = index.search(query, top_k) results = [] for score, idx in zip(scores[0], indices[0]): results.append({'item_id': int(item_ids[idx]), 'score': float(score)}) return jsonify({'user_id': user_id, 'recommendations': results}) if __name__ == '__main__': app.run(host='0.0.0.0', port=5000)启动后访问http://localhost:5000/recommend?user_id=0&top_k=10就能拿到推荐结果。注意user_emb的索引要和训练时的 user_id 映射一致,我一般会额外存一个user_id_to_index.json做转换,避免 ID 不连续导致越界。
这套服务每秒能扛几百次请求,对课程设计或者小规模上线够用了。如果要上生产,还得加缓存、限流、降级策略,以及把 Faiss 索引换成 HNSW 或者 ScaNN 来提速。
最后说一个我自己的习惯:每次改完模型或者特征,先跑一遍离线评估,把 Recall@10、NDCG@10、Coverage 三个数记在表格里,和上一版对比。如果某个指标涨了但另一个跌了,别急着上线,先分析是哪些用户群体受影响。推荐系统没有银弹,只有不断迭代和踩坑。希望帮到你。
本文还有配套的精品资源,点击获取