ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Django高考志愿推荐系统源码解析:混合算法与工程实现

Django高考志愿推荐系统源码解析:混合算法与工程实现

简介:这份资源是基于Django框架与智能算法的高考志愿填报推荐系统完整源码包,面向高中毕业生及家长提供志愿决策辅助,也适合计算机相关专业学生作为毕业设计或课程项目参考。压缩包共631个文件、约85.87MB,涵盖Python后端源码、HTML模板、CSS/JavaScript静态资源、JSON/CSV数据文件、ipynb分析脚本及sqlite3数据库等,能直观看到Django目录结构与前后端交互设计。系统整合了数据模式识别、院校优先级排序、概率预测及多目标优化等算法模块,并结合各省高考政策、专业详情等信息生成个性化推荐方案。已有78人学习下载,适合希望掌握Django工程化开发、智能推荐算法落地,或需要完整项目案例进行二次开发的学习者。

1. 每年出分那天,志愿推荐系统为什么总像黑盒子:这套 Django 源码把算法摊开了

每年六月出分后的那几天,是高三家长最焦虑的时间段。手握分数和位次,打开几个志愿填报 App,屏幕上是满屏的推荐院校和录取概率,但几乎没人能说清这些推荐到底怎么算出来的——是随机还是真有逻辑。这套基于 Django 框架与智能算法的高考志愿填报推荐系统源码,恰好把这块黑匣子从后端拆开了:从院校数据建模、三年分数线入库,到位次换算、加权评分、冲稳保梯度计算,最后通过 RESTful 接口输出结果,全链路可读、可改、可复现。适合两类人:做毕业设计、需要一套能跑通完整前后端链路的 Django 项目实战源码的学生;想自制小型垂直推荐系统、但不想从零写数据层和算法的开发者。这篇笔记会把算法选型、代码结构、参数阈值和部署踩坑一条条讲清楚,拿到源码后不用再靠猜。

2. 推荐引擎的选型逻辑:位次法、协同过滤与加权评分怎么在 Django 里共存

2.1 先搞清楚志愿推荐系统「推荐」的到底是什么

在动任何代码之前,先把问题定义清楚。高考志愿推荐不是一个标准的 TopN 推荐问题,它的输入不只是考生分数,而是一组组合条件:分数或位次、所在省份、科类(老高考的文科/理科,新高考的物理类/历史类)、选科要求,再加上考生自己偏好的城市、专业方向、公办民办、学费区间。输出也不是简单的「给你 10 所院校」,而是一张带梯度分层的志愿表——哪几所能冲、哪几所稳、哪几所保底。

很多第一次接触这个场景的人会犯一个典型错误:把它当成「按分数排序取前 N 所」来解决。实际上一所院校在一个省只有一个录取最低分,但它是分专业组、分批次录取的,同一所学校不同专业之间可能差出几十分。如果只按学校粒度推荐,排序出来的一定是清一色高分学校,完全没法用。源码里最核心的业务实体是「院校-专业组-招生批次」这个三元组,推荐结果也是在这三个维度上做过滤和排序的。

另一个容易忽略的点是:推荐不是一次性算完就结束的。真实的填报流程里,考生会拿着推荐结果反复调整,把某所院校从「稳」档挪到「冲」档,删掉几个不喜欢的城市。所以这套系统的接口设计从一开始就考虑了两层:一层是批量生成推荐,一层是允许前端对推荐列表做二次排序和筛选。只做一个「一键出结果」的接口,做出来也落不了地。

2.2 三种主流算法的适用边界与复杂度

志愿填报推荐领域里,常见做法是把三类算法拼起来用:位次法、协同过滤、加权评分。先看位次法,它的逻辑最直白:把考生今年的位次和院校近三年录取最低位次做对比,位次比院校位次靠前越多,录取概率越大。家长和老师最容易接受这种解释,因为一分一段表是官方公开的,院校往年位次也是公开数据,说「你两千名去报去年录取位次三千名的学校,问题不大」比说「模型给你的分数是 87.3」可信得多。

协同过滤的长处是能挖掘隐性偏好。假设有一批和当前考生位次相近、选科相同的往届生,系统记录了他们最终去了哪些学校,那么这些行为数据就可以用来做「和你相似的人都把哪所学校当保底」的推断。缺点也很致命:大部分志愿填报工具是没有这么多真实填报行为数据的,尤其在中小型部署场景里,冷启动几乎是必然的。源码里把协同过滤放在兜底位置,而不是主推荐链路,就是这个原因。

加权评分则是典型的工程派方案:把院校层次、城市经济水平、专业热度、招生计划变化幅度等因子分别打分,再乘上人工设定的权重求和,最后按总分排序。它的灵活性最好,有什么小众需求都可以加一个因子进去,但权重人工调起来很费,容易过拟合到某一年的数据上,换一个省份就得重新调一轮。下面是三种方案在这个场景下的对比:

维度位次法协同过滤加权评分
推荐依据历史录取位次与考生位次对比相似考生/物品的填报行为多因子人工权重打分
可解释性高,家长能看懂低,基本是黑盒中,权重可调
冷启动较好,有录取数据就能跑严重,样本少时直接失效一般
数据依赖分数线、一分一段表、批次表用户填报行为日志院校标签、城市数据、热度数据
适用边界数据质量高、口径统一的省份用户量大、行为丰富的平台多条件组合排序场景

2.3 为什么选「位次法打底 + 加权评分调节 + 协同过滤兜底」的混合策略

这套源码没有押宝在单一算法上,原因很现实:纯位次法在数据口径不统一的时候会翻车。新高考落地后,不少省份合并了本科批次,往年的「一本线位次」和今年的「特控线位次」根本不是同一套坐标系;还有一些院校近三年招生计划变动很大,位次波动能超过百分之三四十。这时候只在位次上做硬匹配,结果会明显失真。

所以主链路是位次法,保证基础结果可解释;但位次法产生的原始列表会进入一个加权评分模块做二次排序,把招生计划变化量、院校所在地的经济与就业指数、专业供需热度这些位次看不出来的因子叠加上去。最后如果系统里积累了足够多的考生-院校行为数据,协同过滤模块会额外推荐几条「与你情况相似的人也在关注」的院校,补在两个模块的交叉盲区里。

这种混合结构在源码里体现得很清楚:推荐器是一个独立的 service 层,不直接塞在 Django view 里,三个算法分别对应三个内部方法,对外暴露一个统一的generate_recommendations()入口。这样改任何一个算法模块,都不影响接口层和数据层。下面这张权重配置表是源码里默认给的一份参考值,后面调试时你会经常动它:

因子默认权重作用
位次匹配度0.40基础录取概率
招生计划变化量0.15计划扩招/缩招带来的机会变化
院校层次得分0.15985/211/双一流/普通本科
城市吸引力0.15城市 GDP、高教资源、就业指数
专业热度0.15近三年专业报考热度趋势

需要提醒的是,权重不是拍脑袋定的。源码里附带了一个weight_tuning.py脚本,可以把上一年真实录取结果作为测试集,用简单网格搜索来微调权重。如果你只是毕业设计演示,默认权重完全够用;如果是给真实用户跑,务必做一轮基于历史数据的校准,具体做法在第 6 章展开。

3. 拆开核心推荐器:Django ORM 建模、数据清洗与位次匹配算法实现

3.1 数据模型设计:院校、专业组、录取线怎么建表

源码的 Django 项目名是zhiyuan,核心应用叫recommend。数据模型一共五张表:School(院校)、Major(专业)、AdmissionLine(录取分数线,同时聚合了位次信息)、ProvinceMeta(省份批次/科类元数据)、StudentProfile(考生输入快照,配合结果缓存用)。下面是models.py里最关键的三张表:

class School(models.Model): school_code = models.CharField(max_length=20, unique=True, verbose_name="院校代码") name = models.CharField(max_length=100, db_index=True, verbose_name="院校名称") province = models.CharField(max_length=20, verbose_name="所在省份") level = models.CharField( max_length=10, choices=[("985", "985"), ("211", "211"), ("double_first", "双一流"), ("normal", "普通本科")], default="normal", verbose_name="院校层次", ) class Major(models.Model): major_code = models.CharField(max_length=20, verbose_name="专业代码") name = models.CharField(max_length=100, verbose_name="专业名称") category = models.CharField(max_length=20, verbose_name="专业大类") is_hot = models.BooleanField(default=False, verbose_name="是否热门专业") class AdmissionLine(models.Model): school = models.ForeignKey(School, on_delete=models.CASCADE, related_name="admission_lines") major = models.ForeignKey(Major, on_delete=models.CASCADE, related_name="admission_lines") year = models.PositiveSmallIntegerField(verbose_name="年份") province = models.CharField(max_length=20, db_index=True, verbose_name="省份") subject_type = models.CharField(max_length=10, verbose_name="科类") # 物理类/历史类/文科/理科 batch = models.CharField(max_length=20, verbose_name="批次") min_score = models.DecimalField(max_digits=5, decimal_places=1, verbose_name="最低分") min_rank = models.PositiveIntegerField(verbose_name="最低位次") plan_count = models.PositiveIntegerField(default=0, verbose_name="招生计划数") class Meta: unique_together = [("school", "major", "year", "province", "subject_type", "batch")] indexes = [models.Index(fields=["province", "subject_type", "year"])]

字段设计上有几个细节和普通 CRUD 项目不一样。AdmissionLine用unique_together锁住六元组,这是为了防重复导入——数据采集阶段经常出现同一份 CSV 被反复导入的情况,没有这个约束,跑一次导入脚本就多一倍脏数据。min_rank单独存,不通过min_score反推一分一段表,因为很多省份的位次是分段公布的,反推出来的位次不可靠。

plan_count看着不起眼,实际上是加权评分模块的重要输入。招生计划从 80 人扩到 150 人,录取位次大概率要往后移;反过来缩招的学校,位次会往前挤。没有这个字段,算法就少了一条评估录取概率变化的关键线索。

3.2 数据准备:CSV 导入、清洗与批次标准化

数据是这类系统的命脉。源码里提供了一个自定义 management command,用python manage.py import_admission --file lines.csv就能把整理好的分数线 CSV 导入数据库。CSV 的标准列结构是:年份、省份、科类、批次、院校代码、院校名称、专业代码、专业名称、最低分、最低位次、招生计划数。

# recommend/management/commands/import_admission.py 核心逻辑 import csv from django.core.management.base import BaseCommand from recommend.models import School, Major, AdmissionLine class Command(BaseCommand): def add_arguments(self, parser): parser.add_argument("--file", required=True, help="CSV 文件路径") parser.add_argument("--encoding", default="utf-8-sig", help="文件编码,默认 utf-8-sig") def handle(self, *args, **options): with open(options["file"], encoding=options["encoding"]) as f: reader = csv.DictReader(f) for row in reader: school, _ = School.objects.get_or_create( school_code=row["school_code"], defaults={"name": row["school_name"], "province": row["province"]}, ) major, _ = Major.objects.get_or_create( major_code=row["major_code"], defaults={"name": row["major_name"]}, ) AdmissionLine.objects.update_or_create( school=school, major=major, year=int(row["year"]), province=row["province"], subject_type=row["subject_type"], batch=normalize_batch(row["batch"]), defaults={ "min_score": row["min_score"], "min_rank": int(row["min_rank"]), "plan_count": int(row.get("plan_count", 0)), }, ) self.stdout.write(self.style.SUCCESS("导入完成"))

这段脚本里有两个容易出问题的点。第一是encoding参数默认给了utf-8-sig,而不是常见的utf-8。很多省份教育考试院公布的 CSV 是从 Excel 另存出来的,带 BOM 头,用utf-8读会把第一个字段名解析成\ufeffyear,字段直接对不上。给命令行加一个可选的--encoding参数,你就能在不改代码的情况下兼容 Excel 和规范 UTF-8 两种文件。

第二是normalize_batch()这个标准化函数。各省对批次的叫法五花八门:「本科一批」「本科A段」「普通类本科」……如果是新高考省份还有「特殊类型招生」这类特殊批次。批量导入之前必须先把批次名称映射成项目内部的统一枚举值,否则 2022 年存的「本科一批」和 2023 年存的「本科A段」会被当成两个批次,跨年份计算就会断掉。

提示:数据导入前记得先跑一遍python manage.py makemigrations && python manage.py migrate,五张表建好之后再导数据。顺序反了会直接撞上外键约束报错。

3.3 推荐器主逻辑:位次匹配、年份加权与冲稳保判定

推荐器核心实现在recommend/services/recommender.py里,对外暴露一个generate_recommendations()方法。整个流程分四步:候选集召回、年份位次加权、录取概率估算、冲稳保分层。下面把最关键的概率估算部分简化出来:

# recommend/services/recommender.py YEAR_WEIGHTS = {0: 0.5, 1: 0.3, 2: 0.2} # 距今年数 -> 权重 def estimate_admit_probability(student_rank: int, line: AdmissionLine) -> float: """根据考生位次和院校近三年录取位次估算录取概率""" if line.min_rank <= 0: return 0.0 # 数据缺失直接视为不可预测 # 位次比值:大于 1 表示考生位次比录取位次靠前 ratio = line.min_rank / student_rank # 平滑分段映射:0.9 以下风险,0.9~1.15 冲刺,1.15~1.6 稳妥,1.6 以上保底 if ratio < 0.9: return max(0.05, ratio * 0.5) elif ratio < 1.15: return 0.4 + (ratio - 0.9) * 1.6 elif ratio < 1.6: return 0.8 + (ratio - 1.15) * 0.2 else: return 0.95 def weighted_rank(line, current_year: int) -> int: """对同一院校专业近三年位次做加权平均,平滑异常波动""" total_w, total_r = 0.0, 0 for back in (0, 1, 2): lines = AdmissionLine.objects.filter( school=line.school, major=line.major, province=line.province, subject_type=line.subject_type, year=current_year - back, ) if lines.exists(): w = YEAR_WEIGHTS[back] total_w += w total_r += int(lines.first().min_rank * w) return int(total_r / total_w) if total_w > 0 else 0

estimate_admit_probability()里的分段阈值直接决定了推荐结果里三档的分布,是整套系统里最值得调的地方。阈值设太松,保底档会塞进来一堆实际录取概率远高于考生位次的院校,浪费志愿表名额;设太紧,冲刺档全是几乎不可能录取的「做梦院校」。默认阈值是我在跑测试数据时定的经验值,不同省份的位次密度不一样,后面第 6 章会讲怎么用回溯测试把它校准到自己的数据集上。

weighted_rank()解决的是位次年际波动问题。很多院校某一年因为大小年效应录取位次剧烈抖动,直接用最近一年的数据会被带偏。近三年 0.5/0.3/0.2 的加权是项目默认值,如果个别院校某一年计划大幅扩招,还可以在导入时把该年记录标记降权——源码里预留了AdmissionLine.warning布尔字段,有标记时weighted_rank()会自动把该年权重减半。

3.4 查询性能:避免 N+1 与预加载

推荐器如果在循环里反复查AdmissionLine,一次请求就会打几百条 SQL,页面直接卡死。源码里所有批量推荐都强制用select_related和prefetch_related把外键关系一次查上来:

def _load_candidates(self, province, subject_type, current_year): lines = AdmissionLine.objects.filter( province=province, subject_type=subject_type, year__gte=current_year - 2, ).select_related("school", "major") # 按 (school, major) 聚合到内存,后续循环不再触碰数据库 self._candidate_map = defaultdict(list) for l in lines: self._candidate_map[(l.school_id, l.major_id)].append(l) return lines

注意_load_candidates里有个细节:只用year__gte=current_year - 2过滤,批次、科类的进一步过滤放在内存里做。原因是数据库索引只建了(province, subject_type, year),再加上 batch 过滤会迫使数据库走全表扫描;而候选集加载进内存后本来就只服务一次请求,内存过滤开销远小于数据库二次查询。

4. 从算法到接口:DRF 序列化器、视图编排与前端拿到推荐结果

4.1 接口设计:一个请求把「输入 + 推荐 + 梯度说明」全返回

这套源码前后端分离,后端用 Django REST Framework 暴露接口。推荐接口定义成POST /api/recommend/,入参包含考生位次、省份、科类、选科要求、偏好城市列表和可选的专业方向,出参是带分层标识的院校专业列表。下面是一次真实请求的入参结构:

{ "rank": 8246, "province": "河南省", "subject_type": "物理类", "preferred_cities": ["郑州", "武汉", "西安"], "preferred_categories": ["计算机", "电子信息"], "batch": "本科批", "top_n": 45 }

入参设计里有一个值得注意的点:输入的是位次而不是分数。每年试卷难度不同,分数没有跨年份可比性,位次才是稳定坐标系。如果你手头只有分数,前端可以通过当年的一分一段表把它换算成位次再传过来;后端不做得分换算这件事,是为了避免把「一分一段表数据错误」引入推荐链路。

出参结构分成两层。顶层是items列表,每项包含院校代码、院校名称、专业名称、risk_level(对应冲/稳/保)、预计录取概率,以及简要的推荐理由文本。初始化项目时建议直接把risk_level用中文返回,前端不用做字典映射,少一层联动就少一个翻车点。

4.2 视图编排与序列化器代码

Django 里把推荐逻辑放进 view 的方式有很多种,这套源码的做法是:视图只做参数解析和结果包装,算法逻辑全部留在 service 层。views.py里推荐接口的实现很薄:

# recommend/views.py from rest_framework.views import APIView from rest_framework.response import Response from .serializers import RecommendInputSerializer, RecommendOutputSerializer from .services.recommender import Recommender class RecommendView(APIView): def post(self, request): serializer = RecommendInputSerializer(data=request.data) serializer.is_valid(raise_exception=True) params = serializer.validated_data engine = Recommender(current_year=params.pop("current_year", datetime.now().year)) result = engine.generate_recommendations(**params) output = RecommendOutputSerializer(result, many=True) return Response({"items": output.data})

对应的序列化器里,输入校验是关键。位次必须是正整数,省份和科类必须在ProvinceMeta里存在,否则直接返回 400。这个校验不能省:如果你直接拿用户输入的省份字符串去查数据库,Sqlite 数据库里存的是「河南省」还好,万一存的是「河南」,查出来就是空列表,用户会以为系统坏了,实际上只是数据口径不统一。

# recommend/serializers.py class RecommendInputSerializer(serializers.Serializer): rank = serializers.IntegerField(min_value=1, max_value=2000000) province = serializers.CharField(max_length=20) subject_type = serializers.ChoiceField(choices=["物理类", "历史类", "文科", "理科"]) preferred_cities = serializers.ListField(child=serializers.CharField(max_length=20), required=False) preferred_categories = serializers.ListField(child=serializers.CharField(max_length=20), required=False) batch = serializers.CharField(default="本科批") top_n = serializers.IntegerField(default=45, min_value=1, max_value=120) def validate(self, attrs): if not ProvinceMeta.objects.filter(province=attrs["province"], subject_type=attrs["subject_type"]).exists(): raise serializers.ValidationError("该省份暂未收录对应科类的招生数据") return attrs

top_n默认 45 不是随便拍的。大部分省份本科批可以填 45 个「院校专业组」志愿(例如辽宁、山东等新高考省份),所以默认值对齐真实志愿表容量;老高考省份一个院校多个专业,45 也换算合理。如果用户只想看冲刺档,前端直接在top_n传 15,后端会保证冲稳保各档都覆盖,不会出现清一色保底的情况。

4.3 路由配置、缓存与并发控制

路由放在项目根urls.py里,recommend应用内部不单独建urls.py,因为接口就一个,拆太散反而不好找。用 DRF 不一定非要router,单个接口直接path()绑定是最清晰的:

# zhiyuan/urls.py from django.urls import path from recommend.views import RecommendView urlpatterns = [ path("api/recommend/", RecommendView.as_view(), name="recommend"), path("admin/", admin.site.urls), ]

这个项目里我踩过一次坑是:第一次跑通后没做缓存,每次请求都现场算几十所院校的加权位次。Django 本地跑感觉不到,一放到线上、前端连续拖动滑块调参,推荐接口就被打穿。现在的做法是在Recommender里加了一个 5 分钟粒度的进程内缓存,key 是(rank, province, subject_type, preferred_cities, top_n)的哈希值。由于 key 里含位次和偏好,考生之间几乎不会命中同一个 key,又因为位次一次性输入、前端调参是离散动作,缓存还是能在高频刷新时省掉六成以上重复计算。

注意:进程内缓存只在单进程部署下有效。如果用 Gunicorn 起了多个 worker,每个进程各有一份缓存,可以在settings.py里把缓存后端换成 Redis,替换成本很低,因为缓存读写都收敛在 service 层内部,不涉及模型层。

Django 后台的 admin 注册也没落下。School、Major、AdmissionLine三张表都在 admin 里做了list_filter和search_fields,方便数据入口用网页维护分数线而不是直接改数据库。实际使用时最顺手的是按「年份 + 省份 + 科类」三个下拉联动筛选,一分钟能核对完一批数据。

5. 避坑与常见问题排查:部署和调参时最容易被卡住的五个真实场景

5.1 部署层面的三个高频坑

现象一:项目迁移到服务器后,输入中文院校名搜索直接报UnicodeEncodeError,或者 MySQL 写入时报Incorrect string value: '\xE6\xB5\x8B...'。原因多数是数据库连接没有显式指定 UTF-8 字符集,Django 默认连接 MySQL 时可能沿用库级别的latin1编码。解决方法是建库时明确指定字符集,并且settings.py里的连接配置要写成完整的关键字形式:

DATABASES = { "default": { "ENGINE": "django.db.backends.mysql", "NAME": "zhiyuan", "USER": "zhiyuan_user", "PASSWORD": "your_password", "HOST": "127.0.0.1", "PORT": "3306", "OPTIONS": {"charset": "utf8mb4"}, } }

现象二:推荐接口本地一切正常,部署到生产后用 Nginx + Gunicorn 跑,静态页面能访问但api/recommend/返回 404。这个问题九成是ALLOWED_HOSTS配置漏了生产域名,Django 直接拒绝处理请求。解决时把域名加进ALLOWED_HOSTS,再把DEBUG设为False——但DEBUG=False之后又会触发下一个问题。

现象三:DEBUG=False后,前端访问 admin 后台和静态资源一片白,控制台全是 404。原因是DEBUG=False时 Django 不再由自带服务器提供静态文件服务,必须由 Nginx 接管/static/目录。解决方法是先执行python manage.py collectstatic --noinput把静态文件集中到STATIC_ROOT,再在 Nginx 配置文件里加一条 location 规则指向该目录。很多第一次部署 Django 项目的人在这一步耗时最久,其实本质就是「让 Django 只管业务数据,静态文件交给 Web 服务器」这一个原则。

5.2 算法与数据层面的两个深坑

现象四:推荐结果里某所院校连续两年位次正常,第三年突然没有数据,但加权计算时少了一年的权重,导致它的加权位次异常偏高。排查时第一反应是数据漏导,后来发现是当年该院校在该省没有招生计划,不是数据问题。解决方式是在加权函数里对缺失年份做标记,缺失超过一年的院校直接从候选集剔除,而不是让它用剩余两年数据硬算——两年数据算出的加权位次会明显偏离真实水平,放进「冲」档会误导用户。

现象五:几个省份切换测试时,推荐的梯度分层明显错乱。比如把同一个考生位次分别喂给 A、B 两个省份的数据,A 省出来一半「冲刺」,B 省出来全是「保底」。根源是各省批次合并节奏不同,位次密度分布差异巨大——同样的位次在考生密集的省份可能只超过一半人,在考生稀疏的省份却已经排进前百分之几。解决方法是把第 3.3 节的冲稳保分段阈值改成省份可配置,在ProvinceMeta表里加tier_thresholds字段,每个省份存一份自己的[0.9, 1.15, 1.6]阈值组。调参时不再改代码,改数据库记录即可。

提示:切换省份测试前,先看该省最近一年高考人数。人数在 50 万以上的省份和 10 万以下的省份,位次密度完全不是一个量级,默认阈值只在中等规模密度省份上验证过。

6. 进阶验证技巧:用上一年录取数据回溯测试,反向校准你的冲稳保阈值

拿到源码之后,只把runserver跑起来看到推荐列表,并不算真正会用。更值得做的验证是回溯测试:用上一年的真实录取结果来检验当前算法参数好不好。做法很简单但很有效——把去年考生的位次输入系统,看推荐结果里有没有覆盖到去年实际录取他们的那所学校,覆盖了且分层合理,说明参数可信;没有覆盖,就要回头调阈值和权重。

我一般会在本地写一个独立的验证脚本,不放进主工程,避免污染业务代码。脚本的核心逻辑是:从AdmissionLine里取出前年的录取数据当作「真实去向」,用去年考生的位次跑一遍generate_recommendations(),统计推荐列表对真实去向的命中率。这个命中率不是精确率,而是「真实去向是否出现在推荐的 45 个志愿名额里」,相当于给志愿表兜底的覆盖率。

# scripts/backtest.py from recommend.services.recommender import Recommender from recommend.models import AdmissionLine def run_backtest(province: str, subject_type: str, current_year: int): engine = Recommender(current_year=current_year) hits, total = 0, 0 for line in AdmissionLine.objects.filter( province=province, subject_type=subject_type, year=current_year - 1 ): # 用上一个真实的录取位次当作考生位次,验证推荐能否覆盖录取去向 result = engine.generate_recommendations( rank=line.min_rank, province=province, subject_type=subject_type, top_n=45, ) ids = {(item.school_code, item.major_code) for item in result} total += 1 if (line.school.school_code, line.major.major_code) in ids: hits += 1 print(f"{province} {subject_type} 覆盖率: {hits / total:.2%}")

这个脚本跑一遍大概要几分钟,取决于数据量,但信息量很大。如果覆盖率低于 60%,说明推荐结果和实际录取走向偏差太大,优先检查位次口径,其次再看冲稳保阈值;如果覆盖率在 80% 以上,说明主链路没问题,可以放心去调权重和偏好因子。用这组数据设定好参数之后,我还要再跑一次新省份的数据,确认跨省份阈值分组生效,才会把接口放给真实用户。

从那以后,我每次拿到一套新的省份数据,都会强制走一遍「导入 CSV → 跑回溯脚本 → 校准阈值 → 再跑一遍」的流程,不校准完绝不接前端。这套流程看起来多花几个小时,实际上避免了上线后用户拿着错误推荐去填志愿的严重后果。希望帮到你——把这套 Django 源码下载下来,照着第 2 章的权重配置和第 6 章的验证脚本跑一遍,你就能真正控制住这个系统,而不是被它控制。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进