首页 / 资讯中心 / 文章详情

基于Django的高考志愿推荐系统:位次换算、冲稳保梯度与工程实践

基于Django的高考志愿推荐系统:位次换算、冲稳保梯度与工程实践 ★ FEATURED ARTICLE
简介基于Django框架与智能算法的高考志愿填报推荐系统源码面向高中毕业生家庭和教育信息化开发者用于解决志愿填报中院校筛选、专业匹配与录取概率评估等难题。资源包共631个文件、约85.87MB目录涵盖Python后端业务逻辑、Templates模板渲染、static前端静态资源以及JSON/CSV数据文件、ipynb分析笔记、xlsx表格等覆盖从数据清洗、特征处理、模型训练到Web交互展示的完整开发链路。系统采用模型-视图-控制器架构整合多元线性回归、聚类、梯度提升决策树、神经网络与多目标优化算法结合历年录取数据、考生分数分布、学科偏好及职业倾向等多维参数生成个性化院校与专业推荐方案并兼顾地域、录取概率等约束条件同时将各省高考政策、院校专业详情纳入知识库增强推荐结果的可解释性。已有78人学习该资源适合需要完成毕业设计、课程设计或自学Django与推荐系统的读者可作为工程实现参考和源码级学习材料便于研究教育数据挖掘与志愿填报业务逻辑。1. 高考志愿推荐系统到底在推荐什么从位次换算到冲稳保梯度每年出分之后考生和家长真正焦虑的不是“我考了多少分”而是“我这个位次能去哪里”。基于Django框架与智能算法的高考志愿填报推荐系统听起来像是要上协同过滤、深度模型但做过的人都知道这类系统的核心不在“猜你喜欢”而在把录取规则翻译成代码位次换算、往年录取数据对齐、招生计划匹配、冲稳保梯度划分。这四个环节决定了推荐结果能不能用而不是算法名字好不好听。这篇内容按我实际做过的工程思路展开从数据模型讲到Django项目落地再给出最容易翻车的五个排查点。适合拿Django做毕设、给机构搭志愿辅助工具或者想系统梳理推荐系统落地流程的开发者直接照着做。2. 数据模型先行把位次、计划数和选科约束变成可计算的结构2.1 分数位次表、院校专业组表、招生计划表怎么建模志愿填报推荐和电商推荐最大的区别是业务规则优先于用户行为。你没有点击流、没有收藏记录唯一可靠的输入是“分数、位次、选科、省份、年份”。而这些数据落到数据库核心就是三张表一分一段表、院校专业组表、招生计划表。我习惯先建ScoreRank存一分一段数据因为平行志愿投档看的是位次不是分数。同分情况下还要比较单科小分所以每个分数段落要同时记录累计人数的起点和终点。再用School和EnrollmentPlan两张表分别保存院校基本信息和招生计划专业组是多对一关系方便后面按专业组粒度做冲稳保判断。# apps/college/models.py from django.db import models class ScoreRank(models.Model): 一分一段表把分数映射到全省累计位次 province models.CharField(省份, max_length32) year models.PositiveIntegerField(年份, db_indexTrue) subject_group models.CharField(科类, max_length16) # 物理类/历史类 score models.PositiveSmallIntegerField(分数段) rank_start models.PositiveIntegerField(累计人数起点) rank_end models.PositiveIntegerField(累计人数终点) class Meta: constraints [ models.UniqueConstraint( fields[province, year, subject_group, score], nameuk_score_rank ) ] class School(models.Model): name models.CharField(院校名称, max_length64) code models.CharField(院校代码, max_length16, uniqueTrue) city models.CharField(所在城市, max_length32, blankTrue) class EnrollmentPlan(models.Model): 院校专业组招生计划推荐系统的主表 school models.ForeignKey(School, on_deletemodels.CASCADE, related_nameplans) year models.PositiveIntegerField(招生年份, db_indexTrue) major_group models.CharField(专业组代码, max_length12, db_indexTrue) subject_req models.CharField(选科要求, max_length32, default) plan_count models.PositiveIntegerField(计划数, default0) min_rank models.PositiveIntegerField(往年录取最低位次, default0) max_rank models.PositiveIntegerField(往年录取最高位次, default0)UniqueConstraint在这里不是可有可无。志愿数据经常被重复导入没有联合唯一索引bulk_create会写进一堆重复行后面算位次区间时数据直接失真。rank_start和rank_end分开存而不是只存一个平均位次是因为一分一段表里一个分数段往往跨几千人等位换算时要用中位区间取值只有单值不够精确。2.2 数据从哪里来爬虫只是第一步脏数据才是大头常见做法是爬省考试院发布的一分一段表和院校招生计划。网上确实能找到很多现成爬虫源码但我不建议直接抄因为考试院的页面结构每年都变而且落地瓶颈通常不在爬虫在清洗。最坑的是招生计划里的专业组代码。有的数据源混着 4 位院校代码和专业组代码中间还没有分隔符有的把计划数直接写成文本比如“60人”有些年份直接为空。我一般用 pandas 做清洗再走 Django 的bulk_create批量入库几千行数据几秒就能写完。# apps/college/management/commands/import_plan.py import pandas as pd from django.core.management.base import BaseCommand from college.models import EnrollmentPlan class Command(BaseCommand): help 从 CSV 导入招生计划 def handle(self, *args, **options): raw pd.read_csv(data/plan_2025.csv, dtype{code: str, group: str}) # 清洗计划数字段去掉“人”字空值补 0 raw[plan] raw[plan].astype(str).str.replace(人, ) raw[plan] pd.to_numeric(raw[plan], errorscoerce).fillna(0).astype(int) # 专业组代码规范化不足两位补零 raw[group] raw[group].str.zfill(2) # 过滤明显错误的行院校代码必须是 4 位数字 raw raw[raw[code].str.match(r^\d{4}$, naFalse)] rows [ EnrollmentPlan( yearint(row.year), major_grouprow.group, subject_reqrow.subject_req, plan_countrow.plan, ) for row in raw.itertuples() ] EnrollmentPlan.objects.bulk_create(rows, batch_size500)这里的dtype{code: str}是血泪经验。院校代码和很多编号字段都是数字字符串不用str读进来会被 pandas 自动转成int64前导零直接消失后面跟考试院数据一比对就大量落空。errorscoerce把脏值统一转成 NaN 再补 0保证plan_count不会因为一条坏数据让整批导入失败。2.3 选科约束匹配把“物理化学”变成可执行的字符串判断新高考的“院校专业组”模式里选科要求是一个绕不过去的硬约束。比如某专业组要求“物理化学”历史类考生直接不该出现在推荐结果里。这类规则不适合写成复杂的数据库关联最简单可靠的做法是选科要求存成标准字符串匹配时按规则优先级判断。# apps/college/services/subject_matcher.py REQUIREMENT_MAP { 不限: lambda selected: True, 物理: lambda selected: 物理 in selected, 物理化学: lambda selected: 物理 in selected and 化学 in selected, 物理或历史均可: lambda selected: True, } def subject_match(subject_req: str, selected: list) - bool: selected 示例: [物理, 化学, 生物] matcher REQUIREMENT_MAP.get(subject_req) if matcher is None: # 遇到未知要求时宁可保守也不推荐给不符合条件的考生 return False return matcher(selected)判断逻辑看起来简单但前提是数据清洗阶段必须统一选科字段的写法。“物理化学生物”“物化生”“物理化学”这些写法不归一化字典映射就永远匹配不上。所以我建议在导入脚本里加一层字段映射把所有写法统一成标准带的格式。这一层做扎实了后面推荐引擎的过滤条件可以少写三分之二。3. 核心推荐算法落地位次换算、冲稳保梯度与兴趣打分的有效组合3.1 等位分换算一分一段表要用累计位次不是用平均位次很多新手做志愿推荐一上来就按“去年 600 分对应哪些学校”筛然后发现推荐结果完全没法解释。原因很简单每年的试卷难度和考生群体都在变分数本身没有跨年份可比性位次才有。所以第一步是把考生今年的分数通过当年的一分一段表换算成全省位次再拿这个位次去跟目标院校往年录取位次区间比较。一分一段表里一个分数对应一个“累计人数段”。比如 620 分对应累计 3500~3800 人我一般取中间值 3650 作为该分数的位次。分数不在表里时往下找最近一个有效分数段取其rank_end这样偏保守避免高估考生排位。# apps/recommend/services/rank_service.py from college.models import ScoreRank def score_to_rank(score: int, year: int, subject_group: str) - int: 分数转位次取分数段累计人数的中位值 row ScoreRank.objects.filter( yearyear, subject_groupsubject_group, scorescore ).first() if not row: # 分数不在表里取 该分数的最近一条用累计终点偏保守 lower ScoreRank.objects.filter( yearyear, subject_groupsubject_group, score__ltescore ).order_by(-score).first() if not lower: raise ValueError(f{year}年 {subject_group} 一分一段表缺少数据) return lower.rank_end return (row.rank_start row.rank_end) // 2这里的// 2是整数除法取中值rank_end是保守值。两种取值方式对应两种策略取中值适合系统默认模式取rank_end适合考生希望求稳的场景。我通常会把这个策略参数化而不是写死在其中一种因为用户诉求差异很大。3.2 冲稳保梯度录取概率函数与三档阈值怎么设有了学生位次和院校往年录取位次区间就可以算冲稳保。最朴素的做法是学生位次比院校往年最低位次靠前就是“冲”比最高位次靠后就是“保”夹在中间就是“稳”。这里的“靠前”指数字更小因为位次是越小越强。但一个只分三档的硬判断没法在同类里排序。比如同在“冲”档冲 50 名的和冲 5000 名的风险完全不同。所以我用一条连续的概率函数把位次关系映射到 0~1 的录取概率再由概率划分三档。# apps/recommend/services/probability.py def admit_probability(student_rank: int, prev_min_rank: int, prev_max_rank: int) - float: 根据考生今年位次与院校往年录取位次区间计算录取概率。 位次越小越强区间为 [min_rank, max_rank]。 if student_rank prev_min_rank: # 学生位次高于院校往年最好水平冲 base 0.35 delta (prev_min_rank - student_rank) / max(prev_min_rank, 1) return min(base delta * 0.45, 0.85) if student_rank prev_max_rank: # 学生位次低于院校往年最差水平保 base 0.75 delta (student_rank - prev_max_rank) / max(prev_max_rank, 1) return max(base - delta * 0.5, 0.05) # 落在往年录取区间内稳 span max(prev_max_rank - prev_min_rank, 1) progress (student_rank - prev_min_rank) / span return round(0.4 0.3 * (1 - progress), 4) def level_of_probability(prob: float) - str: if prob 0.4: return 冲 if prob 0.75: return 稳 return 保参数上我把“冲”的顶部概率封顶在 0.85不出现 0.99 这种让用户觉得稳上但实际存在投档意外的数字。“保”的底部概率设在 0.05低于 0.05 的直接在推荐引擎里丢弃这样的结果页不会出现一大堆没有意义的低位次院校。阈值 0.4 和 0.75 不是拍脑袋定的它对应的是平行志愿里“可以冲一冲”“比较稳妥”“基本兜底”的三档朴素认知。实际落地时这两个阈值建议做成配置项后面第 6 章会专门讲怎么调。3.3 兴趣与能力匹配霍兰德测评的简化版本完全按位次推荐的问题在于同一档位里学校水平差不多考生和家长会陷入选择困难。这时候需要第二个维度兴趣匹配。常见做法是用霍兰德职业兴趣量表的简化版让用户做一组选择题得出六维分数现实型、研究型、艺术型、社会型、企业型、常规型。再把每个专业打上对应的霍兰德标签算相似度。# apps/recommend/services/interest.py HOLLAND_TAGS { realistic: [机械, 土木, 电气, 材料], investigative: [数学, 物理学, 计算机, 临床医学, 化学], artistic: [文学, 设计学, 新闻传播, 音乐], social: [教育学, 心理学, 法学, 社会学], enterprising: [金融学, 工商管理, 市场营销], conventional: [会计学, 审计学, 行政管理, 图书馆学], } def interest_similarity(user_vector: dict, major_name: str) - float: user_vector 是六维得分 dict例如 {realistic: 8, ...} best 0.0 for tag, majors in HOLLAND_TAGS.items(): if any(keyword in major_name for keyword in majors): score user_vector.get(tag, 0) / 10.0 best max(best, score) return best这段逻辑很轻但效果明显。注意它只做“同档位内的排序加分”不做硬过滤。因为兴趣测评的信效度不如位次数据客观把它当主算法会导致推荐结果背离录取现实比如给一个位次只够普通一本的考生推荐了兴趣匹配但录取概率极低的热门专业组。这个分寸必须在代码注释里写清楚我一般加权占比控制在 20% 以内。4. Django 工程落地App 拆分、服务层设计与推荐接口的完整调用链4.1 从 startproject 到 startapp目录结构先定清楚再动手Django 项目最忌一个 app 写到底。我的划分是按业务域拆college管学校、计划、位次数据recommend管推荐算法和接口user管考生资料与测评结果。这样数据导入、算法迭代、用户体系可以独立演进不会因为课程设计后期要加功能就把一个 app 改成大杂烩。# 初始化命令 django-admin startproject config python manage.py startapp college python manage.py startapp recommend python manage.py startapp userconfig/ settings.py college/ models.py # School / EnrollmentPlan / ScoreRank services/ # subject_matcher.py management/ # import_plan.py 数据导入命令 recommend/ services/ # rank_service.py / probability.py / interest.py views.py # 推荐接口 user/ models.py # UserProfile / InterestTestResultcollege和recommend都各自建了services包而不是把算法函数写进views.py。这一步看似多绕了一圈但后面调参、写单测、换推荐策略时会省非常多时间。新手容易嫌麻烦我见过不少课程项目把推荐逻辑全写在视图函数里最后改动一个位次换算函数要在三个视图里同步修改迟早翻车。4.2 推荐服务层把冲稳保小编排组合成一组可复用的完整策略有了前面的位次换算、概率计算、兴趣匹配推荐引擎的组装在服务层完成。这个引擎的输入是当前用户输出是一串按冲稳保分桶且排好序的推荐条目。我用三层结构先过滤选科硬约束再算录取概率最后按兴趣得分做组内排序。# apps/recommend/services/recommendation_engine.py from typing import List, Dict from college.models import EnrollmentPlan from college.services.subject_matcher import subject_match from .rank_service import score_to_rank from .probability import admit_probability, level_of_probability from .interest import interest_similarity def run_recommendation(user, year: int, top_n: int 30) - List[Dict]: student_rank score_to_rank(user.gaokao_score, year, user.subject_group) plans (EnrollmentPlan.objects .filter(yearyear) .select_related(school) .only(id, school__name, major_group, subject_req, min_rank, max_rank) .iterator()) buckets {冲: [], 稳: [], 保: []} for plan in plans: # 硬约束选科不满足就直接跳过 if not subject_match(plan.subject_req, user.subjects): continue if plan.min_rank 0 or plan.max_rank 0: continue # 数据不完整宁缺毋滥 prob admit_probability(student_rank, plan.min_rank, plan.max_rank) if prob 0.05: continue level level_of_probability(prob) buckets[level].append({ school: plan.school.name, major_group: plan.major_group, probability: round(prob, 2), level: level, interest: interest_similarity(user.interest_vector, plan.major_group), }) # 每个档位按录取概率降序再按兴趣得分二次排序 result [] for level in [冲, 稳, 保]: items sorted(buckets[level], keylambda x: (-x[probability], -x[interest])) result.extend(items[: top_n // 3]) return result排序这里有个细节我在每个档位内部先按概率降序再按兴趣降序而不是直接按加权的总分排序。原因是用户第一眼需要看到的是“这个档位里最有希望的选项”兴趣只能作为同分排序的辅助维度。如果直接用综合分排序概率 0.1 的兴趣高分专业组会排在概率 0.39 的普通专业组前面用户会觉得系统在乱推荐。iterator()的使用是考虑到部分省份的招生计划数据量较大一次all()全量载入内存容易把开发机卡死。配合only()只取需要的字段内存占用可以控制在合理范围。plan.min_rank 0的过滤是兜底逻辑往年录取数据缺失的专业组宁可不推荐也不能用 0 位次算出荒谬的高概率。4.3 推荐接口用 Django 的 View 和缓存把响应时间压到百毫秒级服务层做完后视图层就非常薄了。Django 里我习惯用View而不是TemplateView因为推荐接口需要同时支持页面渲染和 JSON 返回用 View 自己控制响应类型更灵活。课程设计一般要求能看到效果所以我会提供一个简单的 JSON 接口给前端表格页面用。# apps/recommend/views.py from django.http import JsonResponse from django.views import View from django.core.cache import cache from .services.recommendation_engine import run_recommendation class RecommendView(View): def get(self, request): year int(request.GET.get(year, 2025)) # 同一个考生同一年份的推荐结果半小时内直接读缓存 cache_key frec:{request.user.id}:{year} cached cache.get(cache_key) if cached is not None: return JsonResponse({code: 0, data: cached}) if not request.user.is_authenticated: return JsonResponse({code: 401, msg: 请先登录并完善考生信息}, status401) data run_recommendation(request.user, year) cache.set(cache_key, data, timeout60 * 30) return JsonResponse({code: 0, data: data})# config/urls.py from django.urls import path from recommend.views import RecommendView urlpatterns [ path(api/recommend/, RecommendView.as_view()), ]接口先查缓存再计算原因很实际推荐引擎要遍历当年全部招生计划虽然做了字段裁剪但每个请求都要重新算一遍位次换算和概率判断高并发时段还是挺吃 CPU。加了缓存后同一用户半小时内的重复请求直接走内存响应从几百毫秒降到 10 毫秒以内。缓存键的设计需要包含用户 ID 和年份避免不同考生互相串数据。这里我把缓存只放在run_recommendation的结果层没有下沉到单条计划级别因为计划级别的缓存命中率不高反而让代码复杂度翻倍。Django 的缓存配置建议直接上LocMemCache做单机缓存课程设计阶段不需要上 Redis。5. 避坑排查志愿推荐系统最容易翻车的五个现场5.1 用分数差代替位次差推荐结果全偏现象考生 600 分目标院校去年录取线 610 分系统判断“冲”。但今年整体分数偏高600 分在今年的位次实际比去年 610 分还靠后结果整批“冲”的院校全是白冲。原因直接用分数差做比较忽略试题难度和考生分布变化。分数没有跨年可比性。解决丢弃分数差逻辑全部改用score_to_rank换算位次后再与院校往年位次区间比。如果一分一段表齐全这一步能消除大部分偏差。5.2 招生计划数据过期专业组代码漂移现象导入数据时用的是 3 月发布的模拟招生计划到了 6 月官方正式计划公布部分专业组代码调整、计划数变化系统推荐的老专业组已经不存在。原因数据导入是一次性的没有做增量更新和版本管理。解决给EnrollmentPlan增加version字段每次导入使用新的版本号推荐查询时强制指定version。同时保留旧版本数据方便对比今年与往年专业组代码的对应关系。5.3 “大小年”让保底院校变成高危选项现象某院校去年录取位次掉到 3 万名系统把它判定为“保”。但今年报考热度恢复实际录取位次回到 1 万名以内志愿表里排在最后的保底校直接滑档。原因只用最近一年的录取位次建模被“断档”或“爆热”的异常年份带偏。解决取近三年录取位次去掉最高最低后用中间值作为判断区间。min_rank和max_rank字段分别存三年最低位次的中位数和三年最高位次的中位数而不是只存某一年的值。5.4 delete 级联删除把测评数据一起清没现象调试时为了清空测试专业组数据执行EnrollmentPlan.objects.filter(year2025).delete()结果用户保存的测评结果和推荐快照全没了。原因EnrollmentPlan关联了School而Recommendation表又用外键指向EnrollmentPlanCASCADE级联把上下游数据一路删干净。解决对引用用户数据的表外键一律不用CASCADE。推荐快照表用SET_NULL用户测评结果不受影响。删除对象前先确认关联关系# 安全删除套路 from django.db.models import ProtectedError try: plan EnrollmentPlan.objects.get(pkpk) except EnrollmentPlan.DoesNotExist: plan None if plan: try: plan.delete() except ProtectedError as e: print(f该计划被推荐快照引用不能删除{e})这是 Django 执行查询-删除对象时最容易被忽略的坑。凡是带on_deletemodels.CASCADE的外键删除前心里要清楚会连带删掉什么。用户产生的数据必须优先保护。5.5 缓存不失效算法调整后结果还是旧的现象改了冲稳保概率阈值重新启动服务但用户刷新页面看到的还是旧结果。原因缓存键只有rec:{user_id}:{year}没有包含算法版本参数。Django 的本地缓存默认持久到进程重启但如果你之前把缓存配置成了文件缓存旧数据会一直躺着。解决给缓存键加版本号。引入ALGO_VERSION v2配置项缓存键改为rec:{ALGO_VERSION}:{user_id}:{year}。每次改算法参数就递增版本号旧缓存自动逻辑失效。同理招生计划版本变化也要加到键里。# settings.py 中维护一个算法版本 ALGO_VERSION v2 PLAN_VERSION 2025_final # 视图里组装缓存键 cache_key frec:{ALGO_VERSION}:{PLAN_VERSION}:{request.user.id}:{year}6. 让推荐结果更可信解释性输出与三个值得细调的参数只给用户一个“冲”字或者一个概率数值说服力远远不够。我后来给每个推荐条目加了一个reason字段把“为什么推荐”翻译成一句话例如“去年录取位次区间为 8200 至 14500你的位次是 10500处于稳定区间”。这个改动看起来小但志愿规划师用起来体验完全不同他们不再需要自己翻数据去验证系统的判断。def build_reason(level: str, student_rank: int, min_rank: int, max_rank: int) - str: if level 冲: return f你的位次 {student_rank} 接近该专业组往年录取上限 {min_rank}可以冲刺 if level 稳: return f你的位次 {student_rank} 落在往年录取区间 {min_rank}-{max_rank} 内相对稳妥 return f你的位次 {student_rank} 大幅低于往年录取区间上限 {max_rank}可作为保底除了解释性输出还有三个参数值得细调。第一个是冲稳保的厚度比例我的默认值是每档各占三分之一但如果用户接受风险可以把冲的占比调到 40%稳 40%保 20%。这种需求直接用top_n的分配比例控制。第二个是兴趣权重interest_weight默认 0.2当测评数据质量高、样本量大时可以往上调到 0.3超过 0.3 就会开始干扰录取概率排序不建议。第三个是年度计划版本PLAN_VERSION每年出分前后至少更新一次没更新之前系统宁可提示“该年数据未就绪”也不要让用户用旧数据做决策。我吃过一次亏第一版系统只返回概率数字没有解释性语句结果被规划师一句“你凭什么说这个是稳”问住了。后来把判断依据全部落成可见文本用户接受度立刻上了一个台阶。数据模型、算法参数、工程结构都是可以慢慢优化的但信任一旦丢了就找不回来。希望这些经验能帮你在做志愿推荐系统时少走几步弯路。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站