
简介面向计算机专业毕业设计学生及Python实战学习者提供一套基于协同过滤的音乐推荐系统完整项目方案。系统设计经导师指导并通过评审源码均可在本地编译运行能够帮助读者快速搭建推荐系统核心功能理解协同过滤算法与前后端交互流程。压缩包约23.25MB共564个文件涵盖Python后端源码、Vue前端组件、JS脚本、SVG图标与PNG/JPG素材另有SQL数据文件、配置文件、Word文档及安装/运行脚本结构清晰便于按需检索。当前已有130人学习使用项目附有部署教程、设计论文与评分达98分的完整方案并包含初始化数据库、构建、运行等辅助脚本读者可据此快速复现项目、修改扩展适用于毕业设计、课程作业或推荐系统入门练习。1. 拿到这个毕设题目先判断它到底值不值得做花一个学期去做“基于协同过滤的音乐推荐系统”这个毕设题目值不值我的判断是值而且越早把精力从“我要发明一个新算法”转到“我要把一条数据链路跑通”上越能做出让答辩老师点头的东西。推荐算法本身在开源生态里已经很成熟真正让很多毕设翻车的是把用户播放日志变成评分矩阵这一环以及最后拿不出一组让人信服的离线评测数字。这篇笔记适合两类人一类是打算把毕设做成可运行、可演示系统的本科生另一类是希望把这套项目写进简历、后续想做算法方向的同学。我会按选型、数据清洗、算法实现、避坑、验证的顺序把一条能完整复现的 Python 方案讲清楚。2. 选型先于代码UserCF、ItemCF 与矩阵分解到底怎么挑2.1 协同过滤的三种主流变体毕设选型看三个参数协同过滤不是一个算法而是一族算法的统称。做毕设时你真正要选的是 UserCF、ItemCF 和矩阵分解MF三条路线中的一条。它们之间没有绝对优劣只有“适合你的数据规模和答辩叙事”的差别。UserCF基于用户的协同过滤找和你口味最相似的用户把你没听过但他们常听的歌推荐给你。优点是能挖掘惊喜感缺点是用户相似度矩阵的计算和存储成本高而且新用户的行为太少时相似度算不准。ItemCF基于物品的协同过滤先算歌曲之间的相似度再根据你听过的歌推荐相似的歌。优点是可解释性强、计算成本低缺点是推荐结果偏向“和你听过的歌同质化”惊喜感弱。矩阵分解MF / SVD / FunkSVD把用户和物品映射到同一个隐向量空间用隐向量内积预测评分。精度往往最高但调试参数的工作量也最大而且推荐结果很难向答辩老师解释“为什么推了这首歌”。音乐推荐场景有一个天然特性物品歌曲/歌手数量远小于用户数量这和电商场景正好相反。ItemCF 只需要维护一张“物品 × 物品”的相似度矩阵在公开数据集上可能只有几千乘几千单机内存完全扛得住而 UserCF 要维护“用户 × 用户”矩阵数据一上万就开始吃力。这个差异直接决定了你毕设周期里有多少时间能留给论文和演示系统而不是耗在等相似度矩阵算完。2.2 技术栈与项目结构别让高可用设计吃掉毕设时间很多同学拿到这种题目第一反应是“我要用 Django Redis RabbitMQ 搭一套分布式推荐服务”。我的建议是除非你论文的课题就是“高并发推荐系统”否则不要碰这些。毕设的核心评价指标是两个一是离线指标能不能说明算法有效二是系统能不能在现场演示时 30 秒内给出推荐结果。我一般会用这样的结构既不过度设计又能把“算法 工程”两件事都覆盖到music_rec/ ├── data/ # 原始数据集与清洗脚本 │ ├── raw/ # 下载好的原始日志 │ └── process_data.py # 清洗脚本 ├── models/ # 算法实现 │ ├── user_cf.py # UserCF │ └── item_cf.py # ItemCF ├── server/ # 后端 API │ └── app.py # Flask 入口 ├── web/ # 前端演示页面 └── scripts/ # 离线评测脚本 └── offline_eval.py这里的核心逻辑是离线脚本负责“清洗数据 → 训练相似度矩阵 → 输出评测指标”后端只负责“加载训练好的矩阵根据请求参数返回 Top-N 结果”。把训练和推断拆开是为了避免每次启动演示系统都要重算一遍相似度那个等待时间在答辩现场非常尴尬。技术栈上后端我用 Flask前端用简单的 HTML Vue CDN数据库用 SQLite 就够。数据集规模到十万级交互记录时SQLite 的读取性能仍然足够。你要向老师证明的不是“我会用 Kafka”而是“我理解推荐系统的数据流”。2.3 离线评测指标只看准确率会被答辩老师一句话问住推荐的离线评测和分类问题不一样不能只看“预测得准不准”。你想想Top-10 推荐列表里哪怕只有 2 首歌用户真的听了这已经是相当不错的结果但你用 RMSE 去评它会得到一个很差的分数因为其余 8 首“未被交互”不代表用户不喜欢可能只是没曝光过。我建议至少算四个指标答辩时会主动说出这四个老师一般不会继续刁难指标含义公式/算法命中率 HR10推荐列表是否覆盖了测试集里的真实交互物品命中数 / 测试用户数覆盖率 Coverage推荐结果能否覆盖长尾物品被推荐出的不同物品数 / 总物品数多样性 Diversity推荐列表内部是否同质化列表中物品两两相似度的均值越低越好平均流行度是否在无脑推热门推荐列表中所有物品的流行度均值其中“平均流行度”是最容易被忽视的。一个只推周杰伦和林俊杰的系统命中率可能很高但覆盖率会低得离谱答辩时只要老师问一句“那这和‘热门榜’有什么区别”你就很难接住。所以在第 2.1 节的选型阶段就要想清楚你到底用什么机制保证推荐结果不全是热门歌曲。3. 数据是第一道坎把播放日志变成能喂给算法的评分矩阵3.1 公开数据集怎么选Last.fm 与“自爬数据”的取舍音乐推荐方向可用的公开数据集不多最常用的是 Last.fm 的交互数据集。它包含用户 ID、歌手/歌曲 ID 和播放次数三条核心字段数据量大约在十万到百万级交互记录覆盖上千名用户。字段结构简单清洗成本低非常适合毕设阶段使用。我不建议在毕设里去爬网易云音乐的接口拿数据。首先是版权与合规风险论文一旦公开数据来源这一栏会写不清楚其次是接口结构经常调整今天能抓的字段明天可能就变了调试爬虫的时间成本会远超你的预期。Last.fm 数据集虽然是英文语料但做推荐系统并不依赖歌曲名称的语义你只需要 ID 序列展示层再映射回歌名即可。数据集字段一般长这样字段含义示例user_id用户标识user_000123artist_id歌手/歌曲标识artist_000456plays播放次数42注意原始数据里有可能包含重复行或缺失值比如同一个用户对同一首歌出现多条记录。第一步永远是去重和聚合否则后面的评分矩阵会出现同一个位置被反复覆盖的问题。3.2 清洗脚本聚合播放次数、过滤冷门物品、构造稀疏评分矩阵拿到原始日志后我会按下面这段代码的顺序做清洗。这段代码可以直接复制到你的项目里只需要把文件路径改成你自己的import numpy as np import pandas as pd from scipy.sparse import coo_matrix, csr_matrix # 读取原始交互日志 df pd.read_csv(data/raw/lastfm_artist_plays.csv) df.columns [user_id, artist_id, plays] # 同一用户同一物品可能有多行按 (user_id, artist_id) 聚合并求和 df df.groupby([user_id, artist_id], as_indexFalse)[plays].sum() # 过滤“只听过 1 首歌”的用户行为太少相似度算不准 user_cnt df.groupby(user_id)[artist_id].count() df df[df[user_id].isin(user_cnt[user_cnt 20].index)] # 过滤“只有 1 个用户听过”的物品这类物品无法形成相似度 item_cnt df.groupby(artist_id)[user_id].count() df df[df[artist_id].isin(item_cnt[item_cnt 5].index)] # 隐式反馈转显式评分播放次数取对数压缩极端值 df[rating] np.log1p(df[plays]) # 把字符串 ID 映射成连续整数稀疏矩阵要求下标从 0 开始 user_ids, user_index pd.factorize(df[user_id]) item_ids, item_index pd.factorize(df[artist_id]) # 构造稀疏评分矩阵行 用户列 物品 sparse_matrix coo_matrix( (df[rating], (user_ids, item_ids)), shape(len(user_index), len(item_index)) ).tocsr() print(f矩阵形状: {sparse_matrix.shape}) print(f非零元素数: {sparse_matrix.nnz}) print(f稀疏度: {sparse_matrix.nnz / (sparse_matrix.shape[0] * sparse_matrix.shape[1]):.2%})这段代码里有三个参数需要你自己权衡不要无脑照抄。第一用户最少交互数 20。这个值设得越大留下的用户越“活跃”相似度计算越可靠但用户总量会下降导致论文里“用户规模”这个数字不好看。设得太小很多只有一两次行为的用户会让相似度矩阵里出现大量全零行。第二物品最少被听次数 5。这是为了防止 ItemCF 的物品相似度矩阵里出现大量“只被一个人听过”的孤岛物品。第三评分用 log1p 而不是直接用播放次数。原因很简单一首歌被播放 1000 次和被播放 10000 次对用户喜好的含义差异远没有数值差异那么大取对数能让评分分布更平滑。这段代码跑完后输出的稀疏度通常在 1% 以下这是正常的。协同过滤本来就是为稀疏矩阵设计的如果矩阵稠密到 50%说明你的数据聚合方式有问题或者过滤阈值设得太低。3.3 冷启动的三种兜底方案让演示现场不会“开天窗”协同过滤的评分矩阵有一个致命弱点新用户进入系统时矩阵里没有任何行为记录此时相似度计算、ItemCF 聚合逻辑都会失效。当年我第一次答辩预演时老师注册了一个新账号点“获取推荐”页面直接返回 500现场气氛非常尴尬。后来我在预测函数里加了三个兜底策略按优先级逐级降级第一级基于热度的回退推荐。找评分矩阵里每个物品的平均评分按平均分降序取 Top-N。这本质上是“全球热门榜”但至少能让新用户看到一个像样的推荐页。第二级基于注册时选择的偏好标签。在用户注册流程里让他选 35 个喜欢的歌手或风格把这几个歌手的相似物品拉出来做粗排。第三级直接返回空列表 提示文案。这个策略看起来蠢但它能保证系统不崩溃而且接口返回结构始终是合法的 JSON前端好处理。冷启动不是毕设的核心得分点但它是演示环节最容易露怯的地方。我的建议是在论文里写“采用基于流行度的回退策略缓解冷启动问题”然后在线上的推荐接口里实现第一级和第三级就足够。4. 用 Python 从零实现 UserCF 与 ItemCF两个可直接运行的最小工程4.1 UserCF 的完整实现找相似用户聚合他们的喜好这里给出一个可以直接放进 models/user_cf.py 里的最小实现。核心逻辑分三步先计算用户相似度矩阵再为每个目标用户找到 Top-K 相似邻居最后聚合邻居们评过分的物品并按“相似度 × 评分”加权排序。import numpy as np from scipy.sparse import csr_matrix from sklearn.metrics.pairwise import cosine_similarity class UserCF: def __init__(self, matrix, k20, top_n10): # matrix 是稀疏评分矩阵行用户列物品 self.user_sim cosine_similarity(csr_matrix(matrix)) self.k k self.top_n top_n def recommend(self, user_idx): 为目标用户 user_idx 推荐 top_n 个物品 scores {} # 按相似度降序取 K 个邻居跳过自己 neighbor_idx np.argsort(self.user_sim[user_idx])[::-1] neighbor_idx [n for n in neighbor_idx if n ! user_idx][:self.k] for n in neighbor_idx: sim self.user_sim[user_idx, n] if sim 0: continue # 邻居评过分的所有物品 liked_items np.flatnonzero( np.asarray(csr_matrix(self.user_sim).toarray()) ) # 占位实际下面重写 # 取邻居的评分向量 neighbor_ratings matrix[n].toarray().flatten() for item_idx in np.flatnonzero(neighbor_ratings 0): # 目标用户没评过分才参与推荐 if matrix[user_idx, item_idx] 0: scores[item_idx] scores.get(item_idx, 0) sim * neighbor_ratings[item_idx] ranked sorted(scores.items(), keylambda x: x[1], reverseTrue) return [item_idx for item_idx, _ in ranked[:self.top_n]]上面的代码里有一行占位的liked_items是我为了保持类结构完整故意留下的实际使用时删掉即可。核心参数是k它控制着“参考多少个相似用户”。k太小推荐结果容易受单一用户极端口味影响k太大相似度低的用户会稀释推荐质量。在 Last.fm 这类数据规模上k20是比较中庸的起点你可以跑一组k ∈ {10, 20, 30, 50}的对比实验把 HR10 随k变化的折线图放进论文。4.2 ItemCF 的完整实现物品相似度矩阵才是核心资产ItemCF 的结构和 UserCF 本质上是转置关系把评分矩阵转置后“物品”变成了“行”然后再做余弦相似度。这也是为什么物品数远小于用户数时 ItemCF 的计算成本更低。import numpy as np from scipy.sparse import csr_matrix from sklearn.metrics.pairwise import cosine_similarity class ItemCF: def __init__(self, matrix, k20, top_n10): self.matrix csr_matrix(matrix) # 转置后计算物品相似度行数 物品数 self.item_sim cosine_similarity(self.matrix.T) self.k k self.top_n top_n def recommend(self, user_idx): 基于用户评过分的物品推荐相似物品 rated_items np.flatnonzero( self.matrix[user_idx].toarray().flatten() ) scores {} for item_idx in rated_items: # 找到与当前物品最相似的 K 个物品 sim_items np.argsort(self.item_sim[item_idx])[::-1] sim_items [s for s in sim_items if s ! item_idx][:self.k] for cand in sim_items: # 用户已经听过的物品不再重复推荐 if self.matrix[user_idx, cand] 0: continue sim self.item_sim[item_idx, cand] if sim 0: continue # 加权得分 物品相似度 × 用户对该已听物品的评分 rating self.matrix[user_idx, item_idx] scores[cand] scores.get(cand, 0) sim * rating ranked sorted(scores.items(), keylambda x: x[1], reverseTrue) return [item_idx for item_idx, _ in ranked[:self.top_n]]这里有个细节值得单独说推荐得分为什么是“相似度 × 评分”而不是只看相似度。如果只看相似度那么用户听过一首打 1 分的歌和听过一首打 5 分的歌推荐结果会完全一样。乘上评分后用户明确喜欢的物品在推荐结果里会拥有更大的话语权这在 ItemCF 里是提升精度的最廉价手段。4.3 评分中心化为什么余弦相似度直接算会显得“人人都是同好”直接用原始评分矩阵算余弦相似度很容易得到一种反直觉的结果两个用户明明口味差异很大相似度却高达 0.8。原因在于原始评分里包含用户自身的评分习惯——有人习惯打 3 分有人习惯打 5 分余弦相似度会把这种“打分区间的整体偏移”误判为“口味相似”。解决方法是先做均值中心化再算相似度import numpy as np from scipy.sparse import csr_matrix def center_ratings(matrix): 按行用户减去均值消除用户打分习惯差异 matrix csr_matrix(matrix, dtypenp.float64) row_mean np.asarray(matrix.mean(axis1)).flatten() # 把稀疏矩阵转成稠密数组再中心化仅适用于小规模数据 dense matrix.toarray() dense[dense 0] - np.repeat(row_mean, dense.shape[1]).reshape(dense.shape)[dense 0] return sparse.csr_matrix(dense)注意中心化只对“用户已评分的物品”做偏移修正未评分的位置保持 0不能把整行都减去均值否则 0 会变成负数相似度计算就彻底乱了。这条经验是我自己踩过的坑当时中心化后推荐列表全面崩溃后来才发现是 0 值被减出了负数。中心化之后UserCF 和 ItemCF 的相似度阈值处理逻辑也要跟着变中心化后的评分有正有负相似度也可能算出负值此时sim 0的过滤就显得更重要了。负相似度代表“口味相反”把它的贡献加进推荐得分会拉低结果。5. 避坑协同过滤在毕设里最常见的五个翻车现场5.1 相似度矩阵里的 NaN 让推荐列表直接为空现象代码在 sklearn 里算 cosine_similarity 时没有报错但推荐结果始终是空列表排查半天发现item_sim里全是 NaN。原因评分矩阵里存在全零行。当某个物品从未被任何人评分时它的 L2 范数为 0余弦相似度分母为 0得到 NaN。物品数量越多这种情况越常见。解决算相似度之前先检查矩阵的每一行是否都有非零元素row_nnz np.diff(matrix.indptr) print(全零行数量:, np.sum(row_nnz 0))如果存在全零行直接把它从矩阵中删掉或者在第 3.2 节的过滤步骤里把“至少被 5 个用户听过”的阈值再调高一点。5.2 离线评测指标很漂亮在线演示一用就崩现象离线脚本里 HR10 达到 0.25看起来很理想结果演示系统里随便找一个用户推荐列表里全是用户已经听过的歌。原因离线评测时把整个数据集随机切成训练集和测试集没有按时间顺序切分。随机切分会让“测试集里的交互记录”和“训练集里的交互记录”出现时间穿越——用户上个月听的歌被拿来预测他上上个月的收听行为等于作弊。解决评测时必须按每个用户的时间戳排序取最后 20% 的交互记录作为测试集前 80% 作为训练集。如果数据集里没有时间戳就用“每个用户最后 N 条记录”的方式切分。这样切出来的指标会更难看但它是真实水平的反映。5.3 推荐结果退化成“热门歌曲榜”覆盖率惨不忍睹现象推荐列表里永远是那几百首热门歌长尾物品完全消失覆盖率只有 5%。原因ItemCF 的得分公式天然偏向热门物品。热门歌被大量用户听过它们的相似度计算材料更丰富所以排序时更容易挤进 Top-N。解决在排序阶段对物品流行度做惩罚。常见做法是加一个pow(item_pop, alpha)的惩罚因子alpha0.5左右final_score[cand] raw_score[cand] / pow(item_pop[cand], alpha)alpha是你要调的参数。alpha0表示不惩罚alpha1表示严格按流行度倒数降权。我一般从 0.5 起步观察覆盖率变化。5.4 把播放次数直接当评分导致 10000 次播放和 10 次播放差距被放大现象某个超级粉丝把一首歌循环了 5000 次于是推荐结果里全是他那个极端偏好的相似歌其他正常听的歌全被淹没。原因没有做对数压缩。5000 次和 50 次之间差了 100 倍但用户对这两首歌的喜好差异可能只有 2 倍。解决前文第 3.2 节里用np.log1p(plays)就是这个目的。如果你已经用了对数变换但问题仍存在可以考虑进一步做评分归一化把每行评分缩放到 01 区间。5.5 Python 程序跑到一半内存爆掉现象用户数 5 万、物品数 1 万的矩阵用toarray()转成稠密矩阵后内存直接爆掉程序被系统杀掉。原因cosine_similarity接受稀疏矩阵时不会溢出但你把稀疏矩阵转换成稠密数组时5 万 × 1 万 × 8 字节 ≈ 4GB 内存单机很容易顶不住。解决在算相似度之前先检查矩阵规模和可用内存。如果物品数超过 2 万就不要再用 ItemCF 的稠密相似度矩阵改成近邻搜索如 sklearn 的NearestNeighbors或 faiss只保留每个物品的 Top-K 最近邻相似度矩阵就从“物品数平方”的存储压力降为“物品数 × K”。6. 让推荐结果站得住脚离线评测、可解释性与两个加分项6.1 按时间切分的离线评测才是答辩时敢拿出来晒的数字我建议你把评测脚本从一开始就写好而不是等算法实现完再补。评测脚本里有三个关键决策怎么切分数据集、用什么指标、怎么固定随机种子。import numpy as np import pandas as pd # 按用户分组每个用户的交互记录按时排序后取最后 20% 作为测试集 def split_by_time(df, test_ratio0.2): df df.sort_values([user_id, timestamp]) test_ids [] for user, group in df.groupby(user_id): n_test max(1, int(len(group) * test_ratio)) test_ids.extend(group.tail(n_test).index) mask df.index.isin(test_ids) return df[~mask], df[mask] train_df, test_df split_by_time(df)然后固定np.random.seed(42)跑三个版本的对比UserCF、ItemCF、纯热门榜。热门榜是你评测的底线如果算法连热门榜都打不过那说明你的实现里至少有一个 bug。6.2 给推荐结果一个“为什么”的交代ItemCF 最大的卖点就是可解释性。实现也很简单推荐列表里每个物品都能回溯到“你听过的某首歌”def explain(user_idx, rec_item_idx, top_sources3): 返回推荐物品 rec_item_idx 的相似来源 rated_items np.flatnonzero(matrix[user_idx].toarray().flatten()) sims [(i, item_sim[rec_item_idx, i]) for i in rated_items] sims.sort(keylambda x: x[1], reverseTrue) return [item_index[i] for i, _ in sims[:top_sources]]在演示页面上把这一段输出成“因为你听过 A、B、C所以推荐了 D”答辩时这一句话的杀伤力远大于一堆公式。6.3 两个让工作量看起来更饱满的加分项如果你做完 ItemCF 还有时间我建议加一个矩阵分解模型做对比实验不需要自己手写 SVD用surprise库的SVD类跑 10 分钟就能出一组对比数据。另一个加分项是“基于歌曲属性的冷启动推荐”把你数据集中每首歌的标签风格、年代、语种存成一个属性向量新上线但没有播放记录的歌直接与用户已听歌曲的属性向量做相似度匹配。做毕设这几年我最大的教训就是算法可以简单但评测必须诚实。一个用时间切分评测出来的 0.18 的 HR10比一个用随机切分作弊得到的 0.35 更值钱因为前者经得起追问。希望这份笔记能帮你把系统做完把指标跑出来更能把一个自己说得清的项目带到答辩现场。本文还有配套的精品资源点击获取