尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Django实战:高考志愿推荐系统从数据清洗到冲稳保算法落地

Django实战:高考志愿推荐系统从数据清洗到冲稳保算法落地 简介推荐系统在高考志愿填报这类高利害、多约束的决策场景中核心价值在于将复杂筛选转化为可解释的梯度排序。基于位次法、录取概率估算与兴趣匹配的组合规则算法在数据稀疏和冷启动条件下比协同过滤更可靠。数据清洗是地基专业名称对齐、分数位次换算等细节直接决定模型效果。使用Django构建后端通过合理的数据表设计、联合索引和预计算缓存可以应对出分高峰的查询压力。从数据建模、算法设计到服务编排再到冲稳保三档推荐逻辑一套轻量而稳定的实现方案能够帮助考生快速获得志愿参考。本文完整梳理了开发过程中的核心代码、性能优化技巧与关键踩坑记录为教育类推荐系统开发者提供可复用的工程实践。 每年出分后的那几天我的微信基本就是轰炸状态。七大姑八大姨问“我家孩子考了600分能上什么学校”同学群里也都是截图和焦虑的求助。帮了几个人之后我意识到与其一个个回消息不如直接做一个系统让考生输入自己的位次、省份、兴趣方向系统自动生成一份“冲稳保”三个梯度的志愿推荐列表。这就是这个项目的由来——基于Django框架与智能算法实现的高考志愿填报推荐系统。技术本身不算特别难但把这个系统做出来并让它真正可用路上踩的坑比预想多得多。数据怎么清洗、位次怎么换算、推荐算法到底用规则还是模型、Django后端怎么设计才能扛住出分当天的查询压力——这些问题光看文档是找不到答案的。这篇文章把我的完整实现思路、源码核心逻辑、以及上线前后的踩坑记录都整理出来给同样想做这类推荐系统的朋友一个参考。适合谁看如果你正在用Django做Web应用或者想了解一个推荐系统从数据清洗到算法落地需要处理哪些问题又或者你正好有教育领域的产品想法这篇应该能帮你省下不少时间。1. 志愿填报推荐系统到底在解决什么问题1.1 为什么是“推荐系统”而不是“查询系统”很多人第一反应是这不就是个带筛选项的分数线查询网站吗考生选个省份、输入分数数据库里把符合条件的学校列出来就行了。这个思路在数据量小的时候确实能跑实际用起来区别很大。高考志愿填报是一个信息密度极高、决策维度极多的场景——考生的约束条件包括分数、位次、选科组合、地域偏好、专业兴趣、学费接受度、是否服从调剂等等而数据侧每一所学校都有历年录取线、招生计划变化、专业冷热程度、大小年波动。单纯的 SQL 过滤只能做到“分数够了”做不了“这个学校今年可能要涨分还是降分”更做不到在几十个满足条件的选项里帮你排出一个梯度合理、有攻有守的志愿表。推荐系统在这里的核心价值是把高利害、多约束、强时效的决策问题转化成一套可解释的排序和分组逻辑。它不是替考生做决定而是把“范围”和“概率”摆到用户面前。1.2 用户核心需求拆解我接需求时把用户分成了三类考生、家长、还有“替小孩操心的一大家子”。他们的诉求侧重点不太一样考生要的是不要滑档、不要浪费分数、能读到想读的专业。家长更关心地域、学费、学校牌子、就业前景。系统层面要平衡的则是录取概率、专业匹配度、用户显性偏好。所以这个推荐系统本质上是一个多目标排序问题。我在设计算法时没有追求一个“唯一正确答案”而是输出三档梯度建议——冲、稳、保。每一档里的学校再按“专业匹配度 录取概率”做二次排序。这个交互方式用户很容易理解也比给一个冷冰冰的“综合得分”更有说服力。1.3 技术选型Django 加规则算法够用且稳技术栈我选了 Django 4.x PostgreSQL Redis DRFDjango REST Framework算法层独立成模块没有强依赖 Django。选 Django 的核心原因是自带 Admin 后台、ORM、用户鉴权和模板引擎对一个需要快速验证、且要频繁人工维护后台数据的项目来说开发效率高一个量级。推荐算法我最后没有用协同过滤、矩阵分解这类常见“智能算法”。原因后面会细说简单讲就是志愿填报场景基本没有用户行为数据每年数据只更新一次协同过滤的冷启动和数据稀疏问题无解。我用的是一套基于位次法、录取概率估算和兴趣标签匹配的组合规则算法。规则算法在数据量不大、业务逻辑清晰的场景下可解释性和稳定性远好于复杂模型。2. 数据建模推荐系统的地基不能省2.1 核心数据表设计这个系统里最值钱的不是代码是数据。表结构设计直接决定了推荐算法能跑多顺。我主要设计了五张业务表School院校基础信息包含学校名称、所在地、办学层次985/211/双一流/普通本科/专科、院校类型综合/理工/师范等、是否公办。Major专业信息包含专业名称、专业代码、门类、学制、学费区间。这里特别加了一个alias_names字段用来解决同一个专业在不同年份改名的匹配问题。AdmissionScore历年录取数据包含院校ID、专业ID、年份、省份、科类/选科要求、最低分、最低位次、平均分、平均位次、录取人数。EnrollmentPlan招生计划表包含院校ID、专业ID、年份、省份、计划招生人数这是判断“这个专业今年还有没有名额”的关键。UserPreference考生偏好表包含分数、位次、省份、选科组合、意向城市、意向专业方向、是否接受调剂。AdmissionScore 是体量最大的一张表。一个省一年的院校专业录取记录大概有8万到10万条如果系统覆盖多省份多年份数据量会到百万级。索引设计特别重要我最开始只在school_id上建索引结果按省份 年份 位次范围查询时慢得离谱后来加了联合索引(province, year, min_position)才把查询时间压到百毫秒以内。2.2 数据来源与清洗的实战细节数据来源主要是各省官方发布的公开录取数据、招生计划和一分一段表。搞数据的人都知道官方数据拿到手也不是能直接用的清洗的工作量非常大比写代码多三倍不止。我踩得最深的坑是专业名称对齐。同一所学校同一个专业不同年份在官方文件里的名字可能不完全一样。比如“计算机科学与技术”有的年份文件里写成“计算机类”有的写成“计算机科学与技术卓越班”如果直接按字符串匹配历史的录取数据就会断档。我最后用了一组规则先按专业代码精确匹配匹配不到再用去掉后缀括号内文字、方向说明之后的简化名称做模糊匹配。这个方案虽然不完美但能覆盖95%以上的情况。另外一个大坑是分数与位次的匹配。有些省份官方不直接公布专业录取位次只有分数。那我就在清洗阶段用当年的一分一段表反查分数对应的累计人数把分数补算成位次。这个步骤必须在数据入库时完成不能等到查询时再算否则并发高了会拖垮接口。2.3 位次换算与等位分为什么不能用裸分比较直接用分数比对历年录取线在试题难度波动大的年份会出大问题。今年600分可能排全省两万名去年600分可能排一万名拿今年的600分去套去年的录取数据完全没意义。行内通用的办法是位次法。考生在全省排第8000名就去查目标院校历年录取学生的全省位次大概在什么范围。这个方案能有效消除试卷难度和评分标准带来的分数波动。一分一段表拿到后我解析成一张位次映射表分数 - 累计人数。比如“600分 累计12345人”意思是全省考600分及以上的有12345人。等位分的计算逻辑是这样的如果今年考生考了610分查到今年一分一段表中610分对应的累计位次是9500那就去查往年比如去年的一分一段表找出累计位次最接近9500的那个分数。假设去年9500位对应的是615分那么考生今年610分就相当于去年的615分。后面用等位分去和去年的录取线比较比直接用610分靠谱得多。3. 智能算法的核心冲稳保梯度推荐逻辑3.1 算法选型规则为骨架、概率模型做辅助很多朋友一听到“智能算法”就默认要上深度学习、协同过滤。我在这个项目里反其道而行之主推位次法加概率估算原因有三条数据极其稀疏。每个省份每年的有效录取记录只有几万条按院校-专业维度拆开后大部分专业只有一条录取记录协同过滤根本算不出相似度。冷启动无解。系统上线初期零用户行为没有评分数据任何基于“人”的推荐算法都跑不起来。解释性要求高。用户问“为什么给我推荐这个学校”答案必须是“因为你位次接近该校近三年平均录取位次且你的意向专业匹配度较高”这个逻辑必须是透明的。所以我最后的算法是录取概率估算概率模型 冲稳保分档规则引擎 兴趣匹配内容相似度。算法层和 Django 业务层完全解耦所有函数都是纯 Python 实现输入输出都是简单的数据类方便单测和离线回测。3.2 录取概率估算位次比值和逻辑斯蒂映射录取概率不直接等于“我是第8000名学校去年录到第7900名所以我概率是99%”。这里有大小年波动、招生计划变化、冷热门交替等扰动。我设计了一个概率模型输入是考生位次和学校近三年的录取位次序列输出是一个 0 到 1 之间的录取概率估值。核心思路先算考生位次与近三年平均录取位次的比值ratio user_position / avg_position。注意位次数值越小代表排名越靠前所以当ratio 1时考生位次排在平均录取位次之前录取概率较高ratio 1时概率下降。然后把 ratio 映射成概率。我试过线性映射效果不好因为极端情况下ratio 到了 1.3线性函数还会给一个不小的概率这不符合实际。后来改用逻辑斯蒂形式的映射让概率在临界区域快速变化两端平滑收尾import math from dataclasses import dataclass dataclass class AdmissionEstimate: probability: float # 录取概率估算值 level: str # 冲 / 稳 / 保 / 险 avg_position: int # 近三年平均录取位次 trend: str # 上升 / 下降 / 平稳 def estimate_probability(user_position: int, history_positions: list[int]) - float: 基于位次比值的录取概率估算。 history_positions 为近三年最低录取位次列表如 [8200, 7600, 7900] avg_position sum(history_positions) / len(history_positions) # 位次比值小于1说明考生排在平均线之前越靠前概率越高 ratio user_position / avg_position # 逻辑斯蒂映射k 控制概率下降的陡峭程度 k 6.0 probability 1 / (1 math.exp(k * (ratio - 1))) return max(0.0, min(1.0, probability))这个函数的输出我用一个对照表校准过ratio0.7 时概率约 0.86ratio0.85 时约 0.71ratio1.0 时正好 0.5ratio1.2 时约 0.23。对照2023年某省实际录取数据回测这套概率估算在“稳”档的命中率在70%左右排序价值很明显。3.3 冲稳保三档分档规则有了概率估值之后分档规则就非常直观了。我按照志愿填报的通用说法定义了四档每档的评分区间和作用如下档位概率区间策略含义志愿表建议数量冲0.30 - 0.55搏一搏更好的学校2-4个稳0.55 - 0.85性价比最高的选择4-6个保0.85 - 0.97确保有学上3-5个险 0.30 或 0.97前者不建议后者太浪费分数0-1个分档之后再做层内排序。排序的评分函数不是单纯按概率降序而是融合了专业匹配度、院校层次、地域倾向这几个因子。这样能避免一个尴尬情况概率最高的几个学校全是用户根本不感兴趣的地域或专业。3.4 专业兴趣匹配基于内容标签的轻量实现专业兴趣匹配我用的不是复杂语义模型就是一个带权重的关键词匹配。系统预置了一套专业方向标签体系比如人工智能、大数据、金融、医学、机械、法学、师范、土木、艺术、语言。每个专业在入库时打了三到五个方向标签。用户注册时可以勾选感兴趣的标签也可以选“不做限制”。算法层把用户标签和每个推荐专业的标签做集合相似度计算def interest_score(user_tags: set[str], major_tags: list[str]) - float: 专业兴趣匹配分Jaccard 相似度变体。 major_tags 为空时返回中性分 0.5不干扰位次排序。 if not major_tags: return 0.5 intersect len(user_tags set(major_tags)) union len(user_tags | set(major_tags)) return intersect / union if union else 0.5综合评分函数把录取概率和兴趣匹配分做加权录取概率占大头兴趣分作为微调def composite_score(prob: float, interest: float, interest_weight: float 0.25) - float: 综合排序分。 录取概率权重 0.75兴趣匹配权重 0.25。 return prob * (1 - interest_weight) interest * interest_weight这里面有个细节当用户没有勾选任何偏好标签时user_tags是空集Jaccard 计算出来是 0但这时候不应该压低排序分。所以我加了一个判断——用户标签为空时直接让所有interest_score返回 0.5 中性分。这个细节估计查文档查不到纯属写代码的时候想明白的。4. Django 后端实现与代码架构4.1 项目结构与模块边界我用了 Django 标准的项目结构但把算法层单独拆了出来不放在任何一个 app 下面。这样做的目的是强制隔离业务代码和算法逻辑算法层可以独立测试、独立调试、以后替换成别的模型也不影响 Django 业务线。zhihu_app/ ├── manage.py ├── config/ # 工程配置 │ ├── settings.py │ ├── urls.py │ └── celery.py ├── apps/ │ ├── schools/ # 院校、专业、录取数据管理 │ │ ├── models.py │ │ ├── admin.py │ │ └── serializers.py │ ├── users/ # 考生注册、偏好管理 │ │ └── models.py │ └── recommend/ # 推荐业务接口 │ ├── views.py │ └── services.py ├── algorithms/ # 算法层纯 Python不依赖 Django │ ├── position_based.py │ ├── interest.py │ └── pipeline.py └── templates/最关键的模型代码集中在 schools 和 users 两个 apprecommend 只做资源编排和接口输出。三层结构的好处是哪天要加新的推荐策略直接改 algorithms 层不动 models 和 views。4.2 模型层用 DecimalField 存位次避开浮点坑这里有个值得说的细节。位次、分数这些字段我全部用的是IntegerField或DecimalField绝不用FloatField。因为位次比较的是大小关系浮点数的精度误差在极端情况下可能导致排序错位而且数据库里的位次带一堆小数也没意义。AdmissionScore 表的联合索引是查询性能的关键。我在Meta类里明确了ordering和联合索引推荐查询的过滤条件基本都落在(省份, 年份, 科类, 位次范围)这几个字段上class AdmissionScore(models.Model): school models.ForeignKey(School, on_deletemodels.CASCADE, related_namescores) major models.ForeignKey(Major, on_deletemodels.CASCADE, related_namescores) year models.PositiveIntegerField() province models.CharField(max_length32) subject_type models.CharField(max_length32) # 物理类/历史类/理科/文科/综合 min_score models.PositiveIntegerField() min_position models.PositiveIntegerField() avg_score models.PositiveIntegerField() avg_position models.PositiveIntegerField() enroll_count models.PositiveIntegerField() class Meta: indexes [ models.Index(fields[province, year, subject_type, min_position]), models.Index(fields[school, major]), ]这个联合索引建好之后位次范围过滤的查询从全表扫描降到了索引范围扫描查询耗时降低了一个数量级以上。4.3 推荐服务层算法和业务怎么协作recommend/services.py 是整个流程的编排层。它的职责是把用户请求参数拼好调用算法层再把结果包装成 Django 的数据结构返回。核心流程是四步根据用户的分数和省份查一分一段表换算出位次。拉取所有候选院校专业在近三年的录取位次数据。调用pipeline.generate_recommendations()算出每个候选的概率、档位、综合分。按档位分组每组按综合分降序截断到指定数量。关键代码片段from algorithms.pipeline import generate_recommendations from apps.schools.models import AdmissionScore def recommend_for_user(user_profile, limit_each_level5): # 1. 分数换算位次由一分一段表服务完成 user_position get_position_from_score( scoreuser_profile.score, provinceuser_profile.province, yearuser_profile.year, ) # 2. 拉取候选数据近三年有录取记录、且今年有招生计划的院校专业 candidates fetch_candidates( provinceuser_profile.province, subject_typeuser_profile.subject_type, min_positionuser_position - 3000, max_positionuser_position 8000, ) # 3. 交给算法层计算 result generate_recommendations( user_positionuser_position, user_tagsuser_profile.interest_tags, candidatescandidates, ) # 4. 按档位分组并截断 return { 冲: result[high][:limit_each_level], 稳: result[mid][:limit_each_level], 保: result[low][:limit_each_level], }注意这里拉候选数据的时候设了一个位次范围过滤掉完全不可能和稳进的学校减少算法层计算量。范围设置成“考生位次前3000、后8000”是调出来的经验值——太窄会漏掉机会太宽算法层要算几千条数据响应时间受不了。4.4 API 设计与性能优化接口我用 DRF 写了一个只读接口/api/v1/recommendations请求参数是省份、分数、选科、兴趣标签响应是冲稳保三组推荐列表。正式部署时还要接受两个参数科类和年份因为不同省份在不同年份的数据结构不一样不能硬编码。性能优化的三招第一招是预计算。位次换算表一分一段表在数据导入时就生成好存在 Redis 里key 格式是position_map:{province}:{year}:{subject_type}查询时直接 O(1) 命中绝不实时查数据库。第二招是查询结果缓存。同一考分段的考生推荐的学校集合高度重叠所以我把推荐结果按“位次区间 省份 科类”做缓存缓存的 key 是位次区间比如每500名一个区间过期时间设置10分钟。这其实是在做聚合缓存把十分钟内的相同位次段的请求都打到缓存上。第三招是异步预生成热门位次段的结果。出分当天最热门的省我提前用 Celery 把常见位次区间比如从500名到50000名每500名一个区间的推荐结果预生成好写进缓存。这样用户在查询高峰段打过来几乎全是缓存命中数据库压力非常小。5. 上线前必须处理的四个坑5.1 专业改名和院校合并导致的数据断层这是我在回测时发现的。有个学校前年按“计算机类”大类招生去年改成了“计算机科学与技术”“软件工程”两个专业分别招生结果前年的录取数据挂在了一个“计算机类”专业上去年的数据挂在了新专业上。如果算法只看近三年的数据这个学校近三年的位次序列就会缺一块冲稳保判断会严重失真。我的处理方案是在数据清洗阶段专门做了一步“专业继承映射”。用一个维护表记录old_major_id - new_major_id的映射关系查询录取数据的时候把旧专业的数据归并到新专业上。这个表需要人工维护因为院校专业调整没有统一规律代码判断不出来。5.2 同分不同位次和位次相同的问题这是位次法的一个天然难点。同一个分数在全省可能有几百人他们的位次是不同的。官方的一分一段表只能查到“累计人数”查不到某个具体分数的个人位次。同一分数段内的排序省级平台通常会按单科成绩排。我的处理方式是把“位次”按累计人数近似。考生的“位次”就取一分一段表里该分数对应的累计人数比如考生考610分一分一段表显示610分及以上有9500人系统就认为考生位次是9500。这个近似在推荐场景下是够用的因为录取数据也是同一个口径算出来的系统性偏差不会太大。但如果要做精确定位就需要接入省级具体排位数据这属于另一层工程了。5.3 并发请求导致数据库连接被打满上线前压测我拿了两百个并发请求去打推荐接口数据库连接数直接报警。排查发现两个问题第一算法层在处理候选专业时循环里每算一个专业就查一次数据库两三百个候选专业就有两三百次查询第二没有加缓存所有请求都直穿数据库。先解决循环查询把所有候选 ADMISSION SCORE 一次查出来在内存里按school_id major_id year建索引算法层只查这个内存字典数据库查询次数从 N 次降成 1 次。这个改动让响应时间从 4 秒降到了 0.8 秒。再解决缓存把按位次区间聚合的缓存加上之后压测数据从 200 并发 30% 的错误率降到 1000 并发 0 错误P95 响应时间 300ms 以内。这两步应该说是这次上线前最有价值的优化。5.4 推荐结果的解释性让用户敢信功能上线后我发现一个问题用户对推荐结果是有戒心的。尤其是“冲”这一档用户会想“你给我推一个我大概率录不上的学校是不是在害我”。所以在返回结果里我加了三个必填字段matched_probability录取概率估算值、avg_position_text近三年平均录取位次说明、reason_codes推荐理由标签数组比如“位次匹配度高”“兴趣专业匹配”“近三年录取位次稳步上升”。页面展示时直接把这些字段渲染成话术。加了这层解释之后用户留存和再次查询的比例明显提升这也侧面说明推荐系统光有结果不够还得让结果“可解释、可信任”。6. 实测效果与如何继续扩展6.1 离线回测与真实试用数据项目上线前我做了一轮离线回测用考生所在省份上一年的真实录取数据来检验系统推荐质量拿2023年的录取数据作为“真相”用2022年及之前的历年数据作为“已知数据”模拟2023届考生查询看系统推荐的每个志愿有没有被真实录取线验证。结果在模拟2023年某省物理类考生1000个位次样本时“稳”档推荐的专业院校中有68%的实际录取位次落在我预估的稳档区间内“保”档的命中率在82%以上基本做到了“推荐了就没滑档担心”“冲”档的命中率则和策略定义有关大约35%的院校实际录取位次进入了可以录取的范围这个比例和志愿填报的常识规律吻合。小范围真实试用中一位530分左右的考生按系统推荐的三档志愿表提交最终被“稳”档的第一志愿录取。这个结果直接支撑了我的观点对志愿填报这种低频次、高利害的决策场景基于位次和梯度的规则算法实用价值比复杂的协同过滤模型高得多。6.2 可以继续扩展的四个方向第一把院校层次因素加进评分。现在综合评分主要看录取概率和兴趣匹配没有显式处理“学校牌子”的加成。比如同样是稳档一个普通一本的“计算机科学与技术”和一个双一流高校的“信息管理与信息系统”系统应该能识别出前者对大多数用户来说更有吸引力这需要引入院校层次权重。第二用贝叶斯方法动态修正大小年。目前算法假设近三年历史数据等权平均但有的学校录取位次有明显的大小年交替规律——去年分高今年可能分低。下一版可以在历史序列上做一个简单的贝叶斯更新如果检测到过去两年位次方向相反就降低最近一年的权重提高间隔年份的参考价值。第三接入专业就业和薪资数据。这属于内容端的扩展把毕业去向、专业就业率、平均起薪作为附加标签展示在推荐卡片上让用户在做选择时有更多参考信息。第四做成真正可交互的“志愿表模拟填报”工具。推荐只是第一步真正的志愿填报还需要用户在同一表内调整顺序、删减增补。把推荐结果做成一张可编辑的草稿表再内置一个“滑档风险预警”逻辑用户在调整每一步时都能看到整体风险的实时变化这样的产品闭环就很完整了。6.3 我在实际维护中的几点体会这个项目从数据清洗到上线前后用了大概三周。回头看代码量其实不大真正花时间最多的地方是数据和对业务逻辑的理解。位次法不是新东西但要把位次法用得让人信服必须把数据清洗、概率估算、分档规则、用户解释这几个环节都打磨好。如果你也要做类似的教育推荐系统我建议先把数据流程串通再动算法。最后分享一个折腾我两天的小问题。线上环境部署时发现推荐接口偶尔返回空数据排查很久发现是候选专业拉取那里有个参数没传对——用户选择的选科组合和招生计划表里的选科要求字段拼写不一致数据库查出来全是空集合算法层自然也算不出任何结果。这个问题代码不会报错因为空结果也是合法结果只能靠日志里加足够多的上下文看到“候选数量为0”才能反推出来。加了候选数量校验和告警日志之后类似问题再也没出现过。推荐系统难的不是“推”而是“什么时候该闭嘴”。当用户分数确实没有匹配对象时系统要敢告诉用户“这个区间没有合适的冲档学校建议调整预期”而不是硬凑几条结果出来。这一点在算法设计里可能就几行代码但对用户决策的影响比调参大得多。本文还有配套的精品资源点击获取
返回列表