☰
基于Django与智能算法的高考志愿推荐系统设计与实战
2026/10/27 15:04:16 网站建设 项目流程

简介:基于Django和智能算法的高考志愿填报推荐系统源码包,面向高考生、家长以及Django开发者,用于解决志愿填报信息冗余、决策困难的问题。系统采用MVC架构,Django负责后台数据处理与业务逻辑,前端借助Templates模板、CSS与JavaScript实现交互,并将历年录取数据、考生分数、兴趣偏好等纳入推荐模型。包内共631个文件,包含93个Python核心源码、34个HTML页面、90个JavaScript脚本、29个CSV数据集、40个JSON配置,以及xlsx/docx说明文档、ttf字体、模型权重压缩包(7z/zip/rar)等,总大小约85.91MB,可完整支撑项目部署与二次开发。目前已有41人学习下载。资源中除Django工程代码外,还提供了数据预处理脚本、算法调优记录、BERT相关模型文件与笔记文档,适合想深入理解智能推荐算法、学习完整Web项目结构的开发者,也适合考生家庭了解系统逻辑后理性使用。

1. 高考志愿推荐系统是什么:数据驱动的方案,不是替代专家

一个考生拿着 600 分和全省位次,面对上百所学校、上千个专业,要在几天内排出顺序。传统的做法是翻书、问老师、凭经验估,而“基于 Django 和智能算法的高考志愿填报推荐系统”要做的,就是把这件事拆成一组可计算的问题:考生画像是什么、录取数据长什么样、怎么在“冲一冲、稳一稳、保一保”之间找到平衡。它不是一个拍脑袋的“智能算命”工具,而是一个把位次、选科、地域偏好和院校历年录取数据对齐的匹配引擎。这套系统适合两类人:一是做毕业设计或课设、需要完整技术栈的学生开发者;二是想给学校或教育机构做志愿填报辅助工具的从业者。前者关心代码能不能跑通,后者关心推荐结果靠不靠谱——这篇笔记会把两条路都走一遍。

2. 建模先行:为什么位次比分数更值钱,等效分怎么算

2.1 用位次而不是分数:一张一分一段表把两年数据拉平

如果你直接把今年的 600 分拿去比去年的 600 分,结果会非常离谱。每年考题难度、考生人数、招生计划都在变,分数本身不是一个稳定尺度。真正稳定的是位次——全省排名。600 分在题难的年份可能是全省第 8000 名,在题简单的年份可能排到第 15000 名,两者能报的学校完全不同。

所以推荐系统的第一个核心逻辑是:考生输入分数和位次,系统用位次去对齐历史录取数据。具体做法是维护一张“等效分”换算表。假设考生今年位次是 8000 名,系统去去年的“一分一段表”里找位次最接近 8000 的那一行,它对应的分数就是考生的“去年等效分”。有了等效分,历史录取数据才可以直接参与计算。

def calculate_equivalent_score(current_rank: int, history_rank_table: list[dict]) -> float: """ 根据今年位次,在历史一分一段表中找到等效分。 history_rank_table: [{"score": 612, "rank": 7500}, {"score": 610, "rank": 8200}, ...] """ # 按位次绝对值差排序,取最接近的一行 closest = min(history_rank_table, key=lambda row: abs(row["rank"] - current_rank)) return closest["score"]

这段代码的逻辑很直接:min配合key按“位次差的绝对值”找最接近的记录。如果一分一段表的粒度不够细(比如每 3 分一档),还可以做线性插值,但按我的经验,直接用最接近位次的行,精度已经能满足志愿填报的场景——因为录取位次本身就是一个范围,不是精确值。

2.2 画像字段设计:把选科、地区、专业意向变成可计算的标签

考生画像不只是“分数 + 位次”。新高考模式下,选科组合直接决定能报哪些专业。物理类考生报不了要求“历史”的专业,没选化学的报不了绝大多数化工、临床医学类专业。如果系统不校验选科,推荐结果里会出现大量无效志愿,这是新手最容易忽视的地方。

我一般会在画像里维护这几类字段:考试年份、省份、首选科目(物理/历史)、再选科目列表(化学/生物/政治/地理)、位次、分数,以及一个“意向标签”列表——比如想去的城市、想读的学科门类、能接受的学费区间。城市和学科门类就是后面做内容匹配的标签,提前结构化存好,省得推荐阶段再分词。

from django.db import models class StudentProfile(models.Model): SUBJECT_CHOICES = [("physics", "物理"), ("history", "历史")] exam_year = models.IntegerField(verbose_name="高考年份") province = models.CharField(max_length=32, verbose_name="省份") first_subject = models.CharField(max_length=16, choices=SUBJECT_CHOICES, verbose_name="首选科目") electives = models.JSONField(default=list, verbose_name="再选科目") rank = models.IntegerField(verbose_name="全省位次") score = models.IntegerField(verbose_name="高考分数") preferred_cities = models.JSONField(default=list, verbose_name="意向城市,如['杭州','成都']") preferred_majors = models.JSONField(default=list, verbose_name="意向专业) fee_limit = models.IntegerField(default=100000, verbose_name="可接受最高学费(元/年)")

这个表的设计原则是:所有会影响推荐结果的约束都单独建字段,不要塞进一个 JSON 里让推荐引擎自己猜。electives和preferred_cities用 JSONField 是因为它们数量不固定,但查询时如果用 ORM 的contains去做精确匹配会吃亏,推荐阶段我会把它们取出到内存里做过滤,而不是在 SQL 层硬碰。

2.3 数据源和更新策略:录取数据的口径决定推荐结果上限

推荐系统的算法再花哨,数据不准就是零。院校专业的录取数据来源一般有两个口径:省级招生考试院公布的“院校投档线”(只有学校最低位次),以及部分省份公布的“专业录取数据”(每个专业的最低分和最低位次)。能拿到专业级数据,推荐粒度才够细;只有院校级数据,就只能做到“这个学校能冲/能稳”,给不出具体专业建议。

我处理这些数据时有一个习惯:每一行录取记录都带batch(本科批/提前批/专科批)、school_type(公办/民办/中外合作)、fee(学费)字段。原因很现实——提前批和普通批的录取位次相差很大,中外合作办学专业分数低但学费高,三种口径混在一个表里,推荐结果会失真。数据定期更新,每次导入新一年的录取数据后,用DataFrame做一次重复项和异常值检查,比如最低位次为 0、分数为空、专业名称带乱码的记录,直接剔除。

import pandas as pd def clean_admission_data(raw_csv_path: str) -> pd.DataFrame: df = pd.read_csv(raw_csv_path) # 必填字段不能为空 df = df.dropna(subset=["school_name", "major_name", "min_rank"]) # 位次为 0 或者负数是脏数据 df = df[(df["min_rank"] > 0) & (df["min_rank"] < 800000)] return df

这段清洗代码的要点是“宁可少一条数据,不要错一条数据”。脏数据流入推荐阶段后,轻则推荐出一个根本录取不了的学校,重则让整个列表的梯度全部乱掉。后续所有召回和排序都建立在这张干净的底表上,所以我会在导入流程里加一步校验日志,把每次清洗掉的行数打出来,方便追溯。

3. 智能算法选型:协同过滤落不了地,基于内容的混合方案才是正解

3.1 协同过滤在志愿填报场景的两个致命伤

很多做过电影推荐系统的人,容易把协同过滤直接搬到志愿填报上。协同过滤的本质是“和你相似的人喜欢什么,你也可能喜欢什么”,它需要大量历史行为数据——谁把哪个学校放进了志愿表,最后去了哪。但高考志愿填报恰恰没有这种数据:每年全国考生上千万,但一个考生只填几十个志愿,而且这是“人生只有一次”的决策,不存在反复浏览、收藏、评分的行为链路。数据稀疏到这种程度,协同过滤算出来的相似度基本是噪声。

第二个致命伤是冷启动。一个新上线的系统,没有历史志愿数据,协同过滤连“相似用户”都找不到,只能退化成随机推荐。而基于内容的推荐完全没有这个问题:它不需要用户行为,只需要考生画像和院校专业特征,两边都有标签就能算相似度。所以我给这类系统的建议很直接:以基于内容的匹配为主,规则分层做辅助,不要碰协同过滤。这也是很多做过“基于 spring boot 的大学生就业推荐系统”的同行踩过的同一个坑——就业场景同样是低频决策,行为数据一样稀疏。

3.2 基于内容的匹配:给院校、专业打标签,再做加权相似度

基于内容的推荐,核心是把院校和专业变成一张标签向量表。院校的标签可以包括:所在城市、城市等级(一线/新一线/二线)、院校类型(综合/理工/师范/医学)、学科评估档次(A/B/C)、是否是 985/211/双一流。专业的标签可以包括:所属学科门类(工学/理学/医学/经管)、就业方向、适合的选科组合。

考生画像在 2.2 里已经建好了,推荐阶段就是把考生对城市的偏好、对学科门类的偏好,和院校专业标签做加权匹配。这里不需要上深度学习模型,用最朴素的加权相似度就行。我的经验是:位次区间(能不能录取)至少要占 60% 的权重,标签匹配度占 30%,剩下 10% 留给学费和院校声望之类的调节项。先把硬条件卡死,再在硬条件里挑喜欢的,这个顺序不能反。

def content_score(profile: StudentProfile, school: dict, major: dict) -> float: """ 基于内容匹配的加权打分,返回 0~100 的匹配度。 school 和 major 是已经从数据库取出的标签字典。 """ score = 0.0 # 位次区间命中:最核心的硬条件,占 60 分 if school["min_rank"] <= profile.rank <= school["max_rank"]: score += 60 # 城市偏好命中:意向城市列表里有一个匹配就算,占 20 分 if school["city"] in profile.preferred_cities: score += 20 # 专业方向匹配:意向专业和当前专业同属一个学科门类,占 15 分 if major["category"] in profile.preferred_majors: score += 15 # 学费可接受,占 5 分 if school["fee"] <= profile.fee_limit: score += 5 return score

这套打分逻辑故意写得直白,因为它要的就是可解释性。用户问“为什么推荐这个学校”,你能直接说出“位次在录取区间内 + 城市是成都 + 学费能接受”,比一个黑匣子模型算出的 0.87 分有说服力得多。

3.3 冲稳保分层:召回-排序两段式结构里的系数怎么调

“冲稳保”是高考志愿填报的核心方法论,推荐系统必须把它内化成一条明确规则。设定考生位次为 r,常见的分档逻辑是:冲 = 录取位次区间在[r, r*0.8](往年录取位次比考生靠前 0~20%)的学校;稳 =[r, r*1.2];保 =[r*1.2, r*1.5]。这个比例不是绝对的,各省考生密度和院校数量不同,需要微调,但作为默认值是靠谱的。

工程实现上,我一般用两段式结构。第一段“召回”:按批次、选科硬条件、冲稳保位次区间,用 ORM 查询快速选出候选池;第二段“排序”:对候选池里的每一条记录跑content_score,按分数排序,最后按冲稳保三档各取若干个名额,拼成一个推荐列表。这样设计的好处是召回阶段可以放心用数据库索引加速,排序阶段的数据量小了,Python 里逐条算分数也不会慢。

def recall_candidates(profile: StudentProfile) -> list[dict]: """ 第一段:按硬条件从数据库召回候选院校专业。 按位次区间分三档召回,每一档取前 200 条。 """ base_qs = AdmissionRecord.objects.filter( batch="本科批", province=profile.province, exam_year=profile.exam_year - 1, # 用上一年数据做参照 subject_requirement__contains=profile.first_subject, ) candidates = [] # 冲:往年录取位次在 [rank, rank*0.8] 之间 for label, rank_min, rank_max in [ ("冲", int(profile.rank * 0.8), profile.rank), ("稳", profile.rank, int(profile.rank * 1.2)), ("保", int(profile.rank * 1.2), int(profile.rank * 1.5)), ]: qs = base_qs.filter( min_rank__lte=rank_max, max_rank__gte=rank_min, ).order_by("min_rank")[:200] for item in qs: candidates.append({**item.to_dict(), "level": label}) return candidates

这段代码里最需要注意的坑是rank_min和rank_max的方向。在志愿填报里,“位次越小越好”,全省第 5000 名比第 10000 名强。所以“冲”是往年录取位次更小的学校,区间是[r*0.8, r];“保”是往年录取位次更大的学校,区间是[r*1.2, r*1.5]。方向一搞反,推荐列表就是反的。

4. Django 代码落地:三张核心表、一个推荐引擎和两个管理命令

4.1 模型设计:考生画像表、院校专业表、录取数据表怎么建

Django 项目里最怕的是把所有字段塞进一张大表。志愿填报系统我一般分成三张核心表:StudentProfile(用户画像)、SchoolMajor(院校专业基本信息)、AdmissionRecord(历年录取数据)。画像表在 2.2 里已经给了,这里重点说后两张表。

SchoolMajor存静态属性:学校名称、所在城市、院校类型、办学性质、学费、选科要求、专业名称、学科门类。AdmissionRecord存动态数据:年份、批次、最低分、最低位次、平均位次、招生人数。为什么拆开?因为同一个专业在不同年份的录取位次年年变,动态部分单独放一张表,更新数据时只动AdmissionRecord,不动学校专业基础信息。

class SchoolMajor(models.Model): school_name = models.CharField(max_length=128, db_index=True) city = models.CharField(max_length=32) school_type = models.CharField(max_length=16) # 综合/理工/师范/医学 major_name = models.CharField(max_length=128) major_category = models.CharField(max_length=32) # 工学/理学/医学/经管 subject_requirement = models.JSONField(default=list) # 选科要求,如["物理","化学"] fee = models.IntegerField(default=5000) class AdmissionRecord(models.Model): school_major = models.ForeignKey(SchoolMajor, on_delete=models.CASCADE, related_name="records") exam_year = models.IntegerField(db_index=True) batch = models.CharField(max_length=16) # 本科批/提前批/专科批 min_score = models.IntegerField() min_rank = models.IntegerField(db_index=True) max_rank = models.IntegerField() enroll_count = models.IntegerField(default=0) class Meta: # 最常用的查询维度:年份 + 位次范围 indexes = [models.Index(fields=["exam_year", "min_rank", "max_rank"])]

索引一定要加在exam_year + min_rank + max_rank这个组合上,因为推荐引擎的召回查询就是拿年份和一个位次区间去过滤。不加这个索引,录取数据量到几万条以上时,推荐接口会明显变慢。

4.2 推荐引擎写在哪:独立模块 + 管理命令跑批

新手常见的做法是把推荐逻辑直接写进视图函数,用户请求一次就现场算一次。数据量小的时候没问题,但真实场景里一个专业就有 5 年录取记录,一个省有几千个专业,现场算一遍就是几万次相似度计算,接口响应直接卡死。我把推荐引擎写成独立模块recommend/engine.py,用 Django 管理命令触发跑批,结果落库,前端只读结果表。

from django.core.management.base import BaseCommand from recommend.engine import build_recommendations class Command(BaseCommand): help = "为指定考生生成志愿推荐列表并落库" def add_arguments(self, parser): parser.add_argument("--profile-id", type=int, required=True) parser.add_argument("--year", type=int, default=2026) def handle(self, *args, **options): profile_id = options["profile_id"] year = options["year"] count = build_recommendations(profile_id, year) self.stdout.write(f"生成 {count} 条推荐结果")
python manage.py run_recommend --profile-id=1 --year=2026

推荐结果写入一张Recommendation表,包含profile外键、school_major外键、match_score、level(冲/稳/保)、rank排序字段和created_at。用户在前端查看时,永远不需要重新计算,只需要查这张表。这也是 Django 项目实战新手最容易忽略的一点:推荐类系统的计算密度远高于普通 CRUD,跑批落库是标配,不是优化项。

4.3 查询优化和缓存:一次推荐请求背后要查多少次库

排名结果落库之后,读接口的查询很轻,但仍有两个细节要注意。第一个是用select_related一次性把SchoolMajor的外键信息带出来,避免循环里逐条查外键;第二个是推荐结果按level分组展示时,用values或annotate在数据库层做分组,不要把几百条结果取到 Python 里再手动分组。

def get_recommendation_list(profile_id: int) -> dict[str, list]: """ 按 冲/稳/保 三档分组返回推荐结果。 select_related 一次性查出外键关联的院校专业信息。 """ qs = Recommendation.objects.filter(profile_id=profile_id) \ .select_related("school_major") \ .order_by("rank") grouped = {"冲": [], "稳": [], "保": []} for rec in qs: grouped[rec.level].append({ "school": rec.school_major.school_name, "major": rec.school_major.major_name, "score": rec.match_score, "city": rec.school_major.city, }) return grouped

select_related是这里的关键——它会在 SQL 层用 JOIN 把外键表的数据一次性查出来,而不是等你在循环里访问rec.school_major时才发第二条 SQL。如果你在日志里看到推荐列表接口有几十条重复的查询语句,基本就是漏了这一步。

4.4 接口和页面:把推荐结果展示成一个可交互的志愿表

Django 端我习惯提供两个接口:一个是生成推荐的跑批入口(管理命令已经覆盖,接口可以省),另一个是查询推荐结果的 JSON 接口,给前端表格用。用 Django REST Framework 写一个只读的ViewSet,或者直接JsonResponse返回grouped字典,都行——项目里没有复杂的权限和嵌套序列化需求时,没必要为了用 DRF 而用。

前端展示上,最实用的形态是一张“志愿表”样式的表格:左边是冲稳保三档标签,每一档下面罗列院校和专业,点击某一行的“查看详情”,展开显示该专业近三年的最低位次和录取人数。后端在返回grouped之前,顺带把每一条推荐结果的近三年位次趋势一起查出来,减少前端二次请求。

def _attach_rank_trend(rec: dict, school_major_id: int) -> dict: records = AdmissionRecord.objects.filter( school_major_id=school_major_id ).order_by("-exam_year")[:3] rec["rank_trend"] = [(r.exam_year, r.min_rank) for r in records] return rec

这段函数在 4.3 的循环里对每条结果调用一次,产生 N+1 查询。如果推荐结果有几十条,建议加一层prefetch_related或直接在get_recommendation_list里一次性查出所有关联的AdmissionRecord,再在内存里做分组。数据量可控时后者的写法更省事,数据量大时必须上Prefetch对象。

5. 避坑指南:录取数据、批次规则和推荐结果里的五处常见翻车

坑一:位次区间方向搞反,“冲”成了“保”

第一次跑推荐列表时,发现标签为“冲”的学校全是往年录取位次比考生低一大截的学校,也就是所谓的“保底校”。查了代码之后定位到 3.3 里的区间设置:冲的rank_max写成了profile.rank / 0.8,导致冲的区间变成了位次更大的方向。解决方法是把区间参数抽成配置项,写单元测试覆盖三个区间的边界值,而不是靠肉眼检查结果。位次方向上,记住“数字越小越强,冲是找更强的学校”。

坑二:选科要求没有校验,历史类考生拿到了物理类专业

系统上线后收到的第一个用户反馈是:选“历史”的考生被推荐了计算机科学与技术。原因是召回阶段只过滤了batch和province,漏掉了subject_requirement字段。Django 的JSONField存["物理", "化学"]这类列表,ORM 里用contains做包含匹配很容易误判。解决方式是:画像里的first_subject和electives先转成 Python 的set,在召回前做一次内存过滤;如果数据量大,就把选科要求拆成require_first_subject和require_elective_subject两个独立字段,用精确匹配。

坑三:早期录取数据混入提前批和中外合作办学,推荐列表出现“分数倒挂”

有段时间推荐结果里频繁出现一组位次很低但学费极高的“名校”。排查发现是数据导入时把提前批(如军校、公费师范生)和中外合作办学专业混进了普通本科批。提前批录取位次往往低于普通批,中外合作办学学费高但分数低。解决方式是在AdmissionRecord表里把batch和school_type做成必填字段,清洗逻辑里增加规则校验——batch为空或非法值直接丢弃;推荐引擎只认batch="本科批"且school_type为“公办/民办”的记录,杜绝“收费高昂的低分捡漏”出现在常规志愿表里。

坑四:推荐列表梯度不合理,全是一个档次的学校

第一次用基于内容的评分排序时,发现生成的列表里 80% 都是“稳”档学校,“冲”和“保”几乎为空。原因是content_score里的 60 分位次命中权重,把“位次落在区间内”设成了硬条件,而大多数学校往年的位次区间偏宽容,导致它们全落在同一个区间里。解决方式是召回和排序分工:召回阶段按位次区间划分三档,排序阶段不在位次上重复加权,而是用院校声望、城市偏好、专业热度这些“软条件”在档内排序。冲稳保的比例由召回阶段的分档数量决定,排序不会跨档调换。

坑五:缓存键没带年份,用户查到了上一届的推荐结果

有一版代码用了cache.set(f"recommend_{profile_id}", result)做结果缓存,结果新一届录取数据导入后,老用户刷新页面看到的还是旧推荐。原因很简单:缓存键里没有带exam_year,而推荐结果最核心的输入就是年份。解决方式是缓存键一律拼上年份:f"recommend_{profile_id}_{year}",数据更新时按年份批量清理对应的缓存键。这个坑单看代码很难发现,上线后第一次更新数据就会踩到,属于典型的“数据更新导致的黑匣子问题”。

6. 效果验证:用历史数据回测推荐精度,两个指标看清系统水平

推荐系统做完之后,最怕的是“跑通了但不知道准不准”。最直接的验证方法是回测:拿去年的考生画像,喂给今天这个系统,用今年已经公布的录取结果来判断去年的推荐准不准。这里用两个指标就够了:第一个是档位命中率,推荐为“冲”的学校里,有多少所今年的录取位次真的高于考生位次;第二个是整体满意度,推荐列表里的学校,有多少所今年实际录取位次落在考生的冲稳保区间内。

def evaluate(profile, recommendation_result, actual_admission_data): hit = 0 for rec in recommendation_result: school_id = rec["school_major_id"] actual = actual_admission_data.get(school_id) if actual and actual["min_rank"] >= profile.rank: # 实际录取位次更靠前,冲成功了 hit += 1 total = len(recommendation_result) return {"hit_rate": hit / total, "total": total}

第一次跑回测时,我的命中率只有四成。查数据发现是前一年的“本科批”数据里混了一部分征集志愿的记录,把位次区间拉宽了。清洗之后命中率提高到了六成以上。除了回测,还要做一次和人工经验的对比:找三个做了五年志愿填报的老师,对同一个考生各出一份推荐清单,然后看系统推荐和他们重叠多少。重叠率低于三成,说明系统的标签权重设置和现实决策逻辑有偏差,优先调content_score里的城市和专业权重。

最后说一个进阶技巧:把“服从调剂”和“专业组”概念纳入排序。新高考实行院校专业组投档,同一个学校不同专业组的录取线相差很大。如果数据能区分专业组,推荐引擎应该在组内做专业排序,而不是把整个学校的专业混在一起排。这个改造不难,但能把系统从“能用”推到“能真正上线给用户填志愿用”的水平。我自己在做这类系统时养成的习惯是:先跑通最小闭环,再盯着回测指标做迭代,别一上来就堆算法。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询