资讯动态

Spring Boot + 爬虫构建高考志愿推荐系统:数据采集与位次法实战

发布时间:2026/10/7 4:34:50 来源:尧图企业网站定制
简介这套基于SpringBoot与爬虫技术的高考志愿智能推荐系统是一份可直接运行的综合项目源码主要面向高校计算机专业学生、毕业设计选题者以及希望学习实战整合的开发者。系统围绕志愿填报场景实现从教育类网站自动抓取高校招生政策、录取分数线、就业数据再结合考生成绩与偏好通过协同过滤等推荐算法输出个性化志愿建议并配有管理后台对数据与结果进行维护和呈现。后端以SpringBoot构建RESTful接口并整合MySQL持久化数据前端采用Vue框架另含Python爬虫脚本、SQL初始化脚本、项目配置文件以及一键安装/启动的批处理脚本便于快速部署调试。资源共672个文件涵盖Java、Vue、JS、XML、SVG、图片等类型压缩包整体约34.6MB。目前已有83人学习下载适合需要完整源码参考、梳理爬虫与Web系统整合流程或在此基础上扩展推荐功能的学习者。1. Spring Boot 爬虫做高考志愿推荐这个 zip 里装的是什么高考出分到提交志愿多数省份只留三到五天靠翻报考书和 Excel 整理往年录取线根本忙不过来。这个项目标题里的东西本质就是一个“数据采集 推荐服务”的闭环爬虫把历年分数线、专业录取位次、一分一段表抓下来Spring Boot 后端负责把考生分数换算成位次再返回一批冲、稳、保的院校和专业建议。它适合正在做毕业设计、接教育类私活或者给机构做演示的开发者。下面按我搭这类系统的顺序从选型、建表、爬虫、接口到避坑逐层拆开讲清楚每一步怎么做、为什么这么做。2. 系统选型与数据建模录取分数线和位次怎么落库才够推荐用2.1 后端选 Spring Boot、爬虫用 Python 的取舍拿到这个标题先别急着打开压缩包把技术分工想清楚比什么都重要。常见做法是 Python 写爬虫、Spring Boot 写接口requests 加 lxml 处理 HTML 非常顺手抓一个页面写几行代码就能跑通Spring Boot 负责把数据变成可调用的推荐服务定时任务、缓存、限流都有现成组件接。如果坚持全 Java用 HttpClient 抓页面再配 Jsoup 解析也能做但遇到复杂表格和动态标签时解析成本明显比 XPath 高。我一般会把爬虫拆成独立进程而不是放在 Spring Boot 的 Controller 里同步抓取。原因有两个采集任务偶尔会卡在网络上同步抓一次可能耗时几分钟接口超时没法交代Spring Boot 线程池被爬虫任务占满时正常的推荐查询也会跟着排队。写在 Controller 里看起来省事一旦翻车就是整体事故。独立脚本抓完直接写 MySQLSpring Boot 只读库两边互不拖累各自的资源也能单独扩。选型里还能看到 Spring Boot 自动装配的价值引入spring-boot-starter-data-redis之后RedisTemplate 自动配好不用手写连接池引入spring-boot-starter-validation之后参数校验注解直接能用。对志愿推荐这种中小系统来说大部分精力应该花在数据清洗和推荐规则上而不是重复搭基础设施。至于为什么不换 Flask 或 Node.js一句话解释这个 zip 提供的是 Spring Boot 工程骨架直接在骨架上加接口比推倒重来快得多后续部署也更容易。2.2 高考数据模型四张核心表的字段与索引设计数据建模是推荐系统能不能落地的基础。我常用的设计是四张核心表院校表、专业表、录取分数表、一分一段表。录取分数表是核心它要同时支持院校线和专业线两种粒度。如果把专业线单独建表后面查“某校某专业连续三年的最低位次”就要反复 join不如在同一个表里加一个score_type字段区分粒度查询时靠索引过滤逻辑也清爽。表名作用关键字段school院校基础信息id, name, province, level, is_985, is_211major专业基础信息id, school_id, name, category, lengthadmission_score院校/专业录取分数id, school_id, major_id, province, year, batch, min_score, min_rank, avg_score, score_typescore_segment一分一段表province, year, batch, score, rank, same_score_count录取分数表建表语句如下CREATE TABLE admission_score ( id BIGINT PRIMARY KEY AUTO_INCREMENT, school_id BIGINT NOT NULL, major_id BIGINT DEFAULT NULL COMMENT 为空表示院校线, province VARCHAR(20) NOT NULL, year SMALLINT NOT NULL, batch VARCHAR(10) NOT NULL COMMENT 本科批/专科批, min_score INT NOT NULL COMMENT 最低分, min_rank INT NOT NULL COMMENT 最低位次, avg_score INT DEFAULT NULL COMMENT 平均分, score_type TINYINT NOT NULL DEFAULT 1 COMMENT 1院校线2专业线, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_school_province (school_id, province, year), KEY idx_rank (province, year, min_rank) );建表时最需要留意的是索引方向。推荐服务的主要查询是“某省某一年位次在某个范围内的院校”所以(province, year, min_rank)这个联合索引必须建。min_rank是位次数字数字越小排名越靠前查询时用 between 做范围匹配正好能走索引。score_type和major_id不需要进索引因为院校线和专业线的数据量通常几万条内存里过滤完全扛得住。一分一段表的建表语句我一般这样写CREATE TABLE score_segment ( id BIGINT PRIMARY KEY AUTO_INCREMENT, province VARCHAR(20) NOT NULL, year SMALLINT NOT NULL, batch VARCHAR(10) NOT NULL COMMENT 本科批/专科批, score INT NOT NULL, rank INT NOT NULL COMMENT 该分数累计位次, same_score_count INT DEFAULT 0 COMMENT 同分人数, UNIQUE KEY uk_province_year_score (province, year, batch, score) );一分一段表是位次换算的依据。每年各省发布格式不一样有的直接给分数和累计人数有的给同分人数入库前要统一成rank和same_score_count。加唯一键是为了让爬虫可以重复执行用INSERT ... ON DUPLICATE KEY UPDATE做增量更新跑第二次不会主键冲突。除了这四张表还建议加一张province_batch_line存各省历年批次线。线差法要用它位次法做等位分换算时也要参照批次线变化。字段就三个province、year、batch_line新高考省份再补一个category区分物理类和历史类。别小看这张表很多志愿推荐系统算不准都是因为批次线取错了年份。2.3 推荐算法选型位次法加线差法不硬上机器学习很多第一次做智能推荐的人会往协同过滤上想其实高考志愿这个场景不适合黑匣子模型。考生和家长问得最多的一句话是“为什么推荐这所学校”规则算法能把理由摆出来。位次法和线差法是志愿填报里两条成熟口径足够支撑一个实用的推荐系统后期要加机器学习模型也可以在这个基础上做排序。位次法的核心思想是同一所院校每年的录取位次比分数更稳定。考生先通过当年一分一段表查出自己的位次 R再去历史年份里找近似位次对应的等位分。实际落地更简单直接用位次比。假设考生位次 30000某校去年最低位次 20000说明学校门槛比考生位次靠前考生属于冲如果学校最低位次是 50000考生位次明显靠前就是保。线差法是把分数减去当年批次线得到差值再和院校历年线差比较。它比位次法粗但在批次线波动小的省份可以参考。我通常两条同时算位次法做主排序线差法做修正修正的方向只有一个线差法认为能上的学校位次法也要能对上才给“稳”。冲稳保的划分可以做成可调阈值# 位次差距考生位次相对学校门槛位次的比例 diff (candidate_rank - school_min_rank) / max(school_min_rank, 1) if diff 0: level 保 elif diff 0.15: level 稳 else: level 冲这里 diff 是负数表示考生更靠前正数越大表示考生越靠后。阈值 0.15 不是硬标准不同省份考生密度不一样后期要按回测结果调整。推荐结果只给冲、稳、保三档并附上该校近三年最低位次作为解释比丢一个神秘评分更让人信服。如果爬虫阶段一分一段表没抓全位次法就废了一半所以数据采集和算法是绑在一起的前面偷的懒后面全得补。3. 用 requests XPath 写爬虫把公开录取数据抓进 MySQL 的全过程3.1 先抓一分一段表requests 请求与解析的最小可跑脚本爬虫是这套系统里最容易翻车的环节。开写之前先确认数据源是公开可访问的页面别去碰需要登录才能看的内部数据。我的习惯是每天凌晨跑一次爬虫只抓变化的数据每两个请求之间至少停一秒低频、低速、不全量刷新既不给目标站点添麻烦也能让任务长期跑下去。先看抓一分一段表的最小脚本import time import requests from lxml import html HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0 Safari/537.36, Accept: text/html,application/xhtmlxml,application/xml;q0.9,image/webp,*/*;q0.8, } def fetch_score_segment(province, year): # 示例URL实际以目标站点的公开栏目为准 url fhttps://example.com/{province}/{year}/segment resp requests.get(url, headersHEADERS, timeout10) resp.encoding utf-8 tree html.fromstring(resp.text) rows tree.xpath(//table[classsegment-table]/tbody/tr) data [] for row in rows: cells row.xpath(./td/text()) if len(cells) 3: data.append({ score: int(cells[0].strip()), rank: int(cells[1].strip()), count: int(cells[2].strip()), }) return data if __name__ __main__: result fetch_score_segment(henan, 2024) print(result[:5])这段代码里有两个参数值得留意resp.encoding utf-8是因为不少公开页面是 GBK 编码不强制切换中文全变乱码timeout10是必须写的否则请求挂在半路脚本第二天就卡死。headers里的 User-Agent 别省略一部分站点会直接拒绝没有 UA 的请求。解析时最常用的是 XPath 的//table[classsegment-table]/tbody/tr。注意有些页面的tbody标签在浏览器里会被补全但 requests 拿到的原始源码里可能没有这时候要改成//table/tr。这种差异就是爬虫的玄学遇到空结果先把页面源码打印出来看别急着改数据。requests 爬虫通用问题还有返回 403 或 5xx。给请求加上重试退避比一遍遍手动跑靠谱def fetch_with_retry(url, retries3): for i in range(retries): try: resp requests.get(url, headersHEADERS, timeout10) if resp.status_code 200: return resp time.sleep(2 * (i 1)) except requests.RequestException: time.sleep(2 * (i 1)) return None重试次数设 3 次足够间隔按 2 秒、4 秒、6 秒递增。这种低速请求既不会给目标站造成压力也能扛住偶发的网络抖动。对志愿推荐这种低频数据失败了下一次定时任务再补就行不需要复杂的断点续爬。3.2 抓院校分数线时XPath text() 的边界坑和清洗策略一分一段表结构规整抓起来还算顺利。院校分数线页面才是真正的坑。常见结构是每个院校一块既有院校线又有专业分数线标签嵌套得很深。如果用//td/text()去取分数往往会拿到空列表或一堆换行符。原因很简单XPath 的text()只返回当前节点的直接文本子节点不会递归取子孙节点。当 HTML 写成tdspan640/span/td时td/text()返回空数组因为 640 在 span 里。遇到这种结构最省事的解决办法是row.xpath(string(.))def parse_score_row(row): # string(.) 会把当前节点下所有嵌套文本拼成完整字符串 text row.xpath(string(.)).strip() parts [p.strip() for p in text.split() if p.strip()] if len(parts) 2: return parts[0], parts[1] return Nonestring(.)的代价是会把整行的所有文本都拼在一起包括一些不需要的标签文字。所以拿回字符串后要按空白分拆再根据页面结构定位分数和位次。这里没有万能模板每个数据源都要写一次清洗逻辑。我通常会在脚本里加一段调试输出先打印前几行原始结构再决定用td/text()还是string(.)。抓专业分数线时还有个细节专业名和分数经常不在同一个 tr 里。有的页面是一个专业一个 tr有的是一个院校一个 tr、专业名用子行展示。所以建表时major_id允许为空先抓院校线再抓专业线补进去。这样即使某一年专业线没公布院校线也能支撑推荐。抓完专业线后最好再跑一遍“院校线是否大于等于该院校所有专业线”的核验逻辑上录取最低分不可能高于专业线发现异常立刻告警。3.3 爬虫结果进 MySQL直写库与 Spring Boot 接口上报怎么选爬虫把数据解析出来之后下一步是入库。这里有两种常见做法一是 Python 直接连接 MySQL 写库适合批量导入二是通过 Spring Boot 写一个接收接口适合增量更新和二次校验。我建议主力用直写库因为爬虫产出的数据本来就是结构化的少一层接口就少一次序列化和超时问题。import pymysql def save_segment(data_list, province, year): conn pymysql.connect( host127.0.0.1, userroot, password***, databasegaokao, charsetutf8mb4, ) try: with conn.cursor() as cur: sql ( INSERT INTO score_segment (province, year, batch, score, rank, same_score_count) VALUES (%s, %s, %s, %s, %s, %s) ON DUPLICATE KEY UPDATE rank VALUES(rank) ) batch [ (province, year, 本科批, d[score], d[rank], d[count]) for d in data_list ] cur.executemany(sql, batch) conn.commit() finally: conn.close()executemany批量插入比一条条 insert 快得多一条连接处理几百条没问题。ON DUPLICATE KEY UPDATE依赖之前建表时加的唯一键爬虫重复执行不会报主键冲突而是原地更新位次。参数batch的长度控制有讲究一次塞上万条会让 MySQL 的 undo log 吃不消我一般控制在 500 到 1000 条一批。如果坚持走 Spring Boot 接口Controller 里就是一个接收 List 并调用 service 批量入库的方法主要价值是可以在入库前做校验比如分数不能超过 750、位次不能为负数。代价是爬虫和接口要同时上线爬虫挂了接口还开着容易收到一堆脏数据。对新手来说先把直写库跑通再考虑接口上报不要一上来就追求“工程化”。把爬虫任务跑起来后建议每天检查三样东西入库行数有没有暴跌、字段里有没有空值、院校线有没有比专业线低。这三项能覆盖九成以上的数据质量问题。爬虫脚本本身要留日志抓到多少条、失败多少条、耗时多久全写到本地文件不然出了问题连从哪开始查都不知道。4. Spring Boot 定时任务与推荐接口从数据到冲稳保三档结果4.1 用 Scheduled 做定时采集任务锁怎么加数据入库后Spring Boot 这边要做两件事定时触发爬虫、对外提供推荐接口。定时触发用Scheduled是最省事的方案不用引额外框架。先在配置类上打开调度开关Configuration EnableScheduling public class SchedulerConfig { }然后写一个任务类每天凌晨两点执行爬虫脚本Component public class GaokaoCrawlTask { private final ReentrantLock lock new ReentrantLock(); Scheduled(cron 0 0 2 * * ?) public void dailyRefresh() { if (!lock.tryLock()) { return; } try { ProcessBuilder pb new ProcessBuilder( python, spider/score_segment.py, --provincehenan, --year2024 ); pb.redirectErrorStream(true); pb.start(); } catch (Exception e) { // 记录日志让告警系统能看到 } finally { lock.unlock(); } } }Spring 的 cron 是六位表达0 0 2 * * ?表示每天凌晨两点整。经常有人把 Quartz 的七位 cron 抄进来多加一个年份字段启动时直接报错说 cron 表达式非法。这个坑很小但排起来很费时间。ProcessBuilder是让 Java 去拉起 Python 进程注意脚本路径要用绝对路径或项目内的相对路径并且把标准错误重定向到日志文件否则爬虫报错你只能在后台日志里查半天。ReentrantLock这步是我吃过大亏才加的。志愿数据更新频率低但院校线公布那几天凌晨任务和手动触发的任务可能同时跑两个爬虫脚本同时写表唯一键会互相干扰。tryLock()抢不到锁就直接跳过保证同一时刻最多只有一个任务在执行。日志里打印“上次调度未结束跳过本次”比让任务互相踩踏好一百倍。4.2 推荐接口设计分数位次进来冲稳保列表出去推荐接口是系统的脸面。考生和家长只关心结果但接口的参数和返回结构要考虑得尽量细。我通常会设计成 POST JSON接收省份、年份、科类、分数、位次位次可选——如果用户没填后台用一分一段表反查。RestController RequestMapping(/api/recommend) public class RecommendController { private final RecommendService recommendService; PostMapping(/colleges) public ResultListRecommendVO collegeList(Valid RequestBody RecommendReq req) { return Result.ok(recommendService.recommendByScore(req)); } }Service 里的核心逻辑是把分数换算成位次再按位次范围捞院校线最后分档。Service public class RecommendServiceImpl implements RecommendService { Autowired private AdmissionScoreMapper admissionScoreMapper; Autowired private ScoreSegmentMapper segmentMapper; Override public ListRecommendVO recommendByScore(RecommendReq req) { ScoreSegment segment segmentMapper.selectByProvinceYearScore( req.getProvince(), req.getYear(), req.getScore()); if (segment null) { throw new BizException(当前分数没有对应位次数据); } int rank segment.getRank(); // 把位次区间放宽到3倍避免漏掉极端情况 ListAdmissionScore pool admissionScoreMapper.selectByRankRange( req.getProvince(), rank, rank * 3); MapString, ListAdmissionScore grouped new HashMap(); for (AdmissionScore s : pool) { int schoolRank s.getMinRank(); double diff (rank - schoolRank) / (double) Math.max(schoolRank, 1); String level; if (diff 0) { level 保; } else if (diff 0.15) { level 稳; } else { level 冲; } grouped.computeIfAbsent(level, k - new ArrayList()).add(s); } // 每档最多取5条按录取位次从小到大排序 return buildResult(grouped); } }这里最容易被忽略的是位次的比较方向。位次数字越小排名越靠前所以rank - schoolRank是负数时说明考生位次比学校去年最低位次更靠前录取概率高归为“保”。反过来正数越大越危险归为“冲”。rank * 3的上限是一个经验值把位次范围放宽到考生的三倍是为了防止某一年某校冷门、位次突然放大导致的漏判代价是内存里多几条数据完全可接受。selectByRankRange对应的 SQL 大概是SELECT * FROM admission_score WHERE province #{province} AND year #{year} AND score_type 1 AND min_rank BETWEEN #{startRank} AND #{endRank} ORDER BY min_rank ASCSQL 里用score_type 1过滤掉专业线避免专业线的位次混进院校推荐池。BETWEEN天然利用之前建的idx_rank索引几万行数据查下来几十毫秒内返回。返回给前端的 VO 里至少要有院校名、年份、最低分、最低位次、档次这五个字段方便前端直接渲染。4.3 参数校验、Redis 缓存和接口防爬推荐接口一旦上线很快会被高频调用这时候要同时处理性能和安全两件事。性能方面同一省份同一分数的推荐结果短期内不会变用 Redis 缓存能把数据库压力降一大截Cacheable(cacheNames recommend:college, key #req.province _ #req.score _ #req.year) Override public ListRecommendVO recommendByScore(RecommendReq req) { // 方法体同上 }Cacheable会在方法执行前查缓存key 里必须带上省份和年份。如果系统只存了最近一年的数据key 里甚至可以不带年份但带上更保险以后做历史回测或者多省份扩展时不用改代码。Redis 缓存过期时间设为 24 小时数据更新后手动删除对应 key避免用户看到旧推荐。接口防爬这层很多项目只在前端做防右键、防查看源码这是面子工程。真正的防护在服务端Java 的 Controller 层可以写一个拦截器按 IP 限频Component public class RateLimitInterceptor implements HandlerInterceptor { Autowired private StringRedisTemplate redisTemplate; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String ip request.getRemoteAddr(); Long count redisTemplate.opsForValue().increment(rl: ip, 1); if (count ! null count 1L) { redisTemplate.expire(rl: ip, Duration.ofSeconds(60)); } if (count ! null count 60) { response.setStatus(429); return false; } return true; } }这段代码实现的是每个 IP 每分钟最多请求 60 次。increment和expire分两步做第一次请求时设置过期时间计数的窗口是一分钟。如果并发量再大可以把限流值调小或者在 Nginx 层加同一套逻辑。志愿推荐服务的用户量通常不大IP 限频足够拦住大多数批量抓取。Valid校验只解决参数合法性解决不了恶意调用。要在推荐接口上做登录校验或验证码就得引入鉴权框架看项目阶段决定。我见过把分数校验做成“只有 0 到 750 整数”就完事的结果被别人用分数参数遍历了全表接口慢到瘫痪。这种错一次就能长记性。5. 项目避坑排查数据抓不全、位次算错、Spring Boot 版本太高怎么救5.1 爬虫抓回来的列表是空的XPath 路径失效的排查顺序现象跑完爬虫数据库一条新数据都没有脚本也没有报错打印出来是空列表。原因页面结构改版了或者tbody标签在源码里不存在又或者 URL 里的年份参数没有起作用。解决第一步打印resp.text[:2000]看页面是否真的返回了表格第二步在浏览器里按 F12 重新审查元素确认表格的 class 名第三步把//table[classsegment-table]/tbody/tr改成实际的路径。我遇到过几次都是因为网站把表格 class 从segment-table改成了table这种页面变化只能靠人工发现所以爬虫任务一定要有日志抓不到数据时能立刻看到是哪里断的。排查顺序还有一个容易忽略的点先确认页面是服务端渲染还是 JS 动态渲染。requests 只能拿到空壳 HTML如果数据是前端通过 Ajax 加载的XPath 怎么写都是空。这时候去浏览器的 Network 面板里找数据接口直接抓那个 JSON 远比解析 HTML 省力。两种数据源我都用过能抓 JSON 就优先抓 JSON字段清晰不需要清洗。5.2 推荐结果和实际录取线对不上位次换算和批次线波动现象按分数直接比去年的数据给用户推荐了一堆“稳”的学校结果录取线公布后全部落空。原因分数不是跨年份可比的每年考生人数、试题难度、批次线都不一样同一个分数在不同年份差出几万个位次很常见。解决先查一分一段表把当年分数换算成位次再用位次去找院校线。批次线也要单独存做线差法时使用正确的对应年份。常见误区是拿今年的批次线去和去年的分数线直接相减算出虚假的线差。比如今年本科批 440 分去年 460 分同一个 500 分两年的“含金量”完全不同。我后来把位次换算写成了一个公共方法所有推荐入口强制走这个方法禁止直接用分数比较。每次改完算法还要拿前三年数据跑一遍回测抽几个考生样本逐个核对看“稳”档里到底有多少真的被录取。5.3 Spring Boot 3.x 打开旧项目javax 到 jakarta 编译失败现象把别人打包好的 zip 导入新环境pom 用的是 Spring Boot 3.x代码里import javax.servlet.*全部标红maven 编译直接失败。原因Spring Boot 3.x 把 Jakarta EE 的命名空间从javax改成jakarta这是一个硬性变化不是加依赖能绕过去的。解决如果是自己的项目用 IDEA 的全局替换把javax.servlet、javax.validation替换成jakarta.servlet、jakarta.validation如果想省事直接把 Spring Boot 版本降到 2.7.x并且确认 JDK 也改成 8 或 11。Spring Boot 3 要求 JDK 17 起步机器上装的 JDK 版本不够会出现UnsupportedClassVersionError这种低级错误排查半天才发现是版本问题。接手 zip 里已有代码时先看 pom 再决定升不升。还有一个连带坑spring-boot-starter-web从 3.x 开始内嵌 Tomcat 10原来的javax.websocket相关代码也要跟着换别只改两个 import 就以为完事了。5.4 自己的接口被频繁刷服务端防爬不能靠前端小聪明现象系统上线第二天云监控显示数据库连接数打满日志里同一个 IP 每秒请求几十次推荐接口。原因推荐接口是明文 JSON抓包就能拿到别人写个简单的爬虫脚本就能批量拉数据。解决第 4 章的 IP 限频先加上再把分数、位次这些参数做成必填减少接口被乱扫的范围。真正要紧的是别在前端做防右键、禁用 F12 这种表面功夫请求到了服务器就该由服务器说了算。如果业务允许登录后才能调用接口配合滑块验证码基本能挡住九成批量采集。服务端防爬的记录里还可以加上 User-Agent 分布判断正常用户不会几十个 UA 轮着换。我踩过这个坑后给所有写好的接口都加了一个简单的RateLimiter注解统一控制不单独写在每个 Controller 里。项目后期再看日志被刷的次数明显下降。6. 让推荐结果更可信用一分一段表做回测校验的进阶技巧6.1 历史回测看看去年这套规则会推荐出什么推荐算法改完之后不能只看一两个例子顺眼就上线。我用得最顺手的方法是历史回测把年份往前拨一年用更早的数据生成推荐再和真实录取结果比对。命中率的定义很简单考生最终录取学校的 id 落在推荐列表里的比例。def backtest(student_list, admitted_dict, rules): hits 0 for s in student_list: recs rules.recommend(s) # 只用 s 当年之前的数据 ids {r[school_id] for r in recs} if admitted_dict.get(s[student_id]) in ids: hits 1 return hits / len(student_list)回测的价值是暴露阈值问题。比如冲稳保的 diff 阈值设成 0.15回测出来命中率只有 30%说明阈值定得太激进把它放大到 0.25命中率升到 60%但用户能选的学校又变少了。这个取舍没有标准答案得看你面向的是保守型用户还是激进型用户。6.2 把阈值做成配置改参数不重新编译上一节那个 diff 阈值最理想的位置是配置文件里让运营和技术都能调。用 Spring Boot 的ConfigurationProperties可以把 yml 直接映射成对象recommend: thresholds: wen: 0.15 chong: 0.30 years: - 2022 - 2023 - 2024Component ConfigurationProperties(prefix recommend) public class RecommendProperties { private Thresholds thresholds new Thresholds(); private ListInteger years new ArrayList(); // getter/setter 省略 }我一开始把阈值写死在 Service 里每次调一次都要重新打包部署后来改成配置后改推荐策略只需要改 yml 再重启方便很多。做这类系统最忌讳的是拍脑袋定规则然后永远不验证。高考志愿对用户的影响太大了推荐错一个位次可能就会改变一个人的人生方向。宁可保守一点也不要用黑匣子耽误考生。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑