ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

基于SpringBoot与协同过滤的高考志愿推荐系统设计与实现

基于SpringBoot与协同过滤的高考志愿推荐系统设计与实现 每年四月开始找我咨询毕业设计题目的学生就明显多起来了。今年问得最频繁的就是这个基于SpringBoot的高考志愿推荐系统。说实话这题目能在满大街的“图书管理系统”“商城系统”里脱颖而出是有道理的它既有完整的业务闭环又有算法层面的深度可供挖掘还能用“大数据推荐”这种听起来就高级的词撑场面论文也好写、答辩也好讲、演示也好做。我前前后后帮人改过几版类似的系统今天就把这套系统从设计到落地完整的思路、代码级细节、以及那些不踩一遍根本发现不了的坑一次性写清楚。1. 项目定位与整体设计思路1.1 高考志愿填报这个场景到底在解决什么问题先想清楚业务本质。高考志愿填报的痛点非常具体600分和599分看起来只差一分但全省排名可能就差出去上千名对应的可报学校档次就完全不同。学生和家长最焦虑的不是“分高不高”而是“我这个分到底能上什么学校、什么专业怎么填才不浪费分数”。所以这个系统要解决的不是帮用户“猜分”而是帮用户在出分之后快速建立起“分数—位次—院校—专业”的匹配关系并且用推荐算法做个性化匹配。个性化体现在哪儿同样是580分一个想留在本省、一个想去沿海城市、一个非计算机不读推荐结果应该完全不一样。这就是协同过滤算法能切入的地方通过分析“相似用户”的历史选择和评分给你推别人验证过的“靠谱志愿组合”。1.2 技术选型背后的三个关键决策第一层选型是Java生态SpringBoot是绝对主力。为什么不用SSH或者原生Servlet毕业设计要的是快速开发、稳定运行、答辩时能顺手演示功能SpringBoot的自动配置和Starter机制能省掉一大堆XML配置把精力留给业务和算法。而且SpringBoot内嵌Tomcat打包成jar就能直接跑演示的时候不用在答辩机器上装环境这点太重要了。第二层是持久层框架。我见过很多同学用原生JDBC写这套系统数据库操作代码多达上千行后期改动一个字段要翻半天。我自己经手的几版都改用MyBatis-Plus单表CRUD几乎不用写SQL分页查询一行搞定重点精力放在自定义的推荐算法SQL上效率不是一个量级。第三层是推荐算法的选型。毕设系统里推荐算法有很多种选择基于规则的、基于内容的、协同过滤的、深度学习的。我建议核心用协同过滤再配合一个基于规则的热度兜底。原因很简单协同过滤概念清晰、论文里好阐述、代码能完整实现且效果可解释深度学习虽然高级但数据量不够、训练周期长、解释性差答辩时很容易被老师追问细节卡住。1.3 架构分层与模块划分系统整体按经典的三层架构走控制层、业务层、持久层。前端我用的方案是Thymeleaf模板引擎加上AdminLTE的后台模板没有单独做前后端分离。为什么毕设系统最怕前后端联调时跨域问题、Token认证问题一堆拖慢进度。用服务端渲染页面直接通过ModelAndView传数据项目结构一目了然答辩时讲起来也顺。后端模块划分我建议这样用户模块注册、登录、角色权限管理学生/管理员院校信息模块院校库维护、专业库维护、历年分数线管理志愿推荐模块协同过滤算法引擎、推荐结果展示志愿管理模块志愿草稿箱、志愿表生成、冲稳保分析数据统计模块ECharts可视化展示招生数据和推荐结果分布每个模块之间通过Service接口解耦算法引擎单独放在一个包里不跟业务代码混在一起这样论文里画系统架构图也方便。2. 数据库设计与数据准备2.1 核心表结构设计与建表经验数据库是整个系统的地基。表设计得好后面算法实现、报表统计都会顺手设计得不好等写推荐SQL的时候就会发现各种关联查不出来只能改表结构越改越乱。我给出这套常用的核心表结构供参考sys_user用户表id、username、password、role、province、score、rank、interest_tags。注意interest_tags建议用逗号分隔的字符串存储比如“计算机,医学,师范”推荐引擎读取后拆分成列表做匹配。school_info院校表id、school_name、school_code、province、city、level985/211/双一流/普通、type综合/理工/师范/医药...、tags、logo_url、intro。major_info专业表id、school_id、major_name、category、tuition、description。admission_score历年分数线表id、school_id、province、year、batch、min_score、min_rank、avg_score。这张表是推荐算法的核心数据来源之一数据质量直接决定推荐效果。user_rating用户评分表id、user_id、school_id、rating、comment。rating取1-5分1分代表“很不推荐”5分代表“强烈推荐”。这是协同过滤的“用户行为日志”。volunteer_plan志愿表id、user_id、school_id、major_id、plan_type冲/稳/保、priority、create_time。一个容易忽略的细节是分数线表一定要加上province字段。同一个学校在不同省份的录取分数线差别很大如果推荐时不区分省份结果完全没法看。所有推荐计算都必须基于同省份数据来算。2.2 数据来源与数据清洗的土办法数据是毕设系统的老大难。理想情况是爬虫抓取阳光高考平台、各省教育考试院的数据但实际做起来不现实——很多网站有反爬、有验证码数据量虽然大但脏数据也多清洗工作能把人逼疯。我的建议是两条腿走路。第一条找一个开源的数据集项目Gitee和GitHub上搜“高考分数线数据集”“全国高校信息数据集”有很多整理好的CSV和SQL文件格式虽然不一定完全对得上但作为底库完全够用。第二条自己手动补充和修正数据重点保证本省数据准确。数据清洗一定要做的几个点院校代码和名称去重。同一学校不同年份的名称可能不同比如“XX学院”升格为“XX大学”需要保留最新名称并统一代码。分数线缺失值和异常值处理。某年某校分数线为0或者低于省控线明显是数据异常直接剔除或者标注为null。批次类别的统一。本科一批、本科二批在部分省份已经合并为本科批建议统一映射为“本科批”否则同一学校同一专业在表里会有两条批次不一样但实际是一条的记录。这里我踩过一个大坑用爬虫抓了一版数据里面有不少专业名称格式不统一“计算机科学与技术”“计算机科学与技术实验班”“计算机技术”看起来相似但匹配算法当成三个完全不同的专业导致推荐时专业关联性被稀释。后来统一加了专业名称标准化映射表才解决。2.3 冷启动数据怎么构造协同过滤算法的前提是“用户-物品评分矩阵”要足够稠密。但真实场景下新系统上线时根本没有用户哪来的评分数据所以必须构造一批模拟用户数据。构造模拟评分数据时最怕的就是“拍脑袋随机给分数”。正确的做法是结合真实的分数线数据生成有规律的评分如果学生分数高于院校投档线30分以上 → 该用户给这个学校打4-5分表示“我能稳上体验好”如果学生分数比投档线高0-15分 → 打3分压线录取赌的成分大如果学生分数低于投档线10分以内 → 打2分想报但录取希望不大低于投档线10分以上 → 打1分或者不记录纯浪费志愿这样生成的评分矩阵虽然不是真实用户行为但分布合理、逻辑自洽协同过滤算出来的推荐结果初始可看性非常高演示时不会出现“推荐了一堆完全不符合分数段的学校”这种尴尬局面。3. 协同过滤推荐算法的落地实现3.1 为什么选协同过滤以及选UserCF还是ItemCF协同过滤的核心思想一句话就能说清物以类聚人以群分。它不像基于内容推荐那样需要大量院校的专业标签和语义理解只要拿到“用户对院校的历史评分”就能算出用户之间的相似度或者院校之间的相似度从而完成推荐。具体分两种基于用户的协同过滤UserCF和基于物品的协同过滤ItemCFUserCF适合用户量相对小、但每个用户行为数量足够的场景。找到和我最像的一批用户看他们选了哪些学校我也跟着选。ItemCF适合物品数量多、用户行为稀疏的场景。分析院校之间的相似度我点过XX大学那和XX大学相似的YY大学也可能适合我。高考志愿这个场景我更推荐UserCF打底。原因有这么几点第一志愿填报行为本身有很强的年级属性一个省份的考生结构差异大找到“同省、同分段、相似偏好”的学长学姐参考价值极高第二毕设系统的院校数量可能就一两千所但用户模拟数据可以造几百个用户相似度矩阵更好维护第三UserCF在论文里的解释相对直观答辩时老师问一句“你这个相似度怎么算的”你能用大白话讲清楚印象分就上去了。3.2 相似度计算与评分预测的完整实现UserCF落地分三步构建评分矩阵、计算用户相似度、预测评分。用户相似度的计算最常用的是皮尔逊相关系数因为它能剔除用户打分尺度的差异——有的人习惯给高分、有的人习惯给低分皮尔逊系数对这种情况更鲁棒。核心代码如下/** * 计算两个用户之间的皮尔逊相关系数 * param user1Ratings 用户1对院校的评分Mapkey为schoolId * param user2Ratings 另一个用户对院校的评分Map * return 相似度取值范围[-1, 1] */ public double pearsonCorrelation(MapInteger, Double user1Ratings, MapInteger, Double user2Ratings) { // 找两个用户共同评分过的院校 ListInteger commonSchools user1Ratings.keySet().stream() .filter(user2Ratings::containsKey) .collect(Collectors.toList()); int n commonSchools.size(); if (n 0) { return 0.0; } double sum1 0.0, sum2 0.0; double sum1Sq 0.0, sum2Sq 0.0; double pSum 0.0; for (Integer schoolId : commonSchools) { double r1 user1Ratings.get(schoolId); double r2 user2Ratings.get(schoolId); sum1 r1; sum2 r2; sum1Sq r1 * r1; sum2Sq r2 * r2; pSum r1 * r2; } double denominator Math.sqrt((sum1Sq - sum1 * sum1 / n) * (sum2Sq - sum2 * sum2 / n)); if (denominator 0) { return 0.0; } return (pSum - sum1 * sum2 / n) / denominator; }注意一个容易踩坑的地方如果两个用户共同评分过的院校少于3个计算出来的相关性没有统计意义建议直接跳过或者返回0否则会出现极端的值影响推荐质量。预测分数的公式用的是加权平均加均值偏移修正/** * 预测目标用户对某院校的评分 * 用TopN相似用户的评分加权平均并做均值偏移修正防止打分尺度影响 */ public double predictRating(int targetUserId, int schoolId, MapInteger, MapInteger, Double ratingMatrix, ListUserSimilarity topNSimilarUsers) { double weightedSum 0.0; double similaritySum 0.0; double targetAvg getAverageRating(targetUserId, ratingMatrix); for (UserSimilarity sim : topNSimilarUsers) { int otherUserId sim.getUserId(); Double otherRating ratingMatrix.get(otherUserId).get(schoolId); if (otherRating null) { continue; } double otherAvg getAverageRating(otherUserId, ratingMatrix); // 均值偏移用相似用户的评分减去其个人平均分再加权 weightedSum sim.getSimilarity() * (otherRating - otherAvg); similaritySum Math.abs(sim.getSimilarity()); } if (similaritySum 0) { return targetAvg; } return targetAvg weightedSum / similaritySum; }这个实现里“均值偏移”是点睛之笔。如果不做偏移喜欢打高分的用户会把推荐分数整体拉高结果就是所有学校都被预测成4.5分以上排序完全失真。做了偏移之后预测值更接近目标用户自己的评分习惯TopN排序才有区分度。TopN相似用户的选取我一般取20-30个不是越多越好。相似度太低的用户对预测贡献的是噪声不如不加。代码里按相似度降序排序后截取前N个即可。3.3 混合推荐策略与“冲稳保”分层协同过滤算完之后推荐结果直接展示给用户不行还差一步业务包装。真实的高考志愿填报讲究“冲一冲、稳一稳、保一保”其实就是把志愿分成三个梯度冲预测录取概率在30%-50%之间的院校分数比投档线低5-15分稳预测录取概率在50%-80%之间的院校分数基本贴合投档线保预测录取概率在80%以上的院校分数高出投档线15分以上这块我的实现方式是协同过滤算出一个候选学校池按照预测评分排序然后根据用户分数与院校历年投档线的差值再做业务层过滤和分类public ListRecommendSchool generateRecommendList(User user, ListSchoolScoreVO candidates) { ListRecommendSchool result new ArrayList(); for (SchoolScoreVO vo : candidates) { // gapScore为负说明学校录取线高于学生分数需要冲 int gapScore user.getScore() - vo.getMinScore(); String strategy; if (gapScore -5 gapScore -15) { strategy 冲; } else if (gapScore -5 gapScore 15) { strategy 稳; } else if (gapScore 15) { strategy 保; } else { continue; // 分数差太远直接过滤掉 } // 再结合协同过滤的预测评分对每个策略内部做排序 result.add(new RecommendSchool(vo.getSchoolId(), strategy, vo.getMinScore(), predictionScore)); } // 每个策略内按预测评分降序 result.sort(...); return result; }这个“协同过滤粗排序分数差策略分层”的混合方案是我测试下来推荐结果最像一个真人学长在给建议的方案既有算法个性又兼顾“别帮倒忙”的业务底线。3.4 离线评估和调参经验推荐算法做完很多同学直接就开始写前端这是不对的。至少要做一次简单的离线评估证明“我的算法是有效的”这也是论文里的一个重要章节。最简单可行的评估方式是留一法把模拟用户对学校的评分随机隐藏10%让算法基于剩下90%的评分做预测再对比预测值和真实值计算均方根误差RMSE和平均绝对误差MAE。RMSE的计算RMSE sqrt( (1/N) * Σ(predicted_i - actual_i)^2 )一般来讲在1-5分的评分体系里RMSE小于1.0就说明算法效果可以接受小于0.8就是相当不错的水平。如果算出来RMSE大于1.2优先检查相似度是否计算正确、评分数据是否存在噪声、TopN的N值选得是否合理。调参上多试几次TopN取10、20、30时的RMSE以及相似度阈值设0.2和设0.4的区别。我实测的经验是TopN20时效果比较均衡相似度阈值设0.3能过滤掉大量低质量近邻RMSE会有肉眼可见的下降。4. 核心功能模块的实现要点4.1 用户画像构建与个性化标签推荐不是从点击“推荐”按钮才开始而是从用户注册那一刻就开始了。注册时除了账号密码建议强制让用户选择省份、填写预估分数模拟数据阶段为真实分数、勾选3-5个感兴趣的专业方向。为什么要这些信息三个用处省份决定数据过滤范围只推荐本省招生的院校分数用于“冲稳保”策略分层也是冷启动阶段的兜底推荐依据兴趣标签用于基于内容的匹配初始化和推荐结果的人工调整在用户表设计上我用一个interest_tags字段存逗号分隔的标签串查询时配合MySQL的LIKE或者FIND_IN_SET做粗匹配再用Java代码做标签重合度打分。这个方案虽然看起来笨但数据量在几千级别时性能毫无压力而且比引入全文检索引擎简单得多。4.2 志愿推荐主流程的前后端衔接推荐主流程的后端控制器实现Controller RequestMapping(/recommend) public class RecommendController { Autowired private RecommendService recommendService; GetMapping(/list) public String recommendList(HttpSession session, Model model) { User user (User) session.getAttribute(loginUser); if (user null) { return redirect:/login; } RecommendResult result recommendService.generateRecommendation(user); model.addAttribute(chongList, result.getChongList()); model.addAttribute(wenList, result.getWenList()); model.addAttribute(baoList, result.getBaoList()); model.addAttribute(reasonMap, result.getReasonMap()); return recommend/list; } }前端页面用Thymeleaf遍历冲稳保三个列表每个学校卡片展示校名、城市、层次、历年分数线、预测录取概率和“推荐理由”。推荐理由是一个容易被忽略但很加分的点——比如“与你相似度最高的23位学长学姐中有18人选择了该校”。有理由的推荐远比冷冰冰的TopN列表更有说服力答辩现场也能给老师留下好印象。我的实现方式是算法引擎在生成推荐时顺带返回该学校的“相似用户投票数”和“最佳匹配标签”存到一个Reason对象里模板渲染时直接取出展示。4.3 院校库管理与多维筛选院校库模块本质上是一个面向管理后台的CRUD系统但有几个细节值得做好。列表页必须支持多维筛选按省份、城市、层次985/211/双一流、类型综合/理工/师范、历年最低分区间。用MyBatis-Plus的LambdaQueryWrapper动态拼接条件即可注意条件为空时不拼SQL。LambdaQueryWrapperSchoolInfo wrapper new LambdaQueryWrapper(); if (StringUtils.hasText(province)) { wrapper.eq(SchoolInfo::getProvince, province); } if (StringUtils.hasText(schoolType)) { wrapper.like(SchoolInfo::getType, schoolType); } if (minScore ! null maxScore ! null) { // 注意这里关联的是这两年分数线实际写SQL时建议join子查询 wrapper.between(SchoolInfo::getMinScore, minScore, maxScore); }院校详情页展示历年分数线趋势折线图。用ECharts做近5年的分数线变化曲线能直观看出一个学校的“热度走势”这对志愿判断非常关键。数据编辑要做到操作留痕。毕设系统虽然不需要完整的审计日志但建议在院校信息变更时记录管理员ID和操作时间。论文里写“系统具备完善的数据管理与追溯能力”就需要这些字段支撑。4.4 ECharts可视化统计模块可视化是答辩时的最大加分项。再好的算法没有直观的数据图表展示老师听讲解时容易走神。这块我建议做三个图表各省份高校数量分布地图中国地图着色用ECharts的map类型热门专业推荐次数Top10柱状图从用户志愿表聚合统计冲稳保志愿比例饼图从推荐结果聚合统计ECharts使用比较简单引入JS文件后通过Ajax请求后端统计接口拿JSON数据就行。后端接口返回的格式尽量和ECharts要求的格式对齐比如柱状图就是{categories: [计算机, 医学], values: [123, 98]}这种结构前端直接用。热门专业Top10的SQL示例SELECT m.major_name, COUNT(v.id) AS recommend_count FROM volunteer_plan v LEFT JOIN major_info m ON v.major_id m.id GROUP BY v.major_id ORDER BY recommend_count DESC LIMIT 10;这个统计其实是在反应用户真实选择行为答辩时可以由此引出一个“对推荐算法的反向验证”——如果算法推荐逻辑正确被推荐次数最多的专业应该与当年热门专业分布吻合。4.5 后台管理模块与权限控制后台管理模块不需要做成特别复杂但要覆盖三个核心能力用户管理禁用/启用、数据管理院校和专业信息的增删改查、系统监控日志查看、数据统计。权限控制这里我强烈建议不要引Security这种重型框架直接基于SpringBoot拦截器加HandlerInterceptor实现即可。写一个LoginInterceptor校验session中是否存在登录用户和管理员角色标识没有就跳转到登录页。public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { User user (User) request.getSession().getAttribute(loginUser); if (user null) { response.sendRedirect(/login); return false; } return true; } }再在WebConfig里注册配置好拦截哪些路径、放行哪些路径。静态资源和登录接口必须放行否则会出现“样式全丢”“登录页循环重定向”的经典问题。5. 常见问题与排查技巧实录5.1 评分矩阵太稀疏导致相似度算不出来这是协同过滤的经典问题。模拟数据阶段用户量不大每个用户只给几所关注院校打了分两个用户之间共同评分过的院校经常是0皮尔逊系数直接返回0推荐结果完全退化成热门推荐。排查思路先看数据统计一下评分矩阵的非零元素占比。如果少于5%必须加数据。我的做法是给每个模拟用户增加评分院校数量到15-20所并且按照“冲稳保”梯度均匀打标这样用户之间的共同评分概率大幅上升。如果数据量实在加不上去退而求其次用“院校热度的规则推荐”做兜底按历年报考人数、分数线涨幅给院校一个基础热度分直接推TopN。虽然缺乏个性化但至少不会出错。5.2 推荐结果过度集中在前几名热门院校这是TopN推荐的“马太效应”——热门院校被推得越多评分矩阵里它们被评分的次数就越多相似度计算时它们权重越高形成正反馈循环。解决的办法有两个。一个是算法层的对推荐结果做多样性惩罚推荐列表中相邻两个学校的层次或类型差异越大综合得分越高。业内叫MMR最大边际相关性实现也不算复杂// 候选学校按预测分降序排序后逐个计算与已选学校的相似度 // 综合分 预测分 - lambda * max(与已选学校的相似度) // lambda一般取0.5~0.7 double diversityScore predictionScore - lambda * maxSimilarityToSelected;另一个是业务层的冲稳保三类列表各限制最多展示5所并且同一层次如全是985的学校最多展示3所。这样页面看起来更均衡也更接近真实志愿填报的逻辑。5.3 SpringBoot启动报错和依赖冲突最常见的问题是版本兼容性。SpringBoot 3.x和2.x差异很大网上很多教程用的是2.x的写法你导入了3.x的依赖启动就会报错。我经手的几个版本核心依赖配置如下parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.2/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency一个特别容易忽略的点是MyBatis-Plus和SpringBoot 3.x的兼容性问题——MyBatis-Plus的3.5.3.2版本是可以兼容SpringBoot 2.x的但如果用了SpringBoot 3.x需要换用mybatis-plus-spring-boot3-starter。这个坑能让项目启动时报“Error creating bean with name sqlSessionFactory”卡住两三个小时。5.4 答辩演示环节翻车预防答辩演示翻车九成不是功能缺失而是环境问题。我建议按以下清单提前自测数据库是否设置了开机自启动还是手动启动答辩前一定要确认MySQL服务已启动。项目是否打成了可执行jar包打包命令mvn clean package -DskipTests生成jar后用java -jar启动测试一遍。不要依赖IDEA里点运行答辩机器很可能没有IDEA。演示数据量是否足够至少要有200个以上模拟用户和500条以上评分记录推荐结果才有说服力。网络是否可用ECharts如果是通过CDN引的JS文件断网时图表就会消失。建议把ECharts的JS文件下载到本地静态目录这是我在答辩现场吃过的一次亏——老师问“你这个图怎么不显示”我一看是网断了现场极其尴尬。6. 一些实在的补充建议最后的最后分享几点我做这类毕设系统的心得希望能帮到正在做这个题目的同学。第一个建议是关于算法的复杂度控制。毕业设计不必追求算法上的极致创新把协同过滤完整实现清楚、评估得明明白白再配合一个“冲稳保策略”的业务创新点已经可以拿到非常不错的评价了。就算老师问“你这个算法有什么创新”你也可以从“将协同过滤与梯度策略结合”这个角度来回答比硬吹自己改进了一个新算法要诚实且站得住。第二个建议是时间的分配。我见过太多同学把80%的时间花在写前端页面上把推荐算法压缩到最后一星期草草实现。这是本末倒置。推荐算法才是这个系统的灵魂先把算法跑通、调好、评估完再回头做界面美化才是正确的时间投入顺序。第三个建议是论文里一定要写清楚的数据流。从“用户行为数据采集→评分矩阵构建→相似度计算→评分预测→冲稳保分层→推荐结果展示”这条链路是论文核心章节也是答辩时最容易提的问题主线。建议画一张完整的数据流图不是流程图那种可以用表格结合文字描述把每一步的输入输出交代清楚基本上可以应对九成的追问。据我所知已经有学校把这类志愿推荐系统往产品化方向做了——对接真实招生数据、接入当年位次排名、结合多选题库做更精细的专业适配。这个方向越做越有价值对程序员来讲也是一片挺有意思的应用领域。如果你正在做这个毕设希望这篇东西能帮你少走一些弯路。遇到具体问题欢迎在评论区留言我尽量抽时间回复。
返回列表