☰
基于协同过滤与Spring Boot的个性化音乐推荐系统实战
2026/10/8 10:58:28 网站建设 项目流程

简介:这份资源是面向高校计算机专业毕业设计场景的完整项目源码,主题为基于协同过滤算法的个性化音乐推荐系统,适合正在准备Java方向毕设的本科生或需要SpringBoot+Vue全栈练手的开发者。项目采用Java语言与SpringBoot框架,前端使用Vue构建,配套MySQL 5.7数据库,JDK1.8、Tomcat7、Maven等环境均已验证可完美运行。压缩包共366个文件,约10.61MB,其中101个Java文件承载后端业务逻辑与协同过滤算法实现,79个Vue文件构成前后端分离界面,另有PNG、JPG等图片资源、JS脚本、CSS样式、XML配置及SQL建表脚本,结构完整。系统覆盖用户行为跟踪、偏好学习与用户画像构建、相似性计算、推荐列表生成、用户反馈调整策略,以及音乐库管理、用户管理、评论管理和数据可视化等模块,管理员可增删改音乐信息与用户数据。目前已有70人学习,适合需要完整赛题方案、算法落地思路与前后端联调参考的读者。

1. 从一份毕设需求说起:协同过滤怎么把歌单推对人

每年带毕设,总有几个学生拿着“个性化音乐推荐系统”来找我,开口就是“老师我想用深度学习”。我一般会先泼一盆冷水:你手上有多少条真实听歌记录?如果只有几百个用户、几千条播放日志,上深度模型就是拿大炮打蚊子,训练不动、调参玄学、答辩还讲不清。这个标题里的协同过滤算法,恰恰是数据量不大时最稳、最容易讲明白、也最容易做出效果的一档方案。

它解决的事情很朴素:我不知道你喜欢什么风格、什么年代、什么语种,但我知道你和另外一批人听得差不多,而那批人反复听的一首歌你还没听过——那就把它推给你。整套系统落到 Java 技术栈上,就是 Spring Boot 提供接口、MyBatis-Plus 管数据、MySQL 存用户行为、离线算相似度、在线出推荐列表。适合谁?适合数据规模在万级以内、想在一到两个月内跑通“登录—听歌—打分—推荐—反馈”闭环的毕设,也适合想借这个题目把 Java 后端工程能力串一遍的人。下面我按自己带项目的顺序,把选型、建表、算法实现、接口和踩坑一次讲透。

2. 协同过滤在音乐场景里到底怎么算:用户相似度与物品相似度的取舍

2.1 为什么音乐推荐优先选 UserCF 而不是 ItemCF

协同过滤分两条路:基于用户的协同过滤(UserCF)和基于物品的协同过滤(ItemCF)。UserCF 算“和你口味像的人还听了什么”,ItemCF 算“听了这首歌的人还听了什么”。在电商里 ItemCF 是主流,因为商品数量相对稳定、用户兴趣漂移慢;但音乐场景不一样,歌曲库动辄几万首、每天还有新歌进来,物品相似度矩阵维护成本高,而且一首歌被共同收听的数据非常稀疏。

毕设的数据量通常更小,UserCF 反而更合适:用户数少、相似度矩阵小、算得快,而且“找相似用户”这个逻辑在答辩时特别好讲。我一般会这样定:用户数在 5000 以内、行为数据在 10 万条以内,直接上 UserCF;如果用户量明显大于物品量,再考虑 ItemCF。这不是绝对规则,但能让你少走弯路。

2.2 相似度公式:余弦、皮尔逊和调整余弦怎么选

最常用的是余弦相似度。把每个用户对歌曲的评分看成一个向量,两个用户向量的夹角越小越相似:

sim(u, v) = Σ(r_ui · r_vi) / (√Σr_ui² · √Σr_vi²)

但音乐评分有个坑:有人习惯全打 5 分,有人最高只给 3 分,这叫“评分尺度偏差”。余弦对绝对值不敏感,皮尔逊会减去用户均分,能缓解这个问题。我的经验是:如果评分是 1~5 星且用户打分习惯差异大,用皮尔逊;如果行为是“播放/收藏/跳过”这种隐式反馈,用余弦更直接。调整余弦(减去物品均分)在物品维度偏差大时有用,音乐场景用得少。

2.3 用 Java 实现用户相似度矩阵

下面这段是我常用的核心计算,输入是userId -> (songId -> score)的评分表,输出用户两两相似度。

// 计算用户相似度矩阵,ratingMap: userId -> (songId -> score) public Map<Long, Map<Long, Double>> calcUserSimilarity( Map<Long, Map<Long, Double>> ratingMap) { Map<Long, Map<Long, Double>> simMatrix = new HashMap<>(); List<Long> userIds = new ArrayList<>(ratingMap.keySet()); for (int i = 0; i < userIds.size(); i++) { Long u = userIds.get(i); Map<Long, Double> uRatings = ratingMap.get(u); // 预计算 u 的模长,避免内层循环重复开方 double uNorm = Math.sqrt(uRatings.values().stream() .mapToDouble(v -> v * v).sum()); for (int j = i + 1; j < userIds.size(); j++) { Long v = userIds.get(j); Map<Long, Double> vRatings = ratingMap.get(v); // 只遍历共同评分的歌曲,降低稀疏数据下的无效计算 double dot = 0.0; for (Map.Entry<Long, Double> e : uRatings.entrySet()) { Double vScore = vRatings.get(e.getKey()); if (vScore != null) { dot += e.getValue() * vScore; } } if (dot == 0) continue; // 无共同歌曲,相似度为 0 double vNorm = Math.sqrt(vRatings.values().stream() .mapToDouble(x -> x * x).sum()); double sim = dot / (uNorm * vNorm); simMatrix.computeIfAbsent(u, k -> new HashMap<>()).put(v, sim); simMatrix.computeIfAbsent(v, k -> new HashMap<>()).put(u, sim); } } return simMatrix; }

逻辑说明:外层双重循环只算上三角,算完对称写入,省一半计算量。内层遍历共同评分歌曲而不是全集,是因为音乐评分矩阵极度稀疏,一个用户平均可能只评过几十首,遍历全集纯属浪费。uNorm提前算好,避免在内层重复求模。

参数说明:ratingMap的 score 建议归一化到 0~1 或 1~5 统一量纲;如果用的是播放次数,记得先做对数压缩(log(1+count)),否则一首循环播放 200 次的歌会主导整个相似度。相似度为 0 的用户对直接跳过,不写入矩阵,能显著省内存。

2.4 生成推荐:加权求和加去重

有了相似度,推荐就是“取最相似的 K 个用户,把他们听过而我没听过的歌按相似度加权打分,排序取 TopN”。

// 为 targetUser 生成 TopN 推荐,k 为最近邻数量 public List<Long> recommend(Long targetUser, int k, int topN, Map<Long, Map<Long, Double>> ratingMap, Map<Long, Map<Long, Double>> simMatrix) { Map<Long, Double> targetRatings = ratingMap.getOrDefault(targetUser, Map.of()); Map<Long, Double> scores = new HashMap<>(); // 取相似度最高的 k 个邻居 List<Map.Entry<Long, Double>> neighbors = simMatrix .getOrDefault(targetUser, Map.of()).entrySet().stream() .sorted(Map.Entry.<Long, Double>comparingByValue().reversed()) .limit(k) .collect(Collectors.toList()); for (Map.Entry<Long, Double> nb : neighbors) { Long neighbor = nb.getKey(); double sim = nb.getValue(); for (Map.Entry<Long, Double> r : ratingMap.get(neighbor).entrySet()) { // 过滤掉目标用户已经听过的歌 if (targetRatings.containsKey(r.getKey())) continue; scores.merge(r.getKey(), sim * r.getValue(), Double::sum); } } return scores.entrySet().stream() .sorted(Map.Entry.<Long, Double>comparingByValue().reversed()) .limit(topN) .map(Map.Entry::getKey) .collect(Collectors.toList()); }

逻辑说明:sim * r.getValue()是加权核心,相似度越高、邻居打分越高,推荐分越高。merge把多个邻居对同一首歌的贡献累加。最后排序取前 N。

参数说明:K 是最近邻数量,太小推荐不准,太大引入噪声,我一般取 20~50,数据少时取 10~20。TopN 是返回条数,接口层一般 10~20 首。注意这里没有做评分归一化,如果邻居打分尺度差异大,建议先减去各自均分再加权,效果更稳。

3. 从零搭起:Spring Boot + MyBatis-Plus + MySQL 的工程骨架

3.1 表结构设计:用户、歌曲、行为三张核心表

毕设不需要花哨的库表,三张表就能撑起整个闭环。下面是我常用的建表 SQL,字段够用且好扩展。

-- 用户表 CREATE TABLE `user` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL UNIQUE, `password` VARCHAR(100) NOT NULL, `nickname` VARCHAR(50), `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 歌曲表 CREATE TABLE `song` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `title` VARCHAR(200) NOT NULL, `artist` VARCHAR(100), `album` VARCHAR(200), `genre` VARCHAR(50), `duration` INT COMMENT '时长,秒', `cover_url` VARCHAR(500) ); -- 用户行为表:评分/收藏/播放统一记录 CREATE TABLE `user_behavior` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `user_id` BIGINT NOT NULL, `song_id` BIGINT NOT NULL, `score` DOUBLE DEFAULT 0 COMMENT '评分或加权行为分', `behavior_type` TINYINT COMMENT '1播放 2收藏 3评分', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX `idx_user` (`user_id`), INDEX `idx_song` (`song_id`) );

逻辑说明:user_behavior是算法唯一的数据来源,把播放、收藏、评分统一成一张表,用behavior_type区分、用score承载权重,离线计算时直接查这张表就能构造评分矩阵。

参数说明:score用 DOUBLE 而不是 INT,是为了兼容“播放次数加权”这类连续值。索引必须建在user_id和song_id上,否则数据量上万后相似度计算会慢到怀疑人生。behavior_type的权重映射建议:播放 1 分、收藏 3 分、评分按星级,这样隐式反馈和显式反馈能混用。

3.2 用 MyBatis-Plus 快速生成实体和 Mapper

热词里提到的“mybatisplus 根据 java 实体类生成创建表的 sql 语句”其实是反过来的常见需求,但 MyBatis-Plus 的代码生成器确实能省大量体力活。我一般用它的FastAutoGenerator直接生成 entity、mapper、service。

// MyBatis-Plus 代码生成器核心配置 FastAutoGenerator.create("jdbc:mysql://localhost:3306/music_rec", "root", "123456") .globalConfig(builder -> builder .author("yourname") .outputDir(System.getProperty("user.dir") + "/src/main/java")) .packageConfig(builder -> builder .parent("com.example.music") .entity("entity") .mapper("mapper") .service("service")) .strategyConfig(builder -> builder .addInclude("user", "song", "user_behavior") // 指定要生成的表 .entityBuilder().enableLombok() .mapperBuilder().enableBaseResultMap()) .execute();

逻辑说明:addInclude指定表名,避免把无关表也生成一遍。enableLombok让实体自动带 getter/setter,省掉几百行样板代码。

参数说明:数据库连接、账号密码按本地环境改。outputDir指向你的源码目录。生成后记得检查主键策略,MySQL 自增主键要配@TableId(type = IdType.AUTO),否则插入会报错。

3.3 离线计算任务:定时刷新相似度矩阵

相似度矩阵不能每次请求都算,那样接口会卡死。我的做法是写一个定时任务,每天凌晨或数据更新后重算一次,把结果缓存到内存或 Redis。

@Component public class SimilarityRefreshTask { @Resource private UserBehaviorMapper behaviorMapper; @Resource private RecommendCache recommendCache; // 每天凌晨 3 点刷新 @Scheduled(cron = "0 0 3 * * ?") public void refresh() { // 1. 从行为表构造评分矩阵 Map<Long, Map<Long, Double>> ratingMap = buildRatingMap(); // 2. 计算用户相似度 Map<Long, Map<Long, Double>> simMatrix = new UserCFService().calcUserSimilarity(ratingMap); // 3. 为每个用户预生成推荐列表并缓存 for (Long userId : ratingMap.keySet()) { List<Long> recs = new UserCFService() .recommend(userId, 30, 20, ratingMap, simMatrix); recommendCache.put(userId, recs); } } private Map<Long, Map<Long, Double>> buildRatingMap() { List<UserBehavior> list = behaviorMapper.selectList(null); Map<Long, Map<Long, Double>> map = new HashMap<>(); for (UserBehavior b : list) { map.computeIfAbsent(b.getUserId(), k -> new HashMap<>()) .merge(b.getSongId(), b.getScore(), Double::sum); } return map; } }

逻辑说明:先构造评分矩阵,再算相似度,最后为每个用户预生成推荐并写入缓存。接口层直接读缓存,响应时间从秒级降到毫秒级。

参数说明:cron表达式按需调整,数据更新频繁可以改成每小时。recommend的 K 取 30、TopN 取 20,缓存 20 条是为了给接口层留出过滤已听歌曲的余量。缓存用ConcurrentHashMap就够毕设用,上 Redis 更好但非必须。

提示:定时任务记得在启动类加@EnableScheduling,否则@Scheduled不生效,这是新手最常翻的车之一。

4. 接口层与推荐闭环:让推荐结果真正被用户看到

4.1 推荐接口设计:分页、过滤与冷启动兜底

推荐接口不能只返回一个 ID 列表,前端要展示歌名、歌手、封面,所以要么联表查,要么先查 ID 再批量查歌曲。我一般分两步:缓存里拿 ID 列表,再批量查歌曲详情。

@RestController @RequestMapping("/api/recommend") public class RecommendController { @Resource private RecommendCache recommendCache; @Resource private SongMapper songMapper; @GetMapping("/list") public Result<List<SongVO>> list(@RequestParam Long userId, @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "10") int size) { List<Long> ids = recommendCache.get(userId); // 冷启动兜底:新用户没有推荐,返回热门歌曲 if (ids == null || ids.isEmpty()) { return Result.ok(songMapper.selectHotSongs(page, size)); } // 分页截取 int from = (page - 1) * size; if (from >= ids.size()) return Result.ok(List.of()); List<Long> pageIds = ids.subList(from, Math.min(from + size, ids.size())); List<SongVO> songs = songMapper.selectByIds(pageIds); return Result.ok(songs); } }

逻辑说明:先取缓存 ID,空则走热门兜底,解决新用户冷启动。分页在内存里做,因为推荐列表本身只有几十条,没必要再查库分页。

参数说明:page、size控制分页。冷启动兜底查询selectHotSongs按播放量倒序,需要你在 Mapper 里写一条聚合 SQL。注意selectByIds返回顺序不一定和传入 ID 顺序一致,如果前端对排序敏感,要在 Java 里按pageIds重新排一遍。

4.2 行为上报接口:把播放和收藏写回数据源

推荐要越用越准,就得让用户行为回流。播放、收藏、评分都通过一个接口写入user_behavior。

@PostMapping("/behavior") public Result<Void> report(@RequestBody BehaviorDTO dto) { UserBehavior behavior = new UserBehavior(); behavior.setUserId(dto.getUserId()); behavior.setSongId(dto.getSongId()); behavior.setBehaviorType(dto.getType()); // 按行为类型赋权重分 double score = switch (dto.getType()) { case 1 -> 1.0; // 播放 case 2 -> 3.0; // 收藏 case 3 -> dto.getScore(); // 评分 default -> 0.0; }; behavior.setScore(score); behaviorMapper.insert(behavior); return Result.ok(); }

逻辑说明:把不同行为映射成不同权重分,统一写入行为表,离线任务下次刷新时自然会把新数据算进去。

参数说明:权重 1/3/评分 是我常用的经验值,你可以按业务调。注意要做幂等或去重,同一用户对同一首歌短时间内重复播放不该重复累加,否则刷播放量会污染推荐。常见做法是加时间窗口判断,比如 5 分钟内同一首歌只记一次。

4.3 效果验证:离线指标和在线反馈两手抓

毕设答辩最怕被问“你怎么证明推荐有效”。离线用准确率、召回率、覆盖率,在线看点击率和播放完成率。

指标含义计算方式参考目标
准确率推荐中用户真正喜欢的比例命中数 / 推荐总数0.15~0.3
召回率用户喜欢的歌被推荐出的比例命中数 / 用户喜欢总数0.1~0.25
覆盖率被推荐过的歌曲占总曲库比例推荐歌曲去重数 / 总歌曲数越高越好
点击率在线推荐位点击比例点击数 / 曝光数看业务基线

做法是把行为数据按时间切分,前 80% 做训练、后 20% 做测试,用测试集里的真实行为去比对推荐列表。覆盖率低说明推荐总集中在少数热门歌,可以加随机扰动或降低 K 值缓解。

注意:离线指标好看不代表线上好用,答辩时两个都讲,比只报一个数字可信得多。

5. 避坑与排查:协同过滤毕设里最容易翻车的五件事

5.1 相似度全是 0 或 NaN

现象:算出来的相似度矩阵几乎为空,推荐结果为空或报错。 原因:评分矩阵太稀疏,用户之间没有共同评分的歌曲;或者某个用户评分为空导致模长为 0,除零产生 NaN。 解决:先统计共同评分数量,低于阈值(比如 2 首)的用户对直接跳过;模长为 0 时提前continue。数据太少时改用热门推荐兜底,别硬算。

5.2 推荐结果永远不变

现象:用户听了新歌、收藏了新歌,推荐列表纹丝不动。 原因:相似度矩阵是定时任务算的,缓存没刷新;或者行为上报接口没真正写库。 解决:确认@Scheduled生效、cron 时间已过;手动触发一次刷新任务验证;检查行为表是否有新数据写入。开发阶段可以把刷新频率调高,方便调试。

5.3 接口响应慢到超时

现象:推荐接口几秒才返回,数据量一大直接 504。 原因:在接口里实时算相似度,或者每次请求都全表扫描行为表。 解决:相似度必须离线算、缓存读。行为表查询加索引,推荐结果预生成。如果非要在接口里算,至少把评分矩阵缓存到内存,别每次查库。

5.4 新用户和老用户待遇一样

现象:刚注册的用户推荐列表是空的,或者推的全是热门歌,体验差。 原因:没有冷启动策略,新用户不在相似度矩阵里。 解决:新用户走热门推荐或基于歌曲标签的推荐,等积累了几条行为后再切到协同过滤。可以在用户行为数达到阈值(比如 5 条)后才纳入 UserCF 计算。

5.5 评分尺度不统一导致推荐偏斜

现象:推荐结果总偏向某几个“打分大方”的用户喜欢的歌。 原因:不同用户评分习惯不同,有人全 5 分有人全 3 分,余弦相似度对绝对尺度不敏感但加权求和时会放大高分用户的影响。 解决:加权前先减去用户均分做中心化,或者改用皮尔逊相似度。隐式反馈场景下先对播放次数做对数压缩,避免刷播放量的歌主导推荐。

6. 让推荐更耐问:混合策略与答辩加分技巧

纯协同过滤有个绕不开的短板:数据稀疏时效果断崖式下跌,而且没法解释“为什么推这首歌”。我在带毕设时一般会加一层混合策略,既提升效果,也让答辩时有话可讲。

第一个技巧是协同过滤 + 内容标签的加权融合。歌曲表里有genre字段,用户听过的歌的流派分布可以算出一个偏好向量。推荐时把协同过滤得分和流派匹配得分按 7:3 加权,既能缓解稀疏,又能在答辩时说“我做了混合推荐”。实现上就是在recommend方法返回前,对候选歌曲按流派命中情况加一个小的 bonus 分。

第二个技巧是给推荐结果加可解释理由。前端展示“因为你听过 XX,所以推荐 YY”,做法是在生成推荐时记录贡献最大的那个邻居用户和他听过的共同歌曲。这个信息在recommend里顺手就能存下来,答辩时演示一下,比干巴巴报准确率有说服力得多。

第三个技巧是用 A/B 思路做对比验证。把用户随机分成两组,一组走协同过滤、一组走热门推荐,统计两组的点击率差异。毕设不一定真跑线上实验,但你可以用历史数据模拟:取一部分用户的行为做测试,分别用两种策略生成推荐,对比命中率。这个表格往答辩 PPT 上一放,工作量和方法论都立住了。

最后说个我自己的习惯:每次改完算法参数,我都会固定用同一份测试集跑一遍,把准确率、召回率、覆盖率记在一个表格里,而不是凭感觉说“好像变好了”。参数调优最忌讳没有基线,K 从 20 调到 50 到底有没有用,数字说了算。这套流程跑下来,一个能演示、能讲清、能复现的个性化音乐推荐系统就成型了。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询