多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Java协同过滤音乐推荐系统实战:从Spring Boot到算法实现

Java协同过滤音乐推荐系统实战:从Spring Boot到算法实现 1. 项目概述与需求拆解计算机毕设选 Java 协同过滤音乐推荐这个题目某种程度上是个稳中带收益的组合。音乐系统业务逻辑足够简单用户、歌曲、收藏、评分、播放记录这些表建出来就占了半壁江山而推荐算法又让课题看起来有技术含量而不是纯增删改查。再加上 Java 生态在校园里的普及度Spring Boot 一套东西顺手就能搭起服务端协同过滤又是入门推荐算法里最好解释的一类无论做课程设计还是毕业论文都是性价比极高的选择。这个课题的核心价值在于两头兼顾一是让你有机会完整走一遍数据采集 - 特征构建 - 算法建模 - 结果评估的推荐系统流程二是后端工程能力能得到实打实的锻炼。你不仅要写算法还要做接口、做页面、处理并发和数据存储这正是企业里推荐系统开发工程师日常工作中的一小撮影子。适合的人群有两类一类是 Java 基础还行但没接触过算法、想通过一个项目把推荐系统流程串起来的同学另一类是算法听了一些但工程能力弱想借毕设把 Spring Boot 和数据库玩熟的同学。哪怕你现在只会写点 JavaSE 和 JDBC这篇文章也会让你在动手前心里有底。2. 技术选型与架构设计2.1 后端为什么选 Spring Boot MyBatis很多同学纠结 SSM 还是 Spring Boot我的建议是别犹豫直接用 Spring Boot。毕设答辩现场没人会因为没有使用 SSH扣你分但会因为项目跑不起来、配置半天报错而一脸尴尬。Spring Boot 最实际的好处是约定优于配置内嵌 Tomcat打一个 jar 包就能跑省去部署 Web 服务器那一堆麻烦。对于想要快速把核心推荐功能做出来、把时间花在算法上的课题来说这是最务实的选择。MyBatis 是另一个稳妥选项。它的 SQL 直白方便你手写复杂查询比如统计用户听歌次数、计算共现矩阵这类和推荐系统高度相关的 SQL 逻辑比 JPA 的自动派生查询更可控。特别是当你需要处理多表关联、动态拼接条件时MyBatis 的if标签和 ResultMap 会让你觉得一切尽在掌握。话说回来技术选型的核心原则不是谁更酷而是谁能让你在限定时间内把完整系统跑通并且你能在答辩时把为什么选它讲清楚。你不需要深度学习也不需要用 Spark 做分布式一个单机版 Java 应用 MySQL 足够呈现完整推荐链路。2.2 数据库表设计反直觉但实用音乐推荐系统最核心的表是用户表、歌曲表和行为表。行为表又叫用户-物品交互表是协同过滤的原料。设计表的时候有个反直觉的点不要把评分和收藏分开建表。毕设场景下你需要一个统一的user_music_behavior表包含 user_id、music_id、behavior_type收藏、播放、评分、score如果是评分行为就存 1-5 分、timestamp 这些字段。这样做的原因很简单协同过滤算法本质是输入用户对物品的评分矩阵你把所有行为统一成一条条记录后可以非常方便地聚合成矩阵你要是把收藏和评分拆成两张表算法那边还要先做联合查询再组装矩阵白白给自己找麻烦。歌曲表也不要搞得太花哨。music_id、title、artist、album、genre、duration、cover_url 这些就够。genre 字段注意保留后面做冷启动和基于物品的推荐时会用到。第三张核心表是推荐结果表例如recommend_music字段包括 user_id、music_id、score、reason_type说明这条推荐来自用户协同还是物品协同、create_time。这张表是给前端展示用的。推荐算法离线或在线运行后把结果写进表里接口只要负责查表即可。不要每次用户访问推荐页时现算数据量一大效率会很难看。2.3 架构分层与推荐模块的位置工程目录上我是这样组织的src/main/java/com/example/music ├── controller ├── service │ ├── recommend │ ├── user │ └── music ├── mapper ├── model │ ├── entity │ └── vo └── algorithm ├── similarity ├── collaborative └── datacontroller 只做参数接收和结果封装service 层处理业务逻辑algorithm 包放协同过滤相关算法。这样分的好处是算法模块不依赖 Spring 的 web 层你可以单独写单元测试验证算法正确性这在答辩时是非常有说服力的展示点。我在做项目时就习惯先在 main 函数里跑一遍算法拿小数据集验证结果再挂到 Spring 里去。3. 协同过滤核心原理与选型3.1 基于用户的协同过滤UserCF协同过滤的基本假设是相似品位的人喜欢相似的东西。UserCF 的思路分三步先找到与你兴趣最相似的一批用户再看这批用户喜欢了哪些你没有听过的歌按流行程度或者相似程度排序推荐给你。具体来说假设用户 A 听了《晴天》《七里香》《星晴》用户 B 听了《晴天》《七里香》《简单爱》那么 A 和 B 的 Jaccard 相似度就很高。这时候如果 B 还听了《爱在西元前》而 A 没有那系统就会把《爱在西元前》推荐给 A。我在毕设里遇到的实际问题是UserCF 更适合用户量不大、但用户行为足够多的场景。如果整个系统只有几百个注册用户基于用户的推荐效果往往比基于物品的好因为每个用户的兴趣画像相对完整。另外 UserCF 推荐结果带有一点社交发现的味道适合做你可能认识的人也在听这类功能。3.2 基于物品的协同过滤ItemCFItemCF 的思路是和你喜欢的东西相似的东西值得推荐。它不需要内容特征歌手、流派、歌词完全靠用户行为的共现关系计算物品相似度。比如听了《晴天》的人有一大批也听了《七里香》那这两首歌就被判定为相似。这里的相似不是歌曲风格相似而是在用户行为上共现的相似。更适合做喜欢这首歌的人还喜欢这样的推荐。ItemCF 的优点是物品数量相对用户数量通常更稳定因此相似度矩阵可以预先离线算好在线阶段只需要做一次矩阵乘法性能比 UserCF 更可控。3.3 相似度计算的几种方法与选型建议协同过滤离不开相似度计算。常见的有余弦相似度、皮尔逊相关系数、修正余弦相似度三种。余弦相似度直接用两个向量的夹角余弦衡量相似度原理直观适合处理评分 0-5 分的矩阵。皮尔逊相关系数则会把每个用户的评分减去自己的平均分再去计算相似度这样做的好处是消除了用户打分尺度不一致的问题——有人习惯全打 4 分有人习惯 3-5 分波动皮尔逊能让这类差异不至于主导结果。在音乐推荐场景下我实测下来修正余弦相似度表现更稳定。原因在于物品的热门程度差异非常大。比如热门口水歌被大量用户听过而小众民谣只有少数人听过。如果不做中心化热度高的物品会无差别地出现在大量相似度高的位置推荐结果会偏向热门而缺乏个性。修正余弦要先对每个物品的评分做平均分去中心化也就是减去该物品所有评分的均值再用余弦相似度这一步对音乐这种长尾效应明显的场景特别适用。在实际编码中还要注意一个细节相似度矩阵是稀疏的大量物品之间相似度为 0没必要存储完整二维数组。我采用 HashMap 嵌套结构只存储非零相似度能大幅降低内存占用。3.4 冷启动问题怎么破协同过滤一个出名的问题是冷启动新用户没有行为记录新歌曲没有用户听过算法就废了。毕设阶段不需要彻底解决但你可以做得比随便推荐体面一点。我采用的做法是混合策略。对没有行为的新用户根据注册时勾选的音乐风格偏好genre推荐对应流派下热度最高的歌对新上架的歌曲则利用内容属性歌手、流派计算与已有歌曲的内容相似度推荐给喜欢类似内容的老用户。这一部分不需要太复杂靠 SQL 就能实现先按 genre 分组再按播放量排序取前 N 条。这种设计在答辩时是加分项因为说明你想到了真实场景下的问题而不是只在理想数据上跑通一个算法。4. 核心实现从评分矩阵到推荐列表4.1 构建用户-物品评分矩阵算法层的第一步是把数据库里的行为记录转成矩阵或稀疏向量。这里我建议不要用二维数组而是用 Map。public class RatingMatrix { // 用户 - (歌曲 - 评分) private MapLong, MapLong, Double userRatings new HashMap(); // 歌曲 - (用户 - 评分)用于物品相似度计算 private MapLong, MapLong, Double itemRatings new HashMap(); public void addRating(Long userId, Long musicId, double score) { userRatings.computeIfAbsent(userId, k - new HashMap()).put(musicId, score); itemRatings.computeIfAbsent(musicId, k - new HashMap()).put(userId, score); } public double getRating(Long userId, Long musicId) { return userRatings.getOrDefault(userId, Collections.emptyMap()) .getOrDefault(musicId, 0.0); } }这段代码把数据加载成一个双向索引。因为后续既需要某用户的评分列表来计算用户相似度也需要某歌曲被哪些用户评过分来计算物品相似度。两个 Map 并行构建省去了频繁遍历的时间。从数据库加载数据时我直接用一条 SQL 查出所有行为记录而不是在代码里循环查询。这一步是性能的关键。SELECT user_id, music_id, CASE WHEN behavior_type play THEN 1.0 WHEN behavior_type favorite THEN 5.0 ELSE score END AS rating FROM user_music_behavior4.2 基于用户的协同过滤实现流程UserCF 的推荐核心代码比较清晰处理逻辑如下public ListLong recommendByUserCF(Long targetUserId, int k, int n) { MapLong, Double targetVec userRatings.get(targetUserId); if (targetVec null || targetVec.isEmpty()) { return Collections.emptyList(); } // 1. 遍历其它用户计算相似度 MapLong, Double simMap new HashMap(); for (Map.EntryLong, MapLong, Double entry : userRatings.entrySet()) { Long otherUserId entry.getKey(); if (otherUserId.equals(targetUserId)) { continue; } double sim cosineSimilarity(targetVec, entry.getValue()); if (sim 0.01) { simMap.put(otherUserId, sim); } } // 2. 取最相似的K个用户 ListMap.EntryLong, Double sorted new ArrayList(simMap.entrySet()); sorted.sort((a, b) - Double.compare(b.getValue(), a.getValue())); ListLong topKUsers new ArrayList(); for (int i 0; i Math.min(k, sorted.size()); i) { topKUsers.add(sorted.get(i).getKey()); } // 3. 计算目标用户对每首歌的加权得分 MapLong, Double scoreMap new HashMap(); for (Long otherUserId : topKUsers) { double sim simMap.get(otherUserId); MapLong, Double otherVec userRatings.get(otherUserId); for (Map.EntryLong, Double item : otherVec.entrySet()) { if (!targetVec.containsKey(item.getKey())) { scoreMap.merge(item.getKey(), sim * item.getValue(), Double::sum); } } } // 4. 排序返回Top-N return scoreMap.entrySet().stream() .sorted((a, b) - Double.compare(b.getValue(), a.getValue())) .limit(n) .map(Map.Entry::getKey) .collect(Collectors.toList()); }余弦相似度实现时要注意向量的稀疏性不要使用两层循环遍历所有歌曲。正确做法是只遍历公共部分再结合各自的模长。private double cosineSimilarity(MapLong, Double a, MapLong, Double b) { if (a.isEmpty() || b.isEmpty()) { return 0.0; } double dot 0.0; for (Map.EntryLong, Double entry : a.entrySet()) { if (b.containsKey(entry.getKey())) { dot entry.getValue() * b.get(entry.getKey()); } } double normA a.values().stream().mapToDouble(v - v * v).sum(); double normB b.values().stream().mapToDouble(v - v * v).sum(); if (normA 0 || normB 0) { return 0.0; } return dot / (Math.sqrt(normA) * Math.sqrt(normB)); }注意相似度阈值不要设得太低否则大量弱相关的用户都会进候选集既拖慢速度又降低推荐质量。我实测下来阈值 0.01 只是过滤掉完全无交集的用户如果数据量稍微一大建议把阈值提高到 0.05 甚至 0.1让 topK 用户真正相似。4.3 基于物品的协同过滤实现流程ItemCF 分为离线计算和在线推荐两个阶段。离线阶段对每对物品计算相似度定时跑一个任务更新相似度矩阵。在线阶段只需要根据目标用户的历史行为找到向量中不为零的物品然后聚合这些物品的相似物品分数。离线计算物品相似度时如果直接对所有物品两两计算物品数为 M复杂度就是 O(M^2)。当歌曲表达到几万条时哪怕每次只扫描公共用户计算量也不小。我采用的优化思路是只对至少被一个共同用户评分过的物品对计算相似度也就是利用用户维度的倒排表来生成候选物品对。倒排索引构建如下MapLong, ListLong userMusicMap new HashMap(); // 用户 - 物品列表然后遍历每个用户的物品列表对列表内的物品两两组合统计共现次数。这一步看似简单却能让候选物品对的数量降低几个数量级。只有共同出现过至少一次的物品对才有计算相似度的必要。在线推荐阶段对用户已行为的每个音乐取出它的 TopM 个相似音乐累加得分。得分权重可以是用户对该音乐的评分 * 物品之间的相似度。public ListLong recommendByItemCF(Long targetUserId, int n) { MapLong, Double history userRatings.get(targetUserId); MapLong, Double scoreMap new HashMap(); for (Long musicId : history.keySet()) { MapLong, Double similarItems itemSimilarity.getOrDefault(musicId, emptyMap()); for (Map.EntryLong, Double simEntry : similarItems.entrySet()) { Long candMusicId simEntry.getKey(); if (history.containsKey(candMusicId)) { continue; } double rating history.get(musicId); scoreMap.merge(candMusicId, simEntry.getValue() * rating, Double::sum); } } return topN(scoreMap, n); }一个容易被忽视的细节是使用 用户评分 * 物品相似度 的加权方式时如果用户对已听歌曲评分普遍偏低推荐得分会被整体拉低。为了让得分范围更清晰我习惯把历史评分先减去用户平均分得到偏好增量再用增量去乘相似度叠加。这样能突出用户特别喜欢的物品在推荐中的话语权。4.4 两种算法结果的融合策略单一算法的推荐结果往往有明显的偏好偏差UserCF 偏重同好者推荐ItemCF 偏重相似物品扩展。毕设系统里做结果融合能显著提升推荐多样性。我用的融合方式很简单但有效线性加权。设定一个权重参数 alpha推荐得分 alpha * UserCF得分 (1 - alpha) * ItemCF得分。因为两种算法产生的得分量纲不同直接相加没有意义所以要先做 min-max 归一化。private MapLong, Double normalize(MapLong, Double scores) { double min Collections.min(scores.values()); double max Collections.max(scores.values()); double diff max - min; MapLong, Double normalized new HashMap(); for (Map.EntryLong, Double entry : scores.entrySet()) { normalized.put(entry.getKey(), diff 0 ? 0.5 : (entry.getValue() - min) / diff); } return normalized; }归一化后把两组得分相加再排序取 TopN。alpha 我是在一个小的验证集上调出来的初始值取 0.5之后根据推荐列表里热门歌曲占比手动微调。如果你希望在系统中多展示一些长尾内容可以把 ItemCF 的权重调高如果希望效果贴近好友推荐就让 UserCF 权重占更多。5. 项目工程化的必要细节5.1 从用户行为到实时推荐的触发策略毕设阶段不需要上 Flink、Kafka 那套实时架构但你至少要让推荐结果能更新。我采用的是定时离线计算 实时结果读取策略。后端启动后每隔固定时间比如 6 小时跑一次推荐任务把每个用户对应的推荐列表写入 recommend_music 表。用户请求推荐接口时直接从表里查数据。这样推荐接口的响应时间可以稳定在 50ms 以内。如果你想展示一点实时性也可以在用户点击收藏或听完一首歌后异步触发增量更新当前用户的推荐列表只重算该用户的近邻或相似物品然后更新数据库中的记录。异步更新在 Spring Boot 里用异步任务注解很容易实现注意不要阻塞用户请求线程。Component public class RecommendUpdater { Async public void updateUserRecommend(Long userId) { ListLong recommendations recommendService.recommendForUser(userId); recommendMapper.cleanAndInsert(userId, recommendations); } }5.2 接口设计与前端展示的配合推荐接口返回的数据结构要方便前端直接渲染。我定义了一个推荐结果 VO包含推荐原因描述字段。比如基于用户的推荐字段值是 和你有相似听歌品味的用户也在听基于物品的推荐字段值是 因为你喜欢《晴天》为你推荐《七里香》。{ code: 200, data: [ { musicId: 12, title: 七里香, artist: 周杰伦, cover: /cover/7.jpg, reason: 因为你喜欢《晴天》为你推荐《七里香》 } ] }前端拿到数据后不需要关心推荐逻辑直接渲染卡片列表这让前后端开发可以完全并行。很多同学喜欢在 service 里直接塞一堆算法细节到 VO导致接口返回混乱这是要避免的。5.3 后端接口的缓存处理推荐列表不是每次都从数据库查就能完事的。当用户量上来后频繁查询 recommend_music 表压力不小。我在接口层加了一层 Redis 缓存key 为 recommend:user:{userId}缓存时间为 2 小时。定时任务更新完推荐表后顺手删除对应缓存保证用户下次请求拿到新数据。这一步在答辩演示时有个实际好处你可以在答辩前清空缓存现场演示冷启动推荐结果迅速产生或者展示第二次请求接口耗时从 200ms 降到 20ms这种性能对比很有说服力。Spring Cache 加 Redis 是标准做法用注解就能搞定。Cacheable(value recommend, key #userId) public ListMusicVO getRecommendations(Long userId) { return recommendMapper.selectRecommendMusic(userId); }5.4 推荐质量的简单验证方法毕设基本不需要做离线效果评估实验跑 Precision/Recall但你需要有一个可解释的评估机制。我当时的做法是准备 50 个用户的行为数据手工检查推荐结果是否合理。比如我自己用三个测试账号分别听民谣、摇滚、流行然后看推荐列表里是否出现了同类型歌曲。更严谨一点可以写一个简单的 A/B 测试页面一半用户看到协同过滤推荐结果另一半看到热门榜推荐结果记录两个页面的点击率。我后来发现实验周期太短数据不足以支撑结论但实验本身的设计过程写进论文里会显得非常完整。6. 常见问题与排查技巧实录6.1 评分矩阵太稀疏相似度全是 0这是最常见的问题。用户行为数据少两个用户之间几乎没有共同听过的歌相似度矩阵必然稀疏。处理方式有几种一是降低相似度计算门槛允许只计算 Jaccard 系数而不是余弦相似度只要共现数量大于 0 就给一个基础相似度二是引入间接相似比如用户 A 和 C 没有共同歌曲但 A 和 B 相似度高B 和 C 相似度高可以用相似度的传递性粗略填充三是走内容冷启动策略用歌曲流派、歌手来补行为缺失。我实测最省事的是混合协同过滤负责有行为的用户内容标签负责无行为的物品。两套结果合并时不会冲突因为冷启动部分不会产生高得分加权后自然排在后面。6.2 推荐结果全是热门口水歌这个现象说明你没有做去热门化处理。热门歌曲如周杰伦、林俊杰等头部歌手的作品天然有很高的共现概率几乎和任何其他歌曲都有联系很容易霸榜。解决办法是在物品相似度计算时使用用户活跃度惩罚如 IDF 思想或者是最后融合时给热门歌曲一个惩罚系数。我采用过一种简单的降权方案热门歌曲得分乘以一个小于 1 的衰减因子衰减因子由歌曲被行为的人数决定。double penalty Math.log(1 popularity) / Math.log(1 maxPopularity); double finalScore rawScore * (1 - 0.5 * penalty);这样不会完全屏蔽热门但能让长尾歌曲获得更多露脸机会推荐列表看起来更有个性化的味道。6.3 数据量一大推荐计算内存溢出刚开始我用二维数组存储用户-物品评分矩阵几百首歌还好一旦上万首歌数组就爆了。换成 Map 结构后内存减少了 90%。另外定时计算时我建议分批处理用户——一次处理 1000 个用户算完写库再算下一批避免内存同时驻留所有推荐结果。还有一种更省事的办法把推荐任务拆成全量计算和增量计算两部分。每天凌晨全量跑一遍白天只对活跃用户增量更新。这样系统负载会均衡很多。6.4 答辩时怎么讲清楚协同过滤答辩最怕被问算法原理是什么而只能背公式。我的建议是拿具体的用户-歌曲票数举例子边说边在白板上画矩阵演示共同评分 - 计算相似度 - 加权求和 - 排序四步。说清了为什么相似的人喜欢的歌我也可能喜欢这个故事比背出一堆余弦公式更有说服力。被问到为什么选协同过滤而不是深度学习就直接说在数据稀疏、行为数据量不足以支撑神经网络的条件下协同过滤能够以最小的计算成本取得足够好的个性化效果。这样的回答既诚实又展现出你对模型适用边界的理解。7. 拓展与优化思路7.1 从离线到准实时的渐进升级如果学有余力可以把定时更新推荐结果的逻辑升级为一个简单的实时事件驱动模型用户产生行为听歌、收藏时把事件写入内存队列如 Spring 的 ApplicationEvent消费者线程异步重算并更新该用户的推荐列表。不需要引入消息中间件单台服务器用 Java 并发包就能做出可演示的准实时更新效果。我在自己的项目里用了一个单线程调度池处理更新请求每次更新只对该用户做一遍算法计算实测 500 用户以内接口全部操作耗时控制在几十毫秒量级。7.2 引入深度学习之前应该先做好特征工程很多同学一上来就想搞神经网络但忽略了协同过滤本身已经把用户-物品关系用得差不多了。如果要更进一步合理的路径是先加特征音乐的声学特征、歌词文本特征、用户的人口属性特征然后用因子机融合这些特征。这个思路可以在论文里作为下一步展望不需要实际做完整套。答辩时老师问后续可以怎么扩展你就能从工程和算法两个角度给出具体方案。7.3 把项目做成一个可复用的平台骨架如果你不想只做一个毕设而是想让项目有更长久的价值可以抽离出一套 API 供其他小程序或 App 使用。推荐服务只需要暴露 RESTful 接口把用户 ID 作为参数传入返回推荐列表。这个思路另一个好处是当你以后找工作做作品集展示时可以顺带提到你的系统支持多端接入而不是仅仅做了一台孤立的后台网页。8. 一段土办法总结做这个项目的时候我踩过不少坑说实话最耗时间的不是算法推导而是数据清洗和调参。自己生成模拟数据的时候80% 的用户只对 5 首热门歌有行为导致一开始测试 ItemCF 时推荐的歌全是重复的当时我一度以为算法写错了。后来我把行为分布调成少量头部用户行为很多、大部分用户行为很少的偏态分布才看出来算法迭代的效果。这里也想提醒你任何一个推荐项目数据和算法是相互塑形的花在构造像真实场景的数据上的时间绝不会白费。如果你正在准备这个课题不管是课程设计还是毕业论文先把一个最简单的 UserCF 跑通看到推荐列表里有合理但不完全热门的结果再一步步加功能。这条路不会让你惊艳所有人但能让你稳稳当当交出一份扎实满意的作品。
返回列表