免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

SSM+协同过滤旅游推荐平台:ItemCF与余弦相似度从原理到落地

SSM+协同过滤旅游推荐平台:ItemCF与余弦相似度从原理到落地 简介一款基于协同过滤的在线通用旅游平台网站源码面向Java毕业设计学生及SSM框架初学者提供可直接运行的前后端完整项目。项目集成MySQL数据库按模块实现了景点推荐管理、精选路线管理、用户信息管理、系统公告与留言管理等主要功能其中景点信息可进行增删改查精选路线可针对游客需求灵活配置能够直观展示协同过滤推荐算法在旅游平台中的落地过程。资源共1288个文件主要包括java源码、jsp页面、xml配置、jar依赖包、js/css前端脚本以及sql数据库文件等压缩包约52.7MB目录结构完整便于按模块阅读、调试和二次开发。目前已有58人学习使用适合需要快速搭建旅游推荐系统原型、完成毕业设计或提升SSM项目实战能力的开发者参考。 做 java 毕业设计的人最怕的不是写不出来而是打开一套源码发现能跑但讲不清里面的推荐是怎么算出来的。基于协同过滤的在线通用旅游平台这套 SSM 毕设恰好把三类东西压在一起SSMSpring SpringMVC MyBatis做业务、协同过滤做推荐、前端页面做呈现。“通用”意味着它不绑定某一个景区而是把不同城市的景点、路线、餐饮当成可推荐的物品用用户的历史行为去预测下一步想去哪里。它适合三类人准备 java 课程设计或毕业设计的学生、刚学 SSM 整合的初级工程师以及想看看协同过滤在真实业务表上怎么落地的 java 开发。这套源码不是给你抄完就交的而是让你有一个能讲清楚的骨架。2. 算法选型与评分矩阵构造旅游场景为什么先做 ItemCF 而不是 UserCF协同过滤不是一套算法而是两类思路。UserCF 先找“和我兴趣相似的人”再把这些相似的人喜欢的景点推给我ItemCF 则先找“和我看过的景点相似的景点”直接推相似内容。选哪一边取决于平台里用户和物品的数量关系以及反馈数据的稀疏程度而不是哪个名字听着更高级。2.1 UserCF 与 ItemCF 怎么选一张表看完适用边界旅游平台的特点是景点数量远小于用户数量且大多数用户只去过几个地方评分记录非常稀疏。UserCF 要维护用户间的相似度矩阵用户一多、行为一稀疏矩阵里大量是“没交集”的用户对算出来的相似度可信度很低ItemCF 只需要算景点之间的相似度景点数量小一个量级计算量可控而且“去过杭州的人也会去苏州”这类解释在旅游场景里比“和你相似的人喜欢这里”更直观。维度UserCFItemCF最小计算单元用户-用户相似度物品-物品相似度适合规模用户少、物品多、行为稠密用户多、物品少、行为稀疏实时性用户行为变化需重算相似用户物品相似度可离线定时算可解释性“和你兴趣相似的张三也去过”“你看过西湖也可能会喜欢鼋头渚”旅游场景匹配度一般冷启动严重高推荐结果更稳实际写这套毕设时我一般把相似度计算放到一个单独的 Service 里白天跑业务、凌晨定时算一次物品相似度缓存到 Redis 或本地内存。答辩时这是加分点你不仅会调包还知道相似度要离线算不能每次请求都现算。2.2 评分从哪来把浏览、收藏、评论折算成统一评分协同过滤的输入是一个用户-物品评分矩阵但旅游网站通常没有“打分”这个强反馈入口。常见做法是把三类隐式行为折算成分数用户浏览某个景点详情记 1 分收藏记 3 分发表评论按评论星级记 1 到 5 分。把这些统一写入 rating 表字段带上行为类型和创建时间方便后面做时间衰减和调试。CREATE TABLE travel_rating ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, scenic_id INT NOT NULL, behavior_type TINYINT NOT NULL COMMENT 1-浏览 2-收藏 3-评论, score DECIMAL(2,1) NOT NULL DEFAULT 1.0 COMMENT 折算后的分数, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_user_scenic (user_id, scenic_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这表里的score不是让前端传的而是在 Service 层根据behavior_type折算后写入。例如浏览行为写入 1.0收藏写入 3.0评论把 star 字段直接透传。之所以分开存behavior_type和score是为了调参时不改前端业务你觉得“浏览”不该有分把 Service 里那个常量从 1.0 改成 0 就行历史数据不用回刷。2.3 余弦相似度计算一段可以直接用的 Java 方法评分矩阵构造好以后下一步是算相似度。余弦相似度把每个景点看成一个向量向量维度是评过分的用户集合两个景点相似度就是这两个向量的夹角余弦值。一段能直接放进工具类的实现如下public class CosineSimilarity { /** * 计算两个物品的余弦相似度 * param item1Ratings keyuserId value用户对item1的评分 * param item2Ratings keyuserId value用户对item2的评分 */ public static double calculate(MapInteger, Double item1Ratings, MapInteger, Double item2Ratings) { // 取两个物品共同被评分过的用户集合 SetInteger commonUsers new HashSet(item1Ratings.keySet()); commonUsers.retainAll(item2Ratings.keySet()); if (commonUsers.size() 2) { return 0.0; // 共同评分人数太少相似度直接判0 } double dotProduct 0.0; double norm1 0.0; double norm2 0.0; for (Integer userId : commonUsers) { double r1 item1Ratings.get(userId); double r2 item2Ratings.get(userId); dotProduct r1 * r2; norm1 r1 * r1; norm2 r2 * r2; } if (norm1 0.0 || norm2 0.0) { return 0.0; } return dotProduct / (Math.sqrt(norm1) * Math.sqrt(norm2)); } }这里有个容易被忽略的参数commonUsers.size() 2时直接返回 0。很多教程不写这个判断导致只有一两个共同评分的景点对算出 1.0 的高相似度推荐列表里全是“只有一个用户碰巧同时看过”的噪声。代码里的retainAll是求两个评分 Map 的交集复杂度 O(min(n, m))先取共同用户再算点积避免把整个矩阵的零值都遍历一遍。3. SSM 代码落地从数据库表到旅游推荐接口的完整链路算法能跑通和项目能跑通是两回事。这一章把 SSM 的骨架按“数据表 → Spring 配置 → 推荐 Service”的顺序拆开每部分都是可以直接往自己项目里搬的写法。3.1 表结构设计用户、景点与行为评分三张核心表除了上一章的 rating 表还需要用户表和景点表。用户表存基础登录信息景点表存景点名称、城市、分类、封面图 URL、描述和热度。热度字段很关键冷启动时期评分数据不足推荐模块可以直接按热度字段降序输出默认榜。CREATE TABLE travel_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL, nickname VARCHAR(50), city VARCHAR(50), create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE travel_scenic ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL, city VARCHAR(50) NOT NULL, category VARCHAR(30) NOT NULL COMMENT 自然风光/人文古迹/主题乐园等, cover_url VARCHAR(255), description TEXT, heat INT DEFAULT 0 COMMENT 热度用于冷启动兜底推荐, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;设计要点在travel_scenic.city和category两个字段。城市维度用于推荐结果的多样性控制——相似度计算时可以只取同城市或相邻城市的景点减少跨地区推荐造成的“不相关感”分类字段则是给 ItemCF 做物品过滤的候选集。如果两个景点一个在杭州一个在新疆评分交集少相似度天然低不需要额外处理。3.2 SSM 三件套配置Spring、SpringMVC、MyBatis 的启动顺序SSM 整合最容易被绊倒的地方是配置文件各管各的Spring 管 Service 和 DAOSpringMVC 管 ControllerMyBatis 的 Mapper 还要声明扫描路径。我习惯把配置拆成三个文件职责边界清楚出问题也好定位。!-- applicationContext.xml数据源 SqlSessionFactory Mapper 扫描 -- context:property-placeholder locationclasspath:jdbc.properties/ bean iddataSource classcom.alibaba.druid.pool.DruidDataSource property namedriverClassName value${jdbc.driver}/ property nameurl value${jdbc.url}/ property nameusername value${jdbc.username}/ property namepassword value${jdbc.password}/ property nameinitialSize value5/ property namemaxActive value20/ property nametestWhileIdle valuetrue/ property namevalidationQuery valueSELECT 1/ /bean bean idsqlSessionFactory classorg.mybatis.spring.SqlSessionFactoryBean property namedataSource refdataSource/ property nametypeAliasesPackage valuecom.example.travel.entity/ property nameconfiguration bean classorg.apache.ibatis.session.Configuration property namemapUnderscoreToCamelCase valuetrue/ /bean /property /bean mybatis:scan base-packagecom.example.travel.dao/这段配置里最容易漏的是mapUnderscoreToCamelCase。数据库字段user_name要自动映射成 Java 属性userName必须把这个开关打开否则 MyBatis 查出实体后 nickname、createTime 全是 null。mybatis:scan标签负责扫 DAO 接口没有它 Controller 注入 Mapper 时会在启动阶段直接报“No qualifying bean of type”。SpringMVC 部分要单独配一个 spring-mvc.xml重点处理静态资源和注解驱动!-- spring-mvc.xml只扫 Controller 层 -- context:component-scan base-packagecom.example.travel.controller/ mvc:annotation-driven/ mvc:resources mapping/static/** location/static//mvc:resources是很多毕设源码翻车的重灾区。web.xml里把 DispatcherServlet 映射到/之后所有请求都会先进 SpringMVCHTML、CSS、JS 如果不做静态资源放行页面打开时只剩赤裸裸的 HTML 结构样式和脚本全部 404。你要做的不是把映射改成*.do而是把 static 目录放行给默认 Servlet。3.3 推荐服务实现Mapper 查数加内存计算不引额外中间件这个毕设规模在几百个用户、几百个景点量级完全不需要引入 Hadoop、Spark 之类的东西。推荐 Service 的做法是一次查出所有评分记录在内存里组装成MapscenicId, MapuserId, score然后算相似度、取 TopK。Service public class RecommendService { Autowired private RatingMapper ratingMapper; Autowired private ScenicMapper scenicMapper; public ListScenic recommend(Integer userId, int topK) { // 1. 查出全部行为评分组装成物品-用户评分的倒排结构 ListRating allRatings ratingMapper.selectAll(); MapInteger, MapInteger, Double itemRatingsMap new HashMap(); MapInteger, MapInteger, Double userRatingsMap new HashMap(); for (Rating r : allRatings) { itemRatingsMap .computeIfAbsent(r.getScenicId(), k - new HashMap()) .put(r.getUserId(), r.getScore()); userRatingsMap .computeIfAbsent(r.getUserId(), k - new HashMap()) .put(r.getScenicId(), r.getScore()); } // 2. 找出当前用户评过分的景点列表 MapInteger, Double myRatings userRatingsMap .getOrDefault(userId, Collections.emptyMap()); // 3. 候选集用户没去过的景点 MapInteger, Double scores new HashMap(); for (Integer itemId : itemRatingsMap.keySet()) { if (myRatings.containsKey(itemId)) { continue; // 去过的景点不重复推荐 } double totalScore 0.0; for (Integer ratedItemId : myRatings.keySet()) { double sim CosineSimilarity.calculate( itemRatingsMap.get(itemId), itemRatingsMap.get(ratedItemId)); totalScore sim * myRatings.get(ratedItemId); } scores.put(itemId, totalScore); } // 4. 按预测分数降序取前 topK 个景点返回 return scores.entrySet().stream() .sorted(Map.Entry.Integer, DoublecomparingByValue().reversed()) .limit(topK) .map(entry - scenicMapper.selectById(entry.getKey())) .collect(Collectors.toList()); } }这段代码的查询逻辑是当前用户评分过的每一个景点都和候选景点算相似度再用“用户对该景点的评分”作为权重累加。这个加权累加就是 ItemCF 的预测分公式。参数topK控制最终返回几个推荐在 Controller 层我一般固定传 10。如果候选集为空用户什么都没看过Service 里要兜底返回scenicMapper.selectByHeatLimit(10)热度榜就是冷启动阶段的兜底方案。4. SSM 旅游平台部署避坑五个常见运行期问题与排查过程这套源码的价值在“能跑”但 SSM 项目跑起来的过程基本上就是和配置、依赖、序列化搏斗的过程。下面五条都是实际跑项目时反复出现的坑每条按现象、原因、解决的顺序写。4.1 页面样式全部丢失或 JS 不执行现象Tomcat 启动成功首页 HTML 能打开但所有 CSS、JS、图片都 404页面裸奔。原因DispatcherServlet 的 url-pattern 配置成/把所有请求都拦进了 SpringMVC静态资源没有放行。如果你在 JSP 里写的是相对路径css/style.css还要注意页面请求路径的层级否则路径拼接后也会 404。解决在 spring-mvc.xml 里加mvc:resources mapping/static/** location/static//并确保前端资源放在webapp/static/下。如果你的页面用了link hrefcss/style.css最好改成${pageContext.request.contextPath}/static/css/style.css防止在二级路由下路径错乱。4.2 Jackson 序列化堆栈溢出或懒加载异常现象用 AJAX 请求一个返回景点的接口浏览器直接 500后台日志出现StackOverflowError或could not initialize proxy - no Session。原因实体类之间有双向关联。景点实体里放了评论列表评论实体里又引用了用户用户再引用评论Jackson 序列化时无限递归。懒加载导致的 no Session 是另一个变种事务在 Service 层关闭后Controller 才去序列化未加载的关联对象。解决在实体类的多的一侧加JsonIgnore或者在返回对象上单独建 VO只序列化需要展示的字段。我一般偏向建 VO不用动实体类结构也顺便把密码等敏感字段挡在外面。懒加载问题就调整事务边界把查询评论的方法挪到事务内或者用EntityGraph这类方案主动抓取关联数据。4.3 协同过滤算相似度时内存溢出现象部署后第一次触发“为你推荐”接口日志出现java.lang.OutOfMemoryError: Java heap space重启后过几天又出现。原因相似度计算是双重循环物品数 M、评分数 N 的规模下复杂度接近 O(M²·N)。数据量上去之后每一对物品都要建 HashMap、算交集堆内存很快撑满。解决两个方向。一是先过滤无行为物品只对至少有 2 个人评过分的景点做候选二是把相似度计算改成离线任务用 Spring 的Scheduled每天凌晨算一次并缓存结果请求时只查缓存。毕设答辩时能讲到这两点比贴一段“调大 -Xmx”更有说服力。4.4 MyBatis 查询结果全是 null 或者日期字段异常现象景点列表接口返回 200但每条数据的 city、description 全是 null只有 id 有值。原因数据库字段是下划线风格cover_url、create_time实体类是驼峰风格coverUrl、createTimeMyBatis 默认不自动映射下划线到驼峰不匹配的字段全部留空。解决在 sqlSessionFactory 配置里开启mapUnderscoreToCamelCase已经在前一章配置代码里给过。如果你不想动全局配置也可以在 Mapper XML 里给每个字段写 resultMap 显式映射。日期字段出现格式问题的检查一下数据库连接 URL 有没有加serverTimezoneAsia/Shanghai不加会在解析 DATETIME 时直接报错。4.5 隔夜或一段时间后数据库连接失效现象项目刚启动时一切正常第二天第一次请求报Communications link failure刷新一下又好了。原因MySQL 的wait_timeout默认 8 小时空闲超过这个时间的连接会被服务端断开。Druid 连接池默认不主动探测连接池里还躺着那些失效连接第一次请求把它拿出去用自然报错。解决在 Druid 配置里加testWhileIdletrue、validationQuerySELECT 1并把timeBetweenEvictionRunsMillis设成 60000让连接池每分钟检测一次空闲连接。这里有一个血泪经验testOnBorrow不要开加上之后每次请求都多一次 ping高并发下反而拖慢接口。5. 验证推荐效果用离线评测与参数调优把推荐做“看得见”推荐算法不是跑通就结束而是要回答“推得准不准”。最直接的办法是拿历史评分做离线切分把每个用户的评分按时间分成训练集和测试集用训练集学相似度再预测测试集里的景点最后算召回率。5.1 用离线数据集估算准确率与召回率先写一条 SQL 把每个用户最后一条评分当作测试集其余当训练集然后用前面的 RecommendService 给测试用户生成 Top10 推荐再检查测试集景点是否出现在推荐列表里。import pymysql conn pymysql.connect(hostlocalhost, userroot, password123456, databasetravel_db, charsetutf8mb4) cur conn.cursor() cur.execute( SELECT r1.user_id, r1.scenic_id FROM travel_rating r1 JOIN ( SELECT user_id, MAX(create_time) AS max_time FROM travel_rating GROUP BY user_id ) r2 ON r1.user_id r2.user_id AND r1.create_time r2.max_time ) test_pairs cur.fetchall() # 推荐列表从 RecommendService 导出后再做命中判断 hit sum(1 for user_id, scenic_id in test_pairs if scenic_id in recommend_map.get(user_id, [])) recall hit / len(test_pairs) print(f召回率: {recall:.2f})这段脚本把“每个用户的最近一次行为”当标准答案命中率就是召回率。逻辑说明MAX(create_time)找最近行为如果推荐列表里有这个景点说明算法命中实际跑下来召回率在 0.2 到 0.4 都是正常范围毕竟旅游低频、行为稀疏不要指望推得全中。5.2 两个值得先调的参数相似度阈值与邻居数 K参数范围影响调整策略相似度阈值0.2 ~ 0.4太低则噪声多太高则候选集为空先固定在 0.3看召回率再微调邻居数 K10 ~ 30K 太小结果随机太大热门效应明显数据少时用 10数据多了调到 20 到 30兜底热度榜条数5 ~ 10缓解冷启动无行为用户直接返回热度榜调参的次序先调相似度阈值再调 K。一开始我把阈值写成 0.1推荐出来的全是只有一个人评过分的冷门景点用户完全没听过改成 0.3 之后候选集干净了很多。后来我发现一个更稳的做法先给每个景点按城市和分类过滤只对同城或同类景点算相似度召回率比单纯调阈值提升更明显。这里我踩过最大的坑是把阈值调太高导致推荐列表经常不足 10 个后来加了兜底逻辑不足时用热度榜补齐。这个项目给我最大的教训是协同过滤的代码只有几十行难的是让数据、参数、兜底策略匹配业务。面试时被问到协同过滤不要上来背公式先从“评分数据怎么构造”讲起再讲“相似度用什么算、阈值怎么定、冷启动怎么兜底”这几层讲完面试官基本就认可你是真写过而不是背过八股。希望帮到你。本文还有配套的精品资源点击获取
返回列表