简介:本系统以协同过滤算法为核心,基于Java与SSM框架(Spring、SpringMVC、MyBatis)实现图书个性化推荐,覆盖首页展示、用户管理、书籍分类、收藏、订单、系统管理等模块,适合用于毕业设计、课程设计及推荐系统入门学习。压缩包内含829个文件,以Java源码、Vue前端页面、JavaScript脚本、CSS样式、HTML页面、SQL数据库脚本及XML配置文件为主,整体约23.39MB,结构完整,便于部署与二次开发。目前已有57人学习下载,资料包含完整前后端代码及数据库脚本,并附带安装运行批处理文件,可帮助快速搭建环境;对于想了解协同过滤推荐流程、SSM整合开发或完成课设毕设的读者,具有直接参考价值。 把“基于协同过滤算法的图书推荐系统源码(Java毕业设计完整源码+LW).zip”拆开看,这个压缩包交付的是三样东西:一套结构完整的 Java Web 工程、一个答辩必问的推荐算法、一份和源码对得上的论文文档(LW)。很多同学拿到这类源码包,第一反应是导入 IDE 跑起来截图,但答辩时评委大概率只盯着三个问题:相似度怎么算?为什么选 ItemCF 而不是 UserCF?推荐结果有没有验证?这篇文章按我带毕业设计的思路,把协同过滤从公式落到 Java 代码,再从数据库表结构写到 Spring Boot 接口,最后给出一套能直接写进论文的评估方法和避坑清单。适合想在最短时间内跑通项目、又不想在答辩时被问倒的 Java 方向毕业生。
2. 先定算法:协同过滤的两种路线,图书场景该选谁
2.1 UserCF 与 ItemCF:一个看人,一个看书
协同过滤算法在图书推荐系统里有两个主流分支:UserCF(基于用户的协同过滤)和 ItemCF(基于物品的协同过滤)。UserCF 的逻辑是“人以群分”:找到和当前用户评分习惯最接近的一批人,把这些人高分评价过的书推荐出来。ItemCF 的逻辑则是“物以类聚”:先算出图书之间的相似度,再从用户已经评价过的书出发,找出“和这本像”的其他书。
两种算法落地后的行为差异很明显。UserCF 偏社交动态,适合新闻资讯这种用户兴趣变化很快的场景,因为新闻的生命周期只有几个小时,必须靠相似用户实时拉新。ItemCF 偏内容沉淀,适合图书、电商这种物品属性稳定、用户兴趣也相对稳定的场景——一本书的类别、作者、风格基本不变,用户半年前喜欢读的题材,今天大概率还感兴趣。
在图书推荐系统里,ItemCF 还有一个不可忽略的优点:推荐结果可解释性强。系统给用户推荐一本《深入理解Java虚拟机》,可以打上一行字“因为你看过《Java并发编程实战》”。评委看到这种推荐理由,比看一个黑匣子打分更容易接受。UserCF 很难解释,你只能说“因为和你相似的人也看了这本书”,这种话在答辩现场很容易被追问“到底哪里相似”。
图书场景的评分数据天然稀疏。一个学生用户可能只给十几本书评过分,但学校图书馆的藏书上万册。UserCF 需要先找相似用户,再在相似用户集合里筛书,数据一稀疏,相似用户往往只有一两个人,推荐范围很小。ItemCF 只需要用户给少数几本书打过评分,就能从这几本书出发扩散出去,对稀疏数据的容忍度高很多。这也是图书推荐系统里 ItemCF 更常见的原因。
2.2 图书场景的选择理由:Session 短、兴趣稳,ItemCF 更合适
做毕业设计时选哪条路线,我一般建议直接用 ItemCF 作为主算法,理由有三点。第一,图书的“物品特征”比用户特征稳定,一本书的类别、作者、风格几乎不变,但用户的口味会变、活跃周期很短。毕业设计的数据量通常只有几百到几千条评分记录,UserCF 构建出来的用户相似度矩阵会被稀疏数据直接拉垮。第二,推荐理由能直接展示在页面上,这是天然的答辩素材。ItemCF 的推荐理由是“和你读过的某本书相似”,可以在 UI 上完整展示出来。第三,从实现角度看,ItemCF 只要维护一个离线算好的物品相似度矩阵,用户访问时实时计算量很小;UserCF 则要在请求时实时算用户相似度,用户一多成本明显上升。
这不代表 UserCF 完全没用。很多合格的毕设会做混合推荐:冷启动阶段用热门图书榜,老用户用 ItemCF,登录后首页展示“猜你喜欢”。这个方案在真实系统里很常见,也容易讲清楚。但我见过一个典型误区:主算法用了 ItemCF,论文里却只字不提为什么不用 UserCF,也没做对比实验。建议在论文里加一小节 UserCF 与 ItemCF 的对比,哪怕只是同一个测试集上的准确率和召回率数字,也比空泛地写“协同过滤效果良好”有说服力得多。
2.3 相似度计算的三种公式与 Java 代码实现
协同过滤的核心是相似度计算,常见的有三种:余弦相似度、皮尔逊相关系数、Jaccard 相似度。余弦相似度计算两个向量夹角的余弦值,只看方向不看模长,适合评分尺度比较统一的场景。皮尔逊相关系数先对向量做中心化再算余弦,能抵消不同用户的打分习惯偏差——比如一个人习惯打 4 分以上,另一个人习惯打 2 分以下,他们的实际偏好可能一致,皮尔逊能把这层“尺度差异”剥掉。Jaccard 相似度只看两个物品被哪些共同用户评价过,完全不看分数,适合只有隐式反馈(点击、收藏)没有评分数据的场景。
对于图书推荐系统,我通常用调整后的余弦相似度作为默认方案。先对每个用户的评分做均值中心化,再用余弦公式。原因是不同用户的评分尺度差异太大,有人只打 1 分和 5 分,有人永远打 3 分,如果不做中心化,相似度会被评分习惯带偏。下面这段 Java 代码可以直接用在 ItemCF 的相似度计算里:
public double adjustedCosineSimilarity( Map<Integer, Double> itemARatings, Map<Integer, Double> itemBRatings, Map<Integer, Double> userAvgRating) { // itemARatings: key=userId, value=该用户对itemA的评分 // itemBRatings: key=userId, value=该用户对itemB的评分 // userAvgRating: key=userId, value=该用户对所有物品评分的均值 double numerator = 0.0; // 分子:中心化后乘积之和 double sumASq = 0.0; // itemA 中心化后的平方和 double sumBSq = 0.0; // itemB 中心化后的平方和 for (Integer userId : itemARatings.keySet()) { if (!itemBRatings.containsKey(userId)) { continue; // 只统计同时对两个物品评过分的用户 } double avg = userAvgRating.get(userId); double adjustedA = itemARatings.get(userId) - avg; double adjustedB = itemBRatings.get(userId) - avg; numerator += adjustedA * adjustedB; sumASq += adjustedA * adjustedA; sumBSq += adjustedB * adjustedB; } if (sumASq == 0.0 || sumBSq == 0.0) { return 0.0; // 防止除零,方差为0时相似度无意义 } return numerator / (Math.sqrt(sumASq) * Math.sqrt(sumBSq)); }逻辑说明:这个方法的关键在中心化。对于同时给 itemA 和 itemB 评过分的每个用户,把两个评分分别减去该用户自己的评分均值,再做点积和模长计算。如果用户 A 习惯打高分、用户 B 习惯打低分,中心化后双方的数据就处于同一比较平面。返回结果在 -1 到 1 之间,越接近 1 表示两本书相似度越高。
一个重要的参数细节是 userAvgRating 的构建方式。不能在这个方法内部临时遍历用户的历史评分去算均值,否则每对物品都要全表扫描一次,几万条评分数据就能让系统卡死。正确做法是先在启动阶段统一算好每个用户的均值,存成 Map 传进来。分母里的 sumASq 和 sumBSq 只要有一个为 0,说明该物品的评分没有区分度,直接返回 0.0 比抛异常更安全。
| 相似度公式 | 是否处理评分尺度偏差 | 适合数据形态 | 计算成本 |
|---|---|---|---|
| 余弦相似度 | 不处理 | 评分范围接近 | 低 |
| 皮尔逊相关系数 | 处理 | 用户评分习惯差异大 | 中 |
| 调整后余弦 | 处理 | 图书评分稀疏且习惯差异大 | 中 |
写论文时评委会对相似度公式特别感兴趣,我建议三种公式都跑一遍,把效果最好的写进正文,另外两种的实验结果放附录。这样既显得你做过对比,又不浪费篇幅。
3. 把推荐引擎写进 Java:数据模型、矩阵构建与 Top-N 推荐逻辑
3.1 数据表先设计好:用户表、图书表、评分表
教材里的协同过滤算法只讲矩阵,但落到工程里第一步是表结构。图书推荐系统的核心表是评分表,算法全靠它驱动。我一般会设计四张表:用户表、图书表、评分表,外加一张可选的收藏表。用户表和图书表是基础数据,评分表承载算法的输入。
用户表的设计要注意密码不能存明文,角色字段要留出来,管理员和普通用户的权限在毕业设计里虽然用得不深,但表结构里要有:
CREATE TABLE `user` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL UNIQUE, `password` VARCHAR(100) NOT NULL COMMENT 'BCrypt加密后的密文', `nickname` VARCHAR(50) DEFAULT NULL, `role` TINYINT DEFAULT 1 COMMENT '1-普通用户 2-管理员', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP );图书表除了标题、作者、ISBN 这些常规字段,一定要加click_count点击量字段。这个字段在冷启动阶段会用到,新用户没有评分记录时,直接用点击量最高的图书做兜底推荐。如果遗漏这个字段,后面冷启动逻辑就得再补一张热门表,绕远路。
评分表的 unique 约束是很多人忽略的细节:
CREATE TABLE `rating` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `user_id` INT NOT NULL, `book_id` INT NOT NULL, `rating` DOUBLE NOT NULL COMMENT '评分1-5,允许0.5分粒度', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY `uk_user_book` (`user_id`, `book_id`) );uk_user_book这个唯一索引非常重要。如果没有它,用户在前端重复点击评分就会插入两条记录,算法加载数据后同一用户对同一本书的评分出现两次,相似度计算直接出错。很多人花一整天排查推荐结果莫名其妙,最后发现是评分表里有重复数据,这种翻车现场我见得太多了。
3.2 构建用户-物品评分矩阵的 Java 代码
算法层不直接频繁查询数据库,而是把评分表一次性加载到内存,构建两个方向的数据结构:按用户查评分、按物品查评分。这一步做得好坏直接影响后续算法性能。我习惯写一个 RatingMatrix 类:
public class RatingMatrix { // 按用户维度: userId -> (bookId -> rating) private final Map<Integer, Map<Integer, Double>> userRatings = new HashMap<>(); // 按物品维度: bookId -> (userId -> rating) private final Map<Integer, Map<Integer, Double>> itemRatings = new HashMap<>(); // 每个用户的评分均值,用于调整后的余弦相似度 private final Map<Integer, Double> userAvg = new HashMap<>(); public void addRating(int userId, int bookId, double rating) { userRatings.computeIfAbsent(userId, k -> new HashMap<>()).put(bookId, rating); itemRatings.computeIfAbsent(bookId, k -> new HashMap<>()).put(userId, rating); } public void build() { for (Map.Entry<Integer, Map<Integer, Double>> entry : userRatings.entrySet()) { double avg = entry.getValue().values().stream() .mapToDouble(Double::doubleValue).average().orElse(0.0); userAvg.put(entry.getKey(), avg); } } public Map<Integer, Double> getUserRatings(int userId) { return userRatings.getOrDefault(userId, Collections.emptyMap()); } public Map<Integer, Double> getItemRatings(int bookId) { return itemRatings.getOrDefault(bookId, Collections.emptyMap()); } }逻辑说明:addRating 同时维护两个维度的 Map,相当于一次写入、两处索引,后面无论按用户查还是按物品查都是 O(1) 级别的 HashMap 查找。build 方法在数据全部加载完后统一计算均值,避免相似度计算阶段重复遍历。getOrDefault 返回空 Map 而不是 null,这样调用方不需要每次都做空指针判断。
RatingMatrix 通常放在 Spring Boot 的 Service 层,在应用启动时一次性加载:
@Service public class RecommendService { @Autowired private RatingMapper ratingMapper; private RatingMatrix ratingMatrix; @PostConstruct public void init() { ratingMatrix = new RatingMatrix(); List<Rating> allRatings = ratingMapper.selectAll(); for (Rating r : allRatings) { ratingMatrix.addRating(r.getUserId(), r.getBookId(), r.getRating()); } ratingMatrix.build(); } }逻辑说明:@PostConstruct 保证 Spring 完成依赖注入后自动执行初始化逻辑,项目启动时一次性把评分数据读进内存。这种全量加载方案在几千到几万条评分记录下完全够用,也符合毕业设计的体量。如果以后数据量涨到百万级,再考虑用缓存或分布式计算,现阶段不要过度设计。
3.3 物品相似度矩阵与推荐得分计算
有了 RatingMatrix,下一步是计算物品相似度矩阵。这里只关心出现过的图书,两两计算相似度。一个常见的性能优化是只保留相似度大于阈值的对,阈值通常取 0.1,过滤掉接近 0 的噪音数据:
public Map<Integer, Map<Integer, Double>> buildItemSimMatrix(RatingMatrix matrix) { List<Integer> bookIds = matrix.getAllBookIds(); Map<Integer, Map<Integer, Double>> simMatrix = new HashMap<>(); for (int i = 0; i < bookIds.size(); i++) { int bookA = bookIds.get(i); Map<Integer, Double> row = new HashMap<>(); for (int j = i + 1; j < bookIds.size(); j++) { int bookB = bookIds.get(j); double sim = adjustedCosineSimilarity( matrix.getItemRatings(bookA), matrix.getItemRatings(bookB), matrix.getUserAvg() ); if (sim > 0.1) { row.put(bookB, sim); } } simMatrix.put(bookA, row); } return simMatrix; }逻辑说明:内层循环从 i+1 开始,保证每对图书只算一次相似度,节省一半计算量。当然,最后 simMatrix 里只有 A 到 B 的方向,没有反向,所以推荐打分时无论从哪个物品出发,都要同时查两个方向。阈值 0.1 是经验值,设太大会丢推荐结果,设太小矩阵稠密,后续查询变慢。
推荐得分的计算逻辑是:遍历当前用户评分过的所有书,对每本已评图书找到相似图书,把相似度乘以用户对该书的评分,再按候选书累加得分:
public List<Integer> recommendByItemCF(RatingMatrix matrix, Map<Integer, Map<Integer, Double>> simMatrix, int userId, int topN) { Map<Integer, Double> scoreMap = new HashMap<>(); Map<Integer, Double> ratedBooks = matrix.getUserRatings(userId); for (Map.Entry<Integer, Double> rated : ratedBooks.entrySet()) { int ratedBookId = rated.getKey(); double userRating = rated.getValue(); Map<Integer, Double> similarBooks = simMatrix.get(ratedBookId); if (similarBooks == null) { continue; } for (Map.Entry<Integer, Double> sim : similarBooks.entrySet()) { int candidateBookId = sim.getKey(); if (ratedBooks.containsKey(candidateBookId)) { continue; // 过滤掉已经看过的书 } scoreMap.merge(candidateBookId, sim.getValue() * userRating, Double::sum); } } return scoreMap.entrySet().stream() .sorted(Map.Entry.<Integer, Double>comparingByValue().reversed()) .limit(topN) .map(Map.Entry::getKey) .collect(Collectors.toList()); }逻辑说明:外层循环遍历用户已评图书,内层循环遍历与这些书相似的候选书。同一个候选书可能从多个方向进入推荐池,比如用户看过两本书,都和候选书相似,scoreMap.merge 的第三个参数 Double::sum 负责把多条路径的得分累加起来。最后按得分倒序取 topN 个 bookId 返回。
Stream 排序这里有一个容易踩的坑:Map.Entry.comparingByValue()如果 value 类型是 Double 且存在 null 值,排序会抛 NullPointerException。merge 累加的结果理论上不会是 null,但如果你在别的 Service 里传入了允许 null 的 Map,这个错误会非常隐蔽。
4. 跑通完整毕业设计工程:Spring Boot + MyBatis 的项目结构与关键配置
4.1 项目目录结构与 Maven 依赖
拿到源码包,第一步不是改代码,而是确认项目结构。常见的毕设工程是 Maven 管理的 Spring Boot + MyBatis,前端页面用 Thymeleaf 模板引擎。目录结构如下:
book-recommend/ ├── pom.xml ├── src/main/java/com/example/bookrecommend/ │ ├── BookRecommendApplication.java │ ├── entity/User.java │ ├── entity/Book.java │ ├── entity/Rating.java │ ├── mapper/UserMapper.java │ ├── mapper/BookMapper.java │ ├── mapper/RatingMapper.java │ ├── service/RecommendService.java │ ├── service/UserService.java │ └── controller/UserController.java │ └── controller/BookController.java │ └── controller/RecommendController.java └── src/main/resources/ ├── application.yml ├── mapper/UserMapper.xml ├── mapper/BookMapper.xml ├── mapper/RatingMapper.xml └── templates/index.htmlentity 层只放字段和 getter/setter,不写业务逻辑;mapper 层用 @Mapper 标注接口;SQL 统一写进 XML 而不是注解里。推荐系统涉及联表查询和动态条件,XML 维护起来比注解舒服很多。
pom.xml 里的核心依赖就这几个,不要贪多:
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.2</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>Spring Boot 版本和 MyBatis Starter 版本要配对:如果父工程是 Spring Boot 2.7.x,MyBatis Starter 用 2.3.x;如果换 Spring Boot 3.x,MyBatis Starter 必须升到 3.0 以上,否则启动直接报错。这是初学者最容易在环境上翻车的地方。
4.2 数据库初始化脚本:一套能直接导入 MySQL 的 SQL
源码包里的 SQL 文件一般是初始化脚本,直接导入 MySQL 就能跑。脚本逻辑通常是先删库、再建库、建表、插入演示数据:
DROP DATABASE IF EXISTS book_recommend; CREATE DATABASE book_recommend DEFAULT CHARSET utf8mb4; USE book_recommend; CREATE TABLE `user` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL UNIQUE, `password` VARCHAR(100) NOT NULL COMMENT 'BCrypt密文', `nickname` VARCHAR(50) DEFAULT NULL, `role` TINYINT DEFAULT 1, `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE `book` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `title` VARCHAR(200) NOT NULL, `author` VARCHAR(100) DEFAULT NULL, `isbn` VARCHAR(20) DEFAULT NULL, `category` VARCHAR(50) DEFAULT NULL, `publisher` VARCHAR(100) DEFAULT NULL, `cover_url` VARCHAR(300) DEFAULT NULL, `description` TEXT, `click_count` INT DEFAULT 0 ); CREATE TABLE `rating` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `user_id` INT NOT NULL, `book_id` INT NOT NULL, `rating` DOUBLE NOT NULL, `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY `uk_user_book` (`user_id`, `book_id`) ); -- 管理员账号密码用BCrypt加密,不要存明文 INSERT INTO `user` (username, password, nickname, role) VALUES ('admin', '$2a$10$E1Zg8LmYb5vLQxKjXt3sH.abcdefghijklmnopqrstuvwx', '管理员', 2);密码这里特别提醒:如果源码里是明文密码,要么用 Spring Security 的 BCryptPasswordEncoder 生成密文替换,要么在论文里说明这是一个简化处理。评委如果看到数据库里存着一串明文密码,会认为你工程素养不够。加密串百度一下就能生成,成本很低但观感完全不同。
4.3 推荐接口与前端页面:让评委看到真实效果
接推荐算法和数据库打通,接口设计得尽量简单清晰。一个 /recommend/books 接口,接收 userId 和 topN 两个参数,返回推荐图书列表:
@RestController @RequestMapping("/recommend") public class RecommendController { @Autowired private RecommendService recommendService; @GetMapping("/books") public Result recommendBooks(@RequestParam Integer userId, @RequestParam(defaultValue = "10") int topN) { List<Book> books = recommendService.recommendByItemCF(userId, topN); return Result.success(books); } }接口字段说明:topN 默认值是 10,前端可以根据页面布局传不同数值。值得注意的是 RecommendService 里要同时注入 RatingMapper 和 BookMapper,拿到算法返回的 bookId 列表后,再批量查图书详情组装成完整对象。如果只返回 ID,前端还要二次请求,显得接口设计不完整。
前端页面用 Thymeleaf 渲染即可,不需要前后端分离。页面从当前登录用户拿 userId,请求 /recommend/books?userId=1&topN=10,把返回的图书列表循环渲染成卡片。假如用了前后端分离,跨域问题会非常麻烦,要么在 Controller 上逐方法加 @CrossOrigin,要么在全局配置类里统一处理。毕业生喜欢追求前后端分离,但在这个体量的项目里,同源方案省下一大堆配置。
application.yml 里有一个必开配置:
mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: truemapUnderscoreToCamelCase 开启后,数据库里的 create_time 字段能自动映射到 Java 属性的 createTime,不用手写 resultMap。不开这个配置,你会发现所有时间的字段全是 null,还会牵连其他驼峰字段的映射,排查起来非常恼火。
5. 避坑记录:协同过滤图书推荐系统最容易翻车的 5 个现场
5.1 评分数据全是同一个值,相似度计算出 NaN
现象:系统能跑通,但推荐列表经常为空,或者接口偶尔报错。
原因:如果演示数据里所有用户对同一本书的评分全是 5 分,或者某个用户对所有书的评分都相同,调整后余弦的中心化操作会让分子分母同时变成 0,计算结果是 NaN。NaN 进入排序后,Comparator 无法比较,Stream 直接抛异常或者返回空结果。
解决:相似度计算里必须加分母为 0 的判断,sumASq == 0 或 sumBSq == 0 时直接返回 0.0。另外造演示数据时不要制造大量全 5 分的记录,要让不同用户对同一本书评分有差异,给算法留出区分度。我在 2.3 的代码里已经写了这个保护逻辑,但如果你拿到手的源码包里没有,务必自己加上。
5.2 新用户没有评分记录,推荐列表为空
现象:注册后第一次登录,首页“猜你喜欢”区域一片空白。
原因:ItemCF 是从历史评分出发的,新用户没有任何评分,算法无从下手。这不是算法 bug,是推荐系统的冷启动问题。
解决:在 recommendByItemCF 入口处加一个 ratedBooks 为空的判断,走热门图书兜底:
if (ratedBooks.isEmpty()) { return bookMapper.selectHotBooks(topN); // 按点击量倒序 }这是最通用的冷启动方案,答辩时还可以主动补充:冷启动问题在真实推荐系统里会结合用户注册时的兴趣标签、图书分类偏好来综合处理,目前毕业设计阶段先用热门榜兜底。这段话一说,评委就知道你考虑过这个边界问题。
5.3 相似度矩阵越算越大,内存溢出
现象:评分数据量到几万条后,项目启动越来越慢,甚至直接 OOM。
原因:ItemCF 要两两计算物品相似度,n 本书的理论计算量是 O(n²)。图书 3000 本、评分 5 万条时,矩阵里可能存了几十万对相似度,内存很快就撑不住了。
解决:常见做法是每个物品只保留相似度最高的 K 个邻居,其余丢弃。在 buildItemSimMatrix 里,row 每 put 一个值就判断大小,超过 K 就移除最小的一项。K 一般取 30 到 50,既保证推荐效果,又控制内存占用。如果数据量再大,可以把相似度矩阵换到 Redis 存,但毕业设计阶段用内存加阈值截断就够了。
5.4 MyBatis 映射字段对不上,查询结果全是 null
现象:图书列表能显示标题和作者,但分类、出版社、点击量全是 null。
原因:Java 属性是 clickCount,数据库字段是 click_count,中间隔了一层下划线。如果 application.yml 里没开 map-underscore-to-camel-case,这些字段就映射不上去。
解决:两种方案二选一,一是建表时字段直接用驼峰命名,二是开启下划线转驼峰配置。我更推荐后者,因为数据库命名习惯是下划线。另外检查实体类字段名是否和表字段完全对应,比如数据库是isbn,Java 属性也叫isbn没问题,但如果你写成了ISBN,MyBatis 的自动映射会失效。
5.5 答辩被问“数据哪来的”,才发现没给论文准备测试集
现象:评委问测试数据来源,你只能说是自己录的,200 条评分撑不起论文的实验表格。
原因:毕业设计只做了演示数据,没有准备公开数据集做实验验证。
解决:先从演示数据里抽一部分跑通代码,然后再找公开数据集做实验。图书推荐领域最常用的是 Book-Crossing 数据集,包含用户对图书的评分数据,规模适中,非常适合离线测试。论文里写明数据来源、规模、预处理过程,再给出算法在这个数据集上的精确率和召回率,这三样东西远比截图有说服力。数据预处理代码也要留着,答辩时能讲清楚原始数据是怎么清洗成算法输入格式的。
6. 一个能写进论文的验证方法:离线评估与参数选择技巧
6.1 用精确率和召回率评估推荐效果
答辩时最怕评委问“你的推荐效果怎么衡量”。解决办法是做一个离线评估:把评分数据集按 8:2 分成训练集和测试集,训练集用来算相似度矩阵,测试集用来验证推荐命中率。每次给用户推荐 topN 本书,如果推荐列表里有测试集里用户真实评分过的书,就算命中一条。
精确率是命中数量除以推荐列表长度,召回率是命中数量除以测试集里用户实际评分过的图书数量。这两个指标能直接放进论文的表格里,配合不同 topN 值做一组对比实验。页面上也可以加一个很小的“推荐理由”展示位,把 ItemCF 的可解释性利用起来——这算是毕业设计里性价比最高的加分项。
6.2 邻居数 K 与 Top-N 的选择:我的默认参数与调试顺序
两个参数最值得调:相似度邻居数 K 和推荐结果条数 topN。K 太小推荐范围窄,K 太大噪音多;topN 太短覆盖不到用户真实兴趣,太长精度下降。我的默认实验顺序是先固定 topN=10,把 K 从 5 调到 50,观察精确率和召回率的变化曲线,确定 K 的最佳区间后再调 topN。
我自己的血泪经验是:不要在演示数据上反复调参。演示数据通常只有几十条评分,怎么调看起来都合理,一换到真实数据集立刻翻车。正确做法是先跑公开数据集定参数,再把参数迁移到演示数据上。如果论文里有“参数实验”这一节,把不同 K 值下的效果表格放进去,评委基本不会再在这个方向追问。希望帮到你。
本文还有配套的精品资源,点击获取