十个人里估计有八个拿到这个题目时,第一反应都是“协同过滤是不是很难”“底层用Java是不是又要写一堆矩阵运算”。这个念头我在做基于协同过滤的网上书店推荐系统的阶段也有过,但真正把数据、算法、工程三层理清楚之后会发现,它最核心的部分不是数学,而是怎么把用户行为转成可计算的数据结构,再用一种能跟评委讲明白的方式把推荐结果生产出来。
这篇文章就围绕基于Java的协同过滤网上书店推荐系统,把从需求拆分、数据建模、UserCF与ItemCF算法实现,到Spring Boot工程落地、评测、冷启动处理的全过程讲清楚。会给出直接能用的表结构、Java代码、评测思路和踩坑记录。无论你是拿它当毕设课题,还是想给个人项目加一个推荐模块,都可以按这套思路去拆解和复现。
1. 需求拆解与选型:协同过滤为什么是这个题目的标准答案
1.1 网上书店推荐系统真正要解决的问题
书店场景和短视频、电商首页不一样,它有几个很明显的特点:图书SKU数量可以做到很大,但单用户购买频次低;用户兴趣有长期稳定性,也有短期热点偏好;新书入库频繁,很多书在初期几乎没有行为数据。此时如果只靠“强规则推荐”或“数据库随机取书”,效果非常机械,也很难在答辩时讲出算法层面的设计感。
推荐系统的本质,是把“人找书”变成“书找人”。用户进入系统后,我们不仅要展示所有书籍,还要根据这个用户过去的行为预测他接下来最可能下单或阅读的Top-N本书。这个预测过程涉及候选集生成、评分估计、排序、过滤已购买书目等环节。若完全不做个性化,只按销量排行,那不叫推荐系统,更像数据报表。
1.2 协同过滤在这个场景里的适用边界
协同过滤核心思想就一句话:用户过去的行为,比任何人工规则都更能反映用户未来的偏好。它分为基于用户的协同过滤(UserCF)和基于物品的协同过滤(ItemCF)。
图书这种商品的生命周期比较长,用户今天买一本编程书,下个月大概率还会关注同类书籍。这个时候用ItemCF往往比UserCF更直观:把“用户过去喜欢的书”作为种子,找与这些种子相似的其他书推荐出去。由于图书品类属性清晰,这种相似性也更容易向用户解释,例如“因为你读过(或买过)《Java并发编程实战》,所以推荐《Java并发编程的艺术》”。
相比之下,UserCF更适合新闻推荐这类物品生命周期短、用户兴趣变化快的场景。所以网上书店系统的算法主线应该是ItemCF,UserCF适合作为辅助对比算法,放在论文章节里做效果对照。
1.3 Java技术栈下的可行程度
很多同学担心Java写推荐算法效率低。实际上课堂级项目的数据量不大,几千用户、几千本书的规模下,用内存结构存储评分矩阵完全可行。Java的优势在于工程体系成熟:Spring Boot负责接口和事务,MyBatis或JPA负责持久化,定时任务负责离线计算结果,Redis负责在线缓存。算法模型不一定非要上Spark、Flink,那些是超大规模场景下的选择,在这类系统中是过度设计。
我在选型上给出的建议是:JDK 8+、Spring Boot 2.7、MyBatis-Plus、MySQL 5.7或8.0、Redis可选、Maven管理依赖。这套组合本身就能覆盖毕业设计需要的“技术亮点”。
2. 数据建模:评分矩阵与图书行为数据的设计思路
2.1 用户行为如何量化成评分
协同过滤算法吃进去的是“用户对物品的评分矩阵”,但系统里最原始的数据往往是行为日志。网站上的行为包括浏览、收藏、加入购物车、提交订单、写评价、打星等。这些行为不直接等于评分,需要转换。
最理想的是用户显式评分,也就是类似豆瓣的1到5星。但很多网上书店用户根本不评星,多数用户只下单购买,因此必须把隐式行为折算成评分。实践中我常设定如下权重:浏览计1分,收藏计3分,加入购物车计4分,完成购买计5分,显式评星则直接使用用户给的分数。同一用户对同一本书既有浏览又有购买的,取最大值得分,或者按时间衰减累加。注意不要简单把多次浏览重复相加,这样刷页面可能会刷出虚高分数。
对于毕设系统,建议直接在数据库建一张行为表,行为类型用数字字段表示,计算推荐矩阵时再统一转换为评分。这样既保留原始行为证据,也为答辩讲“多行为融合”留出素材。
2.2 数据库表结构设计
我只保留四张核心表:用户表、图书表、用户行为表、推荐结果表。其中推荐结果表是为离线计算准备的,后面会专门讲。
CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(64) NOT NULL, nickname VARCHAR(64) ); CREATE TABLE book ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(128) NOT NULL, author VARCHAR(64), category VARCHAR(32), tags VARCHAR(255), publish_date DATE, stock INT DEFAULT 0 ); CREATE TABLE user_behavior ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, book_id BIGINT NOT NULL, behavior_type TINYINT NOT NULL COMMENT '1浏览 2收藏 3加购 4购买 5评价', score TINYINT DEFAULT 0 COMMENT '评价时填写的分数,其余行为为空', create_time DATETIME NOT NULL, KEY idx_user (user_id), KEY idx_book (book_id) ); CREATE TABLE recommend_result ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, book_id BIGINT NOT NULL, score DOUBLE NOT NULL, rank_no INT NOT NULL, generate_time DATETIME NOT NULL, UNIQUE KEY uk_user_rank (user_id, rank_no) );这里有一个容易被忽视的细节:行为表必须建“用户ID”和“图书ID”两个索引。推荐离线计算会频繁按用户查询评分记录,也频繁按图书查询被评记录,如果没有索引,MySQL全表扫描会很痛。
2.3 用Java内存结构承载评分矩阵
评分矩阵可以用二维数组存,但用户量和书籍量一大就会浪费大量空间。更合理的是用稀疏结构:外层是一个Map,键是userId,内层再嵌套一个Map,键是bookId,值是评分。
Map<Long, Map<Long, Double>> ratingMatrix = new HashMap<>();这个结构支持两种读取:按用户拿到他所有的评分记录,按用户与图书查单个评分。如果做ItemCF,还需要一个转置矩阵:
Map<Long, Map<Long, Double>> itemUserMatrix = new HashMap<>();这个转置矩阵表示“每本书被哪些用户评过分”,它用来计算物品与物品之间的余弦相似度。构建时遍历一次ratingMatrix,把每条记录反向写入即可。
内存矩阵是后续一切计算的载体。建议在项目启动时或定时任务执行时,从MySQL一次性加载行为数据到内存,计算完成后再把推荐结果写回数据库。系统运行期间推荐查询只读内存或读推荐结果表,不直接实时执行算法,性能上会稳很多。
3. 协同过滤算法的Java实现:UserCF与ItemCF的代码细节
3.1 UserCF核心:皮尔逊相似度与评分预测
UserCF的思路是先找到与当前用户兴趣最相似的一群用户,再用这群用户对某本书的评分去预测当前用户对这本书的评分。相似度计算公式我用皮尔逊相关系数,因为它能消除不同用户打分尺度差异的影响。例如甲习惯打4到5分,乙习惯打2到3分,用绝对数值比相似程度会被带偏,皮尔逊公式先把各自打分减去均值再比较,就公平很多。
public class UserCF { private final Map<Long, Map<Long, Double>> ratingMatrix = new HashMap<>(); // 用户1与用户2的皮尔逊相关系数 public double pearsonSimilarity(Long userId1, Long userId2) { Map<Long, Double> ratings1 = ratingMatrix.getOrDefault(userId1, Collections.emptyMap()); Map<Long, Double> ratings2 = ratingMatrix.getOrDefault(userId2, Collections.emptyMap()); Set<Long> common = new HashSet<>(ratings1.keySet()); common.retainAll(ratings2.keySet()); if (common.size() < 2) { return 0.0; } double avg1 = common.stream().mapToDouble(ratings1::get).average().orElse(0.0); double avg2 = common.stream().mapToDouble(ratings2::get).average().orElse(0.0); double numerator = 0.0; double denominator1 = 0.0; double denominator2 = 0.0; for (Long bookId : common) { double sub1 = ratings1.get(bookId) - avg1; double sub2 = ratings2.get(bookId) - avg2; numerator += sub1 * sub2; denominator1 += sub1 * sub1; denominator2 += sub2 * sub2; } if (denomin1 == 0.0 || denominator2 == 0.0) { return 0.0; } return numerator / (Math.sqrt(denominator1) * Math.sqrt(denominator2)); } // 预测用户对指定图书的评分,neighborK为邻居数 public double predictRating(Long userId, Long bookId, int neighborK) { List<Map.Entry<Long, Double>> neighbors = new ArrayList<>(); for (Map.Entry<Long, Map<Long, Double>> entry : ratingMatrix.entrySet()) { Long otherUserId = entry.getKey(); if (otherUserId.equals(userId)) { continue; } if (!entry.getValue().containsKey(bookId)) { continue; } double sim = pearsonSimilarity(userId, otherUserId); if (sim > 0) { neighbors.add(Map.entry(otherUserId, sim)); } } neighbors.sort((a, b) -> Double.compare(b.getValue(), a.getValue())); double sumSim = 0.0; double sumSimPref = 0.0; int count = Math.min(neighborK, neighbors.size()); for (int i = 0; i < count; i++) { Long otherUserId = neighbors.get(i).getKey(); double sim = neighbors.get(i).getValue(); double otherPref = ratingMatrix.get(otherUserId).get(bookId); sumSim += sim; sumSimPref += sim * otherPref; } if (sumSim == 0.0) { return 0.0; } return sumSimPref / sumSim; } }皮尔逊相似度的返回值范围在[-1, 1]之间,计算评分预测时我只取相似度大于0的邻居,过滤负相关用户。这样能防止“讨厌同类型书的人”反向影响推荐结果。
3.2 ItemCF核心:余弦相似度与加权推荐分数
ItemCF在图书推荐里的执行过程可以分成三步:第一步,找出用户已经有过高评分或购买行为的种子书集合;第二步,对每一本种子书,找出与其最相似的若干本书;第三步,把相似度作为权重,对候选书进行加权求和,得到最终推荐分数。
物品之间的相似度计算使用的是余弦相似度。还是那句话,图书数据比用户数据稳定,物品之间共同被评分的用户数越多,余弦相似度的参考价值越高。
public class ItemCF { // 物品-用户评分矩阵,key为bookId,value为用户打分Map private final Map<Long, Map<Long, Double>> itemUserMatrix = new HashMap<>(); // 图书a与图书b的余弦相似度 public double cosineSimilarity(Long bookA, Long bookB) { Map<Long, Double> usersA = itemUserMatrix.getOrDefault(bookA, Collections.emptyMap()); Map<Long, Double> usersB = itemUserMatrix.getOrDefault(bookB, Collections.emptyMap()); Set<Long> commonUsers = new HashSet<>(usersA.keySet()); commonUsers.retainAll(usersB.keySet()); if (commonUsers.isEmpty()) { return 0.0; } double dot = 0.0; for (Long userId : commonUsers) { dot += usersA.get(userId) * usersB.get(userId); } double normA = getNorm(usersA); double normB = getNorm(usersB); if (normA == 0.0 || normB == 0.0) { return 0.0; } return dot / (Math.sqrt(normA) * Math.sqrt(normB)); } private double getNorm(Map<Long, Double> ratingMap) { return ratingMap.values().stream().mapToDouble(v -> v * v).sum(); } // 为指定用户推荐topN本未读过的书 public List<Long> recommend(Long userId, int topN) { Map<Long, Double> userRatings = new HashMap<>(); for (Map.Entry<Long, Map<Long, Double>> entry : itemUserMatrix.entrySet()) { Double rating = entry.getValue().get(userId); if (rating != null) { userRatings.put(entry.getKey(), rating); } } Map<Long, Double> scoreMap = new HashMap<>(); Map<Long, Double> weightSum = new HashMap<>(); for (Map.Entry<Long, Double> seed : userRatings.entrySet()) { Long seedBook = seed.getKey(); double userScore = seed.getValue(); for (Long candidateBook : itemUserMatrix.keySet()) { if (userRatings.containsKey(candidateBook)) { continue; } double sim = cosineSimilarity(seedBook, candidateBook); if (sim <= 0.0) { continue; } scoreMap.merge(candidateBook, sim * userScore, Double::sum); weightSum.merge(candidateBook, sim, Double::sum); } } return scoreMap.entrySet().stream() .sorted((a, b) -> Double.compare( b.getValue() / weightSum.getOrDefault(b.getKey(), 1.0), a.getValue() / weightSum.getOrDefault(a.getKey(), 1.0))) .limit(topN) .map(Map.Entry::getKey) .collect(Collectors.toList()); } }这里用“相似度加权评分总和除以相似度总和”作为最终得分,相当于加权平均。之所以不直接用sum,是因为不同候选书可能只跟少数几本种子书相似,累计的相似度权重天然偏低,如果不做归一化,热门书会因相似种子多而长期霸榜,失去个性化。
3.3 UserCF与ItemCF的取舍:别凭直觉选算法
我在该项目里最终是把ItemCF作为主算法,UserCF作为对照实验。原因有几点:
- 图书数量相对用户数量通常更稳定,新书入库后只要积累少量行为就能参与相似度计算;
- ItemCF可以预先计算“图书相似度矩阵”,在线推荐时直接查表,响应速度快;
- BookCF的推荐解释更好写:“该书与你收藏的《XXX》相似”比“该书的喜欢者与你相似”更有说服力。
UserCF也不是没优点,它能捕捉跨品类的兴趣迁移,例如一个用户买过编程书,又买过推理小说,UserCF可能推荐出他朋友喜欢的散文集。但在书店这种强品类属性场景下,跨品类推荐容易让用户觉得莫名其妙。
如果你答辩时间充裕,我的建议是把两个算法都实现,用同一份数据集跑出两组推荐列表,然后从准确率、覆盖率、推荐解释便利度三个维度做对比。这种对比本身就是很好的毕设创新点。
4. 工程化落地:推荐算法如何接入Spring Boot项目
4.1 用接口隔离推荐算法
写推荐系统最忌讳的就是把算法逻辑和Controller揉在一起。我建议一开始就抽象一个推荐策略接口。
public interface RecommenderStrategy { List<Long> recommend(Long userId, int topN); String strategyName(); }然后分别写ItemCFRecommender、UserCFRecommender、HotRankRecommender三个实现。业务层通过一个策略枚举选择走哪个实现,这样后续加算法、换算法都不会动到控制器代码。同时也能在答辩时讲清楚“开闭原则”在项目里怎么落地。
4.2 离线计算与在线查询的分离
线上推荐如果是用户一请求就实时计算,性能很难扛住。比较务实的做法是全量离线计算,在线直接读结果。我当时的处理流程是:每天凌晨用定时任务从MySQL读取行为数据,构建内存评分矩阵,分别计算UserCF和ItemCF的推荐结果,按照预测分排序后写入recommend_result表;白天用户访问推荐接口时,系统只做一次查库,最多再读一下Redis缓存,就能拿到当天已生成好的推荐列表。
这个“离线圈定结果、在线轻量查询”的架构,在毕业设计答辩里属于很稳的工程亮点。它把算法的高计算量和接口的低延迟需求完全分开,还可以在论文里专门画数据流图说明。
推荐结果表的设计需要注意一个细节:同一个用户每天只需要保留一份推荐结果,旧结果要清理。定时任务可以先把当天的recommend_result清空,再插入新数据。否则数据会无限膨胀,后面看到重复推荐也找不到原因。
4.3 Redis缓存与接口返回体设计
即使读了MySQL,用户频繁刷新接口也可能让数据库压力变大。Redis在这里的价值是缓存:把“推荐接口按userId生成的返回体”缓存起来,过期时间设为2小时或一天。Redis的key可以设计成recommend:user:{userId},value直接存JSON列表或Protobuf字节。缓存命中的时候就不查MySQL,也不组装VO,响应耗时能控制在几十毫秒内。
推荐接口返回给前端的数据至少要包含这些字段:图书ID、图书标题、作者、封面图URL、推荐理由、预测分数。我的做法是在Service层组装一个RecommendVO,推荐理由优先取“相似种子书”的标题,没有种子书时就返回“热门图书”。
public class RecommendVO { private Long bookId; private String title; private String author; private String coverUrl; private String reason; private Double score; }4.4 定时任务与线程池注意事项
定时任务用Spring的@Scheduled(cron = "0 30 2 * * ?")就能解决,不需要引入额外组件。但构建评分矩阵、计算相似度矩阵这两步非常消耗CPU,若图书馆数量多,建议用线程池并行计算。我写过一个小优化:把物品两两相似度计算按物品ID分段提交到线程池,每个线程处理一段,最后合并结果。实测在5000本书的场景下,并行比串行快了好几倍。
如果担心并行写入Map导致线程安全问题,尽量用ConcurrentHashMap,或者干脆在并行计算阶段只返回计算结果,主线程统一做合并。我在实际开发中被ConcurrentModificationException坑过一次,后来凡是遍历Map时涉及修改,一律先把keySet快照出来再操作。
5. 用数据说话:推荐效果评测与实验设计
5.1 训练集与测试集的划分方式
很多同学会在算法做完后直接拍脑袋说“效果不错”,但这个结论站不住脚。正确做法是把用户行为数据划分成训练集和测试集。划分方式有两种:随机划分和按时间划分。推荐系统里我比较推荐按时间划分——取每个用户前70%的行为作为训练集,后30%作为测试集,模拟“用过去预测未来”的真实场景。随机划分虽然更简单,但会出现测试数据时间在训练数据之前的倒挂,评估结果会虚高。
5.2 评测指标到底该看哪几个
针对书店推荐系统,主要看两个维度:推荐排序是否准确,预测评分是否接近实际评分。一般用下面这几个指标:
| 指标 | 含义 | 计算公式 |
|---|---|---|
| RMSE | 预测评分与实际评分的偏差 | sqrt((Σ(pred - real)^2)/N) |
| Precision@10 | 推荐列表前10本书中,用户真正感兴趣的比例 | 命中数/10 |
| Recall@10 | 用户真正感兴趣的书里,有多少被推荐出来 | 命中数/测试集正样本数 |
| F1 | 精确率和召回率的调和平均数 | 2PR/(P+R) |
注意,精度指标里的“命中”定义要提前说清楚。我把测试集中评分大于等于4分的书视为正样本,推荐列表里出现这些书就算命中一次。Precision@10就是看前10个推荐位里命中了几个,除以10。
5.3 写作论文时如何展示实验结果
实验部分建议准备三个数据集规模档位,比如用户数、行为记录数不同的三组数据。然后跑出UserCF、ItemCF、热门推荐三条对照曲线。表格可以这样列:
| 算法 | RMSE | Precision@10 | 单用户平均推荐耗时 |
|---|---|---|---|
| UserCF | 0.74 | 0.22 | 35ms |
| ItemCF | 0.63 | 0.31 | 18ms |
| 热门推荐 | 0.91 | 0.17 | 5ms |
实验设计这里有个很关键的技巧:不要让“热门推荐”全输,它其实在冷启动场景下表现很好,它跟ItemCF的对比结果正好引出下一部分边界问题的讨论。你把热门推荐做成baseline,能突出你做了算法对比,而不是只布一个推荐模型就完事。
5.4 我自己踩过的评估坑
第一次评测时我用全量数据直接当训练集,然后对同样的数据做预测,结果RMSE低到0.2,一度以为算法完美了。后来才意识到这是“用答案考自己”,模型已经把数据背下来了。做推荐评测最怕数据穿越,训练集里不能出现测试集用户评价过的书,否则预测评分会系统性偏高。
还有一个容易忽略的点:测试集里会存在用户没评过分的新书,模型无法预测,这种情况下我统一按0分处理,在统计时剔除,避免把“没见过这本题”的错位从模型能力问题变成数据问题。
6. 绕不开的边界问题:冷启动、稀疏性与覆盖率
6.1 新用户进来推荐什么
新用户没有任何行为数据,协同过滤完全无法工作。这时候最合理也最稳妥的方案是“热门兜底”。热门排序不能只看销量,我用的热门分数是:
hotScore = 0.6 * 平均评价分 + 0.4 * log(购买人数 + 评论数 + 1)
平均分能保证推荐的书质量不差,购买人数对数能压制刷量造成的虚高。这个兜底策略还需要跟用户注册时选的兴趣标签结合:如果用户注册时选了“计算机”“悬疑推理”,就把热门书按分类过滤后返回,这部分个性化虽弱,但体验比纯热门好很多。
6.2 新书没有行为数据怎么办
新书没有评分,ItemCF计算不出它的相似度,协同过滤对它直接失效。补这个洞的方法是引入基于内容的相似性:在计算图书相似度时,把分类、作者、标签、标题关键词作为特征。分类相同加分,作者相同大幅加分,标签重合越多越相似。然后把内容相似度乘以一个置信权重,和行为相似度按比例融合。
融合公式可以是:
finalSim = 0.7 * behaviourSim + 0.3 * contentSim当行为相似度无法计算时,直接降级为纯内容相似度。这就能完成“协同过滤 + 基于内容”的混合式推荐。这是能把毕设从“实现了协同过滤”拔高到“设计了一套混合推荐方案”的关键改进点。
6.3 评分矩阵稀疏导致的退化问题
数据少的时候,相似度计算会失真。两个用户只共同评价过一本书,算出的相似度很高,但置信度很低。解决方法是加支持度过滤:只对共同评分数大于等于5的用户或物品计算相似度,共同评分数不足的直接按0处理。在ItemCF里,推荐时也可以设定候选书必须有至少3个用户评价过,否则不参与推荐。
这个思路来自经验:评分数据稀疏时,过滤比算法调参更有效。一篇论文如果能把“为什么设置支持度阈值”讲清楚,比堆公式有价值得多。
7. 项目复盘:做完这套系统后我学到的两件重要的事
第一件事,推荐系统的大头不在于算法公式写得多么华丽,而在于数据准备和结果验证是否扎实。我最初把时间全耗在优化皮尔逊公式上,后来回头检查才发现行为表里连索引都没建,行为数据导出了3030条,但用户-图书矩阵的覆盖率低得可怜。先把评分矩阵完整、正确地构建出来,算法跑出来的结果才有讨论意义。
第二件事,采用基于物品的协同过滤作为主推荐算法,远比又写协同过滤又写矩阵分解还期待一步到位更合适。做全局个性化推荐时把两种算法做A/B对比,推进了实验的完整性,也在答辩环节给自己留了足够多说的问题。每次答辩老师抛出一个冷启动相关问题,我的回答都是“新用户走热门兜底,新书走内容相似度融合”,这个一句话总结在最终演示中帮了不少忙。
最后可以给拿到这个题目的人一个建议:上手顺序别反了。先把行为表和一本书表建好,把“根据用户购买记录展示他可能感兴趣的书”这个功能跑通,再往里加CF算法。功能先立起来,模型再慢慢优化。这个路径比一上来就抠协同过滤公式省力得多,也更容易让你在两周内看到一套完整、能演示、经得起追问的网上书店推荐系统。