每年高考出分后的几天,我家里就像开了个小型咨询台。亲戚朋友拿着分数截图来问“这个志愿到底怎么填”,我发现把几十个学校的历年分数线摊在Excel里比对,根本处理不了位次波动、选科限制、招生计划变化这些交叉变量。后来我决定用Django写一个基于数据挖掘的高考志愿推荐系统,让历史录取数据自己“说话”。这篇文章把整个项目的设计与实现完整记录下来,包括数据模型、推荐算法、异步推送,以及我在实际开发中踩过的坑。适合正在学Django想做实战项目,或者想了解推荐系统在垂直领域落地流程的同学。
1. 从“查分数线”到“推志愿”:这个系统要解决的核心问题
1.1 传统报考方式的最大瓶颈
传统报考依赖三样东西:上一年的分数线、某几本志愿填报参考书、周围人的经验。这三个信息源单独看都有问题。只参考一年数据,完全忽略录取分数线的年度波动;直接用分数对比,又会因为每年试卷难度、考生整体水平不同而产生系统性偏差;而周围人的经验往往停留在几年前,院校的热门程度早就变了。实际上,比分数更稳定的是“位次”——也就是考生在全省范围内的排名。
举个例子:A校去年最低分600,今年考生考了605,看起来能上。可去年600分在全省对应的是12000名,今年605分可能对应的是15000名,位次反而往后倒退了3000名。所以用分数匹配院校,本质上是个失真过程。但要逐个院校、逐专业去对比近三年位次、招生计划变化,人工根本忙不过来,更别说还要叠加城市偏好、选科要求、专业兴趣这些个性化维度。这正是数据挖掘的用武之地:从海量历史录取数据里把规律自动找出来,再结合考生特征输出推荐结果。
1.2 系统边界:推荐不是“算命”
设计这个系统之前,我先给自己定了一条原则:推荐只是参考,不是决定。数据挖掘模型再漂亮,也只能反映历史统计规律,没法预测某所学校今年的“大小年”,更没法预知哪个专业会突然爆冷或爆热。所以系统输出的不是“你就报这个学校”,而是一组带概率的“冲、稳、保”三档选项。
每个推荐项都要能说清楚“为什么推荐”。比如“该校近三年最低位次在8000到10000之间,你的位次是9500,录取概率约45%,属于冲一冲的范畴”,这就是一条可解释的推荐。带上依据,用户才会信任结果,也更愿意根据自己的风险偏好做调整。项目的后端逻辑、前端展示,都围绕这个可解释原则来设计。这样也避免了推荐系统变成黑箱,减少使用过程中的误导风险。
2. 数据体系搭建:没有干净数据,算法全是空中楼阁
2.1 数据采集范围与合规性处理
高考志愿推荐依赖的数据主要有三块:院校分省分专业的历年录取数据、招生计划数据、每年的一分一段表。录取数据里最关键的字段是年份、省份、院校代码、专业组/专业、选科要求、招生计划数、最低分、最低位次、平均分等。
采集的时候要注意合规问题。我坚持只使用官方发布的公开数据,爬虫请求控制在合理频率内,不影响对方服务器正常服务。为了演示和学习,也可以用模拟数据替代:根据自己的经验构造一批带噪声的院校录取记录,格式和真实数据保持一致,但这部分要和真实数据分目录存放,防止混淆。数据入库前还需要校验:比如最低位次不能为0,录取最低分不能高于平均分等,这些基础规则能挡住大部分脏数据。
2.2 清洗、归一化与特征构造
拿到的原始数据通常乱到让人头大,缺失值、重复记录、字段格式不一致是家常便饭。我常用pandas先做一轮清洗:
import pandas as pd df = pd.read_csv("admission_data.csv") df = df.drop_duplicates(subset=["year", "province", "university_code", "major_code"]) df = df.dropna(subset=["min_score", "min_rank"]) df = df[df["min_rank"] > 0] df["rank_percentile"] = df["min_rank"] / total_candidates位次是最重要的参照量,但我们不能直接拿原始位次跨省份、跨年份比较,所以需要归一化处理,比如把位次除以当年该省考生总数得到“位次百分位”,数值越小代表排名越靠前。再进一步构造三个实用特征:
- 位次波动系数:近三年最低位次的标准差除以均值,衡量某校某专业的录取稳定性。
- 招生计划变化率:当年计划数相比前一年的增降比例,计划缩减通常意味着竞争加剧。
- 热度指数:用招生计划变化率和录取位次变化率复合计算,用来识别前一年“录爆”或“遇冷”的院校。
这些特征最终都会存入特征宽表,供推荐算法直接读取。
2.3 Django数据模型设计实战
数据模型是Django后端的地基。我划分了四个核心模型:院校(University)、专业(Major)、历年录取记录(AdmissionRecord)、推荐结果(RecommendationResult)。下面是一个简化版:
from django.db import models class University(models.Model): code = models.CharField(max_length=10, unique=True) name = models.CharField(max_length=100) province = models.CharField(max_length=50) level = models.CharField(max_length=20) # 985/211/双一流/普通 class Major(models.Model): code = models.CharField(max_length=10) name = models.CharField(max_length=100) category = models.CharField(max_length=50) class AdmissionRecord(models.Model): university = models.ForeignKey(University, on_delete=models.PROTECT) major = models.ForeignKey(Major, on_delete=models.PROTECT) year = models.IntegerField() province = models.CharField(max_length=50) plan_count = models.IntegerField() min_score = models.IntegerField() min_rank = models.IntegerField() avg_score = models.IntegerField() class RecommendationResult(models.Model): student = models.ForeignKey("users.StudentProfile", on_delete=models.CASCADE) university = models.ForeignKey(University, on_delete=models.CASCADE) major = models.ForeignKey(Major, on_delete=models.CASCADE) group_type = models.CharField(max_length=10) # 冲/稳/保 probability = models.FloatField() reason = models.TextField() created_at = models.DateTimeField(auto_now_add=True)外键级联策略要格外小心。AdmissionRecord是历史数据,属于“一旦丢失很难找回”的资料,所以用了PROTECT而不是CASCADE——如果误删关联院校,数据库会拒绝执行并提示存在引用记录。RecommendationResult属于过程数据,学生画像删除后同步删除推荐结果,用CASCADE更合理。模型设计好之后,按常规执行makemigrations和migrate就能建表。
3. 推荐引擎的算法选型与融合策略
3.1 基于位次与计划数构建“冲稳保”梯度
这就是整套推荐系统的地基算法,目标是把海量院校按照考生位次划分成“冲、稳、保”三个档位。通常录取概率大于0.8视为“稳”,0.4到0.8之间视为“冲”,小于0.4则建议作为“保底”。概率计算不能只用一年数据,要汇总近三年的历史录取位次。
import numpy as np def grade_probability(user_rank, history_ranks): mu = np.mean(history_ranks) sigma = np.std(history_ranks) if sigma == 0: sigma = max(100, mu * 0.01) z = (user_rank - mu) / sigma prob = 1 / (1 + np.exp(1.2 * z)) return prob这里需要说明一个容易混淆的点:位次的数值越小代表全省排名越高。所以如果用户位次小于历史平均位次,z是负数,概率会偏高;反之z越大,概率越低。随后用计划数变化率对概率做修正:如果当年招生计划比往年平均增加,就把概率稍微上调,公式可以写成adjusted_prob = prob * (1 + 0.3 * plan_change_rate)。之所以用logistic函数而不是简单的线性比例,是因为录取概率不是线性的,位次越接近录取线,微小位次变化对结果影响越大,logistic曲线的“S”形特征更贴近现实。
3.2 用K-Means给考生分层后做协同过滤
单个位次模型可以解决“能不能上”的问题,但没法解决“该选什么专业方向”的问题。于是我加了一路基于考生画像的聚类协同过滤。
先把考生特征整理成向量:位次百分位、选科组合编码、城市偏好编码、兴趣方向编码等。使用scikit-learn做标准化和K-Means聚类:
from sklearn.cluster import KMeans from sklearn.preprocessing import StandardScaler scaler = StandardScaler() X_scaled = scaler.fit_transform(features) model = KMeans(n_clusters=5, random_state=42) features["cluster"] = model.fit_predict(X_scaled)聚类数用肘部法则确定:分别计算K=3到K=12的轮廓系数或畸变值,选拐点。同一个簇里面的考生在分数水平和偏好上更相似,因此可以用“和你在同一簇、且近两年成功录取的考生”作为相似用户,再对相似用户的录取院校做加权统计,生成推荐候选。这一步可以有效补充位次模型没有覆盖到的专业兴趣和城市偏好。
3.3 基于Apriori的院校-专业关联规则挖掘
数据挖掘里很经典的一个方法就是关联规则挖潜。应用到高考场景里,我会把“同一位考生被某校某专业录取”看作一个事务,事务里面的项是“院校代码-专业代码”组合。用Apriori算法找频繁项集,找出哪些院校专业经常被同一类考生共同选择。
使用mlxtend库可以快速跑通:
from mlxtend.frequent_patterns import apriori, association_rules frequent_itemsets = apriori(onehot_df, min_support=0.05, use_colnames=True) rules = association_rules(frequent_itemsets, metric="confidence", min_threshold=0.6)关联规则能产出一些让人意外的结论,比如“高分段、偏好东部城市的考生,往往同时在关注计算机类和电子信息类院校专业”。这类规则不适合直接作为决策依据,但非常适合做“补充召回”:当考生明确想学计算机时,系统会把关联规则中经常同时出现的电子信息类院校专业也纳入提前批候选,扩大推荐视野。
3.4 多路召回与加权融合排序
上面三条路线各有偏重:位次模型偏“稳”,聚类协同过滤偏“兴趣”,关联规则偏“探索”。单独使用任何一路都会太偏科,所以最终采用多路召回加加权融合。
final_score = 0.5 * admission_prob + 0.3 * user_similarity + 0.2 * rule_supportadmission_prob来自位次模型,user_similarity来自聚类协同过滤的相似度得分,rule_support来自关联规则提升度或支持度。权重可以考虑用户风险偏好进行调整:保守型用户提高admission_prob的权重,冒险型用户提高user_similarity权重。计算完成后按final_score排序,再按“冲、稳、保”三档分组,每组返回前十名。这里的权重初值是我根据离线测试结果调的,实际部署后还应该做小范围A/B测试再迭代。
4. Django工程化实现:ORM、WebSocket与后台管理
4.1 项目结构与核心App划分
从零开始创建项目时,我采用了按业务域拆分的结构:
django-admin startproject gaokao_recommend python manage.py startapp users python manage.py startapp schools python manage.py startapp recommendation python manage.py startapp dashboardusers负责考生画像和登录注册,schools维护院校、专业、录取记录数据,recommendation承载推荐算法和推荐结果接口,dashboard处理页面展示和数据可视化。App拆得清晰,后期加功能就不会牵一发动全身。
4.2 批量查询与对象删除的优化细节
Django ORM用好了很顺手,但还是有几个地方特别容易踩坑。第一是N+1查询。推荐结果列表页如果循环读取每条记录关联的院校和专业,每次循环都会发SQL,页面会很慢。解决方法是使用select_related:
records = RecommendationResult.objects.filter(student=student) \ .select_related("university", "major")第二是对象删除。我们经常会清理过期的推荐结果,比如数据重新计算后,旧记录已经失去时效性,直接执行QuerySet批量删除是最有效率的:
RecommendationResult.objects.filter(created_at__lt=expire_time).delete()但删除院校时要特别小心,AdmissionRecord上设置了PROTECT外键,所以误删院校会直接抛ProtectedError,这正是我们要的结果。
再有一个容易被忽略的性能点:生成推荐结果时,如果一条条save(),几千条数据要等得人心态崩掉。正确做法是构造对象列表后用bulk_create批量写入:
objs = [ RecommendationResult(...) for each_candidate in final_result ] RecommendationResult.objects.bulk_create(objs, batch_size=500)实测效率至少提升一个数量级。
4.3 用Django Channels实现推荐过程实时推送
推荐计算不是一蹴而就的,它要先跑位次模型、聚类模型、关联规则,再合并排序,整个过程可能持续几秒甚至几十秒。如果用户在页面上干等着,体验会很差。我选择用Django Channels建立WebSocket通道,把后台计算进度实时推送到前端。这也是我在开发这个项目时觉得最“活”的一部分。
首先在settings中配置Channels:
INSTALLED_APPS = [ "daphne", "channels", ... ] ASGI_APPLICATION = "gaokao_recommend.asgi.application" CHANNEL_LAYERS = { "default": { "BACKEND": "channels_redis.core.RedisChannelLayer", "CONFIG": {"hosts": [("127.0.0.1", 6379)]}, } }然后写一个异步Consumer:
import json from channels.generic.websocket import AsyncWebsocketConsumer class RecommendationConsumer(AsyncWebsocketConsumer): async def connect(self): self.task_id = self.scope["url_route"]["kwargs"]["task_id"] self.group_name = f"task_{self.task_id}" await self.channel_layer.group_add(self.group_name, self.channel_name) await self.accept() async def receive(self, text_data): pass async def task_progress(self, event): await self.send(text_data=json.dumps(event["data"]))前端建立连接后,后台在算法流程的每个阶段执行完,就通过channel_layer.group_send往该任务组推送进度。用户能看到“正在计算位次梯度”“正在加载考生画像”“推荐完成”这样实时的动态,体验感完全不一样。相比前端定时轮询,WebSocket只在有事件时推送,也更省服务器资源。
4.4 用Django Unfold提升后台管理体验
后台管理是运营人员日常处理院校数据的地方,Django自带的admin功能齐全,但界面确实朴素。我尝试用Django Unfold做了美化,直接把它放到admin前面即可:
INSTALLED_APPS = [ "unfold", "django.contrib.admin", ... ]Unfold提供侧边栏折叠、暗色主题、卡片式列表等能力。配合list_filter和search_fields字段,管理员可以快速按照年份、省分、院校层级筛选录取记录,极大减少了我在演示系统时来回切换后台页面的次数。
5. 性能优化、测试与部署的实操记录
5.1 耗时计算任务交给Celery异步队列
推荐算法不能放在视图函数里同步执行,否则浏览器请求会被卡住几十秒。我把推荐计算封装成Celery任务,发布到队列后立刻返回task_id,进度通过Channels推送给前端。Celery配置如下:
from celery import Celery app = Celery("gaokao_recommend") app.config_from_object("django.conf:settings", namespace="CELERY") app.autodiscover_tasks()推荐计算任务可以定义成这样:
@app.task def run_recommendation(student_id, params): result = recommendation_pipeline(student_id, params) progress_update.delay(student_id, 100, "推荐完成", result_id=result.id)针对于耗时较长的“全量更新模型参数”场景,我会配合django-celery-beat做成定时任务,比如在每天早上低峰期重新计算热门院校的特征数据,避免推荐接口现场做重计算。
5.2 针对重复查询的Redis缓存策略
高考志愿填报有一个很高频的场景:同一个省份、相近位次、相似选科组合的考生,查询结果几乎是相同的。如果每次都重新跑一整套算法流程,服务器压力很大。所以我在Django缓存里按参数组合生成key,命中缓存直接返回:
from django.core.cache import cache def get_recommendations(params): key = f"recommend:{params.province}:{params.rank}:{params.subject}:{params.city_pref}" result = cache.get(key) if result: return result result = recommendation_pipeline(params) cache.set(key, result, timeout=60 * 60 * 24) return result缓存时间设置24小时是因为录取数据一天内几乎不会变化。当管理员手动修改了录取记录以后,我会在后台保存动作里清理对应的缓存key,防止用户看到旧结果。实际运营中也遇到过缓存穿透的问题:大量用户用极端参数查询,每次都绕开缓存打到了数据库和算法层。解决办法是给命中不到结果的情况也做短暂空缓存,比如缓存5分钟。
5.3 接口测试与推荐结果的验证方法
推荐算法没有标准答案,但我可以用过去两年的真实录取数据做离线验证:把去年录取结果当作“标准答案”,跑今年的模拟考生数据,看系统推荐的“冲稳保”名单里,是否包含这名考生最后实际被录取的学校。这个指标叫召回率,能直观反映推荐覆盖度。
除此之外,接口层面的自动化测试也不能少。比如测试冲稳保分档的边界条件:
import pytest from django.test import Client def test_recommendation_api(): client = Client() resp = client.get( "/api/recommend/", {"rank": 10000, "province": "sample", "subject": "physics"}, ) assert resp.status_code == 200 data = resp.json() assert "stable" in data assert len(data["stable"]) <= 10这类测试写多了以后,即使后续调整算法,也不会担心改坏接口。推荐结果本身也要做数据完整性校验:每次生成推荐记录时,probability必须在0到1之间,未完成计算的记录不允许出现在接口返回中。
5.4 Nginx反向代理与ASGI部署
Channels应用必须用ASGI服务器跑。我选择Daphne启动Django,再用Nginx做反向代理。核心Nginx配置如下:
upstream daphne { server 127.0.0.1:8001; } upstream django_http { server 127.0.0.1:8000; } server { listen 80; server_name your_domain; location /static/ { alias /var/www/gaokao_recommend/static/; } location /ws/ { proxy_pass http://daphne; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; } location / { proxy_pass http://django_http; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }部署时几个安全问题必须注意:关闭DEBUG、配置严格SECRET_KEY、设置ALLOWED_HOSTS,数据库也不要暴露公网端口。Celery Worker和Daphne进程都要用systemd或supervisor守护,避免意外退出后服务不可用。
6. 从项目里长出来的经验:踩坑与建议
6.1 数据量不大时别盲目上复杂模型
刚开发这个系统时,我一度想把K-Means、Apriori、协同过滤全部堆上去,觉得这才显得“数据挖掘”。结果数据集只有几千条记录时,关联规则支持度极低,聚类结果也非常不稳定。后来我把重心收回到位次加概率的统计模型上,用户反馈准确率反而明显上升。数据挖掘不是模型越复杂越好,模型复杂度必须匹配数据量。当记录量达到几万条以上,再逐步引入协同过滤和关联规则才是有意义的迭代路径。
6.2 推荐结果的可解释性比精度更重要
考生和家长不会盲信一个从黑箱里蹦出来的分数。系统上线初期,推荐结果只显示“该校录取概率62%”,很多用户留言问“凭什么”。后来我在每个推荐卡片下加上“依据”:近三年位次波动范围、招生计划变化、学生兴趣匹配度等信息,用户的信任度一下子高了。工程实现上并不复杂,只要在生成推荐结果时把每个候选者的特征和档位判断过程写入reason字段。可解释性终究是这类决策辅助系统的基石。
6.3 新手如何顺着这个项目练Django
如果你正准备学Django,这个项目很适合作为完整练手路线:
- 第一步,先把admin后台配好,手动录入几所院校和录取记录,练习Django的ORM增删改查。
- 第二步,写一个最简单的接口,按位次从数据库里筛出院校列表,了解视图和路由的配合。
- 第三步,把算法逻辑拆成独立的pipeline函数,推荐结果写入数据库,练习模型字段设计。
- 第四步,引入Celery和Redis,把耗时任务移到异步队列,感受一下系统性能的质变。
- 第五步,再加WebSocket实时推送,让页面“活”起来。
这样一步一步推进,每一步都能看到实际产出,不会在某个复杂的算法环节卡死。这个系统也可以作为毕业设计选题,因为数据采集、数据清洗、特征工程、推荐算法、Web后端、异步推送、部署维护这些环节都被覆盖到了,而且每个环节都有清晰的业务目标,不是为技术而技术。
最后再分享一个小技巧:在开发这类推荐系统时,一定要把“临时调试用代码”和“正式算法逻辑”严格分开。我一开始把很多调试用的print和matplotlib绘图混在算法脚本里,结果迁移成Celery任务时踩了不少坑。后来专门建了一个debug_scripts目录,只负责调试和可视化,正式推荐流程保持干净可测试。养成这个习惯,后续维护项目的体验会好很多。